Battery UI: history chart polish, axis consistency (#31661)
This commit is contained in:
parent
ab3220c475
commit
6d2ed7d2ac
9 changed files with 159 additions and 151 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue