From 6d2ed7d2aceffc45577b903245dc8d4c4724e488 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Fri, 10 Jul 2026 16:35:19 +0200 Subject: [PATCH] Battery UI: history chart polish, axis consistency (#31661) --- .../components/Battery/BatteryHistoryCard.vue | 9 +- .../Battery/BatteryHistoryChart.vue | 148 ++++++------------ assets/js/components/Forecast/Co2Chart.vue | 7 +- assets/js/components/Forecast/PriceChart.vue | 7 +- assets/js/components/Forecast/SolarChart.vue | 7 +- assets/js/components/Forecast/echarts.ts | 31 +++- assets/js/components/History/GroupChart.vue | 23 +-- assets/js/mixins/formatter.test.ts | 45 ++++++ assets/js/mixins/formatter.ts | 33 ++-- 9 files changed, 159 insertions(+), 151 deletions(-) diff --git a/assets/js/components/Battery/BatteryHistoryCard.vue b/assets/js/components/Battery/BatteryHistoryCard.vue index c420d6980..cb89b2cca 100644 --- a/assets/js/components/Battery/BatteryHistoryCard.vue +++ b/assets/js/components/Battery/BatteryHistoryCard.vue @@ -24,7 +24,6 @@ :win-start="winStart.getTime()" :win-end="winEnd.getTime()" :now="now.getTime()" - :has-forecast="windowHasForecast" :day-offset="dayOffset" :focused="focusedBattery" /> @@ -91,9 +90,6 @@ export default defineComponent({ hasForecastData(): boolean { return this.batteries.some((b) => b.forecast.length > 0); }, - windowHasForecast(): boolean { - return this.hasForecastData && this.winEnd > this.now; - }, winStart(): Date { const baseStartH = this.hasForecastData ? 24 : 48; return new Date(this.now.getTime() - baseStartH * HOUR + this.dayOffset * 24 * HOUR); @@ -103,8 +99,9 @@ export default defineComponent({ return new Date(this.now.getTime() + baseEndH * HOUR + this.dayOffset * 24 * HOUR); }, windowLabel(): string { - // short weekday + day, no month (e.g. "Sa. 27. – Mo. 29.") - const fmt = (d: Date) => `${this.weekdayShort(d)} ${d.getDate()}.`; + // relative day name if close (e.g. "gestern – morgen"), else "Sa. 27." + const fmt = (d: Date) => + this.relativeDayName(d) ?? `${this.weekdayShort(d)} ${d.getDate()}.`; return `${fmt(this.winStart)} – ${fmt(this.winEnd)}`; }, prevDisabled(): boolean { diff --git a/assets/js/components/Battery/BatteryHistoryChart.vue b/assets/js/components/Battery/BatteryHistoryChart.vue index 9cec80c43..3ae4ff3fe 100644 --- a/assets/js/components/Battery/BatteryHistoryChart.vue +++ b/assets/js/components/Battery/BatteryHistoryChart.vue @@ -7,12 +7,13 @@ import { defineComponent, markRaw, type PropType } from "vue"; import { echarts, FONT_FAMILY, + forecastXAxes, forecastYAxis, tooltipStyle, tooltipTable, type TooltipRow, } from "../Forecast/echarts"; -import colors, { dimColor, lighterColor, batteryColor } from "@/colors"; +import colors, { dimColor, setAlpha, batteryColor } from "@/colors"; import formatter, { POWER_UNIT } from "@/mixins/formatter"; import { is12hFormat } from "@/units"; import type { SocPoint, BatterySeries } from "./types"; @@ -20,9 +21,28 @@ import type { SocPoint, BatterySeries } from "./types"; type EChartsType = ReturnType; type Point = [number, number]; -const GRID = { top: 10, right: 36, bottom: 26, left: 0 }; +const GRID = { top: 10, right: 36, bottom: 34, left: 0 }; const BADGE_GAP = 24; // badge height (12px font + 2x4px padding) plus spacing +// diagonal-stripe fill for forecast areas +function hatchPattern(color: string) { + const size = 8; + const dpr = window.devicePixelRatio || 1; + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = size * dpr; + const ctx = canvas.getContext("2d")!; + ctx.scale(dpr, dpr); + ctx.strokeStyle = setAlpha(color, "66") || color; + ctx.lineWidth = 1; + ctx.beginPath(); + for (const o of [-size, 0, size]) { + ctx.moveTo(o, size); + ctx.lineTo(o + size, 0); + } + ctx.stroke(); + return { image: canvas, repeat: "repeat" }; +} + export default defineComponent({ name: "BatteryHistoryChart", mixins: [formatter], @@ -32,7 +52,6 @@ export default defineComponent({ winStart: { type: Number, required: true }, winEnd: { type: Number, required: true }, now: { type: Number, required: true }, - hasForecast: Boolean, dayOffset: { type: Number, default: 0 }, // paging position; slide animates only when it changes focused: { type: Number as PropType, default: null }, }, @@ -85,18 +104,9 @@ export default defineComponent({ z: 3, data: this.socHistory(b), showSymbol: false, - lineStyle: { color: c, width: 2 }, + lineStyle: { color: c, width: 3 }, itemStyle: { color: c }, - ...(this.single - ? { - areaStyle: { - color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ - { offset: 0, color: lighterColor(c) || c }, - { offset: 1, color: (c || "#000000") + "00" }, - ]), - }, - } - : {}), + ...(this.single ? { areaStyle: { color: dimColor(c) } } : {}), emphasis: { disabled: true }, }); series.push({ @@ -105,9 +115,9 @@ export default defineComponent({ z: 3, data: this.socForecast(b), showSymbol: false, - lineStyle: { color: c, width: 2, type: "dashed" }, + lineStyle: { color: c, width: 2, type: "dotted" }, itemStyle: { color: c }, - ...(this.single ? { areaStyle: { color: dimColor(c) } } : {}), + ...(this.single ? { areaStyle: { color: hatchPattern(c) } } : {}), emphasis: { disabled: true }, }); }); @@ -123,9 +133,9 @@ export default defineComponent({ data: this.energyData(b, "hist"), showSymbol: false, smooth: 0.4, - lineStyle: { width: 0 }, + lineStyle: { color: c, width: 3 }, itemStyle: { color: c }, - areaStyle: { color: c }, + areaStyle: { color: setAlpha(c, "60") }, emphasis: { disabled: true }, }); series.push({ @@ -136,47 +146,15 @@ export default defineComponent({ data: this.energyData(b, "fc"), showSymbol: false, smooth: 0.4, - lineStyle: { color: c, width: 1, type: "dashed" }, + lineStyle: { color: c, width: 2, type: "dotted" }, itemStyle: { color: c }, - areaStyle: { color: dimColor(c) }, + areaStyle: { color: hatchPattern(c) }, emphasis: { disabled: true }, }); }); } - // now marker as a 2-point series so it slides with the axis on paging - if (this.nowInWindow) { - series.push({ - id: "now-line", - type: "line", - data: [ - [this.now, 0], - [this.now, this.yMax], - ], - showSymbol: false, - silent: true, - z: 5, - lineStyle: { color: colors.muted || "", width: 1, type: "dashed" }, - emphasis: { disabled: true }, - }); - } - // overlay carries the forecast-region shading - series.push({ - id: "overlay", - type: "line", - data: [], - silent: true, - z: 4, - markArea: this.markArea, - }); return series; }, - markArea(): Record { - const data = - this.hasForecast && this.nowInWindow - ? [[{ xAxis: this.now }, { xAxis: this.winEnd }]] - : []; - return { silent: true, itemStyle: { color: colors.muted || "", opacity: 0.06 }, data }; - }, chartOption(): Record { return { // no echarts animation; paging is driven manually via slideWindow @@ -207,51 +185,18 @@ export default defineComponent({ }; }, xAxes(): Record[] { - const h12 = is12hFormat(); - return [ - // hours every 6h, midnight handled by the day axis below - { - type: "time", - min: this.winStart, - max: this.winEnd, - minInterval: 6 * 3600 * 1000, - axisLine: { show: false }, - axisTick: { show: false }, - splitLine: { show: false }, - axisLabel: { - color: colors.muted || "", - fontSize: 11, - formatter: (value: number) => { - const d = new Date(value); - const h = d.getHours(); - if (d.getMinutes() !== 0 || h === 0 || h % 6 !== 0) return ""; - return h12 ? `${h % 12 || 12} ${h < 12 ? "AM" : "PM"}` : String(h); - }, - }, - }, - // day axis: a tick at every local midnight, labelled with the weekday + dashed divider - { - type: "time", - position: "bottom", - min: this.winStart, - max: this.winEnd, - minInterval: 24 * 3600 * 1000, - maxInterval: 24 * 3600 * 1000, - axisLine: { show: false }, - axisTick: { show: false }, - axisLabel: { - color: colors.muted || "", - fontSize: 11, - formatter: (value: number) => this.weekdayShort(new Date(value)), - }, - splitLine: { - show: true, - showMinLine: false, - showMaxLine: false, - lineStyle: { color: colors.border || "", type: "dashed" }, - }, - }, - ]; + // narrow 48h window: wider "4 PM" labels need extra spacing + const stepHours = is12hFormat() ? 6 : 4; + const [hourAxis, dayAxis] = forecastXAxes( + this.winStart, + this.winEnd, + this.hourShort, + this.weekdayShort, + stepHours + ); + // stronger day divider + dayAxis.splitLine.lineStyle = { color: colors.muted || "", type: "solid" }; + return [hourAxis, dayAxis]; }, }, watch: { @@ -441,7 +386,7 @@ export default defineComponent({ type: "circle", z: 10, shape: { cx: px[0], cy: px[1], r: 4 }, - style: { fill, stroke: colors.background || "", lineWidth: 2 }, + style: { fill: colors.background || "", stroke: fill, lineWidth: 3 }, }); elements.push({ type: "text", @@ -515,11 +460,6 @@ export default defineComponent({ diff --git a/assets/js/components/Forecast/Co2Chart.vue b/assets/js/components/Forecast/Co2Chart.vue index f3e4c6164..2806ce2c0 100644 --- a/assets/js/components/Forecast/Co2Chart.vue +++ b/assets/js/components/Forecast/Co2Chart.vue @@ -84,7 +84,12 @@ export default defineComponent({ return tooltipTable(time, [{ values: [vThis.fmtCo2Medium(p.value[1])] }]); }, }, - xAxis: forecastXAxes(this.startDate, this.endDate, this.weekdayShort), + xAxis: forecastXAxes( + this.startDate, + this.endDate, + this.hourShort, + this.weekdayShort + ), yAxis: forecastYAxis({ splitNumber: 2, axisLabel: { diff --git a/assets/js/components/Forecast/PriceChart.vue b/assets/js/components/Forecast/PriceChart.vue index 3b416fa60..804416f3e 100644 --- a/assets/js/components/Forecast/PriceChart.vue +++ b/assets/js/components/Forecast/PriceChart.vue @@ -117,7 +117,12 @@ export default defineComponent({ return tooltipTable(time, rows); }, }, - xAxis: forecastXAxes(this.startDate, this.endDate, this.weekdayShort), + xAxis: forecastXAxes( + this.startDate, + this.endDate, + this.hourShort, + this.weekdayShort + ), yAxis: forecastYAxis({ ...this.yAxisConfig, axisLabel: { diff --git a/assets/js/components/Forecast/SolarChart.vue b/assets/js/components/Forecast/SolarChart.vue index 750ede612..b2c997e29 100644 --- a/assets/js/components/Forecast/SolarChart.vue +++ b/assets/js/components/Forecast/SolarChart.vue @@ -89,7 +89,12 @@ export default defineComponent({ ]); }, }, - xAxis: forecastXAxes(this.startDate, this.endDate, this.weekdayShort), + xAxis: forecastXAxes( + this.startDate, + this.endDate, + this.hourShort, + this.weekdayShort + ), yAxis: forecastYAxis({ max: (value: { max: number }) => { const m = Math.max(value.max, this.combinedMax); diff --git a/assets/js/components/Forecast/echarts.ts b/assets/js/components/Forecast/echarts.ts index 902453f46..6798c2f3c 100644 --- a/assets/js/components/Forecast/echarts.ts +++ b/assets/js/components/Forecast/echarts.ts @@ -155,7 +155,23 @@ export function forecastGrid() { return { top: 36, right: 16, bottom: 16, left: 24, borderWidth: 0 }; } -export function forecastXAxes(startDate: Date, endDate: Date, weekdayShort: (d: Date) => string) { +// common x-axis label styling across time-based charts +export function xAxisLabelStyle() { + return { + color: colors.muted || "", + fontSize: 14, + lineHeight: Math.round(14 * 1.1), + margin: 4, + }; +} + +export function forecastXAxes( + startDate: Date | number, + endDate: Date | number, + hourShort: (d: Date) => string, + weekdayShort: (d: Date) => string, + stepHours = 4 +) { return [ { type: "time", @@ -164,16 +180,15 @@ export function forecastXAxes(startDate: Date, endDate: Date, weekdayShort: (d: minInterval: 3600 * 1000, maxInterval: 3600 * 1000, axisLabel: { - color: colors.muted, - fontSize: 14, - lineHeight: Math.round(14 * 1.1), - margin: 4, + ...xAxisLabelStyle(), + hideOverlap: false, formatter: (value: number) => { const date = new Date(value); const h = date.getHours(); - if (h % 4 !== 0) return ""; - if (h === 0) return `${h}\n${weekdayShort(date)}`; - return `${h}`; + if (h % stepHours !== 0) return ""; + const label = hourShort(date); + if (h === 0) return `${label}\n${weekdayShort(date)}`; + return label; }, }, splitLine: { show: false }, diff --git a/assets/js/components/History/GroupChart.vue b/assets/js/components/History/GroupChart.vue index 8bde265aa..59d4b0f43 100644 --- a/assets/js/components/History/GroupChart.vue +++ b/assets/js/components/History/GroupChart.vue @@ -13,6 +13,7 @@ import { forecastYAxis, tooltipStyle, tooltipTable, + xAxisLabelStyle, } from "../Forecast/echarts"; import colors, { resolveColors, deviceColorMap, darken, batteryColor, setAlpha } from "@/colors"; import store from "@/store"; @@ -402,21 +403,14 @@ export default defineComponent({ labelForTimestamp(): (t: number) => string { if (this.period === PERIODS.DAY) { // Skip 00:00 so the chart can align with the section title on the left. - // Use every 6h on mobile (06/12/18) and every 3h on desktop. 12 is a - // multiple of both, so noon stays visible. Drop minutes; honor am/pm. - const stepHours = this.isMobile ? 6 : 3; - const h12 = is12hFormat(); + // wider "4 PM" labels get double spacing + const stepHours = (this.isMobile ? 2 : 1) * (is12hFormat() ? 2 : 1); return (t: number) => { const d = new Date(t); if (d.getMinutes() !== 0) return ""; const h = d.getHours(); - if (h === 0) return ""; - if (h % stepHours !== 0) return ""; - if (h12) { - const hh = h % 12 || 12; - return `${hh} ${h < 12 ? "AM" : "PM"}`; - } - return String(h); + if (h === 0 || h % stepHours !== 0) return ""; + return this.hourShort(d); }; } if (this.period === PERIODS.MONTH) { @@ -591,11 +585,8 @@ export default defineComponent({ axisTick: { show: false }, splitLine: { show: false }, axisLabel: { - color: colors.muted || "", - fontSize: 11, - hideOverlap: - this.period !== PERIODS.DAY && - !(this.period === PERIODS.YEAR && this.isMobile), + ...xAxisLabelStyle(), + hideOverlap: !(this.period === PERIODS.YEAR && this.isMobile), interval: this.period === PERIODS.DAY || (this.period === PERIODS.YEAR && this.isMobile) diff --git a/assets/js/mixins/formatter.test.ts b/assets/js/mixins/formatter.test.ts index 3fd2606f8..638d3709e 100644 --- a/assets/js/mixins/formatter.test.ts +++ b/assets/js/mixins/formatter.test.ts @@ -297,3 +297,48 @@ describe("fmtTimeRange", () => { expect(fmt.fmtTimeRange("")).toBe(""); }); }); + +describe("hourShort", () => { + const afternoon = new Date(2026, 0, 1, 16); + + test("should strip locale suffixes in 24h format", () => { + is12hSpy.mockReturnValue(false); + for (const locale of ["de", "fr", "en", "ja"]) { + config.global.mocks["$i18n"].locale = locale; + expect(fmt.hourShort(afternoon)).toBe("16"); + } + config.global.mocks["$i18n"].locale = "de"; + }); + + test("should include day period in 12h format", () => { + is12hSpy.mockReturnValue(true); + const expected = { de: "4 PM", fr: "4 PM", en: "4 PM", ja: "午後 4" }; + for (const [locale, value] of Object.entries(expected)) { + config.global.mocks["$i18n"].locale = locale; + expect(fmt.hourShort(afternoon)).toBe(value); + } + config.global.mocks["$i18n"].locale = "en"; + expect(fmt.hourShort(new Date(2026, 0, 1, 0))).toBe("12 AM"); + is12hSpy.mockReturnValue(false); + config.global.mocks["$i18n"].locale = "de"; + }); +}); + +describe("relativeDayName", () => { + const day = (offset: number) => { + const d = new Date(); + d.setDate(d.getDate() + offset); + return d; + }; + + test("should name adjacent days", () => { + expect(fmt.relativeDayName(day(-1))).toBe("gestern"); + expect(fmt.relativeDayName(day(0))).toBe("heute"); + expect(fmt.relativeDayName(day(1))).toBe("morgen"); + }); + + test("should return null beyond one day", () => { + expect(fmt.relativeDayName(day(-2))).toBeNull(); + expect(fmt.relativeDayName(day(2))).toBeNull(); + }); +}); diff --git a/assets/js/mixins/formatter.ts b/assets/js/mixins/formatter.ts index ad1fece43..83a2fc809 100644 --- a/assets/js/mixins/formatter.ts +++ b/assets/js/mixins/formatter.ts @@ -213,13 +213,15 @@ export default defineComponent({ }).format(date); }, hourShort(date: Date) { - const locale = this.$i18n?.locale; - // special: use shorter german format - if (locale === "de") return date.getHours(); - return new Intl.DateTimeFormat(locale, { + // keep only hour and AM/PM; drops locale noise like "Uhr" (de), "h" (fr) and leading zeros + return new Intl.DateTimeFormat(this.$i18n?.locale, { hour: "numeric", hour12: is12hFormat(), - }).format(date); + }) + .formatToParts(date) + .filter(({ type }) => type === "hour" || type === "dayPeriod") + .map(({ value }) => value.replace(/^0(?=\d)/, "")) + .join(" "); }, weekdayShort(date: Date) { return new Intl.DateTimeFormat(this.$i18n?.locale, { @@ -241,18 +243,21 @@ export default defineComponent({ return `${weekday} ${hour}`.trim(); }, + // "gestern"/"heute"/"morgen" within one day of now, null otherwise + relativeDayName(date: Date) { + const startOfDay = (d: Date) => new Date(d).setHours(0, 0, 0, 0); + const days = Math.round((startOfDay(date) - startOfDay(new Date())) / 86400000); + if (Math.abs(days) > 1) return null; + return new Intl.RelativeTimeFormat(this.$i18n?.locale, { numeric: "auto" }).format( + days, + "day" + ); + }, // relative day plus time, e.g. "heute 16:30", "morgen 5:00", "Freitag 12:15" fmtDayTime(date: Date) { const time = this.fmtHourMinute(date); - const startOfDay = (d: Date) => new Date(d).setHours(0, 0, 0, 0); - const days = Math.round((startOfDay(date) - startOfDay(new Date())) / 86400000); - if (days === 0 || days === 1) { - const day = new Intl.RelativeTimeFormat(this.$i18n?.locale, { - numeric: "auto", - }).format(days, "day"); - return `${day} ${time}`; - } - return `${this.weekdayLong(date)} ${time}`; + const day = this.relativeDayName(date); + return `${day ?? this.weekdayLong(date)} ${time}`; }, fmtHourMinute(date: Date) { return new Intl.DateTimeFormat(this.$i18n?.locale, {