UI Sessions: migrate charts from Chart.js to ECharts (#32625)
This commit is contained in:
parent
285b01f794
commit
136809f9b0
20 changed files with 921 additions and 1026 deletions
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
},
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
89
assets/js/components/Sessions/echarts.ts
Normal file
89
assets/js/components/Sessions/echarts.ts
Normal 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 },
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
73
assets/js/components/Sessions/historyChart.ts
Normal file
73
assets/js/components/Sessions/historyChart.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
7
assets/js/components/Sessions/roundChart.css
Normal file
7
assets/js/components/Sessions/roundChart.css
Normal 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;
|
||||
}
|
||||
59
assets/js/mixins/echartsChart.ts
Normal file
59
assets/js/mixins/echartsChart.ts
Normal 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();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue