From 0659cb4c2ab373a09a3bb174dddb4dbe3e1ac354 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sun, 5 Jul 2026 21:50:03 +0200 Subject: [PATCH] UI: dodge overlapping soc badges in battery history chart (#31512) --- .../Battery/BatteryHistoryCard.stories.ts | 48 +++++++++++++++---- .../Battery/BatteryHistoryChart.vue | 38 +++++++++++++-- 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/assets/js/components/Battery/BatteryHistoryCard.stories.ts b/assets/js/components/Battery/BatteryHistoryCard.stories.ts index d3c34647c..07588c907 100644 --- a/assets/js/components/Battery/BatteryHistoryCard.stories.ts +++ b/assets/js/components/Battery/BatteryHistoryCard.stories.ts @@ -68,9 +68,28 @@ function generateTimeline(from: number, to: number, seed: number): SocPoint[] { const TITLES = ["Sungrow", "Anker", "Fronius", "BYD"]; const CAPACITIES = [13.5, 7.5, 10, 5]; -function buildBatteries(count: number, withForecast: boolean, now: number): BatterySeries[] { +// pull socs toward a target around "now" so batteries end up clustered (overlapping badges) +function convergeNear(pts: SocPoint[], target: number, now: number): SocPoint[] { + return pts.map(({ t, soc }) => { + const f = 1 - smoothstep(0, 6 * HOUR, Math.abs(t - now)); + return { t, soc: soc + (target - soc) * f }; + }); +} + +function buildBatteries( + count: number, + withForecast: boolean, + now: number, + targetSocs?: number[] +): BatterySeries[] { return Array.from({ length: count }, (_, i) => { - const history = generateTimeline(now - 60 * HOUR, now, i); + let history = generateTimeline(now - 60 * HOUR, now, i); + let forecast = withForecast ? generateTimeline(now, now + 36 * HOUR, i) : []; + const target = targetSocs?.[i]; + if (target != null) { + history = convergeNear(history, target, now); + forecast = convergeNear(forecast, target, now); + } const title = TITLES[i] ?? `Battery ${i + 1}`; return { id: title, @@ -78,16 +97,23 @@ function buildBatteries(count: number, withForecast: boolean, now: number): Batt capacity: CAPACITIES[i] ?? 8, currentSoc: history.at(-1)?.soc ?? 50, history, - forecast: withForecast ? generateTimeline(now, now + 36 * HOUR, i) : [], + forecast, }; }); } -function scenario(count: number, withForecast: boolean): StoryFn { +function scenario( + count: number, + withForecast: boolean, + targetSocs?: number[] +): StoryFn { return () => ({ components: { BatteryHistoryCard }, setup() { - return { batteries: buildBatteries(count, withForecast, NOW.getTime()), now: NOW }; + return { + batteries: buildBatteries(count, withForecast, NOW.getTime(), targetSocs), + now: NOW, + }; }, template: `
@@ -101,8 +127,12 @@ export const OneBatteryForecast = scenario(1, true); export const TwoBatteries = scenario(2, false); export const TwoBatteriesForecast = scenario(2, true); -export const ThreeBatteries = scenario(3, false); -export const ThreeBatteriesForecast = scenario(3, true); +// clustered near empty: labels get pushed below the lowest dot, toward the grid bottom +const EMPTY_SOCS = [7, 4, 2]; +export const ThreeBatteries = scenario(3, false, EMPTY_SOCS); +export const ThreeBatteriesForecast = scenario(3, true, EMPTY_SOCS); -export const FourBatteries = scenario(4, false); -export const FourBatteriesForecast = scenario(4, true); +// clustered socs: badges would overlap without label dodging +const CLOSE_SOCS = [99, 98, 95, 93]; +export const FourBatteries = scenario(4, false, CLOSE_SOCS); +export const FourBatteriesForecast = scenario(4, true, CLOSE_SOCS); diff --git a/assets/js/components/Battery/BatteryHistoryChart.vue b/assets/js/components/Battery/BatteryHistoryChart.vue index deb6d5e93..9cec80c43 100644 --- a/assets/js/components/Battery/BatteryHistoryChart.vue +++ b/assets/js/components/Battery/BatteryHistoryChart.vue @@ -20,6 +20,9 @@ import type { SocPoint, BatterySeries } from "./types"; type EChartsType = ReturnType; type Point = [number, number]; +const GRID = { top: 10, right: 36, bottom: 26, left: 0 }; +const BADGE_GAP = 24; // badge height (12px font + 2x4px padding) plus spacing + export default defineComponent({ name: "BatteryHistoryChart", mixins: [formatter], @@ -179,7 +182,7 @@ export default defineComponent({ // no echarts animation; paging is driven manually via slideWindow animation: false, textStyle: { fontFamily: FONT_FAMILY }, - grid: { top: 10, right: 36, bottom: 26, left: 0, borderWidth: 0 }, + grid: { ...GRID, borderWidth: 0 }, tooltip: { trigger: "axis", axisPointer: { @@ -427,7 +430,13 @@ export default defineComponent({ const elements: Record[] = []; if (this.nowInWindow) { // badge sits to the left of the now point (matches the design, avoids clipping the right edge) - const badge = (px: number[], text: string, fill: string, textFill = "#fff") => { + const badge = ( + px: number[], + text: string, + fill: string, + textFill = "#fff", + labelY = px[1] + ) => { elements.push({ type: "circle", z: 10, @@ -438,7 +447,7 @@ export default defineComponent({ type: "text", z: 11, x: px[0] - 9, - y: px[1], + y: labelY, style: { text, fill: textFill, @@ -452,11 +461,32 @@ export default defineComponent({ }); }; if (this.mode === "soc") { + const badges: { px: number[]; text: string; fill: string }[] = []; this.batteries.forEach((b, i) => { if (this.focused !== null && this.focused !== i) return; const px = this.chart!.convertToPixel(finder, [this.now, b.currentSoc]); - if (px) badge(px, this.fmtPercentage(b.currentSoc), batteryColor(i)); + if (px) + badges.push({ + px, + text: this.fmtPercentage(b.currentSoc), + fill: batteryColor(i), + }); }); + // push labels of near-equal socs downward so badges don't overlap; + // dots stay on the data point, only the text pill shifts + badges.sort((a, b) => a.px[1] - b.px[1]); + let minY = GRID.top + BADGE_GAP / 2; + const ys = badges.map((b) => { + const y = Math.max(b.px[1], minY); + minY = y + BADGE_GAP; + return y; + }); + // batteries near empty push the stack past the grid, shift it back up + const bottom = this.chart.getHeight() - GRID.bottom - BADGE_GAP / 2; + const overflow = Math.max(0, (ys.at(-1) ?? 0) - bottom); + badges.forEach((b, i) => + badge(b.px, b.text, b.fill, "#fff", ys[i]! - overflow) + ); } else { // focused: that battery's energy (its band is the only one shown); else the total const f = this.focused;