History UI: improve tooltips, labels, stacks, units (#29917)

This commit is contained in:
Michael Geers 2026-05-15 21:10:19 +02:00 • committed by GitHub
parent 431b606230
commit a628981d8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 134 additions and 45 deletions

View file

@ -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<string, unknown> };
@ -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<string, number>();
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 = `<strong>${formatValue(t.energy)} / ${formatValue(t.returnEnergy)}</strong>`;
const name = this.series[i]?.name ?? "";
const name = nameByIdx.get(i) ?? "";
return showName
? `<div>${name}: ${val}</div>`
: `<div>${val}</div>`;
@ -496,7 +534,7 @@ export default defineComponent({
.map((i) => {
const t = totals.get(i) ?? { energy: 0, returnEnergy: 0 };
const val = `<strong>${formatValue(t.energy + t.returnEnergy)}</strong>`;
const name = this.series[i]?.name ?? "";
const name = nameByIdx.get(i) ?? "";
return showName
? `<div>${name}: ${val}</div>`
: `<div>${val}</div>`;
@ -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,
},

View file

@ -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<string, number>();
// 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<string, number | null> = {};
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;