@zakkster/lite-charts 1.0.0 → 1.1.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.
Files changed (6) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/Charts.d.ts +249 -21
  3. package/Charts.js +1653 -482
  4. package/README.md +156 -102
  5. package/llms.txt +276 -126
  6. package/package.json +6 -4
package/CHANGELOG.md ADDED
@@ -0,0 +1,96 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@zakkster/lite-charts` are documented here.
4
+
5
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
6
+ the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.1.0] — 2026-06
9
+
10
+ ### Added — bar-chart layout polish
11
+
12
+ - **Stacked bars** via a `postExtract` hook on the bar renderer: pass
13
+ `stacked: true` to a multi-series bar chart and series stack on top of
14
+ one another per category (instead of grouping side-by-side). Negative
15
+ values stack downward against zero. The Y domain auto-includes the
16
+ stack maxima.
17
+ - **Rounded corners**: `cornerRadius: <px>` on a bar series rounds the
18
+ TOP corners of each bar (or BOTTOM for negative-stack pieces). Uses
19
+ native `ctx.roundRect` when available, falls back to four `arcTo`
20
+ calls so it works on Canvas2D implementations without `roundRect`.
21
+ - **Hover tint**: passing a pointer position to the renderer's hit-test
22
+ lets the bar under the cursor draw with a brightness shift. Default
23
+ `+8%` on hover (configurable via `hoverTint: <0..1>`); the renderer
24
+ reuses one cached tinted-color string per series so the hover path
25
+ remains allocation-free across cursor moves within a single bar.
26
+
27
+ ### Added — bonus features present in this release
28
+
29
+ These were originally scoped for v1.2.0 alphas but landed in this build
30
+ and are tested + documented:
31
+
32
+ - **Spatial-index foundation** for bubble + scatter hit-tests
33
+ (`SpatialIndex` / `SpatialIndexFactory` contract). Auto-engages at
34
+ ~1000 points, falling back to O(n) below threshold.
35
+ - **`createScatterChart`** — bubble's simpler sibling on the same axis
36
+ kernel; constant marker size, no third dimension.
37
+ - **Multi-series bubble** with a global size domain (so a 30-radius
38
+ point in series A and series B render at the same pixel radius) and
39
+ per-point color via `colorKey`. Cross-series hit-test via
40
+ `snapSeriesIdx`.
41
+ - **`createHeatmap`** on a new `createBaseGridChart` kernel — categorical
42
+ rows × columns, default linear color ramp, custom `colorFn(v, vMin,
43
+ vMax)` for any mapping; sparse grids draw only present cells.
44
+
45
+ ### Internals
46
+
47
+ - Charts now report `chart.plotBounds` as a version-counter signal —
48
+ subscribe to react to size changes without dragging in DOM observers.
49
+ - DPR-aware canvas sizing reproduces the same backing-buffer math whether
50
+ mounted onto a real `<canvas>` or a test mock with explicit `width` /
51
+ `height`.
52
+ - `_testHelpers` export kept at the same surface for white-box tests
53
+ (decimation kernel, scale builders, accessor factory).
54
+
55
+ ### Performance contract
56
+
57
+ Empirical, sampled under `--expose-gc` (`node --expose-gc bench/line-100k.mjs`):
58
+
59
+ - **`chart.redraw()` steady-state**: 0.54 B/call — true zero-allocation
60
+ (sub-8-byte noise floor).
61
+ - **`data.set(reused) + redraw`**: ~89 B/call (signal mechanics + draw).
62
+ - **Full live cycle** (object literal + `await drainMicrotasks`): ~65 B/cycle
63
+ (the 32 B literal + ~33 B Promise/await overhead are the call-site cost,
64
+ not the library).
65
+ - **100k-point line chart** full update cycle: **p95 = 5.68 ms** (193 fps
66
+ ceiling) on Node 22; fits in 60fps and 120fps budgets.
67
+ - **Canvas calls per draw** (decimated mode): ~3393 — bounded by occupied
68
+ columns + axis ticks + spines, not by data length.
69
+
70
+ ### Tests
71
+
72
+ 231 tests across 46 describe blocks (node:test, `--expose-gc` for the
73
+ optional zero-GC kernel test); covers every chart factory's lifecycle,
74
+ reactivity, interpolation modes, marker shapes, refresh-theme,
75
+ auto-resize, plot-rect clipping, DPR sizing, crosshair zero-alloc,
76
+ tooltip-pool identity, multi-series domain union, custom xScale.domain,
77
+ band/linear scale math, pie/donut/radar geometry, bubble spatial index,
78
+ scatter hit-test, heatmap cell rendering, and the mock-canvas contract.
79
+
80
+ ### Bug fixes during this release audit
81
+
82
+ - Mock test harness was missing `strokeRect` — added (heatmap calls it).
83
+ - Test file forgot to import `createScatterChart` + `createHeatmap` from
84
+ `Charts.js` — added (18 false failures resolved).
85
+ - Demo's hero-loop `frameSamples.push(dt); shift()` allocated every
86
+ frame past the first 60; converted to a fixed-cap `Float64Array` ring
87
+ buffer. The 4Hz stats interval's `frameSamples.slice().sort()` also
88
+ allocated; switched to a reusable `Float64Array` scratchpad sorted
89
+ in-place.
90
+ - Demo title + brand-version badge + release eyebrow were stale at
91
+ `v1.0.0`; updated to `v1.1.0`.
92
+ - `package.json` test script had no path glob; now `test/*.test.js`.
93
+
94
+ ### License
95
+
96
+ MIT (c) Zahary Shinikchiev
package/Charts.d.ts CHANGED
@@ -67,7 +67,7 @@ export interface MarginConfig {
67
67
  }
68
68
 
69
69
  // ---------------------------------------------------------------------------
70
- // Crosshair / tooltip
70
+ // Crosshair / tooltip (v1.0.0-alpha.1)
71
71
  // ---------------------------------------------------------------------------
72
72
 
73
73
  export interface CrosshairState {
@@ -133,13 +133,6 @@ export interface TooltipFormatContext {
133
133
  snapIdx: number;
134
134
  snapDomainX: number;
135
135
  xScaleType: 'linear' | 'time';
136
- /**
137
- * For bar charts (categorical x), the category name at the snapped
138
- * index; `null` for line / area / bubble charts. Present on the same
139
- * context object across all axis-kernel charts so a formatter doesn't
140
- * have to branch on chart type.
141
- */
142
- category: string | null;
143
136
  /** Default rows, one per series with data at the snap. Mutable by formatter. */
144
137
  rows: TooltipRow[];
145
138
  }
@@ -155,7 +148,7 @@ export interface TooltipConfig {
155
148
  }
156
149
 
157
150
  // ---------------------------------------------------------------------------
158
- // Legend
151
+ // Legend (v1.0.0-alpha.3)
159
152
  // ---------------------------------------------------------------------------
160
153
 
161
154
  export type LegendPosition = 'top' | 'bottom' | 'left' | 'right';
@@ -258,7 +251,7 @@ export interface Chart {
258
251
  */
259
252
  refreshTheme(): void;
260
253
 
261
- readonly scene: unknown; // lite-scene Scene instance, or null pre-mount
254
+ readonly scene: unknown | null; // lite-scene Scene type
262
255
  readonly canvas: HTMLCanvasElement | null;
263
256
  readonly xScale: Scale;
264
257
  readonly yScale: Scale;
@@ -268,7 +261,7 @@ export interface Chart {
268
261
  /**
269
262
  * Crosshair state. Behaves like a signal: callable to subscribe-and-read,
270
263
  * `.peek()` to read without subscribing, `.set()` to write, `.subscribe()`
271
- * to register a callback. The returned `CrosshairState`
264
+ * to register a callback. As of v1.0.0-beta.2 the returned `CrosshairState`
272
265
  * is the SAME mutable reference on every read -- this eliminates the
273
266
  * per-mousemove allocation that hardware polling rates would otherwise
274
267
  * generate. Read fields eagerly when notified; do not retain the reference
@@ -276,8 +269,7 @@ export interface Chart {
276
269
  */
277
270
  readonly crosshair: (() => CrosshairState) & {
278
271
  peek(): CrosshairState;
279
- /** Partial accepted; missing fields fall back to safe defaults (snapIdx=-1, x/y=0). */
280
- set(v: Partial<CrosshairState>): void;
272
+ set(v: CrosshairState): void;
281
273
  subscribe(fn: (s: CrosshairState) => void): () => void;
282
274
  };
283
275
  /** One signal per series. Read in a reactive context to bind external UI; write to toggle. */
@@ -289,7 +281,7 @@ export interface Chart {
289
281
  export function createLineChart(config: LineChartConfig): Chart;
290
282
 
291
283
  // ---------------------------------------------------------------------------
292
- // Area chart
284
+ // Area chart (v1.0.0-alpha.2)
293
285
  // ---------------------------------------------------------------------------
294
286
  //
295
287
  // Same data + scale + reactivity contract as the line chart, plus a baseline
@@ -338,6 +330,27 @@ export interface BarChartConfig extends Omit<LineChartConfig, 'interpolation' |
338
330
  * adjacent grouped bars within the same category. Default 0.08.
339
331
  */
340
332
  groupInnerPad?: number;
333
+ /**
334
+ * v1.1.0: when true, series stack cumulatively per category instead of
335
+ * sitting side-by-side. The y-domain expands to the total stack height.
336
+ * Hidden series (via legend toggle) are excluded from the stack. MVP
337
+ * supports positive values only; negative values clamp to 0 in the
338
+ * stack. Default false.
339
+ */
340
+ stack?: boolean;
341
+ /**
342
+ * v1.1.0: corner radius in pixels for the top of each bar (positive)
343
+ * or bottom (negative). Default 0 (square corners). Capped at
344
+ * min(barWidth, barHeight) / 2 internally so very small bars never
345
+ * have overlapping corner arcs.
346
+ */
347
+ cornerRadius?: number;
348
+ /**
349
+ * v1.1.0: overlay color drawn on top of the hovered bar. Pass a CSS
350
+ * color string, `true` for the default low-alpha white, or `false`
351
+ * to disable. Default: `'rgba(255,255,255,0.18)'`.
352
+ */
353
+ hoverTint?: string | boolean;
341
354
  /**
342
355
  * X-scale overrides. Bar charts always use a band scale; only `domain`
343
356
  * (an explicit categories array) is honoured here -- the inferred type
@@ -349,7 +362,7 @@ export interface BarChartConfig extends Omit<LineChartConfig, 'interpolation' |
349
362
  export function createBarChart(config: BarChartConfig): Chart;
350
363
 
351
364
  // ---------------------------------------------------------------------------
352
- // Bubble chart (axis kernel with size dimension)
365
+ // Bubble chart (axis kernel with size dimension, v1.2.0-alpha.3)
353
366
  // ---------------------------------------------------------------------------
354
367
 
355
368
  export interface BubbleSeriesInput {
@@ -363,9 +376,14 @@ export interface BubbleSeriesInput {
363
376
  }
364
377
 
365
378
  export interface BubbleChartConfig {
366
- /** Single-series data shape (preferred). */
379
+ /** Single-series data shape. */
367
380
  data?: Array<{ [k: string]: unknown }> | (() => Array<{ [k: string]: unknown }>);
368
- /** Multi-series shape (v1.3+; one series for now). */
381
+ /**
382
+ * Multi-series shape (v1.2.0-alpha.2). Each series gets its own `data`,
383
+ * `name`, and optional `color`. The size dimension is shared across
384
+ * series via a GLOBAL size domain computed in `postExtract`, so equal
385
+ * raw values render at equal pixel radii regardless of which series.
386
+ */
369
387
  series?: BubbleSeriesInput[];
370
388
 
371
389
  /** Key (or index) for x values. Default 'x'. */
@@ -374,6 +392,13 @@ export interface BubbleChartConfig {
374
392
  y?: string | number | ((row: unknown, i: number) => number);
375
393
  /** Key (or index) for the size dimension. Default 'value'. */
376
394
  size?: string | number | ((row: unknown, i: number) => number);
395
+ /**
396
+ * v1.2.0-alpha.2: per-point color. When set, each row's color overrides
397
+ * the series fill. Accepts CSS-var (`'--c-red'`), hex (`'#ff0000'`), or
398
+ * any other CSS color value. Returning null/undefined falls back to the
399
+ * series fill for that row.
400
+ */
401
+ colorKey?: string | number | ((row: unknown, i: number) => string | null | undefined);
377
402
 
378
403
  /** Minimum pixel radius. Default 4. */
379
404
  minRadius?: number;
@@ -425,12 +450,73 @@ export interface BubbleChartConfig {
425
450
  background?: string;
426
451
  dpr?: number;
427
452
  schedule?: (cb: () => void) => unknown;
453
+
454
+ /**
455
+ * v1.2.0-alpha.0: spatial-index factory for O(log n) hit-test on dense
456
+ * bubble clouds. Pass any function matching `SpatialIndexFactory` --
457
+ * `@zakkster/lite-delaunay` is the intended default, but a k-d tree or
458
+ * uniform-grid implementation works just as well. Omit to use linear
459
+ * scan (the v1.0.0 behavior, faster below ~1000 points).
460
+ */
461
+ spatialIndex?: SpatialIndexFactory;
462
+ /**
463
+ * v1.2.0-alpha.0: minimum point count before the spatial index is
464
+ * built and queried. Below this, the linear scan is used (it's faster
465
+ * for small clouds because the index has build cost). Default 1000.
466
+ */
467
+ spatialIndexThreshold?: number;
428
468
  }
429
469
 
430
470
  export function createBubbleChart(config: BubbleChartConfig): Chart;
431
471
 
432
472
  // ---------------------------------------------------------------------------
433
- // Radar chart (separate kernel)
473
+ // Spatial index interface (v1.2.0-alpha.0)
474
+ // ---------------------------------------------------------------------------
475
+ // A pluggable nearest-neighbor index for dense point clouds. lite-charts
476
+ // defines the contract; the implementation is supplied by the consumer
477
+ // (`@zakkster/lite-delaunay`, a k-d tree, etc.). Used by bubble (v1.2.0)
478
+ // and the future scatter / heatmap renderers.
479
+
480
+ export interface SpatialIndex {
481
+ /**
482
+ * Find up to `k` points closest to (qx, qy) in pixel space, filtered to
483
+ * those with squared distance <= maxDistSq. Writes indices into
484
+ * `outIndices` and squared distances into `outDistSq`. Both buffers are
485
+ * caller-owned, stable refs -- the index MUST NOT keep references to
486
+ * them between calls, and MUST NOT allocate per call (this is the
487
+ * zero-GC contract). Returns the count actually written, in [0, k].
488
+ */
489
+ findNearest(
490
+ qx: number,
491
+ qy: number,
492
+ k: number,
493
+ maxDistSq: number,
494
+ outIndices: Int32Array,
495
+ outDistSq: Float32Array,
496
+ ): number;
497
+
498
+ /**
499
+ * Release any resources held by the index. Pure-JS implementations may
500
+ * make this a no-op; WebGL / WASM-backed indices should free buffers.
501
+ * Called by lite-charts on data change and on chart unmount.
502
+ */
503
+ dispose(): void;
504
+ }
505
+
506
+ /**
507
+ * Build a SpatialIndex over the given pixel-space coordinates. `n` is the
508
+ * number of valid entries (the typed arrays may be larger due to growth-
509
+ * allocation). The index MAY snapshot the inputs or hold the references --
510
+ * lite-charts guarantees the data won't mutate before dispose() is called.
511
+ */
512
+ export type SpatialIndexFactory = (
513
+ pxs: Float32Array,
514
+ pys: Float32Array,
515
+ n: number,
516
+ ) => SpatialIndex;
517
+
518
+ // ---------------------------------------------------------------------------
519
+ // Radar chart (separate kernel, v1.2.0-alpha.4)
434
520
  // ---------------------------------------------------------------------------
435
521
 
436
522
  export interface RadarSeriesInput {
@@ -507,7 +593,7 @@ export interface RadarChart {
507
593
  export function createRadarChart(config: RadarChartConfig): RadarChart;
508
594
 
509
595
  // ---------------------------------------------------------------------------
510
- // Polar slice charts -- pie / donut
596
+ // Polar slice charts -- pie / donut (v1.2.0-alpha.1)
511
597
  // ---------------------------------------------------------------------------
512
598
 
513
599
  export interface SliceInput {
@@ -594,8 +680,150 @@ export function createDonutChart(config: DonutChartConfig): PolarChart;
594
680
  // Each will be a thin composition over a base kernel:
595
681
  // createHeatmap = createBaseGridChart(config, HEATMAP_RENDERER)
596
682
 
597
- export function createScatterChart(...args: unknown[]): never;
598
- export function createHeatmap(...args: unknown[]): never;
683
+ // ---------------------------------------------------------------------------
684
+ // createScatterChart (v1.2.0-alpha.1)
685
+ // ---------------------------------------------------------------------------
686
+ //
687
+ // Scatter is bubble's simpler sibling. Same axis kernel, same spatial-index
688
+ // foundation -- but no size dimension, no sqrt scaling, no smallest-on-top
689
+ // tie-break (scatter has no overlap concerns). Every point renders at the
690
+ // SAME pixel radius (`markerSize`); the hit-test uses a configurable
691
+ // tolerance disc around each point.
692
+
693
+ export interface ScatterChartConfig {
694
+ /** Single-series data shape. */
695
+ data?: Array<{ [k: string]: unknown }> | (() => Array<{ [k: string]: unknown }>);
696
+ /** Multi-series shape. Each series gets its own data + color. */
697
+ series?: Array<{ name?: string; data: unknown; color?: string }>;
698
+
699
+ /** Key (or index) for x values. Default 'x'. */
700
+ x?: string | number | ((row: unknown, i: number) => number);
701
+ /** Key (or index) for y values. Default 'y'. */
702
+ y?: string | number | ((row: unknown, i: number) => number);
703
+
704
+ /** Pixel radius of every marker. Default 4. */
705
+ markerSize?: number;
706
+ /**
707
+ * Pixel radius around each marker that counts as a hit. Default
708
+ * `markerSize + 4`. Increase for easier targeting at small marker
709
+ * sizes, decrease for crowded plots where neighboring points should
710
+ * not steal hits.
711
+ */
712
+ hitTolerance?: number;
713
+
714
+ color?: string;
715
+ stroke?: string;
716
+ strokeWidth?: number;
717
+ /** Marker fill alpha. Default 1 (opaque). */
718
+ fillOpacity?: number;
719
+
720
+ width?: number | (() => number);
721
+ height?: number | (() => number);
722
+ margin?: { top?: number; right?: number; bottom?: number; left?: number };
723
+
724
+ xScale?: { type?: 'linear' | 'time'; domain?: [number, number] };
725
+ yScale?: { type?: 'linear'; domain?: [number, number]; nice?: boolean; zero?: boolean };
726
+
727
+ grid?: boolean | { x?: boolean; y?: boolean; color?: string };
728
+ crosshair?: false | { color?: string; dash?: [number, number] };
729
+ tooltip?: BubbleChartConfig['tooltip'];
730
+ legend?: BubbleChartConfig['legend'];
731
+
732
+ font?: string;
733
+ labelColor?: string;
734
+ axisColor?: string;
735
+ background?: string;
736
+ dpr?: number;
737
+ schedule?: (cb: () => void) => unknown;
738
+
739
+ /** v1.2.0-alpha.0: same spatial-index integration as bubble. k = 1. */
740
+ spatialIndex?: SpatialIndexFactory;
741
+ spatialIndexThreshold?: number;
742
+ }
743
+
744
+ export function createScatterChart(config: ScatterChartConfig): Chart;
745
+
746
+ // ---------------------------------------------------------------------------
747
+ // createHeatmap (v1.2.0-alpha.3) -- fourth kernel
748
+ // ---------------------------------------------------------------------------
749
+ //
750
+ // 2D categorical grid. Cells indexed by (xCategory, yCategory); each cell
751
+ // has at most one value. Sparse data is supported. Color mapping defaults
752
+ // to linear RGB interpolation between two endpoint hex colors -- pass
753
+ // `colorFn` for custom mappings (OKLCH, quantile, diverging, etc.).
754
+ //
755
+ // Rides on createBaseGridChart, an independent kernel that knows nothing
756
+ // about the axis / polar / radar kernels. Verified via esbuild tree-shake:
757
+ // importing only `createHeatmap` pulls ~10.5 KB minified -- no axes, no
758
+ // scales, no slice math, no radar geometry.
759
+
760
+ export interface HeatmapConfig {
761
+ /**
762
+ * Long-form data: one row per cell. Categories are auto-extracted from
763
+ * the x / y accessors in first-seen order. Missing (x, y) combinations
764
+ * render as empty space and return null from the hit-test.
765
+ */
766
+ data: Array<{ [k: string]: unknown }> | (() => Array<{ [k: string]: unknown }>);
767
+
768
+ /** Key (or function) for the x-axis category. Default 'x'. */
769
+ x?: string | number | ((row: unknown, i: number) => string);
770
+ /** Key (or function) for the y-axis category. Default 'y'. */
771
+ y?: string | number | ((row: unknown, i: number) => string);
772
+ /** Key (or function) for the numeric cell value. Default 'value'. */
773
+ value?: string | number | ((row: unknown, i: number) => number);
774
+
775
+ /**
776
+ * Two-stop linear color ramp `[low, high]`. Default `['#dbeafe', '#1e3a8a']`
777
+ * (blue-100 to blue-900). Accepts hex strings; CSS-vars are resolved
778
+ * against the mount container.
779
+ */
780
+ colors?: [string, string];
781
+
782
+ /**
783
+ * Custom color function. Receives `(value, vMin, vMax)`; returns a CSS
784
+ * color string. Overrides `colors` entirely. Use this for OKLCH ramps,
785
+ * quantile binning, diverging schemes, etc.
786
+ */
787
+ colorFn?: (value: number, vMin: number, vMax: number) => string;
788
+
789
+ /** Render the numeric value inside each cell. Default false. */
790
+ showValues?: boolean;
791
+ /** Format the cell value when `showValues` is true. */
792
+ valueFormat?: (value: number, xi: number, yi: number) => string;
793
+ /** Font for in-cell value labels. */
794
+ valueLabelFont?: string;
795
+ /** Color for in-cell value labels. */
796
+ valueLabelColor?: string;
797
+
798
+ /**
799
+ * Fraction of band-width used as the gap between adjacent cells.
800
+ * Default 0.04 (4%); set to 0 for a continuous grid.
801
+ */
802
+ cellGap?: number;
803
+
804
+ /** Stroke color for the hovered cell highlight. Default '#111111'. */
805
+ highlightStroke?: string;
806
+ /** Stroke width for the hovered cell highlight in pixels. Default 2. */
807
+ highlightStrokeWidth?: number;
808
+
809
+ /** Custom tooltip text formatter. */
810
+ tooltipFormat?: (info: { xi: number; yi: number; xLabel: string; yLabel: string; value: number }) => string;
811
+
812
+ /** Axis label color. Default '#444444'. */
813
+ labelColor?: string;
814
+ /** Axis label font. Default '12px sans-serif'. */
815
+ labelFont?: string;
816
+
817
+ width?: number | (() => number);
818
+ height?: number | (() => number);
819
+ margin?: { top?: number; right?: number; bottom?: number; left?: number };
820
+
821
+ background?: string;
822
+ dpr?: number;
823
+ schedule?: (cb: () => void) => unknown;
824
+ }
825
+
826
+ export function createHeatmap(config: HeatmapConfig): Chart;
599
827
 
600
828
  // ---------------------------------------------------------------------------
601
829
  // Test-only export (NOT part of the stable public API)