diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index 6572242b5..14f9d7734 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -466,14 +466,9 @@ export default defineComponent({ return Math.max(0, this.gridPower * -1); }, powerUnit() { - const watt = Math.max(this.gridImport, this.selfPv, this.selfBattery, this.pvExport); - if (watt >= 1_000_000) { - return POWER_UNIT.MW; - } else if (watt >= 1000) { - return POWER_UNIT.KW; - } else { - return POWER_UNIT.W; - } + return this.getPowerUnit( + Math.max(this.gridImport, this.selfPv, this.selfBattery, this.pvExport) + ); }, inPower() { return this.gridImport + this.pvProduction + this.batteryDischarge; diff --git a/assets/js/components/Forecast/echarts.ts b/assets/js/components/Forecast/echarts.ts index 6798c2f3c..246b637d9 100644 --- a/assets/js/components/Forecast/echarts.ts +++ b/assets/js/components/Forecast/echarts.ts @@ -120,6 +120,7 @@ export function tooltipStyle( export interface TooltipRow { name?: string; values: string[]; + total?: boolean; } // Shared tooltip: bold date headline, non-bold rows, optional name + value columns. @@ -127,28 +128,40 @@ 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; - // 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"; + // first of two unnamed value cols + if (!hasName && valueCols > 1 && i === 0) return "text-start"; + // named or multiple cols + if (hasName || valueCols > 1) return "text-end ps-3"; + // lone value + return "text-center"; }; const headerRow = headers?.length ? `${hasName ? "" : ""}${headers - .map((h, i) => `${h}`) + .map((h, i) => `${h}`) .join("")}` : ""; + const rowHtml = (r: TooltipRow) => { + const cls = r.total ? " pt-1" : ""; + const nameTd = hasName + ? `${escapeHtml(r.name ?? "")}` + : ""; + const valTds = r.values + .map((v, i) => `${v}`) + .join(""); + return `${nameTd}${valTds}`; + }; const body = rows - .map((r) => { - const nameTd = hasName - ? `${escapeHtml(r.name ?? "")}` - : ""; - const valTds = r.values.map((v, i) => `${v}`).join(""); - return `${nameTd}${valTds}`; - }) + .filter((r) => !r.total) + .map(rowHtml) .join(""); - return `${headerRow}${body}
${head}
`; + const footRows = rows.filter((r) => r.total); + const foot = footRows.length + ? `${footRows + .map(rowHtml) + .join("")}` + : ""; + return `${headerRow}${body}${foot}
${head}
`; } export function forecastGrid() { diff --git a/assets/js/components/History/GroupChart.vue b/assets/js/components/History/GroupChart.vue index 59d4b0f43..d30e86a69 100644 --- a/assets/js/components/History/GroupChart.vue +++ b/assets/js/components/History/GroupChart.vue @@ -14,6 +14,7 @@ import { tooltipStyle, tooltipTable, xAxisLabelStyle, + type TooltipRow, } from "../Forecast/echarts"; import colors, { resolveColors, deviceColorMap, darken, batteryColor, setAlpha } from "@/colors"; import store from "@/store"; @@ -528,12 +529,6 @@ export default defineComponent({ if (!first) return ""; const ts = cats[first.dataIndex]; const head = ts != null ? tooltipDate(ts) : ""; - const formatValue = (v: number) => { - const watts = Math.abs(v) * 1000; - return this.period === PERIODS.DAY - ? this.fmtW(watts, POWER_UNIT.AUTO) - : this.fmtWh(watts, POWER_UNIT.AUTO); - }; // Collect energy/returnEnergy values per entity from this slot's params. const totals = new Map(); @@ -559,8 +554,29 @@ export default defineComponent({ ); const showName = this.series.length > 1 && this.focusedEntity === null; - const rows = indices.map((i) => { - const t = totals.get(i) ?? { energy: 0, returnEnergy: 0 }; + // one unit for all rows, based on the largest individual value (not the total) + const rowValues = indices.map( + (i) => totals.get(i) ?? { energy: 0, returnEnergy: 0 } + ); + const unit = this.getPowerUnit( + Math.max( + 0, + ...rowValues.flatMap((t) => + this.isBidirectional + ? [t.energy, t.returnEnergy] + : [t.energy + t.returnEnergy] + ) + ) * 1000 + ); + const formatValue = (v: number) => { + const watts = Math.abs(v) * 1000; + return this.period === PERIODS.DAY + ? this.fmtW(watts, unit) + : this.fmtWh(watts, unit); + }; + + const rows: TooltipRow[] = indices.map((i, idx) => { + const t = rowValues[idx] ?? { energy: 0, returnEnergy: 0 }; const values = this.isBidirectional ? [formatValue(t.energy), formatValue(t.returnEnergy)] : [formatValue(t.energy + t.returnEnergy)]; @@ -569,6 +585,17 @@ export default defineComponent({ values, }; }); + if (showName) { + const sum = (key: "energy" | "returnEnergy") => + rowValues.reduce((acc, t) => acc + t[key], 0); + rows.push({ + name: this.$t("sessions.total"), + values: this.isBidirectional + ? [formatValue(sum("energy")), formatValue(sum("returnEnergy"))] + : [formatValue(sum("energy") + sum("returnEnergy"))], + total: true, + }); + } return tooltipTable(head, rows, this.directionHeaders ?? undefined); }, }, diff --git a/assets/js/mixins/formatter.test.ts b/assets/js/mixins/formatter.test.ts index 638d3709e..6089e0899 100644 --- a/assets/js/mixins/formatter.test.ts +++ b/assets/js/mixins/formatter.test.ts @@ -56,6 +56,16 @@ describe("fmtW", () => { }); }); +describe("getPowerUnit", () => { + test("should pick unit based on largest value", () => { + expect(fmt.getPowerUnit(0)).eq(POWER_UNIT.W); + expect(fmt.getPowerUnit(999)).eq(POWER_UNIT.W); + expect(fmt.getPowerUnit(1000)).eq(POWER_UNIT.KW); + expect(fmt.getPowerUnit(9_999_999)).eq(POWER_UNIT.KW); + expect(fmt.getPowerUnit(10_000_000)).eq(POWER_UNIT.MW); + }); +}); + describe("fmtWh", () => { test("should format with units", () => { expect(fmt.fmtWh(0, POWER_UNIT.AUTO)).eq("0,0 kWh"); diff --git a/assets/js/mixins/formatter.ts b/assets/js/mixins/formatter.ts index c7d8f88f4..de7e11538 100644 --- a/assets/js/mixins/formatter.ts +++ b/assets/js/mixins/formatter.ts @@ -73,17 +73,14 @@ export default defineComponent({ const base = 10 ** precision; return (Math.round(num * base) / base).toFixed(precision); }, + getPowerUnit(watt: number): POWER_UNIT { + return watt >= 10_000_000 ? POWER_UNIT.MW : watt >= 1000 ? POWER_UNIT.KW : POWER_UNIT.W; + }, fmtW(watt = 0, format = POWER_UNIT.KW, withUnit = true, digits?: number) { let unit = format; let d = digits; if (POWER_UNIT.AUTO === unit) { - if (watt >= 10_000_000) { - unit = POWER_UNIT.MW; - } else if (watt >= 1000 || 0 === watt) { - unit = POWER_UNIT.KW; - } else { - unit = POWER_UNIT.W; - } + unit = watt === 0 ? POWER_UNIT.KW : this.getPowerUnit(watt); } let value = watt; if (POWER_UNIT.KW === unit) { diff --git a/tests/history-grid-swap.spec.ts b/tests/history-grid-swap.spec.ts index 7857d9888..62430f647 100644 --- a/tests/history-grid-swap.spec.ts +++ b/tests/history-grid-swap.spec.ts @@ -26,8 +26,8 @@ test("tooltip shows one merged grid entity", async ({ page }) => { const tooltip = chart.locator("table"); await expect(tooltip).toBeVisible(); - // single unnamed row, no per-entity rows + // single unnamed row, no per-entity rows; one unit across both columns await expect(tooltip).toHaveText( - ["12:00 – 12:15", "imported", "exported", "2.0 kW", "400 W"].join("") + ["12:00 – 12:15", "imported", "exported", "2.0 kW", "0.4 kW"].join("") ); });