UI: dodge overlapping soc badges in battery history chart (#31512)
This commit is contained in:
parent
48efdee6a1
commit
0659cb4c2a
2 changed files with 73 additions and 13 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue