@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.
- package/CanvasGraph.d.ts +136 -0
- package/CanvasGraph.js +273 -0
- package/LICENSE.txt +21 -0
- package/README.md +266 -0
- package/llms.txt +51 -0
- package/package.json +62 -0
package/CanvasGraph.d.ts
ADDED
|
@@ -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
|
+
[](https://www.npmjs.com/package/@zakkster/lite-canvas-graph)
|
|
4
|
+
[](https://bundlephobia.com/result?p=@zakkster/lite-canvas-graph)
|
|
5
|
+
[](https://www.npmjs.com/package/@zakkster/lite-canvas-graph)
|
|
6
|
+
[](https://www.npmjs.com/package/@zakkster/lite-canvas-graph)
|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
[](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(scratch)] --> 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()] --> Q{n > 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
|
+
}
|