Files
frontend/test/benchmarks/down-sample.bench.ts
T
Petar Petrov 69518334bb Keep the frame minimum when a gap marker shares the frame (#53580)
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.
2026-08-26 15:29:12 +03:00

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 }
);
});