History UI: optimize production chart (#29946)

This commit is contained in:
Michael Geers 2026-05-16 22:15:04 +02:00 • committed by GitHub
parent 92072aac40
commit 65dd010227
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 63 additions and 46 deletions

View file

@ -97,10 +97,8 @@ export default defineComponent({
valueFactor(): number {
return this.period === PERIODS.DAY ? 4 : 1;
},
// 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;
// Peak per-slot stacked value in the display unit (kW for day, kWh otherwise).
axisPeak(): number {
const factor = this.valueFactor;
const slotSums = new Map<string, number>();
for (const s of this.visibleSeries) {
@ -113,10 +111,18 @@ export default defineComponent({
for (const v of slotSums.values()) if (v > max) max = v;
return max;
},
maxVisibleKw(): number {
return this.period === PERIODS.DAY ? this.axisPeak : Infinity;
},
// 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;
},
// 1 decimal when echarts may pick 0.5-step splits, else 0 — avoids "1, 1, 2, 2".
axisDigits(): number {
if (this.isBidirectional || this.useWatts) return 0;
return this.axisPeak < 3 ? 1 : 0;
},
unit(): "W" | "kW" | "kWh" {
if (this.period !== PERIODS.DAY) return "kWh";
return this.useWatts ? "W" : "kW";
@ -607,9 +613,9 @@ export default defineComponent({
v * 1000,
this.useWatts ? POWER_UNIT.W : POWER_UNIT.KW,
false,
0
this.axisDigits
)
: this.fmtWh(v * 1000, POWER_UNIT.KW, false, 0),
: this.fmtWh(v * 1000, POWER_UNIT.KW, false, this.axisDigits),
},
}),
series: this.echartsSeries,

View file

@ -53,37 +53,16 @@
class="history-tile mb-4"
:data-testid="`history-section-${group}`"
>
<div class="d-flex align-items-baseline gap-3">
<h3
class="fw-normal my-0 d-flex gap-3 flex-wrap align-items-baseline overflow-hidden history-tile-title flex-grow-1"
>
<span class="d-block no-wrap text-truncate">
{{ $t(`main.history.group.${group}`) }}
</span>
<small class="d-block no-wrap text-truncate">
{{ groupTotalLabel(group) }}
</small>
</h3>
<div
v-if="group === 'pv' && hasForecast && effectivePeriod === 'day'"
class="form-check form-switch mb-0 ms-auto forecast-toggle"
:style="{ '--forecast-color': groupColor('forecast') }"
>
<input
id="historyShowForecast"
v-model="showForecast"
class="form-check-input"
type="checkbox"
role="switch"
/>
<label
class="form-check-label text-muted ms-1"
for="historyShowForecast"
>
{{ $t("main.history.group.forecast") }}
</label>
</div>
</div>
<h3
class="fw-normal my-0 d-flex gap-3 flex-wrap align-items-baseline overflow-hidden history-tile-title"
>
<span class="d-block no-wrap text-truncate">
{{ $t(`main.history.group.${group}`) }}
</span>
<small class="d-block no-wrap text-truncate">
{{ groupTotalLabel(group) }}
</small>
</h3>
<GroupChart
v-if="displayFrom && displayTo && displayPeriod"
:group="group"
@ -96,14 +75,19 @@
"
:overlayColor="groupColor('forecast')"
:overlayLabel="$t('main.history.group.forecast')"
:showOverlay="group === 'pv' && displayPeriod === 'day' && showForecast"
:showOverlay="
group === 'pv' &&
displayPeriod === 'day' &&
hasForecast &&
(focusedEntity[group] ?? null) === null
"
:focusedEntity="focusedEntity[group] ?? null"
:period="displayPeriod"
:from="displayFrom"
:to="displayTo"
/>
<ul
v-if="hasEntityLegend(group)"
v-if="hasEntityLegend(group) || hasForecastLegend(group)"
class="entity-legend p-0 mt-4 mb-0 d-flex flex-wrap column-gap-4 row-gap-2"
>
<li
@ -126,6 +110,24 @@
<span class="text-nowrap">{{ legend.label }}</span>
<span class="text-muted text-nowrap">{{ legend.value }}</span>
</li>
<li
v-if="hasForecastLegend(group)"
class="entity-legend-item entity-legend-item--static d-flex align-items-baseline gap-2 no-wrap"
:class="{
'entity-legend-item--dim': (focusedEntity[group] ?? null) !== null,
}"
>
<span
class="entity-legend-dot entity-legend-dot--line align-self-center"
:style="{ backgroundColor: groupColor('forecast') }"
></span>
<span class="text-nowrap">
{{ $t("main.history.group.forecast") }}
</span>
<span class="text-muted text-nowrap">
{{ forecastTotalLabel }}
</span>
</li>
</ul>
</section>
<p v-if="visibleGroups.length" class="text-end mt-3 mb-0">
@ -193,7 +195,6 @@ export default defineComponent({
loading: true,
interval: null as ReturnType<typeof setInterval> | null,
startDate: new Date(2020, 0, 1),
showForecast: true,
focusedEntity: {} as Record<string, number | null>,
};
},
@ -358,6 +359,14 @@ export default defineComponent({
s.data.some((slot) => slot.energy !== 0 || slot.returnEnergy !== 0)
);
},
forecastTotalLabel(): string {
const list = this.seriesByGroup["forecast"] || [];
let sum = 0;
for (const s of list) {
for (const slot of s.data) sum += slot.energy - slot.returnEnergy;
}
return this.fmtWh(Math.abs(sum) * 1000, POWER_UNIT.AUTO);
},
csvLink(): string {
const params = new URLSearchParams({
format: "csv",
@ -406,6 +415,9 @@ export default defineComponent({
if (group === "pv" || group === "battery") return list.length > 1;
return false;
},
hasForecastLegend(group: string): boolean {
return group === "pv" && this.hasForecast && this.effectivePeriod === PERIODS.DAY;
},
entityLegends(group: string): (Legend & { entityIndex: number })[] {
const list = this.displaySeries(group);
const baseColor = groupColor(group);
@ -594,13 +606,12 @@ export default defineComponent({
color: var(--evcc-default-text);
text-decoration: underline;
}
.forecast-toggle .form-check-input:checked {
background-color: var(--forecast-color);
border-color: var(--forecast-color);
.entity-legend-dot--line {
height: 2px;
border-radius: 1px;
}
.forecast-toggle .form-check-input:focus {
border-color: var(--forecast-color);
box-shadow: 0 0 0 0.25rem color-mix(in srgb, var(--forecast-color) 25%, transparent);
.entity-legend-item--static {
cursor: default;
}
.history-tile-title {
margin: 0 0 0.5rem;