evcc-io/assets/js/components/Sessions/CostHistoryChart.vue
Maschga d07a0741ec
chore: use vite+ (#32297)
Co-authored-by: Michael Geers <michael@geers.tv>
2026-07-31 11:43:57 +02:00

389 lines
11 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" :device-colors="deviceColors" />
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { Bar } from "vue-chartjs";
import {
BarController,
BarElement,
CategoryScale,
Legend,
LinearScale,
LineController,
LineElement,
Tooltip,
type ChartData,
type ScriptableContext,
type TooltipItem,
} 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, type Session } from "./types";
import { CURRENCY, type DeviceColors } from "@/types/evcc";
registerChartComponents([
BarController,
BarElement,
CategoryScale,
Legend,
LinearScale,
LineController,
LineElement,
Tooltip,
]);
export default defineComponent({
name: "CostHistoryChart",
components: { Bar, LegendList },
mixins: [formatter],
props: {
sessions: { type: Array as PropType<Session[]>, default: () => [] },
groupBy: { type: String as PropType<GROUPS>, default: GROUPS.NONE },
costType: { type: String as PropType<TYPES>, default: TYPES.PRICE },
period: { type: String as PropType<PERIODS>, default: PERIODS.TOTAL },
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
},
computed: {
firstDay() {
if (this.sessions.length === 0) {
return null;
}
return new Date(this.sessions[0]!.created);
},
month() {
return (this.firstDay?.getMonth() || 0) + 1;
},
year() {
return this.firstDay?.getFullYear() || 0;
},
lastDay() {
if (this.sessions.length === 0) {
return null;
}
return new Date(this.sessions[this.sessions.length - 1]!.created);
},
chartData(): ChartData<"bar", number[], unknown> {
console.log("update cost history data");
const result: Array<{
[key: string]: number;
totalCost: number;
totalKWh: number;
avgCost: number;
}> = [];
const groups: Set<string> = new Set();
if (!this.firstDay || !this.lastDay) {
return { labels: [], datasets: [] };
}
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] = { totalCost: 0, totalKWh: 0, avgCost: 0 };
}
// 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 || 0
: (session.co2PerKWh || 0) * (session.chargedEnergy || 0);
const item = result[index]!;
item[groupKey] = (item[groupKey] || 0) + value;
item.totalCost = (item.totalCost || 0) + value;
item.totalKWh = (item.totalKWh || 0) + session.chargedEnergy;
item.avgCost = item.totalCost / item.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" as const,
backgroundColor,
label,
data: Object.values(result).map((index) => index[group] || 0),
borderRadius: (context: ScriptableContext<"bar">) => {
const threshold = 0.04; // 400 Wh
const { dataIndex, datasetIndex } = context;
const currentValue = context.dataset.data[dataIndex] as number;
const previousValuesExist = context.chart.data.datasets
.filter((dataset) => dataset.type === "bar")
.slice(datasetIndex + 1)
.some((dataset: any) => (dataset?.data[dataIndex] || 0) > threshold);
return (
currentValue > threshold && !previousValuesExist
? { topLeft: 10, topRight: 10 }
: { topLeft: 0, topRight: 0 }
) as any;
},
};
});
// add average price line
const costColor = this.costType === TYPES.PRICE ? colors.pricePerKWh : colors.co2PerKWh;
datasets.push({
type: "line" as const,
label:
this.costType === TYPES.PRICE
? this.$t("sessions.avgPrice")
: this.$t("sessions.co2"),
data: Object.values(result).map((index) =>
index.totalKWh > 0 ? index.avgCost : null
),
yAxisID: "y1",
tension: 0.25,
pointRadius: 0,
pointHoverRadius: 6,
borderColor: costColor,
backgroundColor: costColor,
borderWidth: 2,
spanGaps: true,
} as any);
return {
labels: Object.keys(result),
datasets: datasets,
};
},
legends() {
const pickable = this.groupBy !== GROUPS.NONE;
return this.chartData.datasets.map((dataset) => {
let value = null;
let type: "area" | "line" = "area";
// line chart handling
if ((dataset as any).type === "line") {
const items = dataset.data.filter((v) => v !== null);
const min = Math.min(...items);
const max = Math.max(...items);
const format = (value: number, withUnit: boolean) => {
return this.costType === TYPES.PRICE
? this.fmtPricePerKWh(value, this.currency, false, withUnit)
: withUnit
? this.fmtCo2Medium(value)
: this.fmtGrams(value, false);
};
value = `${format(min, false)} – ${format(max, true)}`;
type = "line";
} 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,
type,
id: pickable && type !== "line" ? dataset.label || undefined : undefined,
};
});
},
maxBarCost() {
if (this.costType !== TYPES.PRICE) return 0;
const barDatasets = this.chartData.datasets.filter(
(d: any) => (d as any).type === "bar"
);
const labelCount = this.chartData.labels?.length || 0;
let max = 0;
for (let i = 0; i < labelCount; i++) {
const total = barDatasets.reduce(
(sum: number, d: any) => sum + ((d.data[i] as number) || 0),
0
);
if (total > max) max = total;
}
return max;
},
options() {
// capture vue component this to be used in chartjs callbacks
// oxlint-disable-next-line @typescript-eslint/no-this-alias
const vThis = this;
return {
...commonOptions,
locale: this.$i18n?.locale,
color: colors.text || "",
borderSkipped: false,
maxBarThickness: 40,
animation: false as const,
plugins: {
...commonOptions.plugins,
tooltip: {
...commonOptions.plugins.tooltip,
axis: "x",
positioner: (context: any) => {
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: TooltipItem<"bar">[]) => {
const { label } = tooltipItem[0] || { label: "" };
if (this.period === PERIODS.TOTAL) {
return label;
} else if (this.period === PERIODS.YEAR) {
const date = new Date(this.year, Number(label) - 1, 1);
return this.fmtMonth(date, false);
} else {
const date = new Date(this.year, this.month - 1, Number(label));
return this.fmtDayMonth(date);
}
},
label: (tooltipItem: TooltipItem<"bar" | "line">) => {
const datasetLabel = tooltipItem.dataset.label || "";
const value = tooltipItem.dataset.data[tooltipItem.dataIndex];
if (typeof value !== "number") {
return undefined;
}
// line datasets have null values
if (tooltipItem.dataset.type === "line") {
const valueFmt =
this.costType === TYPES.PRICE
? this.fmtPricePerKWh(value, this.currency, false)
: this.fmtCo2Medium(value);
return `${datasetLabel}: ${valueFmt}`;
}
return value
? `${datasetLabel}: ${
this.costType === TYPES.PRICE
? this.fmtMoney(value, this.currency, true, true)
: this.fmtGrams(value)
}`
: undefined;
},
labelColor: tooltipLabelColor(false),
},
itemSort(a: TooltipItem<"bar">, b: TooltipItem<"bar">) {
return b.datasetIndex - a.datasetIndex;
},
},
},
scales: {
x: {
stacked: true,
border: { display: false },
grid: { display: false },
ticks: {
color: colors.muted,
callback(value: number): string {
return vThis.period === PERIODS.YEAR
? vThis.fmtMonth(new Date(vThis.year, value, 1), true)
: (this as any).getLabelForValue(value);
},
},
},
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: number) => {
if (this.costType === TYPES.PRICE) {
const showDecimals = this.maxBarCost < 4;
return this.fmtMoney(value, this.currency, showDecimals, true);
} else {
return this.fmtNumber(value / 1e3, 0);
}
},
color: colors.muted,
maxTicksLimit: 6,
},
suggestedMin: 0,
},
y1: {
position: "left",
border: { display: false },
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: number) =>
this.costType === TYPES.PRICE
? this.fmtPricePerKWh(value, this.currency, false, false)
: this.fmtNumber(value, 0),
color: colors.muted,
maxTicksLimit: 6,
},
},
},
} as any;
},
},
});
</script>