From a3926a7b69cc4dbe3af23acf80a1099299fd23fe Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sun, 21 Jun 2026 11:16:15 +0200 Subject: [PATCH] History: darken stacked bar shades, dim non-hovered slots, tooltip formatting (#31029) --- assets/js/colors.test.ts | 22 +++- assets/js/colors.ts | 11 ++ assets/js/components/Forecast/Co2Chart.vue | 3 +- assets/js/components/Forecast/PriceChart.vue | 18 +-- assets/js/components/Forecast/SolarChart.vue | 5 +- assets/js/components/Forecast/echarts.ts | 30 +++++ assets/js/components/History/GroupChart.vue | 129 ++++++++++++------- assets/js/utils/escapeHtml.ts | 5 + assets/js/views/History.vue | 10 +- 9 files changed, 167 insertions(+), 66 deletions(-) create mode 100644 assets/js/utils/escapeHtml.ts diff --git a/assets/js/colors.test.ts b/assets/js/colors.test.ts index 0de0ad3af..b90da7864 100644 --- a/assets/js/colors.test.ts +++ b/assets/js/colors.test.ts @@ -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; diff --git a/assets/js/colors.ts b/assets/js/colors.ts index bcbd6775d..fe35b6f26 100644 --- a/assets/js/colors.ts +++ b/assets/js/colors.ts @@ -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"); diff --git a/assets/js/components/Forecast/Co2Chart.vue b/assets/js/components/Forecast/Co2Chart.vue index 5a3d15d3d..f3e4c6164 100644 --- a/assets/js/components/Forecast/Co2Chart.vue +++ b/assets/js/components/Forecast/Co2Chart.vue @@ -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}
${vThis.fmtCo2Medium(p.value[1])}`; + return tooltipTable(time, [{ values: [vThis.fmtCo2Medium(p.value[1])] }]); }, }, xAxis: forecastXAxes(this.startDate, this.endDate, this.weekdayShort), diff --git a/assets/js/components/Forecast/PriceChart.vue b/assets/js/components/Forecast/PriceChart.vue index 6a57b9019..3b416fa60 100644 --- a/assets/js/components/Forecast/PriceChart.vue +++ b/assets/js/components/Forecast/PriceChart.vue @@ -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("
"); + 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), diff --git a/assets/js/components/Forecast/SolarChart.vue b/assets/js/components/Forecast/SolarChart.vue index ba97ee967..750ede612 100644 --- a/assets/js/components/Forecast/SolarChart.vue +++ b/assets/js/components/Forecast/SolarChart.vue @@ -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}
${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), diff --git a/assets/js/components/Forecast/echarts.ts b/assets/js/components/Forecast/echarts.ts index 31fc1c7ab..92b91398e 100644 --- a/assets/js/components/Forecast/echarts.ts +++ b/assets/js/components/Forecast/echarts.ts @@ -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 + ? `${hasName ? "" : ""}${headers + .map((h) => `${h}`) + .join("")}` + : ""; + const body = rows + .map((r) => { + const nameTd = hasName + ? `${escapeHtml(r.name ?? "")}` + : ""; + const valTds = r.values.map((v) => `${v}`).join(""); + return `${nameTd}${valTds}`; + }) + .join(""); + return `${headerRow}${body}
${head}
`; +} + export function forecastGrid() { return { top: 36, right: 16, bottom: 16, left: 24, borderWidth: 0 }; } diff --git a/assets/js/components/History/GroupChart.vue b/assets/js/components/History/GroupChart.vue index 31ee39209..bdeb94d6f 100644 --- a/assets/js/components/History/GroupChart.vue +++ b/assets/js/components/History/GroupChart.vue @@ -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 = 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 = `
${ts != null ? tooltipDate(ts) : ""}
`; + 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 = `${formatValue(t.energy)} / ${formatValue(t.returnEnergy)}`; - const name = nameByIdx.get(i) ?? ""; - return showName - ? `
${name}: ${val}
` - : `
${val}
`; - }) - .join(""); - return head + rows; - } - - const rows = indices - .map((i) => { - const t = totals.get(i) ?? { energy: 0, returnEnergy: 0 }; - const val = `${formatValue(t.energy + t.returnEnergy)}`; - const name = nameByIdx.get(i) ?? ""; - return showName - ? `
${name}: ${val}
` - : `
${val}
`; - }) - .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; }, diff --git a/assets/js/utils/escapeHtml.ts b/assets/js/utils/escapeHtml.ts new file mode 100644 index 000000000..601956e3e --- /dev/null +++ b/assets/js/utils/escapeHtml.ts @@ -0,0 +1,5 @@ +export default function escapeHtml(s: string): string { + const el = document.createElement("div"); + el.textContent = s; + return el.innerHTML; +} diff --git a/assets/js/views/History.vue b/assets/js/views/History.vue index 36aa57a8d..172889d58 100644 --- a/assets/js/views/History.vue +++ b/assets/js/views/History.vue @@ -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;