@zakkster/lite-canvas-graph 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @zakkster/lite-canvas-graph
3
+ * Zero-GC canvas rendering for time-series telemetry.
4
+ */
5
+
6
+ import { RingBuffer } from '@zakkster/lite-ring-buffer';
7
+
8
+ /** Constructor options for {@link CanvasGraph}. */
9
+ export interface CanvasGraphOptions {
10
+ /**
11
+ * Override `globalThis.devicePixelRatio`.
12
+ * Required in OffscreenCanvas / Worker contexts where DPR is undefined.
13
+ * @default globalThis.devicePixelRatio || 1
14
+ */
15
+ dpr?: number;
16
+ /**
17
+ * Background fill (rendered with `alpha: false`, so this is opaque).
18
+ * @default '#111'
19
+ */
20
+ background?: string;
21
+ /**
22
+ * Trace stroke style.
23
+ * @default '#00ffcc'
24
+ */
25
+ stroke?: string;
26
+ /**
27
+ * Trace stroke width in CSS pixels.
28
+ * @default 1
29
+ */
30
+ lineWidth?: number;
31
+ }
32
+
33
+ /** Per-call options for {@link CanvasGraph.render}. */
34
+ export interface RenderOptions {
35
+ /**
36
+ * When `true` and `ringBuffer.count > width`, render a per-column
37
+ * min/max envelope (oscilloscope/audio-waveform style). When `false`,
38
+ * always render a polyline regardless of sample count.
39
+ * @default true
40
+ */
41
+ decimate?: boolean;
42
+ /**
43
+ * Lower bound of the value range. Anything below is clamped to this.
44
+ * @default 0
45
+ */
46
+ minValue?: number;
47
+ }
48
+
49
+ /**
50
+ * Optional post-render hook signature. Anything drawn here lands ON TOP of
51
+ * the trace — useful for axis labels, units, peak markers, etc.
52
+ */
53
+ export type LabelBitmapHook = (
54
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
55
+ maxValue: number,
56
+ width: number,
57
+ height: number,
58
+ ) => void;
59
+
60
+ /**
61
+ * Zero-GC canvas renderer for time-series ring buffers.
62
+ *
63
+ * One scratch allocation per resize, nothing per-frame. Reads samples
64
+ * directly from a {@link RingBuffer} via `copyTo` — no array conversion,
65
+ * no `Array.from`, no `[].slice()`.
66
+ *
67
+ * Time flows LEFT → RIGHT: oldest sample on the left edge, newest on the right.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * import { RingBuffer } from '@zakkster/lite-ring-buffer';
72
+ * import { CanvasGraph } from '@zakkster/lite-canvas-graph';
73
+ *
74
+ * const ring = new RingBuffer(1024);
75
+ * const graph = new CanvasGraph(canvas, 400, 120, { stroke: '#0ff' });
76
+ *
77
+ * function frame(t: number) {
78
+ * ring.push(Math.sin(t / 200));
79
+ * graph.render(ring, 1, { minValue: -1 });
80
+ * requestAnimationFrame(frame);
81
+ * }
82
+ * requestAnimationFrame(frame);
83
+ * ```
84
+ */
85
+ export class CanvasGraph {
86
+ /** The bound canvas (HTMLCanvasElement in the main thread, OffscreenCanvas in workers). */
87
+ canvas: HTMLCanvasElement | OffscreenCanvas | null;
88
+ /** Live 2D context. Becomes `null` after {@link destroy}. */
89
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
90
+ /** Logical (CSS) width in pixels. */
91
+ width: number;
92
+ /** Logical (CSS) height in pixels. */
93
+ height: number;
94
+ /** Background fill colour. Mutate freely between frames. */
95
+ background: string;
96
+ /** Trace stroke style. Mutate freely between frames. */
97
+ stroke: string;
98
+ /** Trace stroke width in CSS pixels. Mutate freely between frames. */
99
+ lineWidth: number;
100
+ /**
101
+ * Optional post-render hook. Draws ON TOP of the trace.
102
+ * Set to `null` (default) to skip. Mutate freely between frames.
103
+ */
104
+ labelBitmapHook: LabelBitmapHook | null;
105
+
106
+ /**
107
+ * @param canvas Render target.
108
+ * @param width Logical (CSS) width in pixels, >= 1.
109
+ * @param height Logical (CSS) height in pixels, >= 1.
110
+ * @param options See {@link CanvasGraphOptions}.
111
+ * @throws {TypeError} if `canvas` is missing.
112
+ * @throws {RangeError} if `width` or `height` is < 1 or non-finite.
113
+ */
114
+ constructor(
115
+ canvas: HTMLCanvasElement | OffscreenCanvas,
116
+ width: number,
117
+ height: number,
118
+ options?: CanvasGraphOptions,
119
+ );
120
+
121
+ /**
122
+ * Resize logical drawing area. Forces backing-store reconfig and pixel
123
+ * scratchpad reallocation on the next {@link render} call.
124
+ * No-op if dimensions are unchanged.
125
+ */
126
+ resize(width: number, height: number): void;
127
+
128
+ /**
129
+ * Draw the current contents of `ringBuffer` to the canvas.
130
+ * Allocation-free in the steady state.
131
+ */
132
+ render(ringBuffer: RingBuffer, maxValue: number, options?: RenderOptions): void;
133
+
134
+ /** Release internal scratchpads and the canvas reference. Idempotent. */
135
+ destroy(): void;
136
+ }
package/CanvasGraph.js ADDED
@@ -0,0 +1,273 @@
1
+ /**
2
+ * @zakkster/lite-canvas-graph
3
+ *
4
+ * Zero-GC canvas rendering for time-series telemetry.
5
+ *
6
+ * Time flows LEFT → RIGHT: oldest sample on the left edge, newest on the right.
7
+ * One scratchpad allocation per resize (or capacity change), nothing in the
8
+ * hot path. Renders directly from a `RingBuffer` via `copyTo` — no array
9
+ * conversion, no per-frame `[].slice()`, no `Array.from`.
10
+ *
11
+ * Decimation strategy (when sample count exceeds pixel width):
12
+ * Each pixel column collects min/max of all samples that map to it, and the
13
+ * renderer draws a single vertical line per column from min to max. No
14
+ * diagonals between columns — the output reads as a clean envelope/barcode,
15
+ * which is the standard convention for oscilloscopes and audio waveform
16
+ * views. Empty columns leave a gap rather than fake-filling with neighbours.
17
+ *
18
+ * Worker-friendly:
19
+ * `window` is not referenced. Pass `options.dpr` in OffscreenCanvas/Worker
20
+ * contexts where `globalThis.devicePixelRatio` is undefined.
21
+ */
22
+ export class CanvasGraph {
23
+ /**
24
+ * @param {HTMLCanvasElement|OffscreenCanvas} canvas
25
+ * @param {number} width logical (CSS) width in pixels, >= 1
26
+ * @param {number} height logical (CSS) height in pixels, >= 1
27
+ * @param {object} [options]
28
+ * @param {number} [options.dpr] override devicePixelRatio (required in Workers)
29
+ * @param {string} [options.background='#111']
30
+ * @param {string} [options.stroke='#00ffcc']
31
+ * @param {number} [options.lineWidth=1]
32
+ */
33
+ constructor(canvas, width, height, options = {}) {
34
+ if (!canvas) throw new TypeError('LiteCanvasGraph: canvas is required');
35
+ if (!Number.isFinite(width) || width < 1) {
36
+ throw new RangeError(`LiteCanvasGraph: width must be >= 1 (got ${width})`);
37
+ }
38
+ if (!Number.isFinite(height) || height < 1) {
39
+ throw new RangeError(`LiteCanvasGraph: height must be >= 1 (got ${height})`);
40
+ }
41
+
42
+ this.canvas = canvas;
43
+ this.ctx = canvas.getContext('2d', {alpha: false});
44
+ if (!this.ctx) throw new Error('LiteCanvasGraph: failed to get 2d context');
45
+
46
+ this.width = Math.floor(width);
47
+ this.height = Math.floor(height);
48
+
49
+ this._dprOverride = options.dpr;
50
+ this._dpr = 0; // sentinel: forces backing-store reconfig on first render
51
+ this.background = options.background ?? '#111';
52
+ this.stroke = options.stroke ?? '#00ffcc';
53
+ this.lineWidth = options.lineWidth ?? 1;
54
+
55
+ /** @type {Float32Array|null} sample copy scratchpad */
56
+ this._scratch = null;
57
+ /** @type {Float32Array|null} per-column [min, max] pairs (length = width * 2) */
58
+ this._pixelScratch = null;
59
+
60
+ /**
61
+ * Optional hook called at the end of each `render()`. Useful for
62
+ * overlaying axis labels, units, or peak markers without forcing them
63
+ * into the core renderer. Called with the live 2D context — anything
64
+ * you draw will appear ON TOP of the trace.
65
+ *
66
+ * @type {((ctx: CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D, maxValue: number, width: number, height: number) => void) | null}
67
+ */
68
+ this.labelBitmapHook = null;
69
+ }
70
+
71
+ /**
72
+ * Resize logical drawing area. Forces backing-store reconfig and pixel
73
+ * scratchpad reallocation on the next `render()` call. Cheap to call when
74
+ * dimensions are unchanged — DPR sentinel only resets if values shifted.
75
+ *
76
+ * @param {number} width >= 1
77
+ * @param {number} height >= 1
78
+ */
79
+ resize(width, height) {
80
+ if (!Number.isFinite(width) || width < 1) {
81
+ throw new RangeError(`LiteCanvasGraph.resize: width must be >= 1 (got ${width})`);
82
+ }
83
+ if (!Number.isFinite(height) || height < 1) {
84
+ throw new RangeError(`LiteCanvasGraph.resize: height must be >= 1 (got ${height})`);
85
+ }
86
+ const w = Math.floor(width);
87
+ const h = Math.floor(height);
88
+ if (w === this.width && h === this.height) return;
89
+
90
+ this.width = w;
91
+ this.height = h;
92
+ this._dpr = 0; // force backing-store reconfig
93
+ this._pixelScratch = null; // force decimation scratch reallocation
94
+ }
95
+
96
+ _resolveDpr() {
97
+ if (this._dprOverride !== undefined) return this._dprOverride;
98
+ if (typeof globalThis !== 'undefined' && globalThis.devicePixelRatio) {
99
+ return globalThis.devicePixelRatio;
100
+ }
101
+ return 1;
102
+ }
103
+
104
+ /**
105
+ * Draw the current contents of `ringBuffer` to the canvas.
106
+ *
107
+ * Hot path is allocation-free after the first call: the sample scratch
108
+ * grows once to `ringBuffer.capacity`, the pixel scratch grows once to
109
+ * `width * 2`, and both are reused forever.
110
+ *
111
+ * @param {import('@zakkster/lite-ring-buffer').RingBuffer} ringBuffer
112
+ * @param {number} maxValue upper bound of the value range
113
+ * @param {object} [options]
114
+ * @param {boolean} [options.decimate=true] aggregate to per-column min/max when n > width
115
+ * @param {number} [options.minValue=0] lower bound of the value range
116
+ */
117
+ render(ringBuffer, maxValue, options) {
118
+ // Property access (no destructuring default) keeps this allocation-free
119
+ // when `options` is omitted.
120
+ const decimate = options ? options.decimate !== false : true;
121
+ const minValue = options && options.minValue !== undefined ? options.minValue : 0;
122
+
123
+ // Reconfigure backing store on DPR change (or first render).
124
+ const dpr = this._resolveDpr();
125
+ if (this._dpr !== dpr) {
126
+ this._dpr = dpr;
127
+ this.canvas.width = Math.floor(this.width * dpr);
128
+ this.canvas.height = Math.floor(this.height * dpr);
129
+ if (this.canvas.style) {
130
+ this.canvas.style.width = `${this.width}px`;
131
+ this.canvas.style.height = `${this.height}px`;
132
+ }
133
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
134
+ }
135
+
136
+ // Clear with solid background (alpha: false context, so this is opaque).
137
+ this.ctx.fillStyle = this.background;
138
+ this.ctx.fillRect(0, 0, this.width, this.height);
139
+
140
+ const count = ringBuffer.count;
141
+ if (count === 0) {
142
+ if (this.labelBitmapHook) this.labelBitmapHook(this.ctx, maxValue, this.width, this.height);
143
+ return;
144
+ }
145
+
146
+ const range = maxValue - minValue;
147
+ if (!(range > 0)) {
148
+ // Degenerate range: cannot map values to pixels. Bail honestly.
149
+ if (this.labelBitmapHook) this.labelBitmapHook(this.ctx, maxValue, this.width, this.height);
150
+ return;
151
+ }
152
+
153
+ // Lazy / grow scratch. Grow-only — never shrinks across frames.
154
+ if (!this._scratch || this._scratch.length < ringBuffer.capacity) {
155
+ this._scratch = new Float32Array(ringBuffer.capacity);
156
+ }
157
+ const n = ringBuffer.copyTo(this._scratch, 0); // oldest-first
158
+
159
+ this.ctx.strokeStyle = this.stroke;
160
+ this.ctx.lineWidth = this.lineWidth;
161
+ this.ctx.lineCap = 'square'; // ensures single-sample columns render visibly
162
+
163
+ const usableX = this.width - 1; // last drawable pixel column
164
+ const yScale = this.height / range;
165
+
166
+ if (decimate && n > this.width) {
167
+ this._renderDecimated(this._scratch, n, minValue, maxValue, yScale);
168
+ } else {
169
+ this._renderDirect(this._scratch, n, minValue, maxValue, yScale, usableX);
170
+ }
171
+
172
+ if (this.labelBitmapHook) {
173
+ this.labelBitmapHook(this.ctx, maxValue, this.width, this.height);
174
+ }
175
+ }
176
+
177
+ _renderDirect(samples, n, minValue, maxValue, yScale, usableX) {
178
+ const ctx = this.ctx;
179
+ const h = this.height;
180
+ const stepX = n > 1 ? usableX / (n - 1) : 0;
181
+
182
+ ctx.beginPath();
183
+ let started = false;
184
+ let lastX = 0, lastY = 0;
185
+ for (let i = 0; i < n; i++) {
186
+ const raw = samples[i];
187
+ if (raw !== raw) {
188
+ started = false;
189
+ continue;
190
+ } // NaN → break the line
191
+
192
+ const v = raw < minValue ? minValue : (raw > maxValue ? maxValue : raw);
193
+ const x = i * stepX; // index 0 → x=0 (oldest, left)
194
+ const y = h - (v - minValue) * yScale; // (newest is at x=usableX, right)
195
+
196
+ if (!started) {
197
+ ctx.moveTo(x, y);
198
+ started = true;
199
+ lastX = x;
200
+ lastY = y;
201
+ } else {
202
+ ctx.lineTo(x, y);
203
+ lastX = x;
204
+ lastY = y;
205
+ }
206
+ }
207
+
208
+ // Single-sample edge case: a lone moveTo() draws nothing. Emit a
209
+ // tiny lineTo so lineCap='square' produces a visible dot at lineWidth.
210
+ if (started && n === 1) {
211
+ ctx.lineTo(lastX, lastY);
212
+ }
213
+ ctx.stroke();
214
+ }
215
+
216
+ _renderDecimated(samples, n, minValue, maxValue, yScale) {
217
+ const ctx = this.ctx;
218
+ const h = this.height;
219
+ const w = this.width;
220
+
221
+ if (!this._pixelScratch || this._pixelScratch.length !== w * 2) {
222
+ this._pixelScratch = new Float32Array(w * 2);
223
+ }
224
+ const px = this._pixelScratch;
225
+ for (let i = 0; i < w; i++) {
226
+ px[i * 2] = Infinity;
227
+ px[i * 2 + 1] = -Infinity;
228
+ }
229
+
230
+ // Map sample index i ∈ [0, n-1] → bucket bi ∈ [0, w-1].
231
+ // Endpoints land exactly on the first/last column.
232
+ const denom = n - 1;
233
+ for (let i = 0; i < n; i++) {
234
+ const sample = samples[i];
235
+ if (sample !== sample) continue; // NaN
236
+ const bi = denom > 0 ? Math.floor((i * (w - 1)) / denom) : 0;
237
+ const mi = bi * 2;
238
+ if (sample < px[mi]) px[mi] = sample;
239
+ if (sample > px[mi + 1]) px[mi + 1] = sample;
240
+ }
241
+
242
+ // One vertical span per column. Each column is a discrete subpath
243
+ // (moveTo + lineTo); empty columns leave a gap rather than fake-filling.
244
+ ctx.beginPath();
245
+ for (let bi = 0; bi < w; bi++) {
246
+ const mn = px[bi * 2];
247
+ const mx = px[bi * 2 + 1];
248
+ if (mn === Infinity) continue; // no samples in this column
249
+
250
+ const lo = mn < minValue ? minValue : (mn > maxValue ? maxValue : mn);
251
+ const hi = mx < minValue ? minValue : (mx > maxValue ? maxValue : mx);
252
+
253
+ const yLo = h - (lo - minValue) * yScale;
254
+ const yHi = h - (hi - minValue) * yScale;
255
+ const xPx = bi + 0.5; // crisp 1px-aligned vertical
256
+
257
+ ctx.moveTo(xPx, yHi);
258
+ // For a single-sample bucket (yLo === yHi) the line is degenerate;
259
+ // lineCap='square' guarantees a visible mark at lineWidth pixels.
260
+ ctx.lineTo(xPx, yLo);
261
+ }
262
+ ctx.stroke();
263
+ }
264
+
265
+ /** Release internal scratchpads and the canvas reference. Idempotent. */
266
+ destroy() {
267
+ this.ctx = null;
268
+ this.canvas = null;
269
+ this._scratch = null;
270
+ this._pixelScratch = null;
271
+ this.labelBitmapHook = null;
272
+ }
273
+ }
package/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,266 @@
1
+ # @zakkster/lite-canvas-graph
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@zakkster/lite-canvas-graph.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-canvas-graph)
4
+ [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-canvas-graph?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-canvas-graph)
5
+ [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-canvas-graph?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-canvas-graph)
6
+ [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-canvas-graph?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-canvas-graph)
7
+ ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
8
+ ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
10
+
11
+ **Zero-GC canvas renderer for time-series telemetry.**
12
+
13
+ One scratchpad allocation per resize. Nothing in the hot path. Renders straight from a `RingBuffer` via `copyTo` — no array conversion, no `Array.from`, no `[].slice()`. Decimates to a per-column min/max envelope when sample count exceeds pixel width, the way oscilloscopes and audio waveform views have always done it.
14
+
15
+ ```js
16
+ import { RingBuffer } from '@zakkster/lite-ring-buffer';
17
+ import { CanvasGraph } from '@zakkster/lite-canvas-graph';
18
+
19
+ const canvas = document.getElementById('telemetry-canvas');
20
+ const ring = new RingBuffer(1024);
21
+ const graph = new CanvasGraph(canvas, 400, 120, { stroke: '#0ff' });
22
+
23
+ let lastTime = performance.now();
24
+
25
+ function renderLoop(currentTime) {
26
+ const delta = currentTime - lastTime;
27
+ lastTime = currentTime;
28
+
29
+ ring.push(delta);
30
+ graph.render(ring, 16, { minValue: 0 }); // 0..16ms frame budget baseline
31
+
32
+ requestAnimationFrame(renderLoop);
33
+ }
34
+
35
+ requestAnimationFrame(renderLoop);
36
+ ```
37
+
38
+ ---
39
+
40
+ ## Contents
41
+
42
+ - [Why](#why) · [Install](#install) · [Quick start](#quick-start)
43
+ - [How it works](#how-it-works)
44
+ - [API reference](#api-reference)
45
+ - [Edge cases & guarantees](#edge-cases--guarantees)
46
+ - [FAQ](#faq)
47
+ - [License](#license)
48
+
49
+ ---
50
+
51
+ ## Why
52
+
53
+ A "live graph" component looks innocent until you profile it.
54
+
55
+ The naive approach — copy samples into an array, map over them, call `lineTo` — allocates a fresh array and a fresh path object every single frame. At 60Hz with a 1024-sample window that's **60,000 allocations per second** before you've drawn a pixel. The GC will eventually pause your render loop to clean it up, and your "smooth" graph stutters at exactly the wrong moment.
56
+
57
+ ```mermaid
58
+ flowchart LR
59
+ subgraph Naive["Naive approach (per frame)"]
60
+ A1[ring → Array.from] --> A2[points = data.map]
61
+ A2 --> A3[ctx.beginPath]
62
+ A3 --> A4[for .. lineTo]
63
+ A4 --> A5[stroke]
64
+ A5 --> GC[GC pause]
65
+ end
66
+ subgraph Lite["lite-canvas-graph (per frame)"]
67
+ B1[ring.copyTo&#40;scratch&#41;] --> B2[ctx.beginPath]
68
+ B2 --> B3[lineTo loop]
69
+ B3 --> B4[stroke]
70
+ end
71
+ ```
72
+
73
+ `lite-canvas-graph` allocates once — a `Float32Array` sized to the ring's capacity, plus a per-column min/max scratchpad sized to the canvas width. After that the render path is a tight numeric loop and direct canvas calls. No object graphs, no arrays, no GC churn.
74
+
75
+ For windows larger than the canvas width (the common case — you have 4096 telemetry samples but only 400 horizontal pixels), the renderer **decimates**: each column collects min/max of all samples that map to it and draws a single vertical line from min to max. This is the correct convention for oscilloscope and audio-waveform displays. It preserves spike visibility — a single outlier in a column still pushes the envelope to the edge — where naive subsampling would silently drop it.
76
+
77
+ ---
78
+
79
+ ## Install
80
+
81
+ ```bash
82
+ npm i @zakkster/lite-canvas-graph @zakkster/lite-ring-buffer
83
+ ```
84
+
85
+ ESM only. Zero runtime dependencies (the ring buffer is a peer dep — bring your own).
86
+
87
+ ---
88
+
89
+ ## Quick start
90
+
91
+ ```js
92
+ import { RingBuffer } from '@zakkster/lite-ring-buffer';
93
+ import { CanvasGraph } from '@zakkster/lite-canvas-graph';
94
+
95
+ const canvas = document.getElementById('chart');
96
+ const ring = new RingBuffer(1024); // power-of-2-rounded
97
+ const graph = new CanvasGraph(canvas, 400, 120, {
98
+ background: '#111',
99
+ stroke: '#00ffcc',
100
+ lineWidth: 1,
101
+ });
102
+
103
+ // Push samples from anywhere — performance observer, websocket, RAF, ...
104
+ performance.mark && setInterval(() => {
105
+ ring.push(performance.now() % 1000); // toy data
106
+ }, 16);
107
+
108
+ // Draw at your preferred cadence.
109
+ function frame() {
110
+ graph.render(ring, 1000, { minValue: 0 });
111
+ requestAnimationFrame(frame);
112
+ }
113
+ requestAnimationFrame(frame);
114
+ ```
115
+
116
+ ### Worker / OffscreenCanvas
117
+
118
+ `window` is never referenced. In a worker, pass `dpr` explicitly:
119
+
120
+ ```js
121
+ const off = canvas.transferControlToOffscreen();
122
+ // inside the worker:
123
+ const graph = new CanvasGraph(off, 400, 120, { dpr: 2 });
124
+ ```
125
+
126
+ ### Overlaying labels
127
+
128
+ Use `labelBitmapHook` to draw axis labels, units, or peak markers on top of the trace without forcing them into the core renderer:
129
+
130
+ ```js
131
+ graph.labelBitmapHook = (ctx, maxValue, w, h) => {
132
+ ctx.fillStyle = '#888';
133
+ ctx.font = '10px monospace';
134
+ ctx.fillText(`${maxValue.toFixed(1)} ms`, 4, 12);
135
+ };
136
+ ```
137
+
138
+ ---
139
+
140
+ ## How it works
141
+
142
+ ### Layout
143
+
144
+ ```mermaid
145
+ flowchart TB
146
+ subgraph Canvas["400 × 120 logical px (e.g. 800 × 240 backing at DPR=2)"]
147
+ oldest[oldest sample<br/>x = 0]
148
+ middle[...]
149
+ newest[newest sample<br/>x = width − 1]
150
+ oldest -.-> middle -.-> newest
151
+ end
152
+ ```
153
+
154
+ Time flows left → right. Index `0` of the ring (oldest) lands at `x = 0`, index `count - 1` (newest) at `x = width - 1`. Standard direction for telemetry; the most-recent reading is always at the right edge where your eye expects it.
155
+
156
+ ### Direct vs decimated
157
+
158
+ The renderer picks one of two modes per frame:
159
+
160
+ ```mermaid
161
+ flowchart LR
162
+ Start[render&#40;&#41;] --> Q{n &gt; width?}
163
+ Q -- yes --> Dec[Decimated<br/>per-column min/max envelope]
164
+ Q -- no --> Dir[Direct<br/>polyline moveTo + lineTo]
165
+ ```
166
+
167
+ **Direct** mode is the obvious one: one `moveTo` for the first sample, `lineTo` for every subsequent sample. Used when you have at most one sample per pixel column. NaN samples break the path (no fake connecting lines).
168
+
169
+ **Decimated** mode is what makes the renderer correct at scale. For each pixel column it walks every sample whose index maps to that column, tracks `min` and `max`, and draws a single vertical line from `min` to `max`. Empty columns leave a gap rather than fake-filling with neighbours — if your data has a hole, the graph shows a hole.
170
+
171
+ ### Allocations
172
+
173
+ | Where | What | When |
174
+ |--------------|---------------------------------------|----------------------------|
175
+ | Constructor | nothing | — |
176
+ | First render | `Float32Array(ring.capacity)` | once, grown on capacity ↑ |
177
+ | First render | `Float32Array(width * 2)` (decimated) | once, grown on width ↑ |
178
+ | Resize | re-grow pixel scratch on next render | per resize |
179
+ | Per frame | **0** | always |
180
+
181
+ The `options` object literal in `render(ring, max, { decimate: false })` is the only thing the V8 inliner has to deal with — the renderer itself reads `options.decimate` directly without destructuring, so passing or omitting it changes nothing in the steady state.
182
+
183
+ ---
184
+
185
+ ## API reference
186
+
187
+ ### `new CanvasGraph(canvas, width, height, options?)`
188
+
189
+ | Param | Type | Notes |
190
+ |--------|--------------------------------------------|----------------------------------------|
191
+ | canvas | `HTMLCanvasElement \| OffscreenCanvas` | required |
192
+ | width | `number` | logical CSS pixels, ≥ 1 |
193
+ | height | `number` | logical CSS pixels, ≥ 1 |
194
+ | options.dpr | `number?` | overrides `globalThis.devicePixelRatio` |
195
+ | options.background | `string?` | default `'#111'` |
196
+ | options.stroke | `string?` | default `'#00ffcc'` |
197
+ | options.lineWidth | `number?` | default `1` |
198
+
199
+ Throws `TypeError` on missing canvas, `RangeError` on bad dimensions.
200
+
201
+ ### `.render(ringBuffer, maxValue, options?)`
202
+
203
+ The hot path. Allocation-free in the steady state.
204
+
205
+ | Param | Type | Notes |
206
+ |---------------------|----------------|------------------------------------------|
207
+ | ringBuffer | `RingBuffer` | from `@zakkster/lite-ring-buffer` |
208
+ | maxValue | `number` | upper bound of the value range |
209
+ | options.decimate | `boolean?` | default `true` (envelope when n > width) |
210
+ | options.minValue | `number?` | default `0` |
211
+
212
+ Values outside `[minValue, maxValue]` are clamped to the visible range. NaN samples break the polyline (direct mode) and are skipped (decimated mode). A degenerate range (`maxValue <= minValue`) clears the canvas and bails — labels still run.
213
+
214
+ ### `.resize(width, height)`
215
+
216
+ Updates the logical drawing area. Forces backing-store reconfig and pixel-scratch reallocation on the next `render()`. No-op when dimensions are unchanged.
217
+
218
+ ### `.labelBitmapHook`
219
+
220
+ `(ctx, maxValue, width, height) => void` — runs at the end of each `render()`, drawing on top of the trace. Set to `null` (default) to skip.
221
+
222
+ ### `.destroy()`
223
+
224
+ Releases scratchpads and references. Idempotent. Calling any other method afterwards is undefined behaviour.
225
+
226
+ ---
227
+
228
+ ## Edge cases & guarantees
229
+
230
+ - **Single sample (`n === 1`).** Direct mode emits a degenerate `lineTo` so `lineCap='square'` produces a visible dot at `lineWidth` pixels. Without this, a lone `moveTo` would render nothing.
231
+ - **NaN samples.** Direct: break the path (no fake interpolation). Decimated: skipped during column accumulation; columns with only NaN samples remain empty (gap).
232
+ - **Out-of-range values.** Clamped to `[minValue, maxValue]`. The trace pins to the top or bottom edge instead of overshooting; this is what you want for "value capped" indicators.
233
+ - **`maxValue <= minValue`.** Renderer clears the canvas, runs `labelBitmapHook` if set, returns. No throw — invalid axes happen during init/resize race conditions and shouldn't crash your loop.
234
+ - **DPR changes mid-session.** Detected at the start of each `render()`. Triggers a one-time backing-store reconfig + transform reset. Cheap.
235
+ - **Capacity growth.** If you push to a ring buffer whose capacity grew (you replaced it with a bigger one), the sample scratch is re-grown on next render. `_pixelScratch` only re-grows on resize.
236
+ - **`alpha: false` context.** The renderer requests an opaque context, which is faster on most GPUs. `background` colour is therefore guaranteed visible — there is no transparency to bleed through to the page beneath.
237
+
238
+ ---
239
+
240
+ ## FAQ
241
+
242
+ **Why not just use Chart.js / uPlot / d3?**
243
+
244
+ Those are general-purpose charting libraries with axes, tooltips, animations, legends, themes — and per-frame allocation. This package is one thing: a zero-GC trace renderer for live telemetry. It composes nicely with axes drawn in `labelBitmapHook` if you need them, but it doesn't ship them.
245
+
246
+ **Can I render multiple traces?**
247
+
248
+ Construct multiple `CanvasGraph` instances over different canvases, or layer them — the cheapest way is one canvas per trace stacked in CSS, since each one keeps its own scratchpads and DPR state.
249
+
250
+ **Does it support log scale?**
251
+
252
+ Not directly. Pre-transform your samples before pushing them: `ring.push(Math.log10(rawValue))`. Set `minValue` / `maxValue` in log space.
253
+
254
+ **Can I scroll the view backwards in time?**
255
+
256
+ This renderer always shows "the current contents of the ring buffer." If you want a scrubbing/scrollback view, you want a different component — one that buffers historical data outside the live ring.
257
+
258
+ **Why `Float32` and not `Float64`?**
259
+
260
+ Halves memory bandwidth, doubles the cache density of the scratchpad, and 7 significant decimal digits is more than your screen can show. If you need 64-bit precision for sample storage, `lite-canvas-graph` is the wrong layer.
261
+
262
+ ---
263
+
264
+ ## License
265
+
266
+ MIT © Zahary Shinikchiev
package/llms.txt ADDED
@@ -0,0 +1,51 @@
1
+ # @zakkster/lite-canvas-graph
2
+
3
+ > Zero-GC canvas renderer for time-series telemetry. Decimates to per-column min/max envelope when sample count exceeds pixel width.
4
+
5
+ ## Summary
6
+
7
+ `@zakkster/lite-canvas-graph` draws a live trace from a `RingBuffer` to a `<canvas>` (or `OffscreenCanvas` in a Worker) without per-frame allocation. One `Float32Array` scratch grows once to the ring's capacity; one `Float32Array(width * 2)` holds per-column min/max for decimation. After warmup, every render is a fixed-size numeric loop and a sequence of direct canvas calls.
8
+
9
+ Time flows left → right: the oldest sample renders at `x = 0`, the newest at `x = width - 1`. Out-of-range samples are clamped. NaN samples break the polyline in direct mode and are skipped in decimated mode (their columns stay empty).
10
+
11
+ ## Core API
12
+
13
+ ```ts
14
+ new CanvasGraph(canvas, width, height, options?)
15
+ options: { dpr?, background?='#111', stroke?='#00ffcc', lineWidth?=1 }
16
+
17
+ graph.render(ringBuffer, maxValue, options?)
18
+ options: { decimate?=true, minValue?=0 }
19
+
20
+ graph.resize(width, height)
21
+ graph.labelBitmapHook = (ctx, maxValue, width, height) => void
22
+ graph.destroy()
23
+ ```
24
+
25
+ `render` is the only hot-path method. Allocation-free in the steady state.
26
+
27
+ ## Concepts
28
+
29
+ - **Direct vs decimated.** When `ringBuffer.count <= width`, renders a polyline (`moveTo` + `lineTo` per sample). When `ringBuffer.count > width` and `decimate !== false`, renders a per-column min/max envelope — one vertical line per pixel column. Convention from oscilloscope and audio-waveform displays; preserves spike visibility that naive subsampling would lose.
30
+ - **Scratchpads grow once.** Sample scratch sized to `ringBuffer.capacity`, pixel scratch to `width * 2`. Both re-grow only on capacity / width increase. Resize triggers a one-time backing-store reconfig on next render.
31
+ - **DPR detection.** Reads `globalThis.devicePixelRatio` (or `options.dpr` override — required in Workers). Detected per render; reconfigures backing store + transform when DPR shifts.
32
+ - **`alpha: false` context.** Opaque, faster on most GPUs. `background` is always visible.
33
+
34
+ ## Constraints
35
+
36
+ - Single trace per instance. Use multiple instances for multi-trace charts (one canvas per trace stacked in CSS is the cheapest pattern).
37
+ - Linear scale only. Pre-transform samples (e.g. `Math.log10`) for log axes.
38
+ - Float32 internal storage. 7 significant digits of precision.
39
+ - Renders the *current* ring contents only — no scrollback.
40
+ - Methods are NOT safe to call after `destroy()`.
41
+
42
+ ## Performance
43
+
44
+ - Per-frame allocations: **0** (after warmup).
45
+ - Per-frame canvas calls in direct mode: 1 `fillRect` + 1 `beginPath` + n `moveTo/lineTo` + 1 `stroke`.
46
+ - Per-frame canvas calls in decimated mode: 1 `fillRect` + 1 `beginPath` + 2 × occupied-column-count + 1 `stroke`.
47
+ - DPR handling reconfigures the backing store at most once per actual DPR change.
48
+
49
+ ## License
50
+
51
+ MIT © Zahary Shinikchiev
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@zakkster/lite-canvas-graph",
3
+ "version": "1.0.0",
4
+ "description": "Zero-GC canvas renderer for time-series telemetry. Decimates to per-column min/max envelope when sample count exceeds pixel width.",
5
+ "type": "module",
6
+ "main": "CanvasGraph.js",
7
+ "module": "CanvasGraph.js",
8
+ "types": "CanvasGraph.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "CanvasGraph.d.ts",
12
+ "import": "CanvasGraph.js",
13
+ "default": "CanvasGraph.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "CanvasGraph.js",
18
+ "CanvasGraph.d.ts",
19
+ "README.md",
20
+ "llms.txt",
21
+ "LICENSE.txt"
22
+ ],
23
+ "scripts": {
24
+ "test": "vitest run"
25
+ },
26
+ "keywords": [
27
+ "canvas",
28
+ "graph",
29
+ "chart",
30
+ "telemetry",
31
+ "time-series",
32
+ "oscilloscope",
33
+ "waveform",
34
+ "zero-gc",
35
+ "lite",
36
+ "no-allocation",
37
+ "performance",
38
+ "offscreen-canvas"
39
+ ],
40
+ "engines": {
41
+ "node": ">=18.0.0"
42
+ },
43
+ "peerDependencies": {
44
+ "@zakkster/lite-ring-buffer": "^1.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "vitest": "^2.0.0",
48
+ "@zakkster/lite-ring-buffer": "^1.0.0"
49
+ },
50
+ "license": "MIT",
51
+ "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
52
+ "homepage": "https://github.com/PeshoVurtoleta/lite-canvas-graph#readme",
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/PeshoVurtoleta/lite-canvas-graph.git"
56
+ },
57
+ "bugs": {
58
+ "url": "https://github.com/PeshoVurtoleta/lite-canvas-graph/issues",
59
+ "email": "shinikchiev@yahoo.com"
60
+ },
61
+ "sideEffects": false
62
+ }