History: improve additional meters, tooltip formatting (#31175)
This commit is contained in:
parent
22386f5b17
commit
8f9863538a
7 changed files with 55 additions and 30 deletions
|
|
@ -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
|
||||
? `<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>`
|
||||
: "";
|
||||
const body = rows
|
||||
|
|
@ -133,7 +138,7 @@ export function tooltipTable(head: string, rows: TooltipRow[], headers?: string[
|
|||
const nameTd = hasName
|
||||
? `<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>`;
|
||||
})
|
||||
.join("");
|
||||
|
|
|
|||
|
|
@ -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<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).
|
||||
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<string, number>();
|
||||
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<string, number>();
|
||||
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`;
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@
|
|||
<span class="d-block no-wrap text-truncate">
|
||||
{{ $t(`main.history.group.${group}`) }}
|
||||
</span>
|
||||
<small class="d-block no-wrap text-truncate">
|
||||
<small v-if="groupTotalLabel(group)" class="d-block no-wrap text-truncate">
|
||||
{{ groupTotalLabel(group) }}
|
||||
</small>
|
||||
</h3>
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -1266,6 +1266,10 @@
|
|||
"grid": {
|
||||
"energy": "bezogen",
|
||||
"returnEnergy": "eingespeist"
|
||||
},
|
||||
"meter": {
|
||||
"energy": "Energie",
|
||||
"returnEnergy": "Energie (rückwärts)"
|
||||
}
|
||||
},
|
||||
"downloadCsv": "CSV herunterladen",
|
||||
|
|
|
|||
|
|
@ -1265,6 +1265,10 @@
|
|||
"grid": {
|
||||
"energy": "imported",
|
||||
"returnEnergy": "exported"
|
||||
},
|
||||
"meter": {
|
||||
"energy": "Energy",
|
||||
"returnEnergy": "Energy (reverse)"
|
||||
}
|
||||
},
|
||||
"downloadCsv": "Download CSV",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue