diff --git a/assets/js/components/History/GroupChart.vue b/assets/js/components/History/GroupChart.vue index 3d4e46980..e6b878406 100644 --- a/assets/js/components/History/GroupChart.vue +++ b/assets/js/components/History/GroupChart.vue @@ -32,6 +32,10 @@ export interface HistorySeries { // Marks a synthetic / derived series (e.g. "other consumers"). Gets a neutral // color and is excluded from the source-data table. virtual?: boolean; + // Stable index into the palette, preserved across navigations even when the + // displayed list is filtered (e.g. inactive loadpoints dropped) so an + // entity keeps its color when navigating between periods. + paletteIndex?: number; } type WithChartOption = { chartOption: Record }; @@ -76,6 +80,7 @@ export default defineComponent({ mediaQuery: MediaQueryList | null; previousFocusedEntity: number | null; previousPeriod: PERIODS; + previousSeriesKey: string; } { return { chart: null, @@ -83,6 +88,7 @@ export default defineComponent({ mediaQuery: null, previousFocusedEntity: this.focusedEntity as number | null, previousPeriod: this.period as PERIODS, + previousSeriesKey: "", }; }, computed: { @@ -91,8 +97,29 @@ export default defineComponent({ valueFactor(): number { return this.period === PERIODS.DAY ? 4 : 1; }, - unit(): "kW" | "kWh" { - return this.period === PERIODS.DAY ? "kW" : "kWh"; + // Peak absolute net power per slot in kW (day view only). Absolute so + // export-dominant slots (battery discharge, grid export) count too. + maxVisibleKw(): number { + if (this.period !== PERIODS.DAY) return Infinity; + const factor = this.valueFactor; + const slotSums = new Map(); + for (const s of this.visibleSeries) { + for (const slot of s.data) { + const v = Math.abs(slot.energy - slot.returnEnergy) * factor; + slotSums.set(slot.start, (slotSums.get(slot.start) || 0) + v); + } + } + let max = 0; + for (const v of slotSums.values()) if (v > max) max = v; + return max; + }, + // Switch to watts when the peak is below 1 kW so small values stay readable. + useWatts(): boolean { + return this.period === PERIODS.DAY && this.maxVisibleKw < 1; + }, + unit(): "W" | "kW" | "kWh" { + if (this.period !== PERIODS.DAY) return "kWh"; + return this.useWatts ? "W" : "kW"; }, // Consumer groups have many palette colours per entity — a single neutral // tooltip background reads better than picking one entity's colour. @@ -105,7 +132,7 @@ export default defineComponent({ visibleSeries(): HistorySeries[] { if (this.focusedEntity === null) return this.series; const idx = this.focusedEntity; - return this.series.filter((_, i) => i === idx); + return this.series.filter((s, i) => (s.paletteIndex ?? i) === idx); }, isBidirectional(): boolean { for (const s of this.visibleSeries) { @@ -178,11 +205,13 @@ export default defineComponent({ // stacked segments stay visually distinguishable. if (this.group === "loadpoint" || this.group === "meter") { const mutedColor = colors.muted || this.color; - return this.series.map((s, i) => + return this.series.map((s, i) => { // Virtual "other consumers" entity renders in a neutral gray to set // it apart from explicit meter entities. - s.virtual ? mutedColor : colors.palette[i % colors.palette.length] || this.color - ); + if (s.virtual) return mutedColor; + const idx = s.paletteIndex ?? i; + return colors.palette[idx % colors.palette.length] || this.color; + }); } if (this.series.length <= 1) return [this.color]; return this.series.map((_, i) => @@ -242,7 +271,8 @@ export default defineComponent({ this.series.forEach((s, i) => { const energyValues: (number | null)[] = new Array(cats.length).fill(null); const returnEnergyValues: (number | null)[] = new Array(cats.length).fill(null); - const hidden = this.focusedEntity !== null && this.focusedEntity !== i; + const hidden = + this.focusedEntity !== null && this.focusedEntity !== (s.paletteIndex ?? i); if (!hidden) { for (const slot of s.data) { const idx = index.get(slotKey(slot.start)); @@ -284,6 +314,9 @@ export default defineComponent({ // the cap moves to the topmost non-zero entity per slot, so when the // last entity is empty at a given slot the next-lower one still gets // the rounded top. A focused entity is rendered solo → always caps. + // Stable identity for focus comparison: paletteIndex when set + // (filtered groups), otherwise plain array index. + const stableIdx = s.paletteIndex ?? i; const energyData: ( | number | null @@ -291,7 +324,9 @@ export default defineComponent({ )[] = energyValues.map((v, idx) => { if (v == null) return v; const isTop = - !stackEntities || topEnergyPerSlot[idx] === i || this.focusedEntity === i; + !stackEntities || + topEnergyPerSlot[idx] === i || + this.focusedEntity === stableIdx; if (!isTop) return v; return { value: v, itemStyle: { borderRadius: [radius, radius, 0, 0] } }; }); @@ -304,12 +339,12 @@ export default defineComponent({ const isBottom = !stackEntities || topReturnEnergyPerSlot[idx] === i || - this.focusedEntity === i; + this.focusedEntity === stableIdx; if (!isBottom) return v; return { value: v, itemStyle: { borderRadius: [0, 0, radius, radius] } }; }); result.push({ - id: `entity-${i}-energy`, + id: `entity-${stableIdx}-energy`, name: energyName, type: "bar", stack: stackName, @@ -319,7 +354,7 @@ export default defineComponent({ barGap: "10%", }); result.push({ - id: `entity-${i}-returnEnergy`, + id: `entity-${stableIdx}-returnEnergy`, name: returnEnergyName, type: "bar", stack: stackName, @@ -453,8 +488,8 @@ export default defineComponent({ const formatValue = (v: number) => { const watts = Math.abs(v) * 1000; return this.period === PERIODS.DAY - ? this.fmtW(watts, POWER_UNIT.KW, true, 1) - : this.fmtWh(watts, POWER_UNIT.KW, true, 1); + ? this.fmtW(watts, POWER_UNIT.AUTO) + : this.fmtWh(watts, POWER_UNIT.AUTO); }; // Collect energy/returnEnergy values per entity from this slot's params. @@ -475,7 +510,10 @@ export default defineComponent({ const indices = this.focusedEntity !== null ? [this.focusedEntity] - : this.series.map((_, i) => i); + : this.series.map((s, i) => s.paletteIndex ?? i); + const nameByIdx = new Map( + this.series.map((s, i) => [s.paletteIndex ?? i, s.name]) + ); const showName = this.series.length > 1 && this.focusedEntity === null; if (this.isBidirectional) { @@ -483,7 +521,7 @@ export default defineComponent({ .map((i) => { const t = totals.get(i) ?? { energy: 0, returnEnergy: 0 }; const val = `${formatValue(t.energy)} / ${formatValue(t.returnEnergy)}`; - const name = this.series[i]?.name ?? ""; + const name = nameByIdx.get(i) ?? ""; return showName ? `
${name}: ${val}
` : `
${val}
`; @@ -496,7 +534,7 @@ export default defineComponent({ .map((i) => { const t = totals.get(i) ?? { energy: 0, returnEnergy: 0 }; const val = `${formatValue(t.energy + t.returnEnergy)}`; - const name = this.series[i]?.name ?? ""; + const name = nameByIdx.get(i) ?? ""; return showName ? `
${name}: ${val}
` : `
${val}
`; @@ -565,7 +603,12 @@ export default defineComponent({ hideOverlap: true, formatter: (v: number) => this.period === PERIODS.DAY - ? this.fmtW(v * 1000, POWER_UNIT.KW, false, 0) + ? this.fmtW( + v * 1000, + this.useWatts ? POWER_UNIT.W : POWER_UNIT.KW, + false, + 0 + ) : this.fmtWh(v * 1000, POWER_UNIT.KW, false, 0), }, }), @@ -579,17 +622,30 @@ export default defineComponent({ const opt = (this as unknown as WithChartOption).chartOption; const focusChanged = this.previousFocusedEntity !== this.focusedEntity; const periodChanged = this.previousPeriod !== this.period; - // Snap (no animation) on period switches and on legend focus toggles. - // Other updates use the default merge so bars animate value transitions - // per stable key. - this.chart?.setOption({ - animation: !(focusChanged || periodChanged), - xAxis: opt["xAxis"], - yAxis: opt["yAxis"], - series: opt["series"], - }); + // 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"], + }, + fullReset ? { notMerge: true } : { replaceMerge: ["series"] } + ); this.previousFocusedEntity = this.focusedEntity as number | null; this.previousPeriod = this.period as PERIODS; + this.previousSeriesKey = newSeriesKey; }, deep: true, }, diff --git a/assets/js/views/History.vue b/assets/js/views/History.vue index b9f7ddf07..29bdef336 100644 --- a/assets/js/views/History.vue +++ b/assets/js/views/History.vue @@ -304,13 +304,28 @@ export default defineComponent({ }, // Series shown in the chart. For the consumption (`meter`) group we append // a virtual "Other consumers" series = home.net − sum(meter entities). + // Inactive loadpoints / meters (all-zero data in the selected period) are + // dropped from the displayed list; `paletteIndex` carries each entity's + // original position so its color stays stable when navigating periods. displaySeries(): (group: string) => HistorySeries[] { + const hasEnergy = (s: HistorySeries) => + s.data.some((slot) => slot.energy !== 0 || slot.returnEnergy !== 0); return (group: string): HistorySeries[] => { + if (group === "loadpoint") { + const list = this.seriesByGroup["loadpoint"] || []; + return list.map((s, i) => ({ ...s, paletteIndex: i })).filter(hasEnergy); + } if (group !== "meter") return this.seriesByGroup[group] || []; const meters = this.seriesByGroup["meter"] || []; const home = (this.seriesByGroup["home"] || [])[0]; - if (!home) return meters; + const activeMeters = meters + .map((s, i) => ({ ...s, paletteIndex: i })) + .filter(hasEnergy); + if (!home) return activeMeters; const meterTotals = new Map(); + // Net per slot is computed from all meters (incl. inactive ones), so + // dropping inactive entries from the display doesn't shift the + // "Other consumers" delta. for (const s of meters) { for (const slot of s.data) { const net = slot.energy - slot.returnEnergy; @@ -321,15 +336,19 @@ export default defineComponent({ name: this.$t("main.history.otherConsumers") as string, group: "meter", virtual: true, + // Use meters.length as a stable paletteIndex that can never + // collide with real meters (0..meters.length-1). + paletteIndex: meters.length, data: home.data.map((slot) => { const homeNet = slot.energy - slot.returnEnergy; const v = Math.max(0, homeNet - (meterTotals.get(slot.start) || 0)); return { start: slot.start, end: slot.end, energy: v, returnEnergy: 0 }; }), }; + if (!hasEnergy(other)) return activeMeters; // First entry in the array = bottom of the stack, so "Other consumers" // always sits underneath the explicit meters. - return [other, ...meters]; + return [other, ...activeMeters]; }; }, hasForecast(): boolean { @@ -354,6 +373,22 @@ export default defineComponent({ fetchKey() { this.fetchData(); }, + rawSeries() { + // Drop focused entries whose paletteIndex no longer matches any entity + // in the new data (e.g. a loadpoint was filtered out after a period + // switch). + const next: Record = {}; + for (const [group, focused] of Object.entries(this.focusedEntity)) { + if (focused == null) { + next[group] = null; + continue; + } + const list = this.displaySeries(group); + const stillPresent = list.some((s, i) => (s.paletteIndex ?? i) === focused); + next[group] = stillPresent ? focused : null; + } + this.focusedEntity = next; + }, }, mounted() { this.fetchData(); @@ -378,26 +413,24 @@ export default defineComponent({ const colorFor = (i: number, s: HistorySeries) => { if (s.virtual) return colors.muted || baseColor; if (group === "loadpoint" || group === "meter") { - return colors.palette[i % colors.palette.length] || baseColor; + const idx = s.paletteIndex ?? i; + return colors.palette[idx % colors.palette.length] || baseColor; } return alphaColor(baseColor, stepAlpha(i, Math.max(n, 1))); }; - return list - .map((s, i) => { - let sum = 0; - for (const slot of s.data) sum += slot.energy - slot.returnEnergy; - return { s, i, sum }; - }) - .filter(({ sum }) => group !== "meter" || sum !== 0) - .map(({ s, i, sum }) => { - const watts = Math.abs(sum) * 1000; - return { - entityIndex: i, - label: s.name, - color: colorFor(i, s), - value: this.fmtWh(watts, POWER_UNIT.AUTO), - }; - }); + return list.map((s, i) => { + let sum = 0; + for (const slot of s.data) sum += slot.energy - slot.returnEnergy; + const watts = Math.abs(sum) * 1000; + return { + // Use stable paletteIndex as the focus identifier so that the + // selected entity keeps its identity across period navigations. + entityIndex: s.paletteIndex ?? i, + label: s.name, + color: colorFor(i, s), + value: this.fmtWh(watts, POWER_UNIT.AUTO), + }; + }); }, toggleFocus(group: string, i: number) { const current = this.focusedEntity[group] ?? null;