evcc-io/assets/js/components/Sessions/CostHistoryChart.vue

321 lines
9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>