UI: dodge overlapping soc badges in battery history chart (#31512)

This commit is contained in:
Michael Geers 2026-07-05 21:50:03 +02:00 • committed by GitHub
parent 48efdee6a1
commit 0659cb4c2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 73 additions and 13 deletions

View file

@ -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<typeof BatteryHistoryCard> {
function scenario(
count: number,
withForecast: boolean,
targetSocs?: number[]
): StoryFn<typeof BatteryHistoryCard> {
return () => ({
components: { BatteryHistoryCard },
setup() {
return { batteries: buildBatteries(count, withForecast, NOW.getTime()), now: NOW };
return {
batteries: buildBatteries(count, withForecast, NOW.getTime(), targetSocs),
now: NOW,
};
},
template: `<div class="p-4" style="max-width: 900px">
<BatteryHistoryCard :batteries="batteries" :now="now" kwh-available />
@ -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);

View file

@ -20,6 +20,9 @@ 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 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<string, unknown>[] = [];
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;