History UI: improve tooltips, labels, stacks, units (#29917)
This commit is contained in:
parent
431b606230
commit
a628981d8d
2 changed files with 134 additions and 45 deletions
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue