diff --git a/assets/js/components/Forecast/echarts.ts b/assets/js/components/Forecast/echarts.ts index 92b91398e..5095a9dc9 100644 --- a/assets/js/components/Forecast/echarts.ts +++ b/assets/js/components/Forecast/echarts.ts @@ -121,11 +121,16 @@ export function tooltipTable(head: string, rows: TooltipRow[], headers?: string[ const hasName = rows.some((r) => r.name != null); const valueCols = Math.max(1, ...rows.map((r) => r.values.length)); const colCount = (hasName ? 1 : 0) + valueCols; - // lone value centers under the date; multiple columns line up right - const valCls = hasName || valueCols > 1 ? "fw-normal text-end ps-3" : "fw-normal text-center"; + // No name col + two value cols: first col left-aligned, second right-aligned. + // 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 ? `${hasName ? "" : ""}${headers - .map((h) => `${h}`) + .map((h, i) => `${h}`) .join("")}` : ""; const body = rows @@ -133,7 +138,7 @@ export function tooltipTable(head: string, rows: TooltipRow[], headers?: string[ const nameTd = hasName ? `${escapeHtml(r.name ?? "")}` : ""; - const valTds = r.values.map((v) => `${v}`).join(""); + const valTds = r.values.map((v, i) => `${v}`).join(""); return `${nameTd}${valTds}`; }) .join(""); diff --git a/assets/js/components/History/GroupChart.vue b/assets/js/components/History/GroupChart.vue index bdeb94d6f..1bc84d3c1 100644 --- a/assets/js/components/History/GroupChart.vue +++ b/assets/js/components/History/GroupChart.vue @@ -54,6 +54,9 @@ export function stepAlpha(i: number, n: number): number { // Symmetric axis regardless of whether the period contains both directions. const BIDIRECTIONAL_GROUPS: ReadonlySet = new Set(["grid", "battery"]); +// Multiple entities stack into one bar; grid and meter render side-by-side. +const STACKED_GROUPS: ReadonlySet = new Set(["loadpoint", "consumer", "pv", "battery"]); + // Round up to a nice number (5-tick symmetric axis: -L, -L/2, 0, L/2, L). function niceCeil(v: number): number { if (v <= 0) return 0; @@ -104,19 +107,33 @@ export default defineComponent({ valueFactor(): number { 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 // clipped. Bidirectional: pos/neg separately. axisPeak(): number { 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 sums = new Map(); - for (const s of series) { - for (const slot of s.data) { - sums.set(slot.start, (sums.get(slot.start) || 0) + pick(slot) * factor); + if (this.stackEntities) { + const sums = new Map(); + for (const s of series) { + 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; - 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; }; if (this.isBidirectional) { @@ -269,13 +286,6 @@ export default defineComponent({ // Always render import + export series per entity, even if one direction // is empty (null-filled). Stable series ids/structure across renders so // 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, // which entity is the *visible* top/bottom of the stack — that one // 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"); // Same stack name for import and export means they share one x slot // (positive values stack up, negative stack down, no width penalty). - const stackName = stackEntities ? `group-${this.group}` : `entity-${i}`; - // For non-stacked groups every bar is its own cap. For stacked groups - // 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 stackName = this.stackEntities ? `group-${this.group}` : `entity-${i}`; + // Rounded cap goes on the visible top/bottom per slot: non-stacked bars + // always cap; stacked groups cap the topmost non-zero entity so an empty + // top entity doesn't drop the rounding; a focused entity is solo. const stableIdx = s.paletteIndex ?? i; const energyData: ( | number @@ -342,7 +349,7 @@ export default defineComponent({ )[] = energyValues.map((v, idx) => { if (v == null) return v; const isTop = - !stackEntities || + !this.stackEntities || topEnergyPerSlot[idx] === i || this.focusedEntity === stableIdx; if (!isTop) return v; @@ -355,7 +362,7 @@ export default defineComponent({ )[] = returnEnergyValues.map((v, idx) => { if (v == null) return v; const isBottom = - !stackEntities || + !this.stackEntities || topReturnEnergyPerSlot[idx] === i || this.focusedEntity === stableIdx; if (!isBottom) return v; @@ -416,7 +423,8 @@ export default defineComponent({ : (t: number) => this.fmtMonth(new Date(t), true); }, // 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 { if (!this.isBidirectional) return null; const energyKey = `main.history.direction.${this.group}.energy`; diff --git a/assets/js/views/History.vue b/assets/js/views/History.vue index 172889d58..712728878 100644 --- a/assets/js/views/History.vue +++ b/assets/js/views/History.vue @@ -59,7 +59,7 @@ {{ $t(`main.history.group.${group}`) }} - + {{ groupTotalLabel(group) }} @@ -447,6 +447,8 @@ export default defineComponent({ return focused !== null && focused !== i; }, 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), // not the sum of explicit meter entities. const list = diff --git a/i18n/de.json b/i18n/de.json index 1d62d0407..2bab9c2d9 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1266,6 +1266,10 @@ "grid": { "energy": "bezogen", "returnEnergy": "eingespeist" + }, + "meter": { + "energy": "Energie", + "returnEnergy": "Energie (rückwärts)" } }, "downloadCsv": "CSV herunterladen", diff --git a/i18n/en.json b/i18n/en.json index df34c3032..58f6fe75b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1265,6 +1265,10 @@ "grid": { "energy": "imported", "returnEnergy": "exported" + }, + "meter": { + "energy": "Energy", + "returnEnergy": "Energy (reverse)" } }, "downloadCsv": "Download CSV", diff --git a/tests/config-pv.spec.ts b/tests/config-pv.spec.ts index 7215d5262..5c6fc8608 100644 --- a/tests/config-pv.spec.ts +++ b/tests/config-pv.spec.ts @@ -23,13 +23,15 @@ test.describe("pv meter", async () => { await page.getByRole("button", { name: "Add solar or battery" }).click(); const meterModal = page.getByTestId("meter-modal"); + await expectModalVisible(meterModal); await meterModal.getByRole("button", { name: "Add solar meter" }).click(); await meterModal.getByLabel("Title").fill("PV North"); await meterModal.getByLabel("Manufacturer").selectOption("Demo meter"); - await meterModal.getByLabel("Power optional").fill("5000"); await page.getByRole("button", { name: "Show advanced settings" }).click(); 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 meterModal.getByLabel("Power optional").fill("5000"); + await expect(meterModal.getByLabel("Power optional")).toHaveValue("5000"); await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible(); await meterModal.getByRole("link", { name: "validate" }).click(); await expect(meterModal.getByTestId("device-tag-power")).toContainText("5.0 kW"); diff --git a/tests/energy-history.spec.ts b/tests/energy-history.spec.ts index 53dd209ce..07fe356ee 100644 --- a/tests/energy-history.spec.ts +++ b/tests/energy-history.spec.ts @@ -227,8 +227,8 @@ test.describe("additional meters", () => { const additional = section(page, "meter"); await expect(additional).toBeVisible(); - // Total is the entity sum, not home-derived. - await expect(additional.getByRole("heading")).toContainText("1.2 kWh"); + // No section total: additional meters can be import, export, or consumption. + await expect(additional.getByRole("heading")).not.toContainText("kWh"); // Explicit entity legend, no virtual "Others" (unlike the consumer group). await expect(additional.getByRole("button", { name: "Submeter 1.2 kWh" })).toBeVisible();