evcc-io/assets/js/components/ForecastChart.vue
2025-02-16 16:14:42 +01:00

417 lines
10 KiB
Vue

<template>
<div>
<div
class="overflow-x-auto overflow-x-md-hidden chart-container border-1"
@mouseleave="onMouseLeave"
>
<div style="position: relative; height: 220px" class="chart user-select-none">
<Bar ref="chart" :data="chartData" :options="options" />
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { Bar } from "vue-chartjs";
import {
BarController,
BarElement,
LineController,
LineElement,
LinearScale,
TimeSeriesScale,
Legend,
Tooltip,
PointElement,
Filler,
} from "chart.js";
import ChartDataLabels from "chartjs-plugin-datalabels";
import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm";
import { registerChartComponents, commonOptions } from "./Sessions/chartConfig";
import formatter, { POWER_UNIT } from "../mixins/formatter";
import colors, { lighterColor } from "../colors";
import {
energyByDay,
highestSlotIndexByDay,
type PriceSlot,
ForecastType,
} from "../utils/forecast";
registerChartComponents([
BarController,
BarElement,
LineController,
LineElement,
Filler,
LinearScale,
TimeSeriesScale,
Legend,
Tooltip,
PointElement,
ChartDataLabels,
]);
export default defineComponent({
name: "ForecastChart",
components: { Bar },
mixins: [formatter],
props: {
grid: { type: Array as PropType<PriceSlot[]> },
solar: { type: Array as PropType<PriceSlot[]> },
co2: { type: Array as PropType<PriceSlot[]> },
currency: { type: String as PropType<string> },
selected: { type: String as PropType<ForecastType> },
},
emits: ["selected"],
data(): {
selectedIndex: number | null;
ignoreEvents: boolean;
ignoreEventsTimeout: ReturnType<typeof setTimeout> | null;
} {
return {
selectedIndex: null,
ignoreEvents: false,
ignoreEventsTimeout: null,
};
},
computed: {
startDate() {
const now = new Date();
const slots = this.grid || this.co2 || this.solar || [];
const currentSlot = slots.find(({ start, end }) => {
return new Date(start) <= now && new Date(end) > now;
});
if (currentSlot) {
return new Date(currentSlot.start);
}
return now;
},
solarSlots() {
return this.filterSlots(this.solar);
},
gridSlots() {
return this.filterSlots(this.grid);
},
co2Slots() {
return this.filterSlots(this.co2);
},
currentSlots() {
switch (this.selected) {
case ForecastType.Price:
return this.gridSlots;
case ForecastType.Solar:
return this.solarSlots;
case ForecastType.Co2:
return this.co2Slots;
default:
return [];
}
},
selectedSlot() {
return this.selectedIndex !== null ? this.currentSlots[this.selectedIndex] : null;
},
maxPriceIndex() {
return this.maxIndex(this.gridSlots);
},
minPriceIndex() {
return this.minIndex(this.gridSlots);
},
maxCo2Index() {
return this.maxIndex(this.co2Slots);
},
minCo2Index() {
return this.minIndex(this.co2Slots);
},
maxSolarIndex() {
return this.maxIndex(this.solarSlots);
},
solarHighlights() {
return [0, 1, 2].map((day) => {
const energy = energyByDay(this.solarSlots, day);
const index = highestSlotIndexByDay(this.solarSlots, day);
return { index, energy };
});
},
chartData() {
const datasets: unknown[] = [];
if (this.solarSlots.length > 0) {
const active = this.selected === ForecastType.Solar;
const color = active ? colors.self : colors.border;
datasets.push({
label: ForecastType.Solar,
type: "line",
data: this.solarSlots.map((slot, index) => ({
y: slot.price,
x: new Date(slot.start),
highlight:
active &&
(this.selectedIndex !== null
? this.selectedIndex === index
: this.solarHighlights.find(({ index: i }) => i === index)?.energy),
})),
yAxisID: "yForecast",
backgroundColor: lighterColor(color),
borderColor: color,
fill: "origin",
tension: 0.5,
pointRadius: 0,
pointHoverRadius: active ? 4 : 0,
spanGaps: true,
order: active ? 0 : 1,
});
}
if (this.gridSlots.length > 0) {
const active = this.selected === ForecastType.Price;
const color = active ? colors.price : colors.border;
datasets.push({
label: ForecastType.Price,
data: this.gridSlots.map((slot, index) => ({
y: slot.price,
x: new Date(slot.start),
highlight:
active &&
(this.selectedIndex !== null
? this.selectedIndex === index
: index === this.maxPriceIndex || index === this.minPriceIndex),
})),
yAxisID: "yPrice",
borderRadius: 8,
backgroundColor: color,
borderColor: color,
order: active ? 0 : 1,
});
}
if (this.co2Slots.length > 0) {
const active = this.selected === ForecastType.Co2;
const color = active ? colors.co2 : colors.border;
datasets.push({
label: ForecastType.Co2,
type: "line",
data: this.co2Slots.map((slot, index) => {
const dataActive =
active &&
(this.selectedIndex !== null
? this.selectedIndex === index
: index === this.maxCo2Index || index === this.minCo2Index);
return {
y: slot.price,
x: new Date(slot.start),
highlight: dataActive,
active: dataActive,
};
}),
yAxisID: "yCo2",
backgroundColor: color,
borderColor: color,
tension: 0.25,
pointRadius: 0,
pointHoverRadius: active ? 4 : 0,
spanGaps: true,
order: active ? 0 : 1,
});
}
return {
datasets,
};
},
options() {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const vThis = this;
return {
...commonOptions,
locale: this.$i18n?.locale,
layout: { padding: { top: 32 } },
color: colors.text,
borderSkipped: false,
animation: {
duration: 500, // --evcc-transition-medium
colors: true,
numbers: false,
},
interaction: {
mode: "index",
axis: "x",
intersect: false,
},
categoryPercentage: 0.7,
events: ["mousemove", "click", "touchstart", "touchend"],
onHover: function (event, active) {
if (["touchend", "click"].includes(event.type)) {
vThis.selectIndex(null, true);
return;
}
const element = active.find(({ datasetIndex }) => {
const { label } = event.chart.getDatasetMeta(datasetIndex);
return label === vThis.selected;
});
vThis.selectIndex(element ? element.index : null);
},
plugins: {
...commonOptions.plugins,
datalabels: {
backgroundColor: function (context) {
return context.dataset.borderColor;
},
align: function (context) {
// rotate label position to avoid horizontal clipping
const step = 20;
const adjust = {
0: step * 2,
1: step * 1,
46: step * -1,
47: step * -2,
};
return -90 + (adjust[context.dataIndex] || 0);
},
anchor: "end",
offset: 8,
padding: function (context) {
const data = context.dataset.data[context.dataIndex];
const x = typeof data.highlight === "number" ? 32 : 8;
return {
x,
y: 4,
};
},
borderRadius: 4,
color: colors.background,
font: { weight: "bold" },
formatter: function (data, ctx) {
if (data.highlight) {
switch (ctx.dataset.label) {
case ForecastType.Price:
return vThis.fmtPricePerKWh(
data.y,
vThis.currency,
true,
true
);
case ForecastType.Co2:
return vThis.fmtGrams(data.y);
case ForecastType.Solar:
if (data.highlight === true) {
return vThis.fmtW(data.y, POWER_UNIT.AUTO);
} else {
return vThis.fmtWh(data.highlight, POWER_UNIT.AUTO);
}
default:
return null;
}
}
return null;
},
},
tooltip: null,
},
scales: {
x: {
type: "timeseries",
display: true,
time: { unit: "day" },
border: { display: false },
grid: {
display: true,
color: colors.border,
offset: false,
lineWidth: function (context) {
if (context.type !== "tick") {
return 0;
}
const label = context.tick?.label;
return Array.isArray(label) ? 1 : 0;
},
},
ticks: {
color: colors.muted,
autoSkip: false,
maxRotation: 0,
minRotation: 0,
source: "data",
align: "center",
callback: function (value) {
const date = new Date(value);
const hour = date.getHours();
const hourFmt = vThis.hourShort(date);
if (hour === 0) {
return [hourFmt, vThis.weekdayShort(date)];
}
if (hour % 6 === 0) {
return hourFmt;
}
return "";
},
},
},
yForecast: { display: false, min: 0, max: this.yMax(this.solarSlots) },
yCo2: { display: false, min: 0, max: this.yMax(this.co2Slots) },
yPrice: { display: false, min: 0, max: this.yMax(this.gridSlots) },
},
};
},
},
watch: {
selectedSlot(slot) {
this.$emit("selected", slot);
},
},
methods: {
filterSlots(slots: PriceSlot[] = []) {
return slots.filter((slot) => new Date(slot.end) > this.startDate).slice(0, 48);
},
onMouseLeave() {
this.selectIndex(null, true);
},
selectIndex(index: number | null, timeout = false) {
if (this.ignoreEvents) return;
this.selectedIndex = index;
// reset hover state (points, highlights)
if (this.selectedIndex === null) {
this.$nextTick(() => {
// @ts-expect-error unknown chart type
this.$refs.chart?.chart?.setActiveElements([]);
});
}
// ignore events after selection reset because chart.js triggers delayed mousemove events
if (timeout) {
this.ignoreEvents = true;
this.ignoreEventsTimeout = setTimeout(() => {
this.ignoreEvents = false;
}, 100);
}
},
yMax(slots: PriceSlot[] = []): number | undefined {
const value = this.maxValue(slots);
return value ? value * 1.15 : undefined;
},
maxIndex(slots: PriceSlot[] = []) {
return slots.reduce((max, slot, index) => {
return slot.price > slots[max].price ? index : max;
}, 0);
},
minIndex(slots: PriceSlot[] = []) {
return slots.reduce((min, slot, index) => {
return slot.price < slots[min].price ? index : min;
}, 0);
},
maxValue(slots: PriceSlot[] = []) {
return slots[this.maxIndex(slots)]?.price || null;
},
},
});
</script>
<style scoped>
.chart {
width: 780px;
}
@media (min-width: 992px) {
.chart {
width: 100%;
}
}
</style>