/** * 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))]; } }