@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/Charts.d.ts +638 -0
- package/Charts.js +5123 -0
- package/LICENSE +21 -0
- package/README.md +711 -0
- package/llms.txt +472 -0
- package/package.json +66 -0
package/README.md
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
# @zakkster/lite-charts
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@zakkster/lite-charts)
|
|
4
|
+

|
|
5
|
+
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
6
|
+
[](https://bundlephobia.com/result?p=@zakkster/lite-charts)
|
|
7
|
+
[](https://www.npmjs.com/package/@zakkster/lite-charts)
|
|
8
|
+
[](https://www.npmjs.com/package/@zakkster/lite-charts)
|
|
9
|
+

|
|
10
|
+
[](https://github.com/PeshoVurtoleta/lite-signal)
|
|
11
|
+

|
|
12
|
+
[](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[]` | `Signal<Row[]>` | `() => Row[]` | `{xs, ys}` SoA | -- | Either `data` or `series` required. SoA fast path is zero-copy. |
|
|
136
|
+
| `series` | `SeriesConfig[]` | `Signal<SeriesConfig[]>` | -- | Multi-series form. `{name, data, color, lineWidth}`. |
|
|
137
|
+
| `x` | `string` | `number` | `(row, i) => number` | `'x'` | Accessor key, array index, or function. `Date` is coerced to ms. |
|
|
138
|
+
| `y` | `string` | `number` | `(row, i) => number` | `'y'` | Same. |
|
|
139
|
+
| `width` | `number` | `Signal<number>` | `() => number` | `800` | Static or reactive. |
|
|
140
|
+
| `height` | `number` | `Signal<number>` | `() => 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` | `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'` | `'step'` | `'step-after'` | `'step-before'` | `'step-mid'` | `'monotone'` | `'catmull-rom'` | `'linear'` | Path interpolation mode. Per-series override via `SeriesConfig.interpolation`. |
|
|
152
|
+
| `markers` | `boolean` | `{shape?, size?, fill?, stroke?, strokeWidth?, everyN?}` | `false` | Marker dots at each sample. `true` = circle defaults. `{everyN: 5}` for dense data. |
|
|
153
|
+
| `grid` | `boolean` | `{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` | `{color?, dash?}` | `true` | Vertical line + per-series marker dots. `false` disables. |
|
|
155
|
+
| `tooltip` | `boolean` | `{background?, border?, format?}` | `true` | Canvas-drawn box at the snapped x. `false` disables. |
|
|
156
|
+
| `legend` | `boolean` | `'top'`|`'bottom'`|`'left'`|`'right'` | `{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` | `null` | The underlying `lite-scene` instance. |
|
|
177
|
+
| `chart.canvas` | `HTMLCanvasElement` | `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'` | `'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` | `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` | `'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
|