Skip to content

Commit e87f3d3

Browse files
committed
Stop a percentage Y axis expanding past 100
A percentage has a real ceiling the way zero is a real floor, but the gap that keeps series off the plot edges did not know that: a battery reading 20-100% rounded out to an axis labelled up to 120%, and one sitting flat at 100% — a device left on the charger — reached 160%. Recognise the unit at the two line-chart call sites and hold the axis at 100, mirroring the existing zero clamp. Only while the data stays under it, since power factor is also reported in % and is signed.
1 parent 21cff6b commit e87f3d3

5 files changed

Lines changed: 78 additions & 6 deletions

File tree

src/components/chart/state-history-chart-line.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ export class StateHistoryChartLine extends LitElement {
328328
...createYAxisPrecisionBounds({
329329
min: this._clampYAxis(minYAxis),
330330
max: this._clampYAxis(maxYAxis),
331+
unit: this.unit,
331332
onFractionDigits: (digits) => {
332333
if (digits !== this._yAxisFractionDigits) {
333334
this._yAxisFractionDigits = digits;

src/components/chart/statistics-chart.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,7 @@ export class StatisticsChart extends LitElement {
446446
...createYAxisPrecisionBounds({
447447
min: this._clampYAxis(minYAxis),
448448
max: this._clampYAxis(maxYAxis),
449+
unit: this.unit,
449450
// Bar charts stay anchored at 0, so precision must reflect the
450451
// 0-based range that is actually rendered.
451452
includeZero: !yAxisScale,

src/components/chart/y-axis-fraction-digits.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ const SPLIT_NUMBER = 5;
1818
// headroom out to a full tick, and leaves the rest where they are.
1919
const GAP_FRACTION_OF_SPAN = 0.02;
2020

21+
// A percentage has a real ceiling the way zero is a real floor, so the gap must
22+
// not push the axis past it. Not every `%` sensor is bounded — power factor is
23+
// signed and can read over 100 — so this only applies while the data stays under.
24+
const PERCENT_MAX = 100;
25+
2126
// Derive the number of decimal digits to use for Y-axis labels from the
2227
// observed data range, by asking ECharts for the same tick interval it will
2328
// render. This matches the precision it actually draws, so labels are neither
@@ -81,14 +86,18 @@ export function createYAxisPrecisionBounds(options: {
8186
// Such an axis also gets no gap: pushing it below zero would defeat the zero
8287
// anchoring and leave the bars floating above the axis.
8388
includeZero?: boolean;
89+
// Used to recognise a bounded quantity, so the gap cannot widen the axis past
90+
// a limit the data itself never crosses.
91+
unit?: string;
8492
onFractionDigits: (digits: number) => void;
8593
}): {
8694
min: (values: YAxisExtentValues) => number | undefined;
8795
max: (values: YAxisExtentValues) => number | undefined;
8896
boundaryGap: [number, number];
8997
splitNumber: number;
9098
} {
91-
const { min, max, includeZero, onFractionDigits } = options;
99+
const { min, max, includeZero, unit, onFractionDigits } = options;
100+
const naturalMax = unit === "%" ? PERCENT_MAX : undefined;
92101

93102
const resolveBounds = (values: YAxisExtentValues) => {
94103
const resolvedMin = resolveYAxisBound(min, values);
@@ -105,18 +114,36 @@ export function createYAxisPrecisionBounds(options: {
105114
resolvedMin !== undefined,
106115
resolvedMax !== undefined,
107116
]);
117+
// The expansion is a fraction of the magnitude, so a constant series near
118+
// the ceiling would otherwise overshoot it too.
119+
const flatMax =
120+
naturalMax !== undefined && values.max <= naturalMax
121+
? Math.min(flat.max, naturalMax)
122+
: flat.max;
108123
return {
109124
min: resolvedMin ?? flat.min,
110-
max: resolvedMax ?? flat.max,
125+
max: resolvedMax ?? flatMax,
111126
gap: 0,
112127
};
113128
}
114129
const gap = (values.max - values.min) * GAP_FRACTION_OF_SPAN;
115-
// Never let the gap carry a single-signed series across zero.
130+
// Never let the gap carry a series past a boundary it does not itself cross.
131+
const floor = values.min >= 0 ? 0 : undefined;
132+
const ceiling =
133+
naturalMax !== undefined && values.max <= naturalMax
134+
? naturalMax
135+
: values.max <= 0
136+
? 0
137+
: undefined;
116138
return {
117-
min: resolvedMin ?? (values.min >= 0 && values.min < gap ? 0 : undefined),
139+
min:
140+
resolvedMin ??
141+
(floor !== undefined && values.min - floor < gap ? floor : undefined),
118142
max:
119-
resolvedMax ?? (values.max <= 0 && -values.max < gap ? 0 : undefined),
143+
resolvedMax ??
144+
(ceiling !== undefined && ceiling - values.max < gap
145+
? ceiling
146+
: undefined),
120147
gap,
121148
};
122149
};

test/components/chart/y-axis-boundary-gap.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,11 @@ const renderExtent = (
4242
return extent;
4343
};
4444

45-
const withGap = (includeZero = false) => ({
45+
const withGap = (includeZero = false, unit?: string) => ({
4646
scale: !includeZero,
4747
...createYAxisPrecisionBounds({
4848
includeZero,
49+
unit,
4950
onFractionDigits: () => undefined,
5051
}),
5152
});
@@ -88,6 +89,12 @@ describe("Y-axis tick nudge", () => {
8889
);
8990
});
9091

92+
it("does not round a percentage axis past 100", () => {
93+
const battery = [20, 100, 60, 20, 80];
94+
expect(renderExtent(battery, withGap())).toEqual([0, 120]);
95+
expect(renderExtent(battery, withGap(false, "%"))).toEqual([0, 100]);
96+
});
97+
9198
it("re-anchors at zero when a chart switches to a zero-anchored type", () => {
9299
// setOption merges the Y axis, so a gap that is only emitted conditionally
93100
// survives the switch and leaves the bars floating.

test/components/chart/y-axis-fraction-digits.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,42 @@ describe("createYAxisPrecisionBounds", () => {
186186
expect(max({ min: 0, max: 0 })).toBe(1);
187187
});
188188

189+
it("stops a percentage axis at 100 the way it stops at zero", () => {
190+
const { max } = makeBounds({ unit: "%" });
191+
192+
// A battery that reaches full would otherwise round out to an axis
193+
// labelled up to 120%.
194+
expect(max({ min: 20, max: 100 })).toBe(100);
195+
expect(max({ min: 20, max: 99 })).toBe(100);
196+
197+
// Well clear of the ceiling, so it still gets its gap.
198+
expect(max({ min: 40, max: 60 })).toBeUndefined();
199+
200+
// Not every % sensor is bounded: power factor is signed and can read over
201+
// 100, and a series already past the ceiling must be left alone.
202+
expect(max({ min: -80, max: 140 })).toBeUndefined();
203+
});
204+
205+
it("holds a constant percentage under the ceiling too", () => {
206+
const { max } = makeBounds({ unit: "%" });
207+
208+
// A device left on the charger reads a flat 100%, and the expansion a flat
209+
// series gets is a fraction of its magnitude, so it overshoots on its own.
210+
expect(max({ min: 100, max: 100 })).toBe(100);
211+
expect(max({ min: 80, max: 80 })).toBe(100);
212+
213+
// Far enough below that the expansion never reaches the ceiling.
214+
expect(max({ min: 54, max: 54 })).toBe(90);
215+
});
216+
217+
it("only treats a percentage as bounded", () => {
218+
// The same extent without the unit is widened as usual.
219+
expect(makeBounds().max({ min: 20, max: 100 })).toBeUndefined();
220+
expect(
221+
makeBounds({ unit: "W" }).max({ min: 20, max: 100 })
222+
).toBeUndefined();
223+
});
224+
189225
it("never widens a zero-anchored axis", () => {
190226
const { min, max, boundaryGap } = makeBounds({ includeZero: true });
191227

0 commit comments

Comments
 (0)