Adds backing state for Resistance and Erg control modes, which had no value to hold and so could never satisfy FR-4.3/FR-4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
87 lines
2.7 KiB
TypeScript
87 lines
2.7 KiB
TypeScript
/**
|
|
* Bounded, self-decimating chart history (NFR-3).
|
|
*
|
|
* A two-hour ride at 4 Hz is 28 800 samples per series. Keeping them all is
|
|
* both a memory leak and a rendering cost that grows through the ride — exactly
|
|
* what NFR-3 forbids. Instead the buffer has a hard capacity: when it fills, it
|
|
* averages adjacent pairs in place, halving the point count and doubling the
|
|
* time each point represents. Later samples are then averaged in groups of that
|
|
* same stride before being stored.
|
|
*
|
|
* The result is constant memory and a constant point count for any ride
|
|
* length, with resolution degrading gracefully: full 250 ms detail for the
|
|
* first ~15 minutes, 2-second buckets by two hours. Typed arrays throughout so
|
|
* uPlot can consume them without a copy.
|
|
*/
|
|
|
|
const CAPACITY = 3600;
|
|
|
|
export class History {
|
|
readonly capacity: number;
|
|
readonly seriesCount: number;
|
|
/** Seconds since ride start. */
|
|
readonly x: Float64Array;
|
|
readonly y: Float64Array[];
|
|
/** Number of populated points. */
|
|
length = 0;
|
|
/** Raw samples currently folded into one stored point. */
|
|
stride = 1;
|
|
|
|
private pendingX = 0;
|
|
private pendingY: Float64Array;
|
|
private pendingN = 0;
|
|
|
|
constructor(seriesCount: number, capacity = CAPACITY) {
|
|
this.capacity = capacity;
|
|
this.seriesCount = seriesCount;
|
|
this.x = new Float64Array(capacity);
|
|
this.y = Array.from({ length: seriesCount }, () => new Float64Array(capacity));
|
|
this.pendingY = new Float64Array(seriesCount);
|
|
}
|
|
|
|
push(x: number, values: number[]): void {
|
|
this.pendingX += x;
|
|
for (let s = 0; s < this.seriesCount; s++) this.pendingY[s] += values[s] ?? 0;
|
|
this.pendingN++;
|
|
if (this.pendingN < this.stride) return;
|
|
|
|
if (this.length >= this.capacity) this.compact();
|
|
|
|
const i = this.length++;
|
|
this.x[i] = this.pendingX / this.pendingN;
|
|
for (let s = 0; s < this.seriesCount; s++) this.y[s][i] = this.pendingY[s] / this.pendingN;
|
|
|
|
this.pendingX = 0;
|
|
this.pendingY.fill(0);
|
|
this.pendingN = 0;
|
|
}
|
|
|
|
/** Halve the resolution in place. O(capacity), amortised to O(1) per sample. */
|
|
private compact(): void {
|
|
const half = this.length >> 1;
|
|
for (let i = 0; i < half; i++) {
|
|
const a = i * 2;
|
|
const b = a + 1;
|
|
this.x[i] = (this.x[a] + this.x[b]) / 2;
|
|
for (let s = 0; s < this.seriesCount; s++) {
|
|
this.y[s][i] = (this.y[s][a] + this.y[s][b]) / 2;
|
|
}
|
|
}
|
|
this.length = half;
|
|
this.stride *= 2;
|
|
}
|
|
|
|
clear(): void {
|
|
this.length = 0;
|
|
this.stride = 1;
|
|
this.pendingX = 0;
|
|
this.pendingY.fill(0);
|
|
this.pendingN = 0;
|
|
}
|
|
|
|
/** Views sized to the populated region, ready for `uPlot.setData`. */
|
|
view(): Float64Array[] {
|
|
return [this.x.subarray(0, this.length), ...this.y.map((a) => a.subarray(0, this.length))];
|
|
}
|
|
}
|