History: darken stacked bar shades, dim non-hovered slots, tooltip formatting (#31029)

This commit is contained in:
Michael Geers 2026-06-21 11:16:15 +02:00 • committed by GitHub
parent 2dee05cf68
commit a3926a7b69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 167 additions and 66 deletions

View file

@ -1,5 +1,12 @@
import { describe, it, expect } from "vitest";
import colors, { dimColor, lighterColor, fullColor, resolveColors, deviceColorMap } from "./colors";
import colors, {
dimColor,
lighterColor,
fullColor,
darken,
resolveColors,
deviceColorMap,
} from "./colors";
import type { DeviceColors } from "./types/evcc";
describe("setAlpha helpers", () => {
@ -26,6 +33,19 @@ describe("setAlpha helpers", () => {
});
});
describe("darken", () => {
it("scales rgb toward black by factor, staying opaque", () => {
expect(darken("#ffffff", 0.5)).toBe("#808080");
expect(darken("#0fde41", 1)).toBe("#0fde41");
expect(darken("#0fde41", 0)).toBe("#000000");
});
it("clamps factor and ignores non-6-digit hex", () => {
expect(darken("#abcdef", 2)).toBe("#abcdef");
expect(darken("rgb(1,2,3)", 0.5)).toBe("rgb(1,2,3)");
});
});
describe("resolveColors", () => {
const palette = colors.palette;

View file

@ -119,6 +119,17 @@ export const lighterColor = (color: string | null) => setAlpha(color, "aa");
export const fullColor = (color: string | null) => setAlpha(color, "ff");
// Darken an opaque hex color: scale rgb toward black by `factor` (0-1), stays opaque.
export function darken(color: string, factor: number): string {
if (!/^#[0-9a-f]{6}$/i.test(color)) return color;
const f = Math.max(0, Math.min(1, factor));
const channel = (o: number) =>
Math.round(parseInt(color.slice(o, o + 2), 16) * f)
.toString(16)
.padStart(2, "0");
return `#${channel(1)}${channel(3)}${channel(5)}`;
}
export function updateCssColors() {
const style = window.getComputedStyle(document.documentElement);
colors.text = style.getPropertyValue("--evcc-default-text");

View file

@ -10,6 +10,7 @@ import {
FONT_FAMILY,
markPointLabel,
tooltipStyle,
tooltipTable,
forecastGrid,
forecastXAxes,
forecastYAxis,
@ -80,7 +81,7 @@ export default defineComponent({
if (!p) return "";
const d = new Date(p.value[0]);
const time = `${vThis.weekdayShort(d)} ${vThis.fmtHourMinute(d)}`;
return `${time}<br/>${vThis.fmtCo2Medium(p.value[1])}`;
return tooltipTable(time, [{ values: [vThis.fmtCo2Medium(p.value[1])] }]);
},
},
xAxis: forecastXAxes(this.startDate, this.endDate, this.weekdayShort),

View file

@ -11,6 +11,7 @@ import {
FONT_FAMILY,
markPointLabel,
tooltipStyle,
tooltipTable,
forecastGrid,
forecastXAxes,
forecastYAxis,
@ -104,23 +105,16 @@ export default defineComponent({
if (!p) return "";
const d = new Date(p.value[0]);
const time = `${vThis.weekdayShort(d)} ${vThis.fmtHourMinute(d)}`;
const lines = [time];
const showLabels = params.length > 1;
const labels = [
vThis.$t("main.energyflow.gridImport"),
vThis.$t("main.energyflow.pvExport"),
];
for (const s of params) {
const price = vThis.fmtPricePerKWh(
s.value[1],
vThis.currency,
true,
true
);
const label = showLabels ? `${labels[s.seriesIndex]}: ` : "";
lines.push(`${label}${price}`);
}
return lines.join("<br/>");
const rows = params.map((s) => ({
name: showLabels ? labels[s.seriesIndex] : undefined,
values: [vThis.fmtPricePerKWh(s.value[1], vThis.currency, true, true)],
}));
return tooltipTable(time, rows);
},
},
xAxis: forecastXAxes(this.startDate, this.endDate, this.weekdayShort),

View file

@ -10,6 +10,7 @@ import {
FONT_FAMILY,
markPointLabel,
tooltipStyle,
tooltipTable,
forecastGrid,
forecastXAxes,
forecastYAxis,
@ -83,7 +84,9 @@ export default defineComponent({
if (!p) return "";
const d = new Date(p.value[0]);
const time = `${this.weekdayShort(d)} ${this.fmtHourMinute(d)}`;
return `${time}<br/>${this.fmtW(p.value[1], POWER_UNIT.AUTO)}`;
return tooltipTable(time, [
{ values: [this.fmtW(p.value[1], POWER_UNIT.AUTO)] },
]);
},
},
xAxis: forecastXAxes(this.startDate, this.endDate, this.weekdayShort),

View file

@ -1,5 +1,6 @@
import * as echarts from "echarts/core";
import colors from "@/colors";
import escapeHtml from "@/utils/escapeHtml";
import type { ForecastSlot } from "./types";
import { BarChart, LineChart } from "echarts/charts";
import {
@ -110,6 +111,35 @@ export function tooltipStyle(
};
}
export interface TooltipRow {
name?: string;
values: string[];
}
// Shared tooltip: bold date headline, non-bold rows, optional name + value columns.
export function tooltipTable(head: string, rows: TooltipRow[], headers?: string[]): 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";
const headerRow = headers?.length
? `<tr>${hasName ? "<td></td>" : ""}${headers
.map((h) => `<td class="fw-normal text-end ps-3">${h}</td>`)
.join("")}</tr>`
: "";
const body = rows
.map((r) => {
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("");
return `<tr>${nameTd}${valTds}</tr>`;
})
.join("");
return `<table class="lh-sm"><thead><tr><th colspan="${colCount}" class="fw-bold text-center">${head}</th></tr></thead><tbody>${headerRow}${body}</tbody></table>`;
}
export function forecastGrid() {
return { top: 36, right: 16, bottom: 16, left: 24, borderWidth: 0 };
}

View file

@ -12,8 +12,9 @@ import {
forecastGrid,
forecastYAxis,
tooltipStyle,
tooltipTable,
} from "../Forecast/echarts";
import colors, { lighterColor, resolveColors, deviceColorMap } from "@/colors";
import colors, { resolveColors, deviceColorMap, darken } from "@/colors";
import store from "@/store";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import { PERIODS } from "../Sessions/types";
@ -50,16 +51,6 @@ export function stepAlpha(i: number, n: number): number {
return Math.max(minAlpha, 1 - (n - 1 - i) * step);
}
export function alphaColor(color: string, alpha: number): string {
const c = (color || "").trim().toLowerCase();
const a = Math.round(Math.max(0, Math.min(1, alpha)) * 255)
.toString(16)
.padStart(2, "0");
if (c.length === 7) return c + a;
if (c.length === 9) return c.slice(0, 7) + a;
return c;
}
// Symmetric axis regardless of whether the period contains both directions.
const BIDIRECTIONAL_GROUPS: ReadonlySet<string> = new Set(["grid", "battery"]);
@ -95,6 +86,7 @@ export default defineComponent({
previousFocusedEntity: number | null;
previousPeriod: PERIODS;
previousSeriesKey: string;
activeSlot: number | null;
} {
return {
chart: null,
@ -103,6 +95,7 @@ export default defineComponent({
previousFocusedEntity: this.focusedEntity as number | null,
previousPeriod: this.period as PERIODS,
previousSeriesKey: "",
activeSlot: null,
};
},
computed: {
@ -205,8 +198,21 @@ export default defineComponent({
categoryKeys(): string[] {
return this.categoryTimestamps.map((t) => this.timestampKey(t));
},
// Which category slots carry a bar, so hover can skip empty slots.
slotsWithData(): boolean[] {
const index = new Map(this.categoryKeys.map((k, i) => [k, i]));
const has = new Array(this.categoryKeys.length).fill(false);
for (const s of this.visibleSeries) {
for (const slot of s.data) {
if (slot.energy <= 0 && slot.returnEnergy <= 0) continue;
const idx = index.get(this.timestampKey(new Date(slot.start).getTime()));
if (idx !== undefined) has[idx] = true;
}
}
return has;
},
entryColors(): string[] {
// Picker groups color per entity; pv/battery use alpha steps of the group color.
// Picker groups color per entity; pv/battery use darker steps of the group color.
if (hasColorPicker(this.group)) {
const mutedColor = colors.muted || this.color;
const titles: string[] = [];
@ -222,9 +228,7 @@ export default defineComponent({
});
}
if (this.series.length <= 1) return [this.color];
return this.series.map((_, i) =>
alphaColor(this.color, stepAlpha(i, this.series.length))
);
return this.series.map((_, i) => darken(this.color, stepAlpha(i, this.series.length)));
},
echartsSeries() {
const cats = this.categoryKeys;
@ -305,10 +309,15 @@ export default defineComponent({
}
}
// hover dims all but the active slot (onChartMouseMove); silent stops single-segment highlight
const barEmphasis = {
silent: true,
emphasis: { focus: "self" },
blur: { itemStyle: { opacity: 0.25 } },
};
this.series.forEach((s, i) => {
const c = this.entryColors[i] || this.color;
const returnEnergyColor =
(s.group === "grid" && colors.export) || lighterColor(c) || c;
const returnEnergyColor = (s.group === "grid" && colors.export) || c;
const energyValues = energyByEntity[i]!;
const returnEnergyValues = returnEnergyByEntity[i]!;
const energyName =
@ -361,6 +370,7 @@ export default defineComponent({
itemStyle: { color: c, borderRadius: [0, 0, 0, 0] },
barCategoryGap: "25%",
barGap: "10%",
...barEmphasis,
});
result.push({
id: `entity-${stableIdx}-returnEnergy`,
@ -371,6 +381,7 @@ export default defineComponent({
itemStyle: { color: returnEnergyColor, borderRadius: [0, 0, 0, 0] },
barCategoryGap: "25%",
barGap: "10%",
...barEmphasis,
});
});
@ -404,6 +415,17 @@ export default defineComponent({
? (t: number) => this.fmtMonthNarrow(new Date(t))
: (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.
directionHeaders(): string[] | null {
if (!this.isBidirectional) return null;
const energyKey = `main.history.direction.${this.group}.energy`;
const returnEnergyKey = `main.history.direction.${this.group}.returnEnergy`;
const energy = this.$t(energyKey);
const returnEnergy = this.$t(returnEnergyKey);
if (energy === energyKey || returnEnergy === returnEnergyKey) return null;
return [String(energy), String(returnEnergy)];
},
tooltipDateLabel(): (t: number) => string {
if (this.period === PERIODS.DAY) {
return (t) => this.fmtTimeSlot(new Date(t), 15 * 60 * 1000);
@ -426,7 +448,12 @@ export default defineComponent({
grid: { ...forecastGrid(), left: 0, right: 36 },
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow", shadowStyle: { color: "transparent" } },
// transparent shadow snaps to slots without a band; triggerEmphasis off (it hard-codes notBlur), we dim slots in onChartMouseMove
axisPointer: {
type: "shadow",
triggerEmphasis: false,
shadowStyle: { color: "transparent" },
},
...tooltipStyle(this.tooltipColor),
// Allow the tooltip to float above the 180px chart container instead
// of being clamped by `confine: true` — otherwise tall bars push the
@ -493,7 +520,7 @@ export default defineComponent({
const first = params.find((p) => p.dataIndex != null);
if (!first) return "";
const ts = cats[first.dataIndex];
const head = `<div>${ts != null ? tooltipDate(ts) : ""}</div>`;
const head = ts != null ? tooltipDate(ts) : "";
const formatValue = (v: number) => {
const watts = Math.abs(v) * 1000;
return this.period === PERIODS.DAY
@ -525,31 +552,17 @@ export default defineComponent({
);
const showName = this.series.length > 1 && this.focusedEntity === null;
if (this.isBidirectional) {
const rows = indices
.map((i) => {
const t = totals.get(i) ?? { energy: 0, returnEnergy: 0 };
const val = `<strong>${formatValue(t.energy)} / ${formatValue(t.returnEnergy)}</strong>`;
const name = nameByIdx.get(i) ?? "";
return showName
? `<div>${name}: ${val}</div>`
: `<div>${val}</div>`;
})
.join("");
return head + rows;
}
const rows = indices
.map((i) => {
const t = totals.get(i) ?? { energy: 0, returnEnergy: 0 };
const val = `<strong>${formatValue(t.energy + t.returnEnergy)}</strong>`;
const name = nameByIdx.get(i) ?? "";
return showName
? `<div>${name}: ${val}</div>`
: `<div>${val}</div>`;
})
.join("");
return head + rows;
const rows = indices.map((i) => {
const t = totals.get(i) ?? { energy: 0, returnEnergy: 0 };
const values = this.isBidirectional
? [formatValue(t.energy), formatValue(t.returnEnergy)]
: [formatValue(t.energy + t.returnEnergy)];
return {
name: showName ? (nameByIdx.get(i) ?? "") : undefined,
values,
};
});
return tooltipTable(head, rows, this.directionHeaders ?? undefined);
},
},
xAxis: {
@ -663,6 +676,9 @@ export default defineComponent({
const el = this.$refs["chartEl"] as HTMLElement;
this.chart = markRaw(echarts.init(el));
this.chart.setOption((this as unknown as WithChartOption).chartOption);
const zr = this.chart.getZr();
zr.on("mousemove", this.onChartMouseMove);
zr.on("globalout", this.clearHighlight);
this.mediaQuery = window.matchMedia("(max-width: 575.98px)");
this.isMobile = this.mediaQuery.matches;
this.mediaQuery.addEventListener("change", this.onMediaChange);
@ -677,6 +693,31 @@ export default defineComponent({
resize() {
this.chart?.resize();
},
// highlight hovered slot, dim rest. manual because built-in axis highlight hard-codes notBlur
onChartMouseMove(e: { offsetX: number; offsetY: number }) {
if (!this.chart) return;
const point: [number, number] = [e.offsetX, e.offsetY];
if (!this.chart.containPixel({ gridIndex: 0 }, point)) {
this.clearHighlight();
return;
}
const grid = this.chart.convertFromPixel({ gridIndex: 0 }, point) as number[];
const slot = Math.round(grid[0]!);
if (slot === this.activeSlot) return;
// Skip empty slots, else hovering a gap would dim the whole chart.
if (!this.slotsWithData[slot]) {
this.clearHighlight();
return;
}
this.activeSlot = slot;
this.chart.dispatchAction({ type: "downplay" });
this.chart.dispatchAction({ type: "highlight", dataIndex: slot });
},
clearHighlight() {
if (this.activeSlot === null) return;
this.activeSlot = null;
this.chart?.dispatchAction({ type: "downplay" });
},
onMediaChange(e: MediaQueryListEvent) {
this.isMobile = e.matches;
},

View file

@ -0,0 +1,5 @@
export default function escapeHtml(s: string): string {
const el = document.createElement("div");
el.textContent = s;
return el.innerHTML;
}

View file

@ -116,16 +116,12 @@ import Header from "../components/Top/Header.vue";
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
import DateNavigator from "../components/Sessions/DateNavigator.vue";
import PeriodHeader from "../components/Sessions/PeriodHeader.vue";
import GroupChart, {
type HistorySeries,
alphaColor,
stepAlpha,
} from "../components/History/GroupChart.vue";
import GroupChart, { type HistorySeries, stepAlpha } from "../components/History/GroupChart.vue";
import type { Legend } from "../components/Sessions/types";
import type { DeviceColors } from "@/types/evcc";
import { PERIODS } from "../components/Sessions/types";
import { GROUP_ORDER, groupColor, hasColorPicker } from "../components/History/groups";
import colors, { resolveColors, deviceColorMap } from "../colors";
import colors, { resolveColors, deviceColorMap, darken } from "../colors";
import LegendList from "../components/Sessions/LegendList.vue";
import { handleDownloadClick } from "@/utils/native";
import formatter, { POWER_UNIT } from "../mixins/formatter";
@ -422,7 +418,7 @@ export default defineComponent({
const colorFor = (i: number, s: HistorySeries) => {
if (s.virtual) return colors.muted || baseColor;
if (colorPicker) return palette[s.title] || baseColor;
return alphaColor(baseColor, stepAlpha(i, Math.max(n, 1)));
return darken(baseColor, stepAlpha(i, Math.max(n, 1)));
};
return list.map((s, i) => {
let sum = 0;