@zakkster/lite-charts 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/README.md ADDED
@@ -0,0 +1,711 @@
1
+ # @zakkster/lite-charts
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@zakkster/lite-charts.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-charts)
4
+ ![Zero-GC](https://img.shields.io/badge/Zero--GC-Hot%20path-00C853?style=for-the-badge&logo=leaf&logoColor=white)
5
+ [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
6
+ [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-charts?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-charts)
7
+ [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-charts?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-charts)
8
+ [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-charts?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-charts)
9
+ ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational?style=flat-square)
10
+ [![lite-signal peer](https://img.shields.io/npm/dependency-version/@zakkster/lite-charts/peer/@zakkster/lite-signal?style=for-the-badge&color=blue)](https://github.com/PeshoVurtoleta/lite-signal)
11
+ ![Dependencies](https://img.shields.io/badge/runtime%20deps-0-brightgreen?style=flat-square)
12
+ [![license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE.txt)
13
+
14
+ > Reactive, zero-GC chart library. Signals for data, dimensions, theme. 100k
15
+ > points at 60fps with sub-frame budget. Built on `@zakkster/lite-scene`
16
+ > (Canvas2D scene graph), `@zakkster/lite-signal` (reactive core), and
17
+ > `@zakkster/lite-axis` (tick generation). Three peer deps. ESM-only.
18
+ > Single-file (~5k lines, three independent kernels). MIT.
19
+
20
+ **Status:** v1.0.0 -- seven chart types on three independent kernels.
21
+ Line, area, bar, bubble on the axis kernel; pie, donut on the polar
22
+ slice kernel; radar on its own kernel. Kernel-side auto-resize via
23
+ ResizeObserver -- omit `width` / `height` and the chart tracks its
24
+ container. Tree-shake verified with esbuild: each chart's bundle
25
+ contains only its kernel + renderer code. Zero runtime dependencies
26
+ (three peers: `@zakkster/lite-signal`, `@zakkster/lite-scene`,
27
+ `@zakkster/lite-axis`). 196/196 tests pass.
28
+
29
+ See [ROADMAP.md](./ROADMAP.md) for the development history that led to
30
+ this architecture and the forward plan (stacked bar, heatmap, scatter
31
+ with `@zakkster/lite-delaunay` for O(log n) hit-test, log scale, pan +
32
+ zoom).
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ npm i @zakkster/lite-charts @zakkster/lite-signal @zakkster/lite-scene @zakkster/lite-axis
38
+ ```
39
+
40
+ ## Hello World
41
+
42
+ ```javascript
43
+ import { signal } from '@zakkster/lite-signal';
44
+ import { createLineChart } from '@zakkster/lite-charts';
45
+
46
+ const data = signal([
47
+ { t: new Date('2026-01-01'), v: 100 },
48
+ { t: new Date('2026-02-01'), v: 142 },
49
+ { t: new Date('2026-03-01'), v: 88 },
50
+ { t: new Date('2026-04-01'), v: 175 },
51
+ ]);
52
+
53
+ const chart = createLineChart({
54
+ data,
55
+ x: 't',
56
+ y: 'v',
57
+ width: 800,
58
+ height: 400,
59
+ color: '#3b82f6',
60
+ });
61
+
62
+ chart.mount(document.getElementById('chart-container'));
63
+
64
+ // Mutate the signal anywhere -- the chart redraws automatically.
65
+ setTimeout(() => {
66
+ data.update((rows) => [...rows, { t: new Date('2026-05-01'), v: 210 }]);
67
+ }, 1000);
68
+ ```
69
+
70
+ The chart inferred the time scale from the `Date` probe, auto-fitted the
71
+ y-domain with 5% padding, and threaded a reactive signal end-to-end. No
72
+ explicit re-render needed.
73
+
74
+ ## Why lite-charts
75
+
76
+ | Concern | lite-charts | Chart.js | uPlot | D3 |
77
+ |---|---|---|---|---|
78
+ | **Reactive data binding** | First-class signals | Imperative `.update()` | Imperative `.setData()` | Manual selection re-bind |
79
+ | **100k points** | 1.4 ms / 4.7 ms p95 (CPU) | Drops frames | OK | Hand-rolled |
80
+ | **Zero-GC steady state** | Yes (slab-based) | No | Mostly | No |
81
+ | **Bundle (min, per-chart)** | 13-24 KB (see table) | 78 KB | 40 KB | 70+ KB |
82
+ | **Render substrate** | Canvas2D via lite-scene | Canvas2D | Canvas2D | SVG / Canvas |
83
+ | **API style** | Vega-Lite middle ground | Imperative config | Hand-tuned | Composable primitives |
84
+ | **Twitch Extension fit** | Yes (1MB / 3s budget) | No | Yes | No |
85
+
86
+ Built specifically for performance-critical environments: dashboards that
87
+ stream telemetry, live trading interfaces, game HUDs, monitoring overlays,
88
+ Twitch Extensions. Where Chart.js works fine until you hit 5k points and a
89
+ ~3MB transitive dep graph, lite-charts is engineered to scale to 100k points
90
+ in a 1MB bundle without GC pauses.
91
+
92
+ ## Architecture
93
+
94
+ ```mermaid
95
+ graph TD
96
+ User["User config + data signal"] --> Constructor["createLineChart"]
97
+ Constructor --> Normalize["Normalize: data shorthand -> series[]"]
98
+ Normalize --> Accessors["Build accessors x/y"]
99
+ Accessors --> InferType["Infer x-scale type"]
100
+ InferType --> StateAlloc["Allocate SeriesState slabs"]
101
+
102
+ StateAlloc --> Mount["mount(container)"]
103
+ Mount --> Scene["createScene from lite-scene"]
104
+ Scene --> Effect1["Effect: width/height -> plotBounds"]
105
+ Scene --> Effect2["Effect: data -> SoA extract -> scale -> pixels"]
106
+ Scene --> Axes["buildAxis x2 / lite-axis ticks"]
107
+ Scene --> SeriesNodes["path nodes / one per series"]
108
+ SeriesNodes --> DrawFn["makeLineDrawFn closure"]
109
+
110
+ DrawFn --> PathSelect{"n > 2*cols?"}
111
+ PathSelect -->|yes| Decimate["decimateMinMax kernel<br/>lifted from lite-canvas-graph"]
112
+ PathSelect -->|no| Polyline["Direct polyline / NaN-aware"]
113
+ Decimate --> Stroke["ctx.stroke"]
114
+ Polyline --> Stroke
115
+
116
+ Signal["Any signal write"] --> LiteSignal["lite-signal sync flush"]
117
+ LiteSignal --> EffectsRun["Effects re-run"]
118
+ EffectsRun --> DirtyBridge["scaleVersion bump -> scene.markDirty"]
119
+ DirtyBridge --> SceneDraw["lite-scene drawAll / coalesced via _queued"]
120
+ SceneDraw --> DrawFn
121
+ ```
122
+
123
+ The hot path (line render) is allocation-free: per-frame work is two `O(n)`
124
+ scans (extract extents, project to pixels) plus the decimation kernel
125
+ (`O(plotWidth)`) and a single `ctx.stroke()`. The axis update path allocates
126
+ a small amount per re-layout (label strings, ephemeral props objects), but
127
+ that runs only on data-domain or size changes, not every frame.
128
+
129
+ ## API Reference
130
+
131
+ ### `createLineChart(config) -> chart`
132
+
133
+ | Config key | Type | Default | Notes |
134
+ |---|---|---|---|
135
+ | `data` | `Row[]` &#124; `Signal<Row[]>` &#124; `() => Row[]` &#124; `{xs, ys}` SoA | -- | Either `data` or `series` required. SoA fast path is zero-copy. |
136
+ | `series` | `SeriesConfig[]` &#124; `Signal<SeriesConfig[]>` | -- | Multi-series form. `{name, data, color, lineWidth}`. |
137
+ | `x` | `string` &#124; `number` &#124; `(row, i) => number` | `'x'` | Accessor key, array index, or function. `Date` is coerced to ms. |
138
+ | `y` | `string` &#124; `number` &#124; `(row, i) => number` | `'y'` | Same. |
139
+ | `width` | `number` &#124; `Signal<number>` &#124; `() => number` | `800` | Static or reactive. |
140
+ | `height` | `number` &#124; `Signal<number>` &#124; `() => number` | `400` | Same. |
141
+ | `margin` | `{top,right,bottom,left}` | `{16,24,32,56}` | Pixel space reserved for axes. |
142
+ | `color` | `string` | `'#3b82f6'` | Hex, css var (`--my-token`), or any CSS color string. |
143
+ | `lineWidth` | `number` | `1.5` | Series stroke width in CSS pixels. |
144
+ | `background` | `string` &#124; `null` | `null` | Canvas fill before draw. |
145
+ | `dpr` | `number` | `devicePixelRatio` | Override device pixel ratio. |
146
+ | `xScale` | `{type?, domain?}` | inferred | `type: 'linear' \| 'time'`; `domain: [min, max]` to lock. |
147
+ | `yScale` | `{domain?, zero?, nice?}` | nice + pad | `zero: true` forces 0 inclusion; `nice: true` adds 5% padding. |
148
+ | `axisColor` | `string` | `'#888888'` | Axis spine + tick color. |
149
+ | `labelColor` | `string` | `'#444444'` | Tick label color. |
150
+ | `font` | `string` | `'11px sans-serif'` | Tick label font. |
151
+ | `interpolation` | `'linear'` &#124; `'step'` &#124; `'step-after'` &#124; `'step-before'` &#124; `'step-mid'` &#124; `'monotone'` &#124; `'catmull-rom'` | `'linear'` | Path interpolation mode. Per-series override via `SeriesConfig.interpolation`. |
152
+ | `markers` | `boolean` &#124; `{shape?, size?, fill?, stroke?, strokeWidth?, everyN?}` | `false` | Marker dots at each sample. `true` = circle defaults. `{everyN: 5}` for dense data. |
153
+ | `grid` | `boolean` &#124; `{x?, y?, color?}` | `false` | Gridlines through the plot rect at each tick. `true` = both axes. Object form for per-axis + color override. |
154
+ | `crosshair` | `boolean` &#124; `{color?, dash?}` | `true` | Vertical line + per-series marker dots. `false` disables. |
155
+ | `tooltip` | `boolean` &#124; `{background?, border?, format?}` | `true` | Canvas-drawn box at the snapped x. `false` disables. |
156
+ | `legend` | `boolean` &#124; `'top'`&#124;`'bottom'`&#124;`'left'`&#124;`'right'` &#124; `{position?, container?}` | `'bottom'` | DOM-rendered legend with click-to-toggle. `false` disables. |
157
+ | `schedule` | `(fn) => void` | `requestAnimationFrame` | Frame scheduler. Pass `(fn) => fn()` for sync (tests), `queueMicrotask` for headless batching. |
158
+
159
+ #### Chart methods
160
+
161
+ | Method | Returns | Notes |
162
+ |---|---|---|
163
+ | `chart.mount(target)` | `chart` | `target` is an `HTMLElement` (creates canvas inside) or `HTMLCanvasElement`. |
164
+ | `chart.unmount()` | `void` | Disposes all effects, removes canvas if owned. Idempotent. |
165
+ | `chart.exportPNG({mimeType?, quality?})` | `string` (data URL) | Calls `canvas.toDataURL`. |
166
+ | `chart.redraw()` | `void` | Force a redraw without changing data. |
167
+ | `chart.moveCrosshair(canvasX, canvasY)` | `void` | Programmatic crosshair move. Snaps to nearest sample on the primary series. |
168
+ | `chart.hideCrosshair()` | `void` | Hide crosshair + tooltip. Idempotent. |
169
+ | `chart.setSeriesVisible(idx, visible)` | `void` | Toggle a series. Out-of-range indices are safe no-ops. |
170
+ | `chart.refreshTheme()` | `void` | Re-resolve CSS-var colors and redraw. Call after a theme switch. |
171
+
172
+ #### Chart properties
173
+
174
+ | Prop | Type | Notes |
175
+ |---|---|---|
176
+ | `chart.scene` | `Scene` &#124; `null` | The underlying `lite-scene` instance. |
177
+ | `chart.canvas` | `HTMLCanvasElement` &#124; `null` | The canvas being drawn into. |
178
+ | `chart.xScale` | `Scale` | `{type, dMin, dMax, rMin, rMax, map(v), invert(px)}`. |
179
+ | `chart.yScale` | `Scale` | Same shape. |
180
+ | `chart.xScaleType` | `'linear'` &#124; `'time'` | Resolved at construction. |
181
+ | `chart.plotBounds` | `Signal<number>` | A version-counter signal; subscribe to react to size changes. |
182
+ | `chart.crosshair` | `Signal<CrosshairState>` | Live `{visible, snapIdx, snapDomainX, snapPixelX, mousePixelY}`. Subscribe for synchronized small-multiples. |
183
+ | `chart.seriesVisibility` | `Signal<boolean>[]` | One signal per series. Read in a reactive context to bind external UI; write to toggle. |
184
+ | `chart.legend` | `HTMLElement` &#124; `null` | The legend container, or null if `legend: false` or mounted into a bare canvas. |
185
+
186
+ ## Reactivity
187
+
188
+ Every config value (`width`, `height`, `data`, future `color`, etc.) accepts
189
+ either a static value or a signal accessor. A signal is just a function:
190
+
191
+ ```javascript
192
+ const w = signal(800);
193
+ const chart = createLineChart({ data, width: w, height: 400 });
194
+ chart.mount(el);
195
+
196
+ // Later:
197
+ w.set(1200); // chart resizes and rescales -- no manual redraw call
198
+ ```
199
+
200
+ Internally, lite-charts wraps statics in constant accessors via a tiny
201
+ helper, so the engine only ever calls functions. Zero overhead for static
202
+ config; full reactivity for signal config. Same pattern as `unref` in Vue,
203
+ `toValue` in Solid, etc.
204
+
205
+ ### Bring-your-own scheduling
206
+
207
+ The default schedule is `requestAnimationFrame`. In Node (tests, headless
208
+ benches, SSR-adjacent workflows), pass an explicit schedule:
209
+
210
+ ```javascript
211
+ // Synchronous -- assertions can read ctx.calls immediately. Best for tests.
212
+ const chart = createLineChart({ ..., schedule: (fn) => fn() });
213
+
214
+ // Microtask-coalesced -- draws batch within a tick. Best for headless benches.
215
+ const chart = createLineChart({ ..., schedule: (fn) => queueMicrotask(fn) });
216
+ ```
217
+
218
+ ## Tooltip + crosshair
219
+
220
+ On by default. The crosshair vertical line snaps to the
221
+ nearest sample on the primary series (binary search on sorted xs); markers
222
+ on each additional series snap independently at the same domain x. The
223
+ tooltip is canvas-drawn (no DOM overlay), so it remains headless-testable.
224
+
225
+ ```javascript
226
+ createLineChart({
227
+ data,
228
+ crosshair: { color: '#666', dash: [3, 3] },
229
+ tooltip: {
230
+ // String form: replaces the header, suppresses rows.
231
+ format: (snap) => 'sample #' + snap.snapIdx,
232
+ // Object form: customize both -- snap.rows is pre-filled with one row per series.
233
+ // format: (snap) => ({ header: 'custom', rows: snap.rows }),
234
+ },
235
+ });
236
+
237
+ // Disable per-feature
238
+ createLineChart({ data, crosshair: false }); // tooltip stays on
239
+ createLineChart({ data, tooltip: false }); // crosshair stays on
240
+ createLineChart({ data, crosshair: false, tooltip: false }); // no DOM listener attached
241
+ ```
242
+
243
+ ### Synchronized crosshairs across small multiples
244
+
245
+ The `chart.crosshair` signal exposes live state. To synchronize the
246
+ crosshair across multiple charts sharing an x-axis, write to one and
247
+ forward to the others:
248
+
249
+ ```javascript
250
+ const c1 = createLineChart({ data: a, x: 't', y: 'cpu' });
251
+ const c2 = createLineChart({ data: b, x: 't', y: 'mem' });
252
+ c1.mount(el1); c2.mount(el2);
253
+
254
+ c1.crosshair.subscribe((state) => {
255
+ if (state.visible) c2.moveCrosshair(state.snapPixelX, /* y irrelevant for sync */ 0);
256
+ else c2.hideCrosshair();
257
+ });
258
+ ```
259
+
260
+ ### Programmatic + testing API
261
+
262
+ `chart.moveCrosshair(canvasX, canvasY)` and `chart.hideCrosshair()` drive
263
+ the same path as the DOM mousemove handler. Tests use these directly
264
+ against the mock canvas (no event simulation needed). The mock canvas in
265
+ `test/harness.js` doesn't implement `addEventListener`, so the DOM listener
266
+ is skipped in headless contexts -- the programmatic API is the only way in.
267
+
268
+ ## Area chart
269
+
270
+ `createAreaChart(config)` shares everything with `createLineChart` -- same
271
+ data shape, same accessors, same scales, same reactivity, same crosshair
272
+ and tooltip -- and adds three options:
273
+
274
+ | Config key | Type | Default | Notes |
275
+ |---|---|---|---|
276
+ | `baseline` | `number` &#124; `'bottom'` | `0` | Domain y value to close the area to. `'bottom'` pins to the bottom edge of the plot rect regardless of domain. Numeric baselines clamp to plot rect if outside. |
277
+ | `stroke` | `boolean` | `true` | Whether to stroke the upper boundary of the fill. |
278
+ | `fillOpacity` | `number` | `0.3` | Multiplied into `globalAlpha` before fill. The stroke draws at full alpha. |
279
+
280
+ ```javascript
281
+ import { createAreaChart } from '@zakkster/lite-charts';
282
+
283
+ const chart = createAreaChart({
284
+ data: timeseries,
285
+ x: 't', y: 'cpu',
286
+ color: '#3b82f6',
287
+ baseline: 0, // fills from data line down to y=0
288
+ fillOpacity: 0.25,
289
+ stroke: true, // crisp blue line on top of soft fill
290
+ });
291
+ ```
292
+
293
+ Both render paths from line chart carry over: direct polyline-with-close
294
+ for sparse data, decimated per-column for dense. The decimated path fills
295
+ to the column's upper envelope (max), matching d3-area's default behavior;
296
+ ribbon-style min-max area is a separate primitive in v1.1+.
297
+
298
+ ## Legend
299
+
300
+ Rendered as a DOM element (sibling of the canvas, inside an auto-created
301
+ flex wrapper), so it's keyboard-accessible (each row is a `<button>` with
302
+ `aria-pressed`), CSS-themable (`.lite-charts-legend` class on the
303
+ container), and ready to drop a virtualizer into when v1.2 ships the
304
+ `lite-virtual` integration. Click-to-toggle is wired by default.
305
+
306
+ ```javascript
307
+ createLineChart({
308
+ series: [
309
+ { name: 'CPU', data: cpuRows },
310
+ { name: 'Memory', data: memRows },
311
+ { name: 'Disk', data: diskRows },
312
+ ],
313
+ x: 't', y: 'pct',
314
+ legend: 'bottom', // 'top' | 'bottom' | 'left' | 'right' | false
315
+ });
316
+ ```
317
+
318
+ Position controls the auto-wrapper's flex direction:
319
+
320
+ - `'bottom'` / `'top'` -> column wrapper (canvas above/below legend)
321
+ - `'left'` / `'right'` -> row wrapper (canvas beside legend)
322
+
323
+ For custom DOM placement, pass an existing element via
324
+ `legend: { container: someEl }` -- the legend appends into your element and
325
+ the canvas stays put.
326
+
327
+ ### Series visibility
328
+
329
+ Each series has a `Signal<boolean>` exposed on `chart.seriesVisibility[i]`.
330
+ Toggling it has three effects:
331
+
332
+ 1. The series stops rendering (line/area, crosshair marker, tooltip row).
333
+ 2. The y-domain rescales to fit only the visible series (matching
334
+ Chart.js convention -- toggle reveals detail in the remaining data).
335
+ Pass an explicit `yScale: { domain: [...] }` to lock the scale.
336
+ 3. The legend swatch + label dim (`opacity: 0.4`, `aria-pressed=false`).
337
+
338
+ You can toggle programmatically via `chart.setSeriesVisible(idx, bool)` or
339
+ write directly to the signal:
340
+
341
+ ```javascript
342
+ chart.setSeriesVisible(0, false);
343
+ // or
344
+ chart.seriesVisibility[0].set(false);
345
+ // or
346
+ chart.seriesVisibility[0].update((v) => !v);
347
+ ```
348
+
349
+ For a "show only this" pattern (alt-click), iterate:
350
+
351
+ ```javascript
352
+ const showOnly = (idx) => {
353
+ chart.seriesVisibility.forEach((sig, i) => sig.set(i === idx));
354
+ };
355
+ ```
356
+
357
+ ## Path interpolation
358
+
359
+ Six modes (seven names -- `'step'` is an alias for `'step-after'`).
360
+ Default is `'linear'` (the polyline). Three step variants for
361
+ discrete data (telemetry, state machines, financial OHLC). Two smoothing
362
+ modes for continuous data.
363
+
364
+ ```javascript
365
+ createLineChart({ data, interpolation: 'monotone' });
366
+ ```
367
+
368
+ | Mode | Visual | When to use |
369
+ |---|---|---|
370
+ | `'linear'` | Straight segments between samples | Default; honest about data resolution |
371
+ | `'step'` / `'step-after'` | Horizontal then vertical | Sample held until the next reading (sensor readouts) |
372
+ | `'step-before'` | Vertical then horizontal | Sample took effect at the prior x (event-triggered transitions) |
373
+ | `'step-mid'` | Step at the midpoint of each segment | Symmetric staircase; useful for histogram-like data |
374
+ | `'monotone'` | Fritsch-Carlson cubic Hermite | Smooth without overshooting between samples. Best for noisy time-series. |
375
+ | `'catmull-rom'` | Uniform Catmull-Rom spline | Smooth through all samples. Aesthetic; can overshoot on irregular data. |
376
+
377
+ Per-series override:
378
+
379
+ ```javascript
380
+ createLineChart({
381
+ series: [
382
+ { name: 'CPU', data: cpu, interpolation: 'monotone' },
383
+ { name: 'Events', data: events, interpolation: 'step-after' },
384
+ ],
385
+ });
386
+ ```
387
+
388
+ **Decimation interaction:** when `n > 2 * plotWidth` and the decimated
389
+ render path activates, interpolation is ignored -- smoothing the per-column
390
+ min/max envelope would be visually misleading. Interpolation only changes
391
+ the direct path.
392
+
393
+ **NaN handling:** linear and step modes split on NaN (each contiguous run
394
+ renders independently). Smoothing modes assume contiguous data; if you need
395
+ gaps, use linear or step.
396
+
397
+ ## Markers
398
+
399
+ Marker dots at each sample point. Distinct from crosshair markers (those
400
+ appear only on hover).
401
+
402
+ ```javascript
403
+ createLineChart({ data, markers: true }); // circle defaults
404
+
405
+ createLineChart({
406
+ data,
407
+ markers: {
408
+ shape: 'diamond',
409
+ size: 6,
410
+ fill: '#3b82f6',
411
+ stroke: '#ffffff',
412
+ strokeWidth: 2,
413
+ everyN: 1,
414
+ },
415
+ });
416
+ ```
417
+
418
+ Use `everyN` for dense series:
419
+
420
+ ```javascript
421
+ // 500-point series with markers every 10th sample -- legible without noise.
422
+ createLineChart({ data: dense, markers: { everyN: 10 } });
423
+ ```
424
+
425
+ **Decimation interaction:** markers are suppressed when the decimated path
426
+ runs (>2x plot width). They'd be unreadable.
427
+
428
+ ## Theme reactivity
429
+
430
+ Colors passed as `'--token-name'` get resolved against the container's
431
+ computed style at mount. When you switch themes (dark mode, brand swap),
432
+ call `chart.refreshTheme()` to re-resolve every CSS-var-driven color and
433
+ trigger a redraw.
434
+
435
+ ```javascript
436
+ const chart = createLineChart({
437
+ data,
438
+ color: '--my-brand-primary',
439
+ axisColor: '--my-text-muted',
440
+ });
441
+ chart.mount(el);
442
+
443
+ // On theme change:
444
+ document.documentElement.setAttribute('data-theme', 'dark');
445
+ chart.refreshTheme();
446
+ ```
447
+
448
+ Hex / oklch / named colors pass through unchanged; only CSS-var tokens
449
+ re-resolve. Legend swatches update too.
450
+
451
+ > **MutationObserver auto-detection** is deliberately not bundled in v1.0.0.
452
+ > Which element to observe (container? `<html>`? `<body>`?), which
453
+ > attributes (class? data-theme? both?), and how to debounce all depend on
454
+ > the host app's theming convention. Wire your own observer to call
455
+ > `chart.refreshTheme()`, or pair it with whatever theme-change event your
456
+ > framework emits.
457
+
458
+ ## Bar chart
459
+
460
+ ```javascript
461
+ import { createBarChart } from '@zakkster/lite-charts';
462
+
463
+ // Single series:
464
+ const chart = createBarChart({
465
+ data: [
466
+ { x: 'Q1', y: 42 },
467
+ { x: 'Q2', y: 58 },
468
+ { x: 'Q3', y: 65 },
469
+ { x: 'Q4', y: 78 },
470
+ ],
471
+ color: '--c-primary',
472
+ });
473
+ chart.mount(document.getElementById('chart'));
474
+ ```
475
+
476
+ Multi-series renders **grouped** side-by-side at each category. Each bar
477
+ takes a slice of the band centered on its series index
478
+ (`offsetX = (i - (count - 1)/2) * groupWidth`):
479
+
480
+ ```javascript
481
+ createBarChart({
482
+ series: [
483
+ { name: 'Revenue', data: [{x:'Q1',y:42}, {x:'Q2',y:58}, ...], color: '--c-primary' },
484
+ { name: 'Expenses', data: [{x:'Q1',y:30}, {x:'Q2',y:35}, ...], color: '--c-amber' },
485
+ { name: 'Profit', data: [{x:'Q1',y:12}, {x:'Q2',y:23}, ...], color: '--c-cyan' },
486
+ ],
487
+ });
488
+ ```
489
+
490
+ | Config | Type | Default | Notes |
491
+ |---|---|---|---|
492
+ | `baseline` | `number` | `0` | Y value where bars anchor. Negatives extend downward. |
493
+ | `paddingInner` | `number` | `0.15` | Gap between bands as fraction of step. d3 convention. |
494
+ | `paddingOuter` | `number` | `0.1` | Padding at each end of the range as fraction of step. |
495
+ | `groupInnerPad` | `number` | `0.08` | Inner gap between bars within a grouped slot. |
496
+
497
+ **Hit detection is discrete.** Unlike line/area which uses `bisectNearest`
498
+ (O(log n) over the x array), bar uses `bandScale.invert(canvasX)` which is
499
+ a single floor-division: `Math.floor((px - origin) / step)`. The user is
500
+ either inside a band or in a gap that snaps to the nearest band. O(1)
501
+ regardless of category count.
502
+
503
+ **Y-domain includes the baseline by default** so bars don't visually float.
504
+ Override with `yScale: { domain: [...] }` if you need a fixed window.
505
+
506
+ **Stacked layout** ships in v1.1.1. The current beta only supports grouped
507
+ multi-series (which is the right default -- stacked introduces design
508
+ choices around shared y-domain and tooltip ordering that are worth a
509
+ dedicated session).
510
+
511
+ ## Tree-shakeable architecture
512
+
513
+ `lite-charts` is built on a tiny shared kernel that's parameterized by a
514
+ **renderer object** per chart type:
515
+
516
+ ```javascript
517
+ const createBaseAxisChart = (config, renderer) => { /* shared scaffold */ };
518
+
519
+ const LINE_RENDERER = { extractData, makeDrawFn, hitTest, buildXAxis, ... };
520
+ const AREA_RENDERER = { ...AREA_specific };
521
+ const BAR_RENDERER = { ...BAR_specific };
522
+
523
+ export const createLineChart = (config) => createBaseAxisChart(config, LINE_RENDERER);
524
+ export const createAreaChart = (config) => createBaseAxisChart(config, AREA_RENDERER);
525
+ export const createBarChart = (config) => createBaseAxisChart(config, BAR_RENDERER);
526
+ ```
527
+
528
+ `createBaseAxisChart` calls renderer methods **polymorphically** -- it
529
+ never references any specific renderer by name. The bundler can statically
530
+ prove which renderers are reachable from the entry import and drop the
531
+ rest, along with all their renderer-specific helpers.
532
+
533
+ **Measured bundle sizes** (esbuild --bundle --minify, peer deps externalized):
534
+
535
+ | Entry | Bundle size | What's included |
536
+ |---|---|---|
537
+ | `import { createLineChart }` | **24 KB** | Line renderer + interp helpers + decimation + shared axis kernel + auto-resize |
538
+ | `import { createAreaChart }` | **24 KB** | Area renderer + interp helpers + decimation + shared axis kernel + auto-resize |
539
+ | `import { createBarChart }` | **23 KB** | Bar renderer + bandScale + bar helpers + shared axis kernel + auto-resize |
540
+ | `import { createBubbleChart }` | **22 KB** | Bubble renderer + sqrt size scale + distance hit-test + axis kernel + auto-resize |
541
+ | `import { createPieChart }` | **14 KB** | Slice renderer + polar kernel (no axes / scales / interp / decimation) + auto-resize |
542
+ | `import { createDonutChart }` | **14 KB** | Same as pie (shared renderer; only innerRadius default differs) |
543
+ | `import { createRadarChart }` | **13 KB** | Radar kernel (cos/sin tables, polygon draw, spokes, grid rings, vertex hit-test) -- zero axis/polar code |
544
+ | All seven together | **56 KB** | Axis + polar + radar kernels deduplicated; all renderers; shared utilities (resolveColor, ensureFloat32, mount/DPR, legend, auto-resize) shared once |
545
+
546
+ The ~400 byte uptick from earlier alphas is the kernel-side
547
+ `ResizeObserver` helper -- shared across all kernels, inlined into
548
+ each `mount()`.
549
+
550
+ **Auto-resize:** omit `width` / `height` from the config and the chart
551
+ observes its mount container, updating dimensions on container resize
552
+ through the existing reactive graph:
553
+
554
+ ```js
555
+ // Reactive to container size, no demo helpers needed
556
+ createLineChart({ series: [...] }).mount(document.getElementById('chart'));
557
+
558
+ // Explicit static -- bypasses auto-observation
559
+ createLineChart({ series: [...], width: 800, height: 400 }).mount(canvas);
560
+
561
+ // Explicit reactive -- user-provided signal
562
+ createLineChart({ series: [...], width: mySignal }).mount(div);
563
+ ```
564
+
565
+ Falls back gracefully (keeps default size) when `ResizeObserver` is
566
+ unavailable. rAF-throttled so burst resize events coalesce into one
567
+ re-extract per frame.
568
+
569
+ **What gets dropped from the radar bundle:** every axis-chart helper
570
+ (xScale, yScale, axes, grid, decimation, interp, bisect, bandScale,
571
+ makeLineDrawFn, makeBarDrawFn, makeBubbleDrawFn) and every polar-slice
572
+ helper (extractSliceData, sliceHitTest, computeSliceGeometry,
573
+ makeSliceDrawFn). What's *kept*: the precomputed cos/sin tables, polygon
574
+ draw, spoke renderer with angle-aware label alignment, and 12-px
575
+ nearest-vertex hit-test.
576
+
577
+ **Requirements for tree-shaking to work** (already in place):
578
+ 1. `"sideEffects": false` in package.json
579
+ 2. Every renderer is a separate top-level `const`
580
+ 3. Renderers don't reference each other (no spread inheritance -- shared
581
+ methods are top-level `const`s)
582
+ 4. Pure test helpers live on a separate `_testHelpers` export, not on
583
+ chart instance `_internal` -- production code never references it, so
584
+ it gets dropped along with everything it transitively references
585
+
586
+ The same architecture extends to upcoming chart families:
587
+
588
+ ```javascript
589
+ // Pie + donut already ship -- shared SLICE_RENDERER on the polar kernel
590
+ // (only the innerRadius default differs between the two factories).
591
+ const createBasePolarChart = (config, renderer) => { /* polar scaffold */ };
592
+ export const createPieChart = (c) => createBasePolarChart(_pieCfg(c), SLICE_RENDERER);
593
+ export const createDonutChart = (c) => createBasePolarChart(_donutCfg(c), SLICE_RENDERER);
594
+
595
+ // Radar has its own kernel -- no renderer indirection in v1.0.0 because
596
+ // there's only one radar variant. A second variant will trigger the
597
+ // extraction into createBaseRadarChart(config, renderer).
598
+ export const createRadarChart = (config) => { /* radar kernel inline */ };
599
+
600
+ // Bubble ships too -- BUBBLE_RENDERER on the axis kernel.
601
+ export const createBubbleChart = (c) => createBaseAxisChart(c, BUBBLE_RENDERER);
602
+
603
+ // v1.2.0 -- heatmap (2D categorical grid) lands on a new kernel.
604
+ const createBaseGridChart = (config, renderer) => { /* grid scaffold */ };
605
+ export const createHeatmap = (c) => createBaseGridChart(c, HEATMAP_RENDERER);
606
+ ```
607
+
608
+ Each chart type added to the library costs nothing for users who don't
609
+ import it. A dashboard that only needs line and bar charts gets a ~30 KB
610
+ bundle even after pie, donut, radar, bubble, and heatmap ship.
611
+
612
+ ## Performance
613
+
614
+ All numbers are from `bench/line-100k.mjs` running on Node 22 against a
615
+ mock canvas (so the GPU paint cost is not included -- see disclaimer below).
616
+ Dataset is 100,000 points; canvas is 1600x800.
617
+
618
+ ```
619
+ full update cycle (data -> draw) p50 = 1.39 ms p95 = 4.66 ms p99 = 5.11 ms
620
+ decimation kernel only p50 = 0.52 ms p95 = 0.56 ms p99 = 0.65 ms
621
+ draw only (cached data + scales) p50 = 0.48 ms p95 = 0.58 ms p99 = 3.90 ms
622
+ ```
623
+
624
+ CPU-bound fps ceiling at p95:
625
+ - full cycle: **214 fps**
626
+ - draw only: **1735 fps**
627
+
628
+ Both 60fps (16.67ms) and 120fps (8.33ms) frame budgets fit with material
629
+ headroom on the CPU side.
630
+
631
+ **Honest disclaimer.** The bench runs against a recording mock canvas
632
+ context (see `test/harness.js`). This measures the library's CPU work
633
+ (scale math, decimation, canvas-call issuing) but does NOT measure real GPU
634
+ paint cost. In a real browser, paint is additional and depends on GPU, DPR,
635
+ blending, and what else is on the compositor. The 60fps-at-100k claim is
636
+ meaningful only when CPU + GPU together fit under 16.67ms. This bench
637
+ validates the CPU side. The browser bench (`bench/browser/`, coming in
638
+ v1.0.1) measures real paint.
639
+
640
+ ### Zero-GC discipline
641
+
642
+ Hot-path allocation target: <100 bytes/cycle. Current measurement on
643
+ the `bench/line-100k.mjs` full-update cycle (200-sample mean after
644
+ `global.gc()`) sits at roughly 270-330 bytes/cycle in Node 22, which is
645
+ within `process.memoryUsage().heapUsed` noise band but still above the
646
+ target. The library-side accounting:
647
+
648
+ - **Per-frame line draw closure: 0 bytes.** Verified by the
649
+ `decimateMinMax` zero-alloc test in `test/Charts.test.js` (asserts
650
+ <100 B/call across 200 iterations under `--expose-gc`).
651
+ - **Per-mousemove tooltip path: 0 row objects, 0 ctx objects.** The
652
+ `_tooltipRowPool` and `_tooltipFormatterCtx` are chart-instance
653
+ scratch; only `formatTooltipValue` allocates the value string itself
654
+ (already minimised via the shared char buffer + `String.fromCharCode`).
655
+ Identity-stability is enforced by the
656
+ `tooltip pool zero-alloc contract` test suite.
657
+ - **Per-data-update y-domain: 0 bytes.** `_yDomScratch` is a 2-element
658
+ array filled by `_niceYDomainInto`; the legacy `niceYDomain` that
659
+ returned a fresh `[lo, hi]` is still exported on `_testHelpers` for
660
+ the test API but not used by the kernel.
661
+ - **Axis label string concatenation via `String.fromCharCode`** still
662
+ allocates ~5-10 B per tick label rebuild (~120 B for 20 labels), but
663
+ only fires on size or domain change, not per frame. Switching to a
664
+ shared `Uint8Array` and stringifying only when text changes is a
665
+ v1.1 nicety.
666
+ - **Bench harness `{xs, ys}` wrapper** is allocated by the test rotor
667
+ itself (~32 B per cycle), not the library. The honest framing: most
668
+ of the remaining heap delta is V8 measurement noise plus the bench
669
+ harness's own object literal.
670
+
671
+ None of these touch the per-frame draw -- they're in the data / axis
672
+ update path that fires only on actual changes, or in the bench harness.
673
+
674
+ ### What's measured, what isn't
675
+
676
+ | | Measured | Notes |
677
+ |---|---|---|
678
+ | Per-frame CPU work | YES | Bench p95 |
679
+ | Decimation kernel zero-alloc | YES | Test asserts <100 bytes/call |
680
+ | GPU paint cost | NO | Browser bench coming v1.0.1 |
681
+ | Cold-start overhead | NO | Single-figure ms; not yet measured |
682
+ | Bundle size minified | YES | Per-chart 13-24 KB, all seven ~56 KB (esbuild --bundle --minify) |
683
+
684
+ ## Roadmap
685
+
686
+ v1.0.0 ships seven chart types on three independent kernels with
687
+ kernel-side auto-resize. See [ROADMAP.md](./ROADMAP.md) for the full
688
+ forward plan and the development history that led here. Headlines:
689
+
690
+ | Version | Scope |
691
+ |---|---|
692
+ | **v1.0.0** (this) | Seven chart types, three kernels, auto-resize, 196 tests, full tree-shake verification |
693
+ | v1.1.0 | Stacked + grouped bar; per-bar hover tint; rounded corners; SVG export across all charts |
694
+ | v1.2.0 | `createHeatmap` on new grid kernel; multi-series bubble + per-point colour; `createScatterChart` with `@zakkster/lite-delaunay` for O(log n) nearest-point hit-test |
695
+ | v1.3.0 | Log scale; pan + zoom; brushing primitives |
696
+ | v1.4.0 | Time-series specialized variants; legend virtualization via `lite-virtual`; annotation layer |
697
+
698
+ ## Ecosystem
699
+
700
+ Part of the `@zakkster/*` zero-GC stack:
701
+
702
+ - `@zakkster/lite-signal` -- reactive core (peer)
703
+ - `@zakkster/lite-scene` -- Canvas2D scene graph (peer)
704
+ - `@zakkster/lite-axis` -- tick generation (peer)
705
+ - `@zakkster/lite-canvas-graph` -- the decimation kernel was lifted from here
706
+ - `@zakkster/lite-bvh` / `lite-aabb` -- spatial tooltip backend for v1.2 scatter
707
+ - `@zakkster/lite-virtual` -- legend virtualization for v1.2
708
+
709
+ ## License
710
+
711
+ MIT (c) Zahary Shinikchiev