321 lines
9 KiB
Vue
321 lines
9 KiB
Vue
<template>
|
||
<div>
|
||
<div style="position: relative; height: 300px" class="my-3">
|
||
<Bar :data="chartData" :options="options" />
|
||
</div>
|
||
<LegendList :legends="legends" />
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import { Bar } from "vue-chartjs";
|
||
import { BarController, BarElement, CategoryScale, LinearScale, Legend, Tooltip } from "chart.js";
|
||
import { registerChartComponents, commonOptions, tooltipLabelColor } from "./chartConfig";
|
||
import LegendList from "./LegendList.vue";
|
||
import formatter from "../../mixins/formatter";
|
||
import colors from "../../colors";
|
||
import { TYPES, GROUPS, PERIODS } from "./types";
|
||
|
||
registerChartComponents([BarController, BarElement, CategoryScale, LinearScale, Legend, Tooltip]);
|
||
|
||
export default {
|
||
name: "CostHistoryChart",
|
||
components: { Bar, LegendList },
|
||
props: {
|
||
sessions: { type: Array, default: () => [] },
|
||
groupBy: { type: String, default: GROUPS.NONE },
|
||
costType: { type: String, default: TYPES.PRICE },
|
||
period: { type: String, default: PERIODS.TOTAL },
|
||
currency: { type: String, default: "EUR" },
|
||
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
|
||
suggestedMaxCost: { type: Number, default: 0 },
|
||
},
|
||
mixins: [formatter],
|
||
computed: {
|
||
firstDay() {
|
||
if (this.sessions.length === 0) {
|
||
return null;
|
||
}
|
||
return new Date(this.sessions[0].created);
|
||
},
|
||
month() {
|
||
return this.firstDay?.getMonth() + 1;
|
||
},
|
||
year() {
|
||
return this.firstDay?.getFullYear();
|
||
},
|
||
lastDay() {
|
||
if (this.sessions.length === 0) {
|
||
return null;
|
||
}
|
||
return new Date(this.sessions[this.sessions.length - 1].created);
|
||
},
|
||
chartData() {
|
||
console.log("update cost history data");
|
||
const result = {};
|
||
const groups = new Set();
|
||
|
||
if (this.sessions.length > 0) {
|
||
//const lastDay = new Date(this.year, this.month, 0);
|
||
//const daysInMonth = this.lastDay.getDate();
|
||
let xFrom, xTo;
|
||
if (this.period === PERIODS.TOTAL) {
|
||
xFrom = this.firstDay.getFullYear();
|
||
xTo = this.lastDay.getFullYear();
|
||
} else if (this.period === PERIODS.YEAR) {
|
||
xFrom = 1;
|
||
xTo = 12;
|
||
} else {
|
||
xFrom = 1;
|
||
xTo = new Date(
|
||
this.lastDay.getFullYear(),
|
||
this.lastDay.getMonth() + 1,
|
||
0
|
||
).getDate();
|
||
}
|
||
|
||
// initialize result with empty arrays
|
||
for (let i = xFrom; i <= xTo; i++) {
|
||
result[i] = {};
|
||
}
|
||
|
||
// Populate with actual data
|
||
this.sessions.forEach((session) => {
|
||
let index;
|
||
const date = new Date(session.created);
|
||
if (this.period === PERIODS.MONTH) {
|
||
index = date.getDate();
|
||
} else if (this.period === PERIODS.YEAR) {
|
||
index = date.getMonth() + 1;
|
||
} else {
|
||
index = date.getFullYear();
|
||
}
|
||
|
||
const groupKey =
|
||
this.groupBy === GROUPS.NONE ? this.costType : session[this.groupBy];
|
||
groups.add(groupKey);
|
||
|
||
const value =
|
||
this.costType === TYPES.PRICE
|
||
? session.price
|
||
: session.co2PerKWh * session.chargedEnergy;
|
||
|
||
result[index][groupKey] = (result[index][groupKey] || 0) + value;
|
||
|
||
result[index].totalCost = (result[index].totalCost || 0) + value;
|
||
result[index].totalKWh = (result[index].totalKWh || 0) + session.chargedEnergy;
|
||
result[index].avgCost = result[index].totalCost / result[index].totalKWh;
|
||
});
|
||
}
|
||
|
||
const datasets = Array.from(groups).map((group) => {
|
||
const colorGroup = this.groupBy === GROUPS.NONE ? "cost" : this.groupBy;
|
||
const backgroundColor = this.colorMappings[colorGroup][group];
|
||
const label =
|
||
this.groupBy === GROUPS.NONE ? this.$t(`sessions.group.${group}`) : group;
|
||
|
||
return {
|
||
type: "bar",
|
||
backgroundColor,
|
||
label,
|
||
data: Object.values(result).map((index) => index[group] || 0),
|
||
borderRadius: (context) => {
|
||
const threshold = 0.04; // 400 Wh
|
||
const { dataIndex, datasetIndex } = context;
|
||
const currentValue = context.dataset.data[dataIndex];
|
||
const previousValuesExist = context.chart.data.datasets
|
||
.filter((dataset) => dataset.type === "bar")
|
||
.slice(datasetIndex + 1)
|
||
.some((dataset) => (dataset?.data[dataIndex] || 0) > threshold);
|
||
return currentValue > threshold && !previousValuesExist
|
||
? { topLeft: 10, topRight: 10 }
|
||
: { topLeft: 0, topRight: 0 };
|
||
},
|
||
};
|
||
});
|
||
|
||
// add average price line
|
||
const costColor = this.costType === TYPES.PRICE ? colors.pricePerKWh : colors.co2PerKWh;
|
||
datasets.push({
|
||
type: "line",
|
||
label:
|
||
this.costType === TYPES.PRICE
|
||
? this.pricePerKWhUnit(this.currency, false)
|
||
: "gCO₂e/kWh",
|
||
data: Object.values(result).map((index) => index.avgCost || null),
|
||
yAxisID: "y1",
|
||
tension: 0.25,
|
||
pointRadius: 0,
|
||
pointHoverRadius: 6,
|
||
borderColor: costColor,
|
||
backgroundColor: costColor,
|
||
borderWidth: 2,
|
||
spanGaps: true,
|
||
});
|
||
|
||
return {
|
||
labels: Object.keys(result),
|
||
datasets: datasets,
|
||
};
|
||
},
|
||
legends() {
|
||
return this.chartData.datasets.map((dataset) => {
|
||
let value = null;
|
||
|
||
// line chart handling
|
||
if (dataset.type === "line") {
|
||
const items = dataset.data.filter((v) => v !== null);
|
||
const min = Math.min(...items);
|
||
const max = Math.max(...items);
|
||
const format = (value, withUnit) => {
|
||
return this.costType === TYPES.PRICE
|
||
? this.fmtPricePerKWh(value, this.currency, true, withUnit)
|
||
: this.fmtGrams(value, withUnit);
|
||
};
|
||
value = `${format(min, false)} – ${format(max, true)}`;
|
||
} else {
|
||
const total = dataset.data.reduce((acc, curr) => acc + curr, 0);
|
||
value =
|
||
this.costType === TYPES.PRICE
|
||
? this.fmtMoney(total, this.currency, true, true)
|
||
: this.fmtGrams(total);
|
||
}
|
||
return {
|
||
label: dataset.label,
|
||
color: dataset.backgroundColor,
|
||
value,
|
||
};
|
||
});
|
||
},
|
||
options() {
|
||
return {
|
||
...commonOptions,
|
||
locale: this.$i18n?.locale,
|
||
color: colors.text,
|
||
borderSkipped: false,
|
||
maxBarThickness: 40,
|
||
animation: false,
|
||
plugins: {
|
||
...commonOptions.plugins,
|
||
tooltip: {
|
||
...commonOptions.plugins.tooltip,
|
||
mode: "index",
|
||
intersect: false,
|
||
positioner: (context) => {
|
||
const { chart, tooltipPosition } = context;
|
||
const { tooltip } = chart;
|
||
const { width, height } = tooltip;
|
||
const { x, y } = tooltipPosition();
|
||
const { innerWidth, innerHeight } = window;
|
||
|
||
return {
|
||
x: Math.min(x, innerWidth - width),
|
||
y: Math.min(y, innerHeight - height),
|
||
};
|
||
},
|
||
callbacks: {
|
||
title: (tooltipItem) => {
|
||
const { label } = tooltipItem[0];
|
||
if (this.period === PERIODS.TOTAL) {
|
||
return label;
|
||
} else if (this.period === PERIODS.YEAR) {
|
||
const date = new Date(this.year, label - 1, 1);
|
||
return this.fmtMonth(date);
|
||
} else {
|
||
const date = new Date(this.year, this.month, label);
|
||
return this.fmtDayMonth(date);
|
||
}
|
||
},
|
||
label: (tooltipItem) => {
|
||
const datasetLabel = tooltipItem.dataset.label || "";
|
||
const value = tooltipItem.raw;
|
||
|
||
// line datasets have null values
|
||
if (tooltipItem.dataset.type === "line") {
|
||
if (value === null) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
"⌀ " +
|
||
(this.costType === TYPES.PRICE
|
||
? this.fmtPricePerKWh(value, this.currency, false)
|
||
: this.fmtCo2Medium(value))
|
||
);
|
||
}
|
||
|
||
return (
|
||
datasetLabel +
|
||
": " +
|
||
(this.costType === TYPES.PRICE
|
||
? this.fmtMoney(value || 0, this.currency, true, true)
|
||
: this.fmtGrams(value || 0))
|
||
);
|
||
},
|
||
labelColor: tooltipLabelColor(false),
|
||
labelTextColor: (item) => {
|
||
return !item.raw ? colors.muted : "#fff";
|
||
},
|
||
},
|
||
itemSort: function (a, b) {
|
||
return b.datasetIndex - a.datasetIndex;
|
||
},
|
||
},
|
||
},
|
||
scales: {
|
||
x: {
|
||
stacked: true,
|
||
border: { display: false },
|
||
grid: { display: false },
|
||
ticks: { color: colors.muted },
|
||
},
|
||
y: {
|
||
stacked: true,
|
||
position: "right",
|
||
border: { display: false },
|
||
grid: { color: colors.border },
|
||
title: {
|
||
text: "kgCO₂e",
|
||
display: this.costType === TYPES.CO2,
|
||
color: colors.muted,
|
||
},
|
||
ticks: {
|
||
callback: (value) =>
|
||
this.costType === TYPES.PRICE
|
||
? this.fmtMoney(value, this.currency, false, true)
|
||
: this.fmtNumber(value / 1e3, 0),
|
||
color: colors.muted,
|
||
maxTicksLimit: 6,
|
||
},
|
||
min: 0,
|
||
},
|
||
y1: {
|
||
position: "left",
|
||
border: { display: false },
|
||
suggestedMax: this.suggestedMaxCost,
|
||
grid: {
|
||
drawOnChartArea: false,
|
||
},
|
||
title: {
|
||
text:
|
||
this.costType === TYPES.CO2
|
||
? "gCO₂e/kWh"
|
||
: this.pricePerKWhUnit(this.currency, false),
|
||
display: true,
|
||
color: colors.muted,
|
||
},
|
||
ticks: {
|
||
callback: (value) =>
|
||
this.costType === TYPES.PRICE
|
||
? this.fmtPricePerKWh(value, this.currency, false, false)
|
||
: this.fmtNumber(value, 0),
|
||
color: colors.muted,
|
||
maxTicksLimit: 6,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
},
|
||
},
|
||
};
|
||
</script>
|