History: improve additional meters, tooltip formatting (#31175)

This commit is contained in:
Michael Geers 2026-06-25 10:02:55 +02:00 • committed by GitHub
parent 22386f5b17
commit 8f9863538a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 55 additions and 30 deletions

View file

@ -121,11 +121,16 @@ export function tooltipTable(head: string, rows: TooltipRow[], headers?: string[
const hasName = rows.some((r) => r.name != null); const hasName = rows.some((r) => r.name != null);
const valueCols = Math.max(1, ...rows.map((r) => r.values.length)); const valueCols = Math.max(1, ...rows.map((r) => r.values.length));
const colCount = (hasName ? 1 : 0) + valueCols; const colCount = (hasName ? 1 : 0) + valueCols;
// lone value centers under the date; multiple columns line up right // No name col + two value cols: first col left-aligned, second right-aligned.
const valCls = hasName || valueCols > 1 ? "fw-normal text-end ps-3" : "fw-normal text-center"; // Otherwise: lone value centers, multiple/named columns right-align.
const valClsFn = (i: number): string => {
if (!hasName && valueCols > 1 && i === 0) return "fw-normal text-start";
if (hasName || valueCols > 1) return "fw-normal text-end ps-3";
return "fw-normal text-center";
};
const headerRow = headers?.length const headerRow = headers?.length
? `<tr>${hasName ? "<td></td>" : ""}${headers ? `<tr>${hasName ? "<td></td>" : ""}${headers
.map((h) => `<td class="fw-normal text-end ps-3">${h}</td>`) .map((h, i) => `<td class="${valClsFn(i)}">${h}</td>`)
.join("")}</tr>` .join("")}</tr>`
: ""; : "";
const body = rows const body = rows
@ -133,7 +138,7 @@ export function tooltipTable(head: string, rows: TooltipRow[], headers?: string[
const nameTd = hasName const nameTd = hasName
? `<td class="fw-normal text-start">${escapeHtml(r.name ?? "")}</td>` ? `<td class="fw-normal text-start">${escapeHtml(r.name ?? "")}</td>`
: ""; : "";
const valTds = r.values.map((v) => `<td class="${valCls}">${v}</td>`).join(""); const valTds = r.values.map((v, i) => `<td class="${valClsFn(i)}">${v}</td>`).join("");
return `<tr>${nameTd}${valTds}</tr>`; return `<tr>${nameTd}${valTds}</tr>`;
}) })
.join(""); .join("");

View file

@ -54,6 +54,9 @@ export function stepAlpha(i: number, n: number): number {
// Symmetric axis regardless of whether the period contains both directions. // Symmetric axis regardless of whether the period contains both directions.
const BIDIRECTIONAL_GROUPS: ReadonlySet<string> = new Set(["grid", "battery"]); const BIDIRECTIONAL_GROUPS: ReadonlySet<string> = new Set(["grid", "battery"]);
// Multiple entities stack into one bar; grid and meter render side-by-side.
const STACKED_GROUPS: ReadonlySet<string> = new Set(["loadpoint", "consumer", "pv", "battery"]);
// Round up to a nice number (5-tick symmetric axis: -L, -L/2, 0, L/2, L). // Round up to a nice number (5-tick symmetric axis: -L, -L/2, 0, L/2, L).
function niceCeil(v: number): number { function niceCeil(v: number): number {
if (v <= 0) return 0; if (v <= 0) return 0;
@ -104,19 +107,33 @@ export default defineComponent({
valueFactor(): number { valueFactor(): number {
return this.period === PERIODS.DAY ? 4 : 1; return this.period === PERIODS.DAY ? 4 : 1;
}, },
stackEntities(): boolean {
return STACKED_GROUPS.has(this.group);
},
// Peak of stacked per-slot sums, incl. overlay when shown so its line isn't // Peak of stacked per-slot sums, incl. overlay when shown so its line isn't
// clipped. Bidirectional: pos/neg separately. // clipped. Bidirectional: pos/neg separately.
axisPeak(): number { axisPeak(): number {
const factor = this.valueFactor; const factor = this.valueFactor;
// Stacked groups: sum entities per slot. Unstacked (grid, meter): max per entity.
const peak = (series: HistorySeries[], pick: (slot: HistorySlot) => number) => { const peak = (series: HistorySeries[], pick: (slot: HistorySlot) => number) => {
const sums = new Map<string, number>(); if (this.stackEntities) {
for (const s of series) { const sums = new Map<string, number>();
for (const slot of s.data) { for (const s of series) {
sums.set(slot.start, (sums.get(slot.start) || 0) + pick(slot) * factor); for (const slot of s.data) {
sums.set(slot.start, (sums.get(slot.start) || 0) + pick(slot) * factor);
}
} }
let max = 0;
for (const v of sums.values()) if (v > max) max = v;
return max;
} }
let max = 0; let max = 0;
for (const v of sums.values()) if (v > max) max = v; for (const s of series) {
for (const slot of s.data) {
const v = pick(slot) * factor;
if (v > max) max = v;
}
}
return max; return max;
}; };
if (this.isBidirectional) { if (this.isBidirectional) {
@ -269,13 +286,6 @@ export default defineComponent({
// Always render import + export series per entity, even if one direction // Always render import + export series per entity, even if one direction
// is empty (null-filled). Stable series ids/structure across renders so // is empty (null-filled). Stable series ids/structure across renders so
// echarts can animate value transitions instead of redrawing from zero. // echarts can animate value transitions instead of redrawing from zero.
// Groups with multiple entities stack them; grid keeps bars side-by-side.
const stackEntities =
this.group === "loadpoint" ||
this.group === "consumer" ||
this.group === "meter" ||
this.group === "pv" ||
this.group === "battery";
// Build value arrays per entity first so we can determine, per slot, // Build value arrays per entity first so we can determine, per slot,
// which entity is the *visible* top/bottom of the stack — that one // which entity is the *visible* top/bottom of the stack — that one
// gets the rounded cap even if higher-index entities are zero/null. // gets the rounded cap even if higher-index entities are zero/null.
@ -327,13 +337,10 @@ export default defineComponent({
const returnEnergyName = this.directionLabel(s, "returnEnergy"); const returnEnergyName = this.directionLabel(s, "returnEnergy");
// Same stack name for import and export means they share one x slot // Same stack name for import and export means they share one x slot
// (positive values stack up, negative stack down, no width penalty). // (positive values stack up, negative stack down, no width penalty).
const stackName = stackEntities ? `group-${this.group}` : `entity-${i}`; const stackName = this.stackEntities ? `group-${this.group}` : `entity-${i}`;
// For non-stacked groups every bar is its own cap. For stacked groups // Rounded cap goes on the visible top/bottom per slot: non-stacked bars
// the cap moves to the topmost non-zero entity per slot, so when the // always cap; stacked groups cap the topmost non-zero entity so an empty
// last entity is empty at a given slot the next-lower one still gets // top entity doesn't drop the rounding; a focused entity is solo.
// 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 stableIdx = s.paletteIndex ?? i;
const energyData: ( const energyData: (
| number | number
@ -342,7 +349,7 @@ export default defineComponent({
)[] = energyValues.map((v, idx) => { )[] = energyValues.map((v, idx) => {
if (v == null) return v; if (v == null) return v;
const isTop = const isTop =
!stackEntities || !this.stackEntities ||
topEnergyPerSlot[idx] === i || topEnergyPerSlot[idx] === i ||
this.focusedEntity === stableIdx; this.focusedEntity === stableIdx;
if (!isTop) return v; if (!isTop) return v;
@ -355,7 +362,7 @@ export default defineComponent({
)[] = returnEnergyValues.map((v, idx) => { )[] = returnEnergyValues.map((v, idx) => {
if (v == null) return v; if (v == null) return v;
const isBottom = const isBottom =
!stackEntities || !this.stackEntities ||
topReturnEnergyPerSlot[idx] === i || topReturnEnergyPerSlot[idx] === i ||
this.focusedEntity === stableIdx; this.focusedEntity === stableIdx;
if (!isBottom) return v; if (!isBottom) return v;
@ -416,7 +423,8 @@ export default defineComponent({
: (t: number) => this.fmtMonth(new Date(t), true); : (t: number) => this.fmtMonth(new Date(t), true);
}, },
// Column headers for bidirectional tooltips (grid: imported/exported, // Column headers for bidirectional tooltips (grid: imported/exported,
// battery: charged/discharged). Null when the group has no direction labels. // battery: charged/discharged, meter: energy/reverse). Null when the group
// has no direction labels.
directionHeaders(): string[] | null { directionHeaders(): string[] | null {
if (!this.isBidirectional) return null; if (!this.isBidirectional) return null;
const energyKey = `main.history.direction.${this.group}.energy`; const energyKey = `main.history.direction.${this.group}.energy`;

View file

@ -59,7 +59,7 @@
<span class="d-block no-wrap text-truncate"> <span class="d-block no-wrap text-truncate">
{{ $t(`main.history.group.${group}`) }} {{ $t(`main.history.group.${group}`) }}
</span> </span>
<small class="d-block no-wrap text-truncate"> <small v-if="groupTotalLabel(group)" class="d-block no-wrap text-truncate">
{{ groupTotalLabel(group) }} {{ groupTotalLabel(group) }}
</small> </small>
</h3> </h3>
@ -447,6 +447,8 @@ export default defineComponent({
return focused !== null && focused !== i; return focused !== null && focused !== i;
}, },
groupTotalLabel(group: string): string { groupTotalLabel(group: string): string {
// Additional meters can be import, export, or consumption, so no meaningful sum.
if (group === "meter") return "";
// Consumption total comes from `home` (overall consumption), // Consumption total comes from `home` (overall consumption),
// not the sum of explicit meter entities. // not the sum of explicit meter entities.
const list = const list =

View file

@ -1266,6 +1266,10 @@
"grid": { "grid": {
"energy": "bezogen", "energy": "bezogen",
"returnEnergy": "eingespeist" "returnEnergy": "eingespeist"
},
"meter": {
"energy": "Energie",
"returnEnergy": "Energie (rückwärts)"
} }
}, },
"downloadCsv": "CSV herunterladen", "downloadCsv": "CSV herunterladen",

View file

@ -1265,6 +1265,10 @@
"grid": { "grid": {
"energy": "imported", "energy": "imported",
"returnEnergy": "exported" "returnEnergy": "exported"
},
"meter": {
"energy": "Energy",
"returnEnergy": "Energy (reverse)"
} }
}, },
"downloadCsv": "Download CSV", "downloadCsv": "Download CSV",

View file

@ -23,13 +23,15 @@ test.describe("pv meter", async () => {
await page.getByRole("button", { name: "Add solar or battery" }).click(); await page.getByRole("button", { name: "Add solar or battery" }).click();
const meterModal = page.getByTestId("meter-modal"); const meterModal = page.getByTestId("meter-modal");
await expectModalVisible(meterModal);
await meterModal.getByRole("button", { name: "Add solar meter" }).click(); await meterModal.getByRole("button", { name: "Add solar meter" }).click();
await meterModal.getByLabel("Title").fill("PV North"); await meterModal.getByLabel("Title").fill("PV North");
await meterModal.getByLabel("Manufacturer").selectOption("Demo meter"); await meterModal.getByLabel("Manufacturer").selectOption("Demo meter");
await meterModal.getByLabel("Power optional").fill("5000");
await page.getByRole("button", { name: "Show advanced settings" }).click(); await page.getByRole("button", { name: "Show advanced settings" }).click();
await expect(meterModal.getByLabel("Minimum charge")).not.toBeVisible(); // battery usage only await expect(meterModal.getByLabel("Minimum charge")).not.toBeVisible(); // battery usage only
await expect(meterModal.getByLabel("Maximum AC power of the hybrid inverter")).toBeVisible(); // pv usage only await expect(meterModal.getByLabel("Maximum AC power of the hybrid inverter")).toBeVisible(); // pv usage only
await meterModal.getByLabel("Power optional").fill("5000");
await expect(meterModal.getByLabel("Power optional")).toHaveValue("5000");
await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible(); await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible();
await meterModal.getByRole("link", { name: "validate" }).click(); await meterModal.getByRole("link", { name: "validate" }).click();
await expect(meterModal.getByTestId("device-tag-power")).toContainText("5.0 kW"); await expect(meterModal.getByTestId("device-tag-power")).toContainText("5.0 kW");

View file

@ -227,8 +227,8 @@ test.describe("additional meters", () => {
const additional = section(page, "meter"); const additional = section(page, "meter");
await expect(additional).toBeVisible(); await expect(additional).toBeVisible();
// Total is the entity sum, not home-derived. // No section total: additional meters can be import, export, or consumption.
await expect(additional.getByRole("heading")).toContainText("1.2 kWh"); await expect(additional.getByRole("heading")).not.toContainText("kWh");
// Explicit entity legend, no virtual "Others" (unlike the consumer group). // Explicit entity legend, no virtual "Others" (unlike the consumer group).
await expect(additional.getByRole("button", { name: "Submeter 1.2 kWh" })).toBeVisible(); await expect(additional.getByRole("button", { name: "Submeter 1.2 kWh" })).toBeVisible();