Battery UI: history chart polish, axis consistency (#31661)

This commit is contained in:
Michael Geers 2026-07-10 16:35:19 +02:00 • committed by GitHub
parent ab3220c475
commit 6d2ed7d2ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 159 additions and 151 deletions

View file

@ -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 {

View file

@ -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<typeof echarts.init>;
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<number | null>, 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<string, unknown> {
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<string, unknown> {
return {
// no echarts animation; paging is driven manually via slideWindow
@ -207,51 +185,18 @@ export default defineComponent({
};
},
xAxes(): Record<string, unknown>[] {
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({
<style scoped>
.battery-chart {
width: 100%;
height: 260px;
}
@media (max-width: 575.98px) {
.battery-chart {
height: 220px;
}
height: 240px;
}
</style>

View file

@ -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: {

View file

@ -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: {

View file

@ -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);

View file

@ -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 },

View file

@ -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)

View file

@ -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();
});
});

View file

@ -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, {