mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-30 16:59:17 +01:00
The chart data modules push a null y value to break the line where an entity was unavailable. downSampleLineData read it with Number(), and Number(null) is 0, which is not NaN, so the isNaN guard did not fire. The marker then competed as a real value of 0 and won its frame's minimum slot whenever the readings were positive, discarding the frame's actual minimum and widening the rendered gap. Keep markers out of the min/max comparisons entirely and hold at most one per frame in its own slot. It is emitted, after the frame's values, only when no kept value follows it: a marker followed by a value in its own frame is a gap that closed within one frame, which is about one device pixel wide and too narrow to show. That check runs per frame at emit time, so the per-point path stays as it was. Keeping every marker instead would blow up the output on series that are mostly null, such as the climate heating dataset, which went from 823 to 14525 points before this was bounded. Skipping markers before the numeric work also makes gapped series faster: 16% on a series with a few gaps, 27% on one that is mostly gaps. Both now have benchmark coverage, which the gap path lacked. Mean mode no longer averages markers in as zero.
88 lines
2.5 KiB
TypeScript
88 lines
2.5 KiB
TypeScript
import { bench, describe } from "vitest";
|
|
import { downSampleLineData } from "../../src/components/chart/down-sample";
|
|
import { FIXED_EPOCH_MS, SCALES } from "../fixtures/history-states";
|
|
import { createSeededRandom } from "../fixtures/random";
|
|
|
|
// A typical chart is a few hundred CSS pixels wide
|
|
const MAX_DETAILS = 500;
|
|
|
|
const generatePoints = (seed: number, count: number): [number, number][] => {
|
|
const random = createSeededRandom(seed);
|
|
const points: [number, number][] = [];
|
|
let y = 100;
|
|
for (let i = 0; i < count; i++) {
|
|
y = Math.max(0, y + (random() - 0.5) * 10);
|
|
points.push([FIXED_EPOCH_MS + i * 30_000, y]);
|
|
}
|
|
return points;
|
|
};
|
|
|
|
// The chart data modules break the line with a null value. A handful of them
|
|
// stands for an entity that went unavailable; a series that is mostly null
|
|
// stands for the climate heating dataset, which emits one per inactive state.
|
|
const withGaps = (
|
|
points: [number, number][],
|
|
isGap: (index: number) => boolean
|
|
): [number, number | null][] =>
|
|
points.map(([x, y], index) => (isGap(index) ? [x, null] : [x, y]));
|
|
|
|
const small = generatePoints(1, SCALES.small);
|
|
const medium = generatePoints(2, SCALES.medium);
|
|
const large = generatePoints(3, SCALES.large);
|
|
const largeObjects = large.map((value) => ({ value }));
|
|
const largeFewGaps = withGaps(large, (index) => index % 20_000 === 0);
|
|
const largeMostlyGaps = withGaps(
|
|
large,
|
|
(index) => Math.floor(index / 50) % 3 !== 0
|
|
);
|
|
|
|
describe("downSampleLineData", () => {
|
|
bench("min/max small (1k points)", () => {
|
|
downSampleLineData(small, MAX_DETAILS);
|
|
});
|
|
|
|
bench("min/max medium (10k points)", () => {
|
|
downSampleLineData(medium, MAX_DETAILS);
|
|
});
|
|
|
|
bench(
|
|
"min/max large (100k points)",
|
|
() => {
|
|
downSampleLineData(large, MAX_DETAILS);
|
|
},
|
|
{ time: 1000, warmupIterations: 2 }
|
|
);
|
|
|
|
bench(
|
|
"mean large (100k points)",
|
|
() => {
|
|
downSampleLineData(large, MAX_DETAILS, undefined, undefined, true);
|
|
},
|
|
{ time: 1000, warmupIterations: 2 }
|
|
);
|
|
|
|
bench(
|
|
"min/max large object points (100k points)",
|
|
() => {
|
|
downSampleLineData(largeObjects, MAX_DETAILS);
|
|
},
|
|
{ time: 1000, warmupIterations: 2 }
|
|
);
|
|
|
|
bench(
|
|
"min/max large with a few gaps (100k points)",
|
|
() => {
|
|
downSampleLineData(largeFewGaps, MAX_DETAILS);
|
|
},
|
|
{ time: 1000, warmupIterations: 2 }
|
|
);
|
|
|
|
bench(
|
|
"min/max large mostly gaps (100k points)",
|
|
() => {
|
|
downSampleLineData(largeMostlyGaps, MAX_DETAILS);
|
|
},
|
|
{ time: 1000, warmupIterations: 2 }
|
|
);
|
|
});
|