UI Sessions: migrate charts from Chart.js to ECharts (#32625)

This commit is contained in:
Michael Geers 2026-08-10 17:09:51 +02:00 • committed by GitHub
parent 285b01f794
commit 136809f9b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 921 additions and 1026 deletions

View file

@ -3,23 +3,21 @@
</template>
<script lang="ts">
import { defineComponent, markRaw, type PropType } from "vue";
import { defineComponent, type PropType } from "vue";
import {
echarts,
FONT_FAMILY,
forecastXAxes,
forecastYAxis,
registerTouchTooltip,
tooltipStyle,
tooltipTable,
type TooltipRow,
} from "../Forecast/echarts";
import colors, { dimColor, setAlpha, batteryColor } from "@/colors";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import echartsChart from "@/mixins/echartsChart";
import { is12hFormat } from "@/units";
import type { SocPoint, BatterySeries } from "./types";
type EChartsType = ReturnType<typeof echarts.init>;
type Point = [number, number];
const GRID = { top: 10, right: 36, bottom: 34, left: 0 };
@ -46,7 +44,7 @@ function hatchPattern(color: string) {
export default defineComponent({
name: "BatteryHistoryChart",
mixins: [formatter],
mixins: [formatter, echartsChart],
props: {
batteries: { type: Array as PropType<BatterySeries[]>, default: () => [] },
mode: { type: String as PropType<"soc" | "energy">, default: "soc" },
@ -58,7 +56,6 @@ export default defineComponent({
},
data() {
return {
chart: null as EChartsType | null,
previousMode: "soc" as string,
prevWinStart: 0,
prevWinEnd: 0,
@ -200,52 +197,9 @@ export default defineComponent({
return [hourAxis, dayAxis];
},
},
watch: {
chartOption: {
handler() {
const modeChanged = this.previousMode !== this.mode;
const windowChanged =
this.prevWinStart !== this.winStart || this.prevWinEnd !== this.winEnd;
// a running slide re-renders from the latest chartOption every frame, so data that
// loads mid-slide is picked up automatically. only intervene if the target changed.
if (this.slideRaf && !windowChanged && !modeChanged) return;
// animate only on real paging (dayOffset change); forecast arrival and clock
// advance also shift the window but must not trigger the slide
const paging =
this.hasRendered && !modeChanged && this.dayOffset !== this.prevDayOffset;
const fromStart = this.prevWinStart;
const fromEnd = this.prevWinEnd;
this.cancelSlide();
this.previousMode = this.mode;
this.prevWinStart = this.winStart;
this.prevWinEnd = this.winEnd;
this.prevDayOffset = this.dayOffset;
if (paging) {
this.slideWindow(fromStart, fromEnd, this.winStart, this.winEnd);
} else {
this.chart?.setOption(
{ ...this.chartOption, animation: false },
modeChanged ? { notMerge: true } : { replaceMerge: ["series"] }
);
this.hasRendered = true;
this.$nextTick(() => this.updateGraphic());
}
},
deep: true,
},
},
mounted() {
const el = this.$refs["chartEl"] as HTMLElement;
this.chart = markRaw(echarts.init(el));
this.chart.setOption(this.chartOption);
registerTouchTooltip(this.chart, el);
this.$nextTick(() => this.updateGraphic());
window.addEventListener("resize", this.resize);
// initial render done here (not via the watcher), so paging animates from the first click
// initial render done in the mixin (not via the watcher), so paging animates from the first click
this.hasRendered = true;
this.prevWinStart = this.winStart;
this.prevWinEnd = this.winEnd;
@ -253,11 +207,42 @@ export default defineComponent({
this.previousMode = this.mode;
},
beforeUnmount() {
window.removeEventListener("resize", this.resize);
this.cancelSlide();
this.chart?.dispose();
},
methods: {
applyChartOption() {
const modeChanged = this.previousMode !== this.mode;
const windowChanged =
this.prevWinStart !== this.winStart || this.prevWinEnd !== this.winEnd;
// a running slide re-renders from the latest chartOption every frame, so data that
// loads mid-slide is picked up automatically. only intervene if the target changed.
if (this.slideRaf && !windowChanged && !modeChanged) return;
// animate only on real paging (dayOffset change); forecast arrival and clock
// advance also shift the window but must not trigger the slide
const paging =
this.hasRendered && !modeChanged && this.dayOffset !== this.prevDayOffset;
const fromStart = this.prevWinStart;
const fromEnd = this.prevWinEnd;
this.cancelSlide();
this.previousMode = this.mode;
this.prevWinStart = this.winStart;
this.prevWinEnd = this.winEnd;
this.prevDayOffset = this.dayOffset;
if (paging) {
this.slideWindow(fromStart, fromEnd, this.winStart, this.winEnd);
} else {
this.chart?.setOption(
{ ...this.chartOption, animation: false },
modeChanged ? { notMerge: true } : { replaceMerge: ["series"] }
);
this.hasRendered = true;
this.$nextTick(() => this.updateGraphic());
}
},
// stable echarts series id per battery/part; single source for building and tooltip lookup
seriesId(id: string, part: "hist" | "fc"): string {
return `${this.mode}-${id}-${part}`;

View file

@ -1,11 +1,9 @@
import { defineComponent, markRaw } from "vue";
import { echarts, registerTouchTooltip } from "./echarts";
import { defineComponent } from "vue";
import echartsChart from "@/mixins/echartsChart";
import "./chartStyles.css";
// chartOption is provided by each consuming component's computed
type WithChartOption = { chartOption: Record<string, unknown> };
export default defineComponent({
mixins: [echartsChart],
props: {
chartWidth: { type: Number, required: true },
endDate: { type: Date, required: true },
@ -13,23 +11,15 @@ export default defineComponent({
},
emits: ["scroll"],
data(): {
chart: echarts.ECharts | null;
startDate: Date;
tooltipVisible: boolean;
} {
return {
chart: null,
tooltipVisible: false,
startDate: new Date(),
};
},
watch: {
chartOption: {
handler() {
this.chart?.setOption((this as unknown as WithChartOption).chartOption);
},
deep: true,
},
scrollLeft(val: number) {
const el = this.$refs["scrollEl"] as HTMLElement;
if (el && Math.abs(el.scrollLeft - val) > 1) {
@ -37,12 +27,9 @@ export default defineComponent({
}
},
},
mounted() {
created() {
// before the first chart render so chartOption sees the rounded date
this.updateStartDate();
this.initChart();
},
beforeUnmount() {
this.chart?.dispose();
},
methods: {
updateStartDate() {
@ -53,17 +40,16 @@ export default defineComponent({
onScroll(e: Event) {
this.$emit("scroll", (e.target as HTMLElement).scrollLeft);
},
initChart() {
const el = this.$refs["chartEl"] as HTMLElement;
this.chart = markRaw(echarts.init(el));
this.chart.setOption((this as unknown as WithChartOption).chartOption);
this.chart.on("showTip", () => {
onChartInit() {
this.chart?.on("showTip", () => {
this.tooltipVisible = true;
});
this.chart.on("hideTip", () => {
this.chart?.on("hideTip", () => {
this.tooltipVisible = false;
});
registerTouchTooltip(this.chart, el, () => (this.tooltipVisible = false));
},
onTouchTooltipReset() {
this.tooltipVisible = false;
},
},
});

View file

@ -85,6 +85,8 @@ export function tooltipStyle(
) {
return {
confine: true,
// re-show after hide would otherwise slide in from the stale position
transitionDuration: 0,
backgroundColor: color,
borderColor: color,
borderWidth: 0,

View file

@ -5,13 +5,11 @@
</template>
<script lang="ts">
import { defineComponent, markRaw, type PropType } from "vue";
import { defineComponent, type PropType } from "vue";
import {
echarts,
FONT_FAMILY,
forecastGrid,
forecastYAxis,
registerTouchTooltip,
tooltipStyle,
tooltipTable,
xAxisLabelStyle,
@ -20,6 +18,7 @@ import {
import colors, { resolveColors, deviceColorMap, darken, batteryColor, setAlpha } from "@/colors";
import store from "@/store";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import echartsChart from "@/mixins/echartsChart";
import { PERIODS } from "../Sessions/types";
import { is12hFormat } from "@/units";
import { hasColorPicker } from "./groups";
@ -71,7 +70,7 @@ function niceCeil(v: number): number {
export default defineComponent({
name: "GroupChart",
mixins: [formatter],
mixins: [formatter, echartsChart],
props: {
group: { type: String, required: true },
color: { type: String, required: true },
@ -86,7 +85,6 @@ export default defineComponent({
to: { type: Date, required: true },
},
data(): {
chart: echarts.ECharts | null;
isMobile: boolean;
mediaQuery: MediaQueryList | null;
previousFocusedEntity: number | null;
@ -95,7 +93,6 @@ export default defineComponent({
activeSlot: number | null;
} {
return {
chart: null,
isMobile: false,
mediaQuery: null,
previousFocusedEntity: this.focusedEntity as number | null,
@ -678,62 +675,50 @@ export default defineComponent({
};
},
},
watch: {
chartOption: {
handler() {
const opt = (this as unknown as WithChartOption).chartOption;
const focusChanged = this.previousFocusedEntity !== this.focusedEntity;
const periodChanged = this.previousPeriod !== this.period;
// Fingerprint the set of series IDs in their render order so we can
// detect when entities are added or removed (e.g. a filtered loadpoint
// re-appears after navigating to a new day).
const newSeriesKey = (opt["series"] as Array<{ id?: string }>)
.map((s) => s.id ?? "")
.join(",");
// Full reset on period/composition change — replaceMerge re-appends
// re-introduced series at the end and flips stack order. Otherwise
// partial update lets stable IDs animate value transitions.
const fullReset = periodChanged || newSeriesKey !== this.previousSeriesKey;
this.chart?.setOption(
fullReset
? opt
: {
animation: !focusChanged,
xAxis: opt["xAxis"],
yAxis: opt["yAxis"],
series: opt["series"],
tooltip: opt["tooltip"],
},
fullReset ? { notMerge: true } : { replaceMerge: ["series", "yAxis"] }
);
this.previousFocusedEntity = this.focusedEntity as number | null;
this.previousPeriod = this.period as PERIODS;
this.previousSeriesKey = newSeriesKey;
},
deep: true,
},
},
mounted() {
const el = this.$refs["chartEl"] as HTMLElement;
this.chart = markRaw(echarts.init(el));
this.chart.setOption((this as unknown as WithChartOption).chartOption);
const zr = this.chart.getZr();
zr.on("mousemove", this.onChartMouseMove);
zr.on("globalout", this.clearHighlight);
registerTouchTooltip(this.chart, el, this.clearHighlight);
const zr = this.chart?.getZr();
zr?.on("mousemove", this.onChartMouseMove);
zr?.on("globalout", this.clearHighlight);
this.mediaQuery = window.matchMedia("(max-width: 575.98px)");
this.isMobile = this.mediaQuery.matches;
this.mediaQuery.addEventListener("change", this.onMediaChange);
window.addEventListener("resize", this.resize);
},
beforeUnmount() {
window.removeEventListener("resize", this.resize);
this.mediaQuery?.removeEventListener("change", this.onMediaChange);
this.chart?.dispose();
},
methods: {
resize() {
this.chart?.resize();
applyChartOption() {
const opt = (this as unknown as WithChartOption).chartOption;
const focusChanged = this.previousFocusedEntity !== this.focusedEntity;
const periodChanged = this.previousPeriod !== this.period;
// Fingerprint the set of series IDs in their render order so we can
// detect when entities are added or removed (e.g. a filtered loadpoint
// re-appears after navigating to a new day).
const newSeriesKey = (opt["series"] as Array<{ id?: string }>)
.map((s) => s.id ?? "")
.join(",");
// Full reset on period/composition change — replaceMerge re-appends
// re-introduced series at the end and flips stack order. Otherwise
// partial update lets stable IDs animate value transitions.
const fullReset = periodChanged || newSeriesKey !== this.previousSeriesKey;
this.chart?.setOption(
fullReset
? opt
: {
animation: !focusChanged,
xAxis: opt["xAxis"],
yAxis: opt["yAxis"],
series: opt["series"],
tooltip: opt["tooltip"],
},
fullReset ? { notMerge: true } : { replaceMerge: ["series", "yAxis"] }
);
this.previousFocusedEntity = this.focusedEntity as number | null;
this.previousPeriod = this.period as PERIODS;
this.previousSeriesKey = newSeriesKey;
},
onTouchTooltipReset() {
this.clearHighlight();
},
// highlight hovered slot, dim rest. manual because built-in axis highlight hard-codes notBlur
onChartMouseMove(e: { offsetX: number; offsetY: number }) {

View file

@ -1,7 +1,7 @@
<template>
<div v-if="chartData.labels.length > 1" class="row">
<div class="col-12 col-md-6 col-lg-12 col-xxl-6 mb-3">
<PolarArea :data="chartData" :options="options" />
<div ref="chartEl" class="round-chart"></div>
</div>
<div class="col-12 col-md-6 col-lg-12 col-xxl-6 d-flex align-items-center">
<LegendList :legends="legends" :device-colors="deviceColors" grid />
@ -10,22 +10,19 @@
</template>
<script lang="ts">
import { PolarArea } from "vue-chartjs";
import { RadialLinearScale, ArcElement, Legend, Tooltip, type TooltipItem } from "chart.js";
import { registerChartComponents, commonOptions, tooltipLabelColor } from "./chartConfig.ts";
import { defineComponent, type PropType } from "vue";
import { FONT_FAMILY, topBottomCenterPosition, tooltipStyle, tooltipTable } from "./echarts";
import echartsChart from "@/mixins/echartsChart";
import formatter from "@/mixins/formatter";
import colors, { dimColor } from "@/colors";
import LegendList from "./LegendList.vue";
import { defineComponent, type PropType } from "vue";
import type { CURRENCY, DeviceColors } from "@/types/evcc";
import { TYPES, GROUPS, type Session } from "./types.ts";
registerChartComponents([RadialLinearScale, ArcElement, Legend, Tooltip]);
export default defineComponent({
name: "AvgCostGroupedChart",
components: { PolarArea, LegendList },
mixins: [formatter],
components: { LegendList },
mixins: [formatter, echartsChart],
props: {
sessions: { type: Array as PropType<Session[]>, default: () => [] },
currency: { type: String as PropType<CURRENCY>, default: "EUR" },
@ -35,12 +32,10 @@ export default defineComponent({
},
colorMappings: { type: Object, default: () => ({ loadpoint: {}, vehicle: {} }) },
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
suggestedMax: { type: Number, default: 0 },
costType: { type: String as PropType<TYPES>, default: TYPES.PRICE },
},
computed: {
chartData() {
console.log(`update ${this.costType} grouped data`);
chartData(): { labels: string[]; data: number[]; colors: string[] } {
const aggregatedData: Record<string, { energy: number; cost: number }> = {};
this.sessions.forEach((session) => {
@ -58,87 +53,78 @@ export default defineComponent({
}
});
const sortedEntries = Object.entries(aggregatedData).sort(
(a, b) => b[1].cost - a[1].cost
// stable alphabetical order so entries don't jump between periods
const sortedEntries = Object.entries(aggregatedData).sort((a, b) =>
a[0].localeCompare(b[0])
);
const labels = sortedEntries.map(([label]) => label);
const data = sortedEntries.map(([, value]) => value.cost / value.energy);
const entryColors = labels.map((label) => this.colorMappings[this.groupBy][label]);
const borderColors = labels.map((label) => this.colorMappings[this.groupBy][label]);
const backgroundColors = borderColors.map((color) => dimColor(color));
return {
labels: labels,
datasets: [
{
data: data,
borderColor: borderColors,
backgroundColor: backgroundColors,
},
],
};
return { labels, data, colors: entryColors };
},
legends() {
return this.chartData.labels.map((label, index) => {
const dataset = this.chartData.datasets[0]!;
const dataValue = dataset.data[index] as number;
return {
label: label,
color: dataset.borderColor[index],
value: this.formatValue(dataValue),
id: label || undefined,
};
});
const { labels, data, colors: entryColors } = this.chartData;
return labels.map((label, index) => ({
label,
color: entryColors[index],
value: this.formatValue(data[index] as number),
id: label || undefined,
}));
},
options() {
chartOption(): Record<string, unknown> {
const { labels, data, colors: entryColors } = this.chartData;
return {
...commonOptions,
locale: this.$i18n?.locale,
aspectRatio: 1,
borderRadius: 8,
borderWidth: 3,
color: colors.text || "",
spacing: 0,
radius: "100%",
plugins: {
...commonOptions.plugins,
tooltip: {
...commonOptions.plugins.tooltip,
axis: "r",
position: "topBottomCenter",
callbacks: {
title: () => null,
label: (tooltipItem: TooltipItem<"polarArea">) => {
const { label, dataset, dataIndex } = tooltipItem;
const d = dataset.data[dataIndex];
return (
label +
": " +
(this.costType === TYPES.CO2
? this.fmtCo2Long(d)
: this.fmtPricePerKWh(d, this.currency))
);
animation: false,
textStyle: { fontFamily: FONT_FAMILY },
tooltip: {
trigger: "item",
...tooltipStyle(colors.text || ""),
position: topBottomCenterPosition(() => this.chart),
formatter: (params: { name: string; value: number }) =>
tooltipTable(params.name, [
{
values: [
this.costType === TYPES.CO2
? this.fmtCo2Long(params.value)
: this.fmtPricePerKWh(params.value, this.currency),
],
},
labelColor: tooltipLabelColor(true),
},
} as any,
]),
},
scales: {
r: {
suggestedMin: 0,
suggestedMax: this.suggestedMax,
beginAtZero: false,
ticks: {
color: colors.muted || "",
backdropColor: colors.box || "",
font: { size: 10 },
callback: this.formatValue,
maxTicksLimit: 6,
},
angleLines: { display: false },
grid: { color: colors.border || "" },
} as any,
polar: { radius: "95%" },
angleAxis: { type: "category", data: labels, show: false, startAngle: 90 },
radiusAxis: {
min: 0,
splitNumber: 4,
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: colors.border || "" } },
axisLabel: {
fontSize: 10,
color: colors.muted || "",
backgroundColor: colors.box || "",
padding: [1, 3],
formatter: this.formatValue,
},
},
series: [
{
type: "bar",
coordinateSystem: "polar",
barCategoryGap: "0%",
data: data.map((value, i) => ({
value,
itemStyle: {
color: dimColor(entryColors[i]),
borderColor: entryColors[i],
borderWidth: 3,
// round outer corners only, sharp apex at the center
borderRadius: [0, 0, 8, 8],
},
})),
},
],
};
},
},

View file

@ -1,7 +1,7 @@
<template>
<div v-if="chartData.labels.length > 1" class="row">
<div class="col-12 col-md-6 col-lg-12 col-xxl-6 mb-3">
<Doughnut :data="chartData" :options="options" />
<div ref="chartEl" class="round-chart"></div>
</div>
<div class="col-12 col-md-6 col-lg-12 col-xxl-6 d-flex align-items-center">
<LegendList :legends="legends" :device-colors="deviceColors" grid />
@ -10,29 +10,18 @@
</template>
<script lang="ts">
import { Doughnut } from "vue-chartjs";
import {
DoughnutController,
ArcElement,
LinearScale,
Legend,
Tooltip,
type TooltipItem,
} from "chart.js";
import LegendList from "./LegendList.vue";
import { registerChartComponents, commonOptions, tooltipLabelColor } from "./chartConfig";
import formatter from "@/mixins/formatter";
import colors from "@/colors";
import { TYPES, GROUPS, type Session } from "./types";
import { defineComponent, type PropType } from "vue";
import { doughnutOption } from "./echarts";
import echartsChart from "@/mixins/echartsChart";
import LegendList from "./LegendList.vue";
import formatter from "@/mixins/formatter";
import { TYPES, GROUPS, type Session } from "./types";
import { CURRENCY, type DeviceColors } from "@/types/evcc";
registerChartComponents([DoughnutController, ArcElement, LinearScale, Legend, Tooltip]);
export default defineComponent({
name: "CostGroupedChart",
components: { Doughnut, LegendList },
mixins: [formatter],
components: { LegendList },
mixins: [formatter, echartsChart],
props: {
sessions: { type: Array as PropType<Session[]>, default: () => [] },
groupBy: {
@ -45,8 +34,7 @@ export default defineComponent({
costType: { type: String as PropType<TYPES>, default: TYPES.PRICE },
},
computed: {
chartData() {
console.log(`update ${this.costType} grouped data`);
chartData(): { labels: string[]; data: number[]; colors: string[] } {
const aggregatedData: Record<string, number> = {};
this.sessions.forEach((session) => {
@ -62,58 +50,37 @@ export default defineComponent({
}
});
const sortedEntries = Object.entries(aggregatedData).sort((a, b) => b[1] - a[1]);
// stable alphabetical order so entries don't jump between periods
const sortedEntries = Object.entries(aggregatedData).sort((a, b) =>
a[0].localeCompare(b[0])
);
const labels = sortedEntries.map(([label]) => label);
const data = sortedEntries.map(([, value]) => value);
const backgroundColor = labels.map((label) => this.colorMappings[this.groupBy][label]);
const entryColors = labels.map((label) => this.colorMappings[this.groupBy][label]);
return {
labels: labels,
datasets: [{ data, backgroundColor }],
};
return { labels, data, colors: entryColors };
},
legends() {
const dataset = this.chartData.datasets[0]!;
const total = dataset.data.reduce((acc, curr) => acc + curr, 0);
const { labels, data, colors: entryColors } = this.chartData;
const total = data.reduce((acc, curr) => acc + curr, 0);
const fmtShare = (value: number) => this.fmtPercentage((100 / total) * value, 1);
return this.chartData.labels.map((label, index) => {
const dataValue = dataset.data[index] as number;
return labels.map((label, index) => {
const dataValue = data[index] as number;
return {
label: label,
color: dataset.backgroundColor[index],
label,
color: entryColors[index],
value: [this.formatValue(dataValue), fmtShare(dataValue)],
id: label || undefined,
};
});
},
options() {
return {
...commonOptions,
locale: this.$i18n?.locale,
aspectRatio: 1,
borderRadius: 10,
color: colors.text || "",
borderWidth: 3,
borderColor: colors.box || "",
cutout: "70%",
radius: "95%",
animation: { duration: 250 },
plugins: {
...commonOptions.plugins,
tooltip: {
...commonOptions.plugins.tooltip,
axis: "r",
position: "center",
callbacks: {
label: (tooltipItem: TooltipItem<"doughnut">) =>
this.formatValue(
tooltipItem.dataset.data[tooltipItem.dataIndex] || 0
),
labelColor: tooltipLabelColor(false),
},
},
},
} as any;
chartOption(): Record<string, unknown> {
const { labels, data, colors: entryColors } = this.chartData;
return doughnutOption(
labels.map((name, i) => ({ name, value: data[i]!, color: entryColors[i]! })),
this.formatValue,
() => this.chart
);
},
},
methods: {

View file

@ -1,80 +1,48 @@
<template>
<div>
<div style="position: relative; height: 300px" class="my-3">
<Bar :data="chartData" :options="options" />
</div>
<div ref="chartEl" class="chart my-3"></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";
FONT_FAMILY,
forecastGrid,
forecastYAxis,
roundedStackData,
tooltipStyle,
tooltipTable,
xAxisLabelStyle,
type TooltipRow,
} from "./echarts";
import historyChart from "./historyChart";
import LegendList from "./LegendList.vue";
import formatter from "@/mixins/formatter";
import colors from "@/colors";
import { TYPES, GROUPS, PERIODS, type Session } from "./types";
import { TYPES, GROUPS } from "./types";
import { CURRENCY, type DeviceColors } from "@/types/evcc";
registerChartComponents([
BarController,
BarElement,
CategoryScale,
Legend,
LinearScale,
LineController,
LineElement,
Tooltip,
]);
interface Dataset {
type: "bar" | "line";
label: string;
color: string;
data: (number | null)[];
}
export default defineComponent({
name: "CostHistoryChart",
components: { Bar, LegendList },
mixins: [formatter],
components: { LegendList },
mixins: [historyChart],
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");
chartData(): { labels: string[]; datasets: Dataset[] } {
const result: Array<{
[key: string]: number;
totalCost: number;
@ -83,116 +51,69 @@ export default defineComponent({
}> = [];
const groups: Set<string> = new Set();
if (!this.firstDay || !this.lastDay) {
const range = this.bucketRange;
if (!range) {
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;
});
for (let i = range[0]; i <= range[1]; i++) {
result[i] = { totalCost: 0, totalKWh: 0, avgCost: 0 };
}
const datasets = Array.from(groups).map((group) => {
this.sessions.forEach((session) => {
const index = this.bucketIndex(new Date(session.created));
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;
});
// stable alphabetical order so entries don't jump between periods
const sortedGroups = Array.from(groups).sort((a, b) => a.localeCompare(b));
const datasets: Dataset[] = sortedGroups.map((group) => {
const colorGroup = this.groupBy === GROUPS.NONE ? "cost" : this.groupBy;
const backgroundColor = this.colorMappings[colorGroup][group];
const color = this.colorMappings[colorGroup][group];
const label =
this.groupBy === GROUPS.NONE ? this.$t(`sessions.group.${group}`) : group;
return {
type: "bar" as const,
backgroundColor,
type: "bar",
color,
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;
const costColor =
(this.costType === TYPES.PRICE ? colors.pricePerKWh : colors.co2PerKWh) || "";
datasets.push({
type: "line" as const,
type: "line",
label:
this.costType === TYPES.PRICE
? this.$t("sessions.avgPrice")
: this.$t("sessions.co2"),
color: costColor,
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,
datasets,
};
},
legends() {
@ -202,8 +123,8 @@ export default defineComponent({
let type: "area" | "line" = "area";
// line chart handling
if ((dataset as any).type === "line") {
const items = dataset.data.filter((v) => v !== null);
if (dataset.type === "line") {
const items = dataset.data.filter((v): v is number => v !== null);
const min = Math.min(...items);
const max = Math.max(...items);
const format = (value: number, withUnit: boolean) => {
@ -216,15 +137,15 @@ export default defineComponent({
value = `${format(min, false)} – ${format(max, true)}`;
type = "line";
} else {
const total = dataset.data.reduce((acc, curr) => acc + curr, 0);
const total = dataset.data.reduce((acc: number, curr) => acc + (curr || 0), 0);
value =
this.costType === TYPES.PRICE
? this.fmtMoney(total, this.currency, true, true)
: this.fmtGrams(total);
}
return {
label: dataset.label || "",
color: dataset.backgroundColor,
label: dataset.label,
color: dataset.color,
value,
type,
id: pickable && type !== "line" ? dataset.label || undefined : undefined,
@ -233,157 +154,153 @@ export default defineComponent({
},
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;
const barDatasets = this.chartData.datasets.filter((d) => d.type === "bar");
const labelCount = this.chartData.labels.length;
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
);
const total = barDatasets.reduce((sum, d) => sum + (d.data[i] || 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/no-this-alias
const vThis = this;
chartOption(): Record<string, unknown> {
const { labels, datasets } = this.chartData;
const head = this.tooltipHead;
const isPrice = this.costType === TYPES.PRICE;
const fmtBarValue = (value: number) =>
isPrice ? this.fmtMoney(value, this.currency, true, true) : this.fmtGrams(value);
const fmtLineValue = (value: number) =>
isPrice
? this.fmtPricePerKWh(value, this.currency, false)
: this.fmtCo2Medium(value);
const barDatasets = datasets.filter((d) => d.type === "bar");
const stackData = roundedStackData(barDatasets, labels.length);
const line = datasets.find((d) => d.type === "line");
const series: Record<string, unknown>[] = barDatasets.map((dataset, i) => ({
name: dataset.label,
type: "bar",
stack: "cost",
barMaxWidth: 40,
itemStyle: { color: dataset.color },
data: stackData[i],
}));
if (line) {
series.push({
name: line.label,
type: "line",
yAxisIndex: 1,
data: line.data,
smooth: 0.25,
symbol: "circle",
symbolSize: 12,
showSymbol: false,
connectNulls: true,
lineStyle: { color: line.color, width: 2 },
itemStyle: { color: line.color },
});
}
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;
},
animation: false,
textStyle: { fontFamily: FONT_FAMILY },
grid: { ...forecastGrid(), left: 36, right: 36 },
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow", shadowStyle: { color: "transparent" } },
...tooltipStyle(colors.text || ""),
// read values from chartData, rendered series hide tiny top slivers
formatter: (params: { dataIndex: number }[]) => {
const idx = params?.[0]?.dataIndex;
if (idx == null) return "";
const rows: TooltipRow[] = [];
// avg line first, then bars top of stack first
const lineValue = line?.data[idx];
if (line && lineValue != null) {
rows.push({ name: line.label, values: [fmtLineValue(lineValue)] });
}
[...barDatasets].reverse().forEach((dataset) => {
const value = dataset.data[idx] || 0;
if (!value) return;
rows.push({ name: dataset.label, values: [fmtBarValue(value)] });
});
if (!rows.length) return "";
return tooltipTable(head(labels[idx] ?? ""), rows);
},
},
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);
},
},
xAxis: {
type: "category",
data: labels,
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: {
...xAxisLabelStyle(),
formatter: this.xAxisLabel,
},
y: {
stacked: true,
},
yAxis: [
forecastYAxis({
position: "right",
border: { display: false },
grid: { color: colors.border },
title: {
text: "kgCO₂e",
display: this.costType === TYPES.CO2,
color: colors.muted,
min: 0,
splitLine: {
showMinLine: true,
showMaxLine: true,
lineStyle: { color: colors.border || "" },
},
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,
...(this.costType === TYPES.CO2 ? { name: "kg" } : {}),
nameLocation: "end",
nameGap: 18,
nameTextStyle: {
color: colors.muted || "",
fontFamily: FONT_FAMILY,
fontSize: 10,
opacity: 0.75,
align: "left",
// align with the value labels' left edge (8px default label margin)
padding: [0, 0, 0, 8],
},
suggestedMin: 0,
},
y1: {
axisLabel: {
color: colors.muted || "",
hideOverlap: true,
formatter: (value: number) =>
isPrice
? this.fmtMoney(value, this.currency, this.maxBarCost < 4, true)
: this.fmtNumber(value / 1e3, 0),
},
}),
forecastYAxis({
position: "left",
border: { display: false },
grid: {
drawOnChartArea: false,
splitLine: { show: false },
name: isPrice ? this.pricePerKWhUnit(this.currency, false) : "g/kWh",
nameLocation: "end",
nameGap: 18,
nameTextStyle: {
color: colors.muted || "",
fontFamily: FONT_FAMILY,
fontSize: 10,
opacity: 0.75,
align: "right",
padding: [0, 8, 0, 0],
},
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
axisLabel: {
color: colors.muted || "",
hideOverlap: true,
formatter: (value: number) =>
isPrice
? this.fmtPricePerKWh(value, this.currency, false, false)
: this.fmtNumber(value, 0),
color: colors.muted,
maxTicksLimit: 6,
},
},
},
} as any;
}),
],
series,
};
},
},
});
</script>
<style scoped>
.chart {
width: 100%;
height: 300px;
}
</style>

View file

@ -1,7 +1,7 @@
<template>
<div v-if="chartData.labels.length > 1" class="row">
<div class="col-12 col-md-6 col-lg-12 col-xxl-6 mb-3">
<Doughnut :data="chartData" :options="options" />
<div ref="chartEl" class="round-chart"></div>
</div>
<div class="col-12 col-md-6 col-lg-12 col-xxl-6 d-flex align-items-center">
<LegendList :legends="legends" :device-colors="deviceColors" grid />
@ -11,28 +11,17 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { Doughnut } from "vue-chartjs";
import {
DoughnutController,
ArcElement,
LinearScale,
Legend,
Tooltip,
type TooltipItem,
} from "chart.js";
import { doughnutOption } from "./echarts";
import echartsChart from "@/mixins/echartsChart";
import LegendList from "./LegendList.vue";
import { registerChartComponents, commonOptions, tooltipLabelColor } from "./chartConfig";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import colors from "@/colors";
import { GROUPS, type Session } from "./types";
import type { DeviceColors } from "@/types/evcc";
registerChartComponents([DoughnutController, ArcElement, LinearScale, Legend, Tooltip]);
export default defineComponent({
name: "EnergyGroupedChart",
components: { Doughnut, LegendList },
mixins: [formatter],
components: { LegendList },
mixins: [formatter, echartsChart],
props: {
sessions: { type: Array as PropType<Session[]>, default: () => [] },
groupBy: { type: String as PropType<GROUPS>, default: GROUPS.NONE },
@ -40,8 +29,7 @@ export default defineComponent({
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
},
computed: {
chartData() {
console.log("update energy aggregate data");
chartData(): { labels: string[]; data: number[]; colors: string[] } {
const aggregatedData: { [key: string]: number } = {};
if (this.groupBy === GROUPS.NONE) {
@ -62,69 +50,44 @@ export default defineComponent({
});
}
// Sort the data by energy in descending order
const sortedEntries = Object.entries(aggregatedData); //.sort((a, b) => b[1] - a[1]);
const labels = sortedEntries.map(([label]) =>
// stable alphabetical order so entries don't jump between periods
const entries = Object.entries(aggregatedData).sort((a, b) => a[0].localeCompare(b[0]));
const labels = entries.map(([label]) =>
this.groupBy === GROUPS.NONE ? this.$t(`sessions.group.${label}`) : label
);
const data = sortedEntries.map(([, value]) => value);
const data = entries.map(([, value]) => value);
const colorGroup = this.groupBy === GROUPS.NONE ? "solar" : this.groupBy;
const backgroundColor = sortedEntries.map(
([label]) => this.colorMappings[colorGroup][label]
);
const entryColors = entries.map(([label]) => this.colorMappings[colorGroup][label]);
return {
labels: labels,
datasets: [{ data, backgroundColor }],
};
return { labels, data, colors: entryColors };
},
legends() {
const dataset = this.chartData.datasets[0]!;
const total = dataset.data.reduce((acc, curr) => acc + curr, 0);
const maxEnergy = Math.max(...dataset.data);
const { labels, data, colors: entryColors } = this.chartData;
const total = data.reduce((acc, curr) => acc + curr, 0);
const maxEnergy = Math.max(...data);
// sync energy units for label grid view
const unit =
maxEnergy < 1 ? POWER_UNIT.W : maxEnergy > 1e4 ? POWER_UNIT.MW : POWER_UNIT.KW;
const fmtShare = (value: number) => this.fmtPercentage((100 / total) * value, 1);
const fmtValue = (value: number) => this.fmtWh(value * 1e3, unit);
const pickable = this.groupBy !== GROUPS.NONE;
return this.chartData.labels.map((label, index) => {
const dataValue = dataset.data[index] as number;
return labels.map((label, index) => {
const dataValue = data[index] as number;
return {
label: label,
color: dataset.backgroundColor[index],
label,
color: entryColors[index],
value: [fmtValue(dataValue), fmtShare(dataValue)],
id: pickable ? label || undefined : undefined,
};
});
},
options() {
return {
...commonOptions,
locale: this.$i18n?.locale,
aspectRatio: 1,
borderRadius: 10,
color: colors.text,
borderWidth: 3,
borderColor: colors.box,
cutout: "70%",
radius: "95%",
animation: { duration: 250 },
plugins: {
...commonOptions.plugins,
tooltip: {
...commonOptions.plugins.tooltip,
axis: "r",
position: "center",
callbacks: {
label: (tooltipItem: TooltipItem<"doughnut">) =>
this.formatValue(tooltipItem.raw as number),
labelColor: tooltipLabelColor(false),
},
},
},
} as any;
chartOption(): Record<string, unknown> {
const { labels, data, colors: entryColors } = this.chartData;
return doughnutOption(
labels.map((name, i) => ({ name, value: data[i]!, color: entryColors[i]! })),
this.formatValue,
() => this.chart
);
},
},
methods: {

View file

@ -1,106 +1,57 @@
<template>
<div>
<div style="position: relative; height: 300px" class="my-3">
<Bar :data="chartData" :options="options" />
</div>
<div ref="chartEl" class="chart my-3"></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,
LinearScale,
Legend,
Tooltip,
type ChartData,
type ScriptableContext,
type TooltipModel,
type TooltipItem,
} from "chart.js";
import { registerChartComponents, commonOptions, tooltipLabelColor } from "./chartConfig";
FONT_FAMILY,
forecastGrid,
forecastYAxis,
roundedStackData,
tooltipStyle,
tooltipTable,
xAxisLabelStyle,
type TooltipRow,
} from "./echarts";
import historyChart from "./historyChart";
import LegendList from "./LegendList.vue";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import { POWER_UNIT } from "@/mixins/formatter";
import colors from "@/colors";
import { GROUPS, PERIODS, type Session } from "./types";
import { GROUPS } from "./types";
import type { DeviceColors } from "@/types/evcc";
registerChartComponents([BarController, BarElement, CategoryScale, LinearScale, Legend, Tooltip]);
interface Dataset {
label: string;
color: string;
data: number[];
}
export default defineComponent({
name: "EnergyHistoryChart",
components: { Bar, LegendList },
mixins: [formatter],
components: { LegendList },
mixins: [historyChart],
props: {
sessions: { type: Array as PropType<Session[]>, default: () => [] },
groupBy: { type: String as PropType<GROUPS>, default: GROUPS.NONE },
period: { type: String as PropType<PERIODS>, default: PERIODS.TOTAL },
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 energy history data");
chartData(): { labels: string[]; datasets: Dataset[] } {
const result: Record<number, Record<string, number>> = {};
const groups: Set<string> = new Set();
if (this.firstDay && this.lastDay) {
//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++) {
const range = this.bucketRange;
if (range) {
for (let i = range[0]; i <= range[1]; 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 index = this.bucketIndex(new Date(session.created));
if (this.groupBy === GROUPS.NONE) {
groups.add("grid");
@ -120,40 +71,31 @@ export default defineComponent({
});
}
const datasets = Array.from(groups).map((group) => {
// stable alphabetical order so entries don't jump between periods
const sortedGroups = Array.from(groups).sort((a, b) => a.localeCompare(b));
const datasets = sortedGroups.map((group) => {
const colorGroup = this.groupBy === GROUPS.NONE ? "solar" : this.groupBy;
const backgroundColor = this.colorMappings[colorGroup][group];
const color = this.colorMappings[colorGroup][group];
const label =
this.groupBy === GROUPS.NONE ? this.$t(`sessions.group.${group}`) : group;
return {
backgroundColor,
color,
label,
data: Object.values(result).map((day) => day[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
.slice(datasetIndex + 1)
.some((dataset: any) => (dataset?.data[dataIndex] || 0) > threshold);
return currentValue > threshold && !previousValuesExist
? { topLeft: 10, topRight: 10, bottomLeft: 0, bottomRight: 0 }
: { topLeft: 0, topRight: 0, bottomLeft: 0, bottomRight: 0 };
},
};
});
return {
labels: Object.keys(result),
datasets: datasets,
datasets,
};
},
legends() {
const pickable = this.groupBy !== GROUPS.NONE;
return this.chartData.datasets.map((dataset) => ({
label: dataset.label || "",
color: dataset.backgroundColor,
label: dataset.label,
color: dataset.color,
value: this.fmtWh(
dataset.data.reduce((acc, curr) => acc + curr, 0) * 1e3,
POWER_UNIT.AUTO
@ -161,103 +103,92 @@ export default defineComponent({
id: pickable ? dataset.label || undefined : undefined,
}));
},
options() {
// capture vue component this to be used in chartjs callbacks
// oxlint-disable-next-line typescript/no-this-alias
const vThis = this;
chartOption(): Record<string, unknown> {
const { labels, datasets } = this.chartData;
const head = this.tooltipHead;
const stackData = roundedStackData(datasets, labels.length);
return {
...commonOptions,
locale: this.$i18n?.locale,
color: colors.text,
borderSkipped: false,
maxBarThickness: 40,
animation: false,
plugins: {
...commonOptions.plugins,
tooltip: {
...commonOptions.plugins.tooltip,
axis: "x",
positioner: (context: TooltipModel<"bar">) => {
const { chart, tooltipPosition } = context;
const { tooltip } = chart;
const { width, height } = tooltip || {};
const { x, y } = tooltipPosition(false);
const { innerWidth, innerHeight } = window;
return {
x: Math.min(x ?? 0, innerWidth - (width ?? 0)),
y: Math.min(y ?? 0, innerHeight - (height ?? 0)),
};
},
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">) => {
const datasetLabel = tooltipItem.dataset.label || "";
const value =
(tooltipItem.dataset.data[tooltipItem.dataIndex] as number) ||
0;
return value
? `${datasetLabel}: ${this.fmtWh(value * 1e3, POWER_UNIT.AUTO)}`
: null;
},
labelColor: tooltipLabelColor(false),
labelPointStyle() {
return {
pointStyle: "circle",
};
},
},
itemSort(a: TooltipItem<"bar">, b: TooltipItem<"bar">) {
return b.datasetIndex - a.datasetIndex;
},
textStyle: { fontFamily: FONT_FAMILY },
grid: { ...forecastGrid(), left: 0, right: 36 },
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow", shadowStyle: { color: "transparent" } },
...tooltipStyle(colors.text || ""),
// read values from chartData, rendered series hide tiny top slivers
formatter: (params: { dataIndex: number }[]) => {
const idx = params?.[0]?.dataIndex;
if (idx == null) return "";
const rows: TooltipRow[] = [];
const values = datasets.map((d) => d.data[idx] || 0).filter((v) => v > 0);
if (!values.length) return "";
const unit = this.getPowerUnit(Math.max(...values) * 1000);
// top of stack first
[...datasets].reverse().forEach((dataset) => {
const value = dataset.data[idx] || 0;
if (!value) return;
rows.push({
name: datasets.length > 1 ? dataset.label : undefined,
values: [this.fmtWh(value * 1e3, unit)],
});
});
return tooltipTable(head(labels[idx] ?? ""), rows);
},
},
scales: {
x: {
stacked: true,
border: { display: false },
grid: { display: false },
ticks: {
color: colors.muted,
callback(value: number) {
return vThis.period === PERIODS.YEAR
? vThis.fmtMonth(new Date(vThis.year, value, 1), true)
: (this as any).getLabelForValue(value);
},
},
},
y: {
stacked: true,
border: { display: false },
grid: { color: colors.border },
title: {
text: "kWh",
display: true,
color: colors.muted,
},
ticks: {
callback: (value: number) =>
this.fmtWh(value * 1e3, POWER_UNIT.KW, false, 0),
color: colors.muted,
maxTicksLimit: 6,
},
position: "right",
min: 0,
xAxis: {
type: "category",
data: labels,
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: {
...xAxisLabelStyle(),
formatter: this.xAxisLabel,
},
},
} as any;
yAxis: forecastYAxis({
position: "right",
min: 0,
splitLine: {
showMinLine: true,
showMaxLine: true,
lineStyle: { color: colors.border || "" },
},
name: "kWh",
nameLocation: "end",
nameGap: 18,
nameTextStyle: {
color: colors.muted || "",
fontFamily: FONT_FAMILY,
fontSize: 10,
opacity: 0.75,
align: "left",
// align with the value labels' left edge (8px default label margin)
padding: [0, 0, 0, 8],
},
axisLabel: {
color: colors.muted || "",
hideOverlap: true,
formatter: (v: number) => this.fmtWh(v * 1e3, POWER_UNIT.KW, false, 0),
},
}),
series: datasets.map((dataset, i) => ({
name: dataset.label,
type: "bar",
stack: "energy",
barMaxWidth: 40,
itemStyle: { color: dataset.color },
data: stackData[i],
})),
};
},
},
});
</script>
<style scoped>
.chart {
width: 100%;
height: 300px;
}
</style>

View file

@ -1,7 +1,7 @@
<template>
<div v-if="chartData.labels.length > 1" class="row">
<div class="col-12 col-md-6 col-lg-12 col-xxl-6 mb-3">
<PolarArea :data="chartData" :options="options" />
<div ref="chartEl" class="round-chart"></div>
</div>
<div class="col-12 col-md-6 col-lg-12 col-xxl-6 d-flex align-items-center">
<LegendList :legends="legends" :device-colors="deviceColors" grid />
@ -11,21 +11,18 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { PolarArea } from "vue-chartjs";
import { RadialLinearScale, ArcElement, Legend, Tooltip, type TooltipItem } from "chart.js";
import { registerChartComponents, commonOptions, tooltipLabelColor } from "./chartConfig.ts";
import { FONT_FAMILY, topBottomCenterPosition, tooltipStyle, tooltipTable } from "./echarts";
import echartsChart from "@/mixins/echartsChart";
import formatter from "@/mixins/formatter";
import colors, { dimColor } from "@/colors";
import LegendList from "./LegendList.vue";
import { GROUPS, type Session } from "./types.ts";
import type { DeviceColors } from "@/types/evcc";
registerChartComponents([RadialLinearScale, ArcElement, Legend, Tooltip]);
export default defineComponent({
name: "SolarGroupedChart",
components: { PolarArea, LegendList },
mixins: [formatter],
components: { LegendList },
mixins: [formatter, echartsChart],
props: {
sessions: { type: Array as PropType<Session[]>, default: () => [] },
groupBy: {
@ -36,8 +33,7 @@ export default defineComponent({
deviceColors: { type: Object as PropType<DeviceColors>, default: () => ({}) },
},
computed: {
chartData() {
console.log("update solar grouped data");
chartData(): { labels: string[]; data: number[]; colors: string[] } {
const aggregatedData: Record<string, { grid: number; self: number }> = {};
this.sessions.forEach((session) => {
@ -52,84 +48,77 @@ export default defineComponent({
aggregatedData[groupKey].grid += grid;
});
// Sort the data by total energy in descending order
const sortedEntries = Object.entries(aggregatedData).sort(
(a, b) => b[1].grid + b[1].self - (a[1].grid + a[1].self)
// stable alphabetical order so entries don't jump between periods
const sortedEntries = Object.entries(aggregatedData).sort((a, b) =>
a[0].localeCompare(b[0])
);
const labels = sortedEntries.map(([label]) => label);
const data = sortedEntries.map(([, value]) => {
const total = value.grid + value.self;
const selfPercentage = (value.self / total) * 100;
return selfPercentage;
return (value.self / total) * 100;
});
const entryColors = labels.map((label) => this.colorMappings[this.groupBy][label]);
const borderColors = labels.map((label) => this.colorMappings[this.groupBy][label]);
const backgroundColors = borderColors.map((color) => dimColor(color));
return { labels, data, colors: entryColors };
},
legends() {
const { labels, data, colors: entryColors } = this.chartData;
return labels.map((label, index) => ({
label,
color: entryColors[index],
value: this.fmtPercentage(data[index] as number, 1),
id: label || undefined,
}));
},
chartOption(): Record<string, unknown> {
const { labels, data, colors: entryColors } = this.chartData;
return {
labels: labels,
datasets: [
animation: false,
textStyle: { fontFamily: FONT_FAMILY },
tooltip: {
trigger: "item",
...tooltipStyle(colors.text || ""),
position: topBottomCenterPosition(() => this.chart),
formatter: (params: { name: string; value: number }) =>
tooltipTable(params.name, [
{ values: [this.fmtPercentage(params.value, 1)] },
]),
},
polar: { radius: "95%" },
angleAxis: { type: "category", data: labels, show: false, startAngle: 90 },
radiusAxis: {
min: 0,
max: 100,
interval: 25,
axisLine: { show: false },
axisTick: { show: false },
splitLine: { lineStyle: { color: colors.border || "" } },
axisLabel: {
fontSize: 10,
color: colors.muted || "",
backgroundColor: colors.box || "",
padding: [1, 3],
},
},
series: [
{
data: data,
borderColor: borderColors,
backgroundColor: backgroundColors,
type: "bar",
coordinateSystem: "polar",
barCategoryGap: "0%",
data: data.map((value, i) => ({
value,
itemStyle: {
color: dimColor(entryColors[i]),
borderColor: entryColors[i],
borderWidth: 3,
// round outer corners only, sharp apex at the center
borderRadius: [0, 0, 8, 8],
},
})),
},
],
};
},
legends() {
const dataset = this.chartData.datasets[0]!;
return this.chartData.labels.map((label, index) => {
const dataValue = dataset.data[index] as number;
return {
label: label,
color: dataset.borderColor[index],
value: this.fmtPercentage(dataValue, 1),
id: label || undefined,
};
});
},
options() {
return {
...commonOptions,
locale: this.$i18n?.locale,
aspectRatio: 1,
borderRadius: 8,
borderWidth: 3,
color: colors.text,
spacing: 0,
radius: "100%",
plugins: {
...commonOptions.plugins,
tooltip: {
...commonOptions.plugins.tooltip,
axis: "r",
position: "topBottomCenter",
callbacks: {
title: () => null,
label: (tooltipItem: TooltipItem<"polarArea">) => {
const { label, dataset, dataIndex } = tooltipItem;
const d = dataset.data[dataIndex] as number;
return label + ": " + this.fmtPercentage(d, 1);
},
labelColor: tooltipLabelColor(true),
},
},
},
scales: {
r: {
min: 0,
max: 100,
ticks: {
stepSize: 25,
color: colors.muted,
backdropColor: colors.box,
},
grid: { color: colors.border },
},
},
} as any;
},
},
});
</script>

View file

@ -1,9 +1,9 @@
<template>
<div v-if="chartData.labels.length > 1" class="row">
<div class="col-12 col-md-6 mb-3">
<Radar :data="chartData" :options="options" />
<div class="col-12 col-md-6 col-lg-12 col-xxl-6 mb-3">
<div ref="chartEl" class="round-chart"></div>
</div>
<div class="col-12 col-md-6 d-flex align-items-center py-0 py-md-3">
<div class="col-12 col-md-6 col-lg-12 col-xxl-6 d-flex align-items-center">
<LegendList :legends="legends" small-equal-widths />
</div>
</div>
@ -11,27 +11,30 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { Radar } from "vue-chartjs";
import {
RadialLinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
type TooltipItem,
} from "chart.js";
import { registerChartComponents, commonOptions, tooltipLabelColor } from "./chartConfig.ts";
FONT_FAMILY,
topBottomCenterPosition,
tooltipStyle,
tooltipTable,
type TooltipRow,
} from "./echarts";
import echartsChart from "@/mixins/echartsChart";
import formatter from "@/mixins/formatter";
import colors, { dimColor } from "@/colors";
import LegendList from "./LegendList.vue";
import type { Legend, PERIODS, Session } from "./types.ts";
registerChartComponents([RadialLinearScale, PointElement, LineElement, Filler, Tooltip]);
interface Dataset {
label: string;
borderColor: string | undefined;
data: (number | null)[];
yearData: { self: number; grid: number };
}
export default defineComponent({
name: "SolarYearChart",
components: { Radar, LegendList },
mixins: [formatter],
components: { LegendList },
mixins: [formatter, echartsChart],
props: {
sessions: { type: Array as PropType<Session[]>, default: () => [] },
period: { type: String as PropType<PERIODS>, default: "total" },
@ -49,9 +52,7 @@ export default defineComponent({
}
return new Date(this.sessions[this.sessions.length - 1]!.created);
},
chartData() {
console.log("update solar month data");
chartData(): { labels: string[]; datasets: Dataset[] } {
if (!this.firstDay || !this.lastDay) {
return { labels: [], datasets: [] };
}
@ -68,7 +69,6 @@ export default defineComponent({
const yearString = `${year}`;
years.push(yearString);
result[yearString] = {};
console.log("year", yearString);
for (let month = 1; month <= 12; month++) {
result[yearString][month] = { self: 0, grid: 0 };
@ -91,10 +91,7 @@ export default defineComponent({
const datasets = years.map((year) => {
const borderColor = colors.selfPalette[years.indexOf(year)] || undefined;
const backgroundColor =
years.length === 1 && borderColor ? dimColor(borderColor) : "transparent";
return {
backgroundColor,
borderColor,
label: year,
data: Object.values(result[year] || {}).map(({ self = 0, grid = 0 }) => {
@ -146,49 +143,72 @@ export default defineComponent({
});
}
},
options() {
chartOption(): Record<string, unknown> {
const { labels, datasets } = this.chartData;
const singleYear = datasets.length === 1;
return {
...commonOptions,
locale: this.$i18n?.locale,
aspectRatio: 1,
borderWidth: 4,
color: colors.text || "",
spacing: 0,
radius: "100%",
elements: { line: { tension: 0.05 } },
plugins: {
...commonOptions.plugins,
tooltip: {
...commonOptions.plugins.tooltip,
axis: "xy",
position: "topBottomCenter",
callbacks: {
label: (tooltipItem: TooltipItem<"radar">) => {
const value = tooltipItem.dataset.data[tooltipItem.dataIndex] || 0;
const datasetLabel = tooltipItem.dataset.label || "";
return datasetLabel + ": " + this.fmtPercentage(value, 1);
},
labelColor: tooltipLabelColor(true),
},
} as any,
},
scales: {
r: {
min: 0,
max: 100,
beginAtZero: false,
ticks: {
stepSize: 20,
color: colors.muted,
backdropColor: colors.box,
font: { size: 10 },
callback: (value: number) => this.fmtPercentage(value, 0),
},
angleLines: { display: false },
grid: { color: colors.border },
animation: false,
textStyle: { fontFamily: FONT_FAMILY },
tooltip: {
trigger: "item",
...tooltipStyle(colors.text || ""),
position: topBottomCenterPosition(() => this.chart),
formatter: (params: { name: string; value: (number | null)[] }) => {
const rows: TooltipRow[] = [];
(params.value || []).forEach((value, index) => {
if (value === null) return;
rows.push({
name: labels[index] ?? "",
values: [this.fmtPercentage(value, 1)],
});
});
return tooltipTable(params.name, rows);
},
},
} as any;
radar: {
// tick labels on the first spoke only
indicator: labels.map((name, i) => ({
name,
min: 0,
max: 100,
...(i === 0
? {
axisLabel: {
show: true,
fontSize: 10,
color: colors.muted || "",
backgroundColor: colors.box || "",
padding: [1, 3],
formatter: (value: number) => this.fmtPercentage(value, 0),
},
}
: {}),
})),
radius: "72%",
axisNameGap: 12,
splitNumber: 5,
axisName: { color: colors.muted || "", fontSize: 14 },
axisLine: { show: false },
splitLine: { lineStyle: { color: colors.border || "" } },
splitArea: { show: false },
},
series: [
{
type: "radar",
symbol: "none",
data: datasets.map((dataset) => ({
name: dataset.label,
value: dataset.data,
lineStyle: { color: dataset.borderColor, width: 4 },
itemStyle: { color: dataset.borderColor },
areaStyle:
singleYear && dataset.borderColor
? { color: dimColor(dataset.borderColor) }
: undefined,
})),
},
],
};
},
},
});

View file

@ -0,0 +1,89 @@
import colors from "@/colors";
import "./roundChart.css";
import { echarts, FONT_FAMILY, tooltipStyle, tooltipTable } from "../Forecast/echarts";
import { PieChart, RadarChart } from "echarts/charts";
import { PolarComponent, RadarComponent } from "echarts/components";
// additional chart types used by the Sessions view
echarts.use([PieChart, RadarChart, PolarComponent, RadarComponent]);
export * from "../Forecast/echarts";
type ChartSize = { getWidth(): number; getHeight(): number } | null;
// tooltip centered horizontally, in the upper or lower fifth depending on the pointer
export function topBottomCenterPosition(getChart: () => ChartSize) {
return (point: [number, number], _params: unknown, el: HTMLElement): [number, number] => {
const w = getChart()?.getWidth() || 0;
const h = getChart()?.getHeight() || 0;
const isTop = point[1] > h / 2;
const y = (isTop ? h / 5 : h - h / 5) - (el?.offsetHeight || 0) / 2;
return [(w - (el?.offsetWidth || 0)) / 2, y];
};
}
// rounded caps for stacked bars: the topmost segment above 2% of the tallest
// bar carries the cap; smaller slivers above it are hidden (they would float
// as a detached hairline) and only appear in tooltips
export function roundedStackData(datasets: { data: (number | null)[] }[], count: number) {
const totals = Array.from({ length: count }, (_, idx) =>
datasets.reduce((sum, d) => sum + (d.data[idx] || 0), 0)
);
const threshold = Math.max(0, ...totals) * 0.02;
const topPerSlot = totals.map((_, idx) => {
for (let i = datasets.length - 1; i >= 0; i--) {
if ((datasets[i]!.data[idx] || 0) > threshold) return i;
}
return -1;
});
return datasets.map((dataset, i) =>
dataset.data.map((v, idx) => {
if (topPerSlot[idx] === i) {
return { value: v, itemStyle: { borderRadius: [10, 10, 0, 0] } };
}
if (topPerSlot[idx]! >= 0 && i > topPerSlot[idx]!) return null;
return v;
})
);
}
// shared doughnut chart option, tooltip centered in the cutout
export function doughnutOption(
data: { name: string; value: number; color: string }[],
formatValue: (value: number) => string,
getChart: () => ChartSize
) {
return {
animation: false,
textStyle: { fontFamily: FONT_FAMILY },
tooltip: {
trigger: "item",
...tooltipStyle(colors.text || ""),
position: (_point: [number, number], _params: unknown, el: HTMLElement) => [
((getChart()?.getWidth() || 0) - (el?.offsetWidth || 0)) / 2,
((getChart()?.getHeight() || 0) - (el?.offsetHeight || 0)) / 2,
],
formatter: (params: { name: string; value: number }) =>
tooltipTable(params.name, [{ values: [formatValue(params.value)] }]),
},
series: [
{
type: "pie",
radius: ["70%", "95%"],
itemStyle: {
borderRadius: 10,
borderWidth: 3,
borderColor: colors.box || "",
},
label: { show: false },
labelLine: { show: false },
emphasis: { scale: false },
data: data.map(({ name, value, color }) => ({
name,
value,
itemStyle: { color },
})),
},
],
};
}

View file

@ -0,0 +1,73 @@
import { defineComponent, type PropType } from "vue";
import formatter from "@/mixins/formatter";
import echartsChart from "@/mixins/echartsChart";
import { PERIODS, type Session } from "./types";
// chartOption is provided by each consuming component's computed
type WithChartOption = { chartOption: Record<string, unknown> };
// shared date bucketing and axis formatting for the history bar charts
export default defineComponent({
mixins: [formatter, echartsChart],
props: {
sessions: { type: Array as PropType<Session[]>, default: () => [] },
period: { type: String as PropType<PERIODS>, default: PERIODS.TOTAL },
},
computed: {
firstDay(): Date | null {
if (this.sessions.length === 0) {
return null;
}
return new Date(this.sessions[0]!.created);
},
lastDay(): Date | null {
if (this.sessions.length === 0) {
return null;
}
return new Date(this.sessions[this.sessions.length - 1]!.created);
},
month(): number {
return (this.firstDay?.getMonth() || 0) + 1;
},
year(): number {
return this.firstDay?.getFullYear() || 0;
},
// bucket index range [from, to] for the selected period
bucketRange(): [number, number] | null {
if (!this.firstDay || !this.lastDay) return null;
if (this.period === PERIODS.TOTAL) {
return [this.firstDay.getFullYear(), this.lastDay.getFullYear()];
}
if (this.period === PERIODS.YEAR) {
return [1, 12];
}
return [1, new Date(this.lastDay.getFullYear(), this.lastDay.getMonth() + 1, 0).getDate()];
},
tooltipHead(): (label: string) => string {
if (this.period === PERIODS.TOTAL) {
return (label) => label;
}
if (this.period === PERIODS.YEAR) {
return (label) => this.fmtMonth(new Date(this.year, Number(label) - 1, 1), false);
}
return (label) => this.fmtDayMonth(new Date(this.year, this.month - 1, Number(label)));
},
},
methods: {
bucketIndex(date: Date): number {
if (this.period === PERIODS.MONTH) return date.getDate();
if (this.period === PERIODS.YEAR) return date.getMonth() + 1;
return date.getFullYear();
},
xAxisLabel(label: string): string {
return this.period === PERIODS.YEAR
? this.fmtMonth(new Date(this.year, Number(label) - 1, 1), true)
: label;
},
applyChartOption() {
this.chart?.setOption((this as unknown as WithChartOption).chartOption, {
notMerge: true,
});
},
},
});

View file

@ -0,0 +1,7 @@
/* square chart area for round charts (doughnut, polar, radar), consistent size across cards */
.round-chart {
width: 100%;
max-width: 420px;
aspect-ratio: 1;
margin-inline: auto;
}

View file

@ -0,0 +1,59 @@
import { defineComponent, markRaw } from "vue";
import { echarts, registerTouchTooltip } from "@/components/Forecast/echarts";
// chartOption is provided by each consuming component's computed
type WithChartOption = { chartOption: Record<string, unknown> };
// Shared echarts lifecycle: init on a `chartEl` template ref (lazily re-checked
// so v-if-gated charts work), option updates via deep watch, window resizing,
// touch tooltips, disposal. Override `applyChartOption`, `onChartInit`,
// `onTouchTooltipReset` or `resize` for custom behavior.
export default defineComponent({
data(): { chart: echarts.ECharts | null } {
return { chart: null };
},
watch: {
chartOption: {
handler() {
this.$nextTick(() => {
this.initChart();
this.applyChartOption();
});
},
deep: true,
},
},
mounted() {
this.initChart();
window.addEventListener("resize", this.resize);
},
beforeUnmount() {
window.removeEventListener("resize", this.resize);
this.chart?.dispose();
this.chart = null;
},
methods: {
initChart() {
const el = this.$refs["chartEl"] as HTMLElement | undefined;
if (this.chart) {
if (el && this.chart.getDom() === el) return;
// element removed or replaced by v-if
this.chart.dispose();
this.chart = null;
}
if (!el) return;
this.chart = markRaw(echarts.init(el));
this.chart.setOption((this as unknown as WithChartOption).chartOption);
registerTouchTooltip(this.chart, el, () => this.onTouchTooltipReset());
this.onChartInit();
},
applyChartOption() {
this.chart?.setOption((this as unknown as WithChartOption).chartOption);
},
onChartInit() {},
onTouchTooltipReset() {},
resize() {
this.chart?.resize();
},
},
});

View file

@ -103,9 +103,9 @@ export default function setupRouter(i18n: VueI18nInstance) {
return {
month: month ? parseInt(month as string, 10) : undefined,
year: year ? parseInt(year as string, 10) : undefined,
period,
loadpointFilter: loadpoint,
vehicleFilter: vehicle,
period: period ?? undefined,
loadpointFilter: loadpoint ?? undefined,
vehicleFilter: vehicle ?? undefined,
};
},
},
@ -128,7 +128,7 @@ export default function setupRouter(i18n: VueI18nInstance) {
day: day ? parseInt(day as string, 10) : undefined,
month: month ? parseInt(month as string, 10) : undefined,
year: year ? parseInt(year as string, 10) : undefined,
period,
period: period ?? undefined,
};
},
},

View file

@ -48,6 +48,9 @@ export function attachTouchTooltipGate(el: HTMLElement, hide: () => void): () =>
const touch = e.touches[0];
if (!touch) return;
e.stopPropagation();
// WebKit long-press jumps selection to the nearest selectable content
// outside the chart; block selection page-wide for the gesture
document.body.classList.add("user-select-none");
shown = false;
latched = false;
x = touch.clientX;
@ -84,12 +87,10 @@ export function attachTouchTooltipGate(el: HTMLElement, hide: () => void): () =>
if (e.cancelable) e.preventDefault();
if (timer) clearTimeout(timer);
shown = false;
document.body.classList.remove("user-select-none");
hide();
};
// long-press must not start text selection of chart or tooltip content
el.classList.add("user-select-none");
el.addEventListener("touchstart", onTouchStart, { capture: true, passive: true });
el.addEventListener("touchmove", onTouchMove, { capture: true, passive: true });
el.addEventListener("touchend", onTouchEnd, { capture: true });
@ -97,7 +98,7 @@ export function attachTouchTooltipGate(el: HTMLElement, hide: () => void): () =>
return () => {
if (timer) clearTimeout(timer);
el.classList.remove("user-select-none");
document.body.classList.remove("user-select-none");
el.removeEventListener("touchstart", onTouchStart, { capture: true });
el.removeEventListener("touchmove", onTouchMove, { capture: true });
el.removeEventListener("touchend", onTouchEnd, { capture: true });

View file

@ -20,10 +20,10 @@ export default defineComponent({
notifications: Array as PropType<Notification[]>,
selectedLoadpointIndex: Number,
},
data() {
return store;
},
computed: {
state() {
return store.state;
},
uiForecast() {
return store.uiForecast.value;
},

View file

@ -108,7 +108,6 @@
:sessions="currentTypeSessions"
:color-mappings="colorMappings"
:device-colors="deviceColors"
:suggested-max-price="suggestedMaxAvgCost"
:group-by="selectedGroupWithoutNone"
:cost-type="activeType"
:currency="currency"
@ -477,59 +476,22 @@ export default defineComponent({
return deviceColorMap(store.state.deviceColors);
},
colorMappings() {
const lastThreeMonths = new Date();
lastThreeMonths.setMonth(lastThreeMonths.getMonth() - 3);
// Aggregate energy to get sorted list of loadpoints/vehicles for coloring
const aggregateEnergy = (group: Exclude<GROUPS, GROUPS.NONE>) => {
return this.sessionsWithDefaults.reduce((acc: Record<string, number>, session) => {
if (new Date(session.created) >= lastThreeMonths) {
const key = session[group];
acc[key] = (acc[key] || 0) + session.chargedEnergy;
}
return acc;
}, {});
};
// Ordered key list: sessions ranked by recent energy first, then any
// remaining keys encountered in the dataset.
const orderedKeys = (
energyAggregation: Record<string, number>,
colorType: Exclude<GROUPS, GROUPS.NONE>
): string[] => {
const ranked = Object.entries(energyAggregation)
.sort((a, b) => b[1] - a[1])
.map(([k]) => k)
.filter(Boolean);
const seen = new Set(ranked);
// alphabetical keys for stable palette assignment; manual overrides win
const uniqueKeys = (group: Exclude<GROUPS, GROUPS.NONE>): string[] => {
const keys = new Set<string>();
for (const s of this.sessionsWithDefaults) {
const k = s[colorType];
if (k && !seen.has(k)) {
ranked.push(k);
seen.add(k);
}
const k = s[group];
if (k) keys.add(k);
}
return ranked;
return Array.from(keys).sort((a, b) => a.localeCompare(b));
};
const overrides = this.deviceColors;
const loadpointColors = resolveColors(
orderedKeys(aggregateEnergy(GROUPS.LOADPOINT), GROUPS.LOADPOINT),
overrides
);
const vehicleColors = resolveColors(
orderedKeys(aggregateEnergy(GROUPS.VEHICLE), GROUPS.VEHICLE),
overrides
);
const solar = { self: colors.self, grid: colors.grid };
const cost = { price: colors.price, co2: colors.co2 };
return {
loadpoint: loadpointColors,
vehicle: vehicleColors,
solar,
cost,
loadpoint: resolveColors(uniqueKeys(GROUPS.LOADPOINT), overrides),
vehicle: resolveColors(uniqueKeys(GROUPS.VEHICLE), overrides),
solar: { self: colors.self, grid: colors.grid },
cost: { price: colors.price, co2: colors.co2 },
};
},
groupIcons() {
@ -590,23 +552,6 @@ export default defineComponent({
return (isGrouped && hasMultipleEntries) || (isSolar && isNotMonth && !isGrouped);
},
suggestedMaxAvgPrice() {
// returns the 98th percentile of avg prices for all sessions
const sessionsWithPrice = this.sessions.filter((s) => s.pricePerKWh !== null);
const prices = sessionsWithPrice.map((s) => s.pricePerKWh ?? 0);
return this.percentile(prices, 98) ?? 0;
},
suggestedMaxAvgCo2() {
// returns the 98th percentile of avg co2 emissions for all sessions
const sessionsWithCo2 = this.sessions.filter((s) => s.co2PerKWh !== null);
const co2 = sessionsWithCo2.map((s) => s.co2PerKWh ?? 0);
return this.percentile(co2, 98) ?? 0;
},
suggestedMaxAvgCost() {
return this.activeType === TYPES.PRICE
? this.suggestedMaxAvgPrice
: this.suggestedMaxAvgCo2;
},
},
watch: {
offline() {
@ -670,12 +615,6 @@ export default defineComponent({
updateDate({ year, month }: { year: number; month: number }) {
this.$router.push({ query: { ...this.$route.query, year, month } });
},
percentile(arr: number[], p: number): number | null {
if (arr.length === 0) return null;
const sorted = arr.sort((a, b) => a - b);
const index = (p / 100) * (sorted.length - 1);
return sorted[Math.floor(index)] ?? null;
},
},
});
</script>