# @zakkster/lite-charts

> Reactive, zero-GC chart library built on @zakkster/lite-scene. Signals
> for data, dimensions, theme, series visibility. v1.0.0 ships SEVEN
> chart types on THREE independent kernels: line/area/bar/bubble on the
> axis kernel, pie/donut on the polar slice kernel, radar on its own
> kernel. Kernel boundaries are strict and verified with esbuild tree-
> shaking: line bundle = 24 KB minified, bubble = 22 KB, pie = 14 KB,
> radar = 13 KB. Importing only radar drops every axis-chart AND every
> polar-slice helper. Importing only bubble drops every polar AND radar
> helper. Etc. Kernel-side auto-resize: omit width / height from config
> and the chart observes its mount container via ResizeObserver,
> updating through the existing reactive graph (synchronous initial read
> avoids size pop; rAF-throttled updates coalesce burst events). Falls
> back gracefully (keeps default size) when ResizeObserver is absent.
> Bubble uses sqrt size scale by default (area-proportional, Tukey
> convention): r = sqrt(rMin^2 + t*(rMax^2 - rMin^2)). Radar precomputes
> cos/sin per axis into Float64 tables -- polygons, grid rings, and
> spokes share them, zero per-frame trig. Hit detection is nearest-vertex
> within 12 px across visible series. Pie and donut share SLICE_RENDERER;
> only innerRadius default differs (0 vs 0.5). Polar angles use Float64
> (Float32(PI/2) widens enough to misclassify boundary hits). 196/196
> tests pass. ESM-only. Single-file ~4.9k lines. Three peer deps
> (lite-signal, lite-scene, lite-axis), zero runtime deps. MIT.
> See ROADMAP.md for forward plan: v1.1 stacked bar + SVG export, v1.2
> heatmap + scatter + @zakkster/lite-delaunay for dense hit-test, v1.3
> log scale + pan/zoom, v1.4 time-series + annotations.

## Why use this library

- You need a chart that re-renders automatically when a signal changes
  (data, width, theme, etc.) without manual `.update()` calls.
- You're streaming live data (100-100k points/sec) and Chart.js drops frames.
- You're in a Twitch Extension or game HUD with a 1 MB / 60 fps budget that
  rules out Chart.js (~78 KB) or D3 (~70+ KB plus selection allocation).
- You want a Vega-Lite middle ground: sensible defaults + escape hatches.

## When NOT to use

- You need SVG output -- coming v1.1. Today only `exportPNG` (canvas
  `toDataURL`).
- You need server-side rendering -- the renderer is Canvas2D-bound.
- You need stacked bars today -- only grouped multi-series in v1.0.0;
  stacked layout lands v1.1.
- You need heatmap or scatter today -- on the v1.2.0 roadmap with the
  new grid kernel and the `@zakkster/lite-delaunay` integration.
- You need legend virtualization for hundreds of series -- v1.4.0 via
  `@zakkster/lite-virtual`.
- You need `<2000` points and don't care about GC -- Chart.js is simpler.

## Module shape

ESM-only. Named exports from the single `Charts.js` entry:

```ts
// Axis kernel
function createLineChart(config: LineChartConfig):     Chart;
function createAreaChart(config: AreaChartConfig):     Chart;
function createBarChart(config:  BarChartConfig):      Chart;
function createBubbleChart(config: BubbleChartConfig): Chart;

// Polar slice kernel (pie + donut share SLICE_RENDERER; only the
// innerRadius default differs).
function createPieChart(config:   PieChartConfig):     PolarChart;
function createDonutChart(config: DonutChartConfig):   PolarChart;

// Radar kernel (third independent kernel)
function createRadarChart(config: RadarChartConfig):   RadarChart;

// Stubs that throw at runtime so version mismatches surface immediately.
// These ship in v1.2.0 on a new createBaseGridChart kernel.
function createScatterChart(): never;
function createHeatmap():      never;

// Test-only export -- NOT part of the stable public API. Pure helpers
// for white-box unit testing; the leading underscore signals private.
// Production code that imports only chart factories never references
// _testHelpers, so the bundler drops it and everything it pins.
const _testHelpers: { /* decimateMinMax, makeBandScale, ... */ };
```

`AreaChartConfig extends LineChartConfig` with three additional fields:

```ts
interface AreaChartConfig extends LineChartConfig {
    baseline?: number | 'bottom';   // default 0; 'bottom' = bottom of plot rect
    stroke?: boolean;               // default true; whether to stroke upper boundary
    fillOpacity?: number;           // default 0.3; multiplied into globalAlpha for fill
}
```

`BarChartConfig` is line config minus interpolation/markers (don't apply
to bars), plus bar-specific layout:

```ts
interface BarChartConfig extends Omit<LineChartConfig, 'interpolation' | 'markers' | 'xScale'> {
    baseline?: number;        // default 0; where bars anchor on the y-axis
    paddingInner?: number;    // default 0.15; gap between bands as fraction of step
    paddingOuter?: number;    // default 0.1;  padding at each end of the range
    groupInnerPad?: number;   // default 0.08; gap between bars within a grouped slot
    xScale?: { domain?: string[] };   // explicit categories if needed
}
```

Bar charts treat x as a categorical key. Data shape: `[{x: 'Q1', y: 42}, ...]`.
String, number, or any value that survives `String(x)` round-trip works.
Multi-series renders **grouped** side-by-side; each bar occupies a slice of
the band centered on its series index. The y-domain always includes the
`baseline` (default 0) so bars don't visually float.

Hit detection is discrete and O(1): `Math.floor((px - origin) / step)`.
No bisection -- the discriminator is categorical. The crosshair snaps to
the nearest band; per-series markers are skipped (bars highlight
themselves).

## Core types

```ts
interface LineChartConfig {
    // Data shape: either `data` (single series shorthand) or `series`.
    data?: Row[] | (() => Row[]) | { xs: Float32Array; ys: Float32Array };
    series?: SeriesConfig[];

    // Accessors. String key, integer index, or function. Default 'x' / 'y'.
    x?: string | number | ((row: any, i: number) => number);
    y?: string | number | ((row: any, i: number) => number);

    // Static-or-signal dimensions.
    width?: number | (() => number);
    height?: number | (() => number);

    margin?: { top?: number; right?: number; bottom?: number; left?: number };

    // Style. CSS color strings (hex/rgb/oklch/named) or "--css-var" tokens
    // resolved against the container via getComputedStyle at mount time.
    color?: string;
    lineWidth?: number;
    background?: string | null;
    axisColor?: string;
    labelColor?: string;
    font?: string;

    // Device pixel ratio override. Default: globalThis.devicePixelRatio.
    dpr?: number;

    // Scale overrides. Without these, x-type is inferred from the first
    // data row (Date probe -> time; numeric -> linear) and y-domain is
    // computed from union of all series with 5% nice padding.
    xScale?: { type?: 'linear' | 'time'; domain?: [number, number] };
    yScale?: { domain?: [number, number]; zero?: boolean; nice?: boolean };

    // Crosshair: vertical line + per-series marker dots at the snapped x.
    // Default true. `crosshair: false` disables the line + markers but keeps
    // the tooltip if it's also enabled.
    crosshair?: boolean | { color?: string; dash?: number[] };

    // Tooltip: canvas-drawn box at the snapped x. Default true. `format`
    // receives `{snapIdx, snapDomainX, xScaleType, rows}` and may return a
    // string (header-only, suppresses rows) or `{header?, rows?}`.
    tooltip?: boolean | {
        background?: string;
        border?: string;
        format?: (ctx: {
            snapIdx: number;
            snapDomainX: number;
            xScaleType: 'linear' | 'time';
            rows: Array<{ color: string; label: string; value: string }>;
        }) => string | { header?: string; rows?: any[] };
    };

    // Legend: DOM-rendered with click-to-toggle visibility. Default 'bottom'.
    // Auto-wraps the canvas in a flex container; pass `legend.container` to
    // append into an existing element instead.
    legend?: boolean | 'top' | 'bottom' | 'left' | 'right' | {
        position?: 'top' | 'bottom' | 'left' | 'right';
        container?: HTMLElement;
    };

    // Path interpolation (v1.0.0). Default 'linear'. Per-series override
    // via SeriesConfig.interpolation. Decimated branch ignores this.
    interpolation?: 'linear' | 'step' | 'step-after' | 'step-before'
                  | 'step-mid' | 'monotone' | 'catmull-rom';

    // Marker dots at each sample point (v1.0.0). `true` for circle defaults.
    // `everyN` thins markers for dense series. Suppressed in decimated mode.
    markers?: boolean | {
        shape?: 'circle' | 'square' | 'triangle' | 'diamond';
        size?: number;          // default 5
        fill?: string;          // defaults to series color
        stroke?: string;        // default '#ffffff'
        strokeWidth?: number;   // default 1
        everyN?: number;        // default 1
    };

    // Frame scheduler. Default rAF in browser, synchronous in Node (for
    // headless tests). Pass `queueMicrotask` for coalesced headless benches.
    schedule?: (fn: () => void) => void;
}

interface SeriesConfig {
    name?: string;
    data: Row[] | (() => Row[]) | { xs: Float32Array; ys: Float32Array };
    color?: string;
    lineWidth?: number;
    interpolation?: InterpolationMode;       // per-series override
    markers?: boolean | MarkerConfig;         // per-series override
}

interface Chart {
    mount(target: HTMLElement | HTMLCanvasElement): Chart;
    unmount(): void;
    exportPNG(opts?: { mimeType?: string; quality?: number }): string;
    redraw(): void;

    // v1.0.0-alpha.1: crosshair / tooltip control.
    moveCrosshair(canvasX: number, canvasY: number): void;
    hideCrosshair(): void;

    // v1.0.0-alpha.3: series visibility control.
    setSeriesVisible(idx: number, visible: boolean): void;

    // v1.0.0-beta.0: theme reactivity.
    refreshTheme(): void;                    // re-resolve CSS-var colors + redraw

    readonly scene: Scene | null;          // lite-scene instance
    readonly canvas: HTMLCanvasElement | null;
    readonly xScale: Scale;
    readonly yScale: Scale;
    readonly xScaleType: 'linear' | 'time';
    plotBounds: Signal<number>;            // version counter; read to subscribe
    crosshair: Signal<{                     // live crosshair state; subscribe for sync small-multiples
        visible: boolean;
        snapIdx: number;                    // -1 if hidden
        snapDomainX: number;
        snapPixelX: number;
        mousePixelY: number;
    }>;
    seriesVisibility: Signal<boolean>[];   // one per series; write to toggle
    legend: HTMLElement | null;             // null if legend disabled or bare-canvas mount
}

interface Scale {
    type: 'linear' | 'time';
    dMin: number; dMax: number;
    rMin: number; rMax: number;
    map(value: number): number;            // domain -> pixel
    invert(pixel: number): number;         // pixel -> domain
}
```

## Static-or-signal acceptance

Every config value that could plausibly be reactive accepts both forms.
Internally lite-charts wraps statics in constant accessors via a `asAccessor`
helper, so the engine only ever calls functions. Zero overhead for static
config; full reactivity for signal config:

```js
const w = signal(800);
const chart = createLineChart({ data, width: w, height: 400 });  // mixed
w.set(1200);  // triggers resize + rescale + redraw automatically
```

## Data shape conventions

Two accepted forms:

1. **AoS (most ergonomic)**: array of row objects.
   ```js
   data: [{ x: 0, y: 1 }, { x: 1, y: 4 }, ...]
   ```
   Internally extracted to SoA `Float32Array` slabs once per data update.

2. **SoA (zero-copy fast path)**: `{ xs: Float32Array, ys: Float32Array }`.
   ```js
   const xs = new Float32Array(N), ys = new Float32Array(N);
   // ... fill ...
   data: { xs, ys }
   ```
   The chart references the buffers directly. Use this when you're already
   producing typed-array data (audio, telemetry, sensor streams, WebGL
   pipelines, etc.).

Multiple series can mix forms.

## Architecture invariants

- **One scene per chart.** `mount` creates a `lite-scene` over the canvas.
  All axes, lines, ticks, labels are scene nodes.
- **Series state is pre-allocated and grown on demand.** Each series has a
  pair of `Float32Array` slabs (`xs`, `ys`, `pxs`, `pys`) plus decimation
  working buffers. Slabs grow by power-of-two doubling; never shrink.
- **The decimation kernel was lifted from `@zakkster/lite-canvas-graph`.**
  Per-column min/max envelope for `n > 2 * plotWidth`. Preserves spike
  visibility. Allocation-free; writes into caller-owned buffers.
- **Two render paths, selected per-draw**: direct polyline for sparse data
  (`n <= 2 * plotWidth`), decimated envelope for dense data. NaN samples
  break the polyline in direct mode and are skipped in decimated mode.
- **Three effects per chart, set up at `mount` time**:
  1. Size/plot-bounds effect (tracks `widthAcc`, `heightAcc`)
  2. Data/scale effect (tracks each series' `dataAccessor` + `plotBoundsSignal`)
  3. Dirty-bridge effect (tracks `scaleVersion` + `plotBoundsSignal`, calls
     `scene.markDirty`)
- **Plus two axis effects** (X and Y), tracking `scaleVersion` and rebuilding
  tick positions from the live scale via `lite-axis.linearTicks` /
  `timeTicks` and `thinLabels`.
- **The line series itself is a `path` node** with a raw draw function. The
  draw closure reads `state.pxs/pys` and color/lineWidth refs directly;
  zero allocation per frame.

## Performance characteristics

Measured at N=100,000 points, canvas 1600x800, Node 22 (CPU only -- mock
canvas):

- Full update cycle (data.set -> draw): **p50 1.39 ms, p95 4.66 ms**
- Decimation kernel alone: **p50 0.52 ms, p95 0.56 ms**
- Draw alone (cached): **p50 0.48 ms, p95 0.58 ms**
- Steady-state heap delta: **~270 bytes/cycle** (target <100; gap is
  niceYDomain tuple + axis label strings + queueMicrotask Promise; v1.0.1
  closes)

Both 60fps (16.67 ms) and 120fps (8.33 ms) budgets fit on the CPU side.
Real GPU paint is additional; browser bench comes in v1.0.1.

## Testing

```
npm test     # 43 deterministic tests via node:test, --expose-gc
npm run bench  # 100k-point latency + allocation report
```

Tests use a recording mock canvas context (`test/harness.js`) that captures
every method call and property assignment into a flat `calls` array. Tests
assert on the call sequence (what was drawn, with what state) so no real
canvas is needed. Bench mode supports `ctx.recordingEnabled = false` to
avoid heap blowup over many frames.

## Common patterns

### Live streaming with a ring buffer

```js
const xs = new Float32Array(1024), ys = new Float32Array(1024);
const data = signal({ xs, ys });
const chart = createLineChart({ data, width: 800, height: 200 });
chart.mount(el);

// In the data source -- writes mutate xs/ys in place; signal.set on the
// same object reference re-extracts and re-projects.
const newDataTick = (newY) => {
    // ... shift the ring, write newY at the head ...
    data.set({ xs, ys });  // forces effect re-fire
};
```

### Multi-series with mixed forms

```js
createLineChart({
    series: [
        { name: 'cpu', data: cpuRows, x: 't', y: 'pct', color: 'red' },
        { name: 'mem', data: { xs: memXs, ys: memYs }, color: 'blue' },
    ],
});
```

### Locked x-domain (for synchronized small multiples)

```js
const sharedX = signal([Date.now() - 3600_000, Date.now()]);
createLineChart({
    data: series1,
    xScale: { type: 'time', domain: untrack(sharedX) },
    // Pass a computed for live sync:
    // xScale: { type: 'time', domain: sharedX() },
});
```

## Gotchas

- **`data: { xs, ys }` is reference-tracked.** The signal compares by
  identity. Mutating the buffers in place and calling `signal.set(sameRef)`
  works -- lite-signal's default `equals` is `Object.is`, which is false
  for `set(x)` vs prior `x` only if you... wait, `Object.is(x, x)` is
  true. So passing the same reference will NOT trigger. Either swap to a
  new wrapper object `signal.set({xs, ys})` (one alloc) or use
  `signal.update(() => ({xs, ys}))`. Future v1.1: add an opt-in
  "rebroadcast" flag to skip the equality check.
- **`exportPNG` requires a real HTMLCanvasElement.** Throws on mock canvases.
- **Mount target may be either an element (canvas created inside) or a
  canvas itself.** Test mocks duck-type via `getContext`.
- **In Node, the default schedule is synchronous.** This is correct for
  tests but causes lite-scene to skip draw coalescing -- if you're driving
  many `node.set()` calls per signal write, use
  `schedule: (fn) => queueMicrotask(fn)`. The bench documents this clearly.

## Roadmap

- v1.0.0-beta.0 - beta.3: line + area polish (interp, markers, theme, gridlines,
  zero-alloc crosshair, DPR fix)
- v1.0.0: line + area API lock
- v1.1.0-alpha.0: createBarChart (band scale, grouped, O(1) hit detection)
- **v1.2.0-alpha.0: architectural refactor.** _createChartImpl(config,
  renderKind) extracted into createBaseAxisChart(config, renderer) + per-
  chart renderer objects (LINE_RENDERER, AREA_RENDERER, BAR_RENDERER). 22
  renderKind branches eliminated. Polymorphic dispatch via renderer
  interface: buildXAccessor, createXScale, extractData, updateXScale,
  buildXAxis, makeDrawFn, hitTest, lookupRow, formatTooltipHeader.
  rendererCtx singleton (xScale, yScale, opts, categoriesRef) mutated in
  place -- preserves zero-alloc on hot path. _testHelpers moved to separate
  top-level export so chart._internal doesn't pin pure helpers.
- **v1.2.0-alpha.1: pie + donut chart family.** New
  createBasePolarChart kernel -- completely independent from
  createBaseAxisChart. Polar state struct: parallel arrays for values
  (Float32), labels (string[]), colors, visibility (Uint8), startAngles
  (Float64 -- Float32 widens PI/2 enough that exact-boundary hits land in
  wrong slice), arcAngles (Float64). extractSliceData normalizes array-of-
  objects, parallel-arrays, or plain number arrays. computeSliceGeometry
  centers in plot rect with configurable inner radius. sliceHitTest is
  O(n) atan2 + linear scan inside ring (n typically 3-12; binary search
  overkill). makeSliceDrawFn renders wedge (pie) or arc-ring (donut) per
  slice; hovered slice expands 4px. Per-slice legend with click-to-toggle
  visibility -- hidden slices give up their wedge, others grow to fill.
  Pie and donut share SLICE_RENDERER; only innerRadius default differs
  (0 vs 0.5; overridable). Pie bundle 13 KB minified (no axis kernel),
  line bundle 23 KB (no polar kernel). 29 new tests, 148 total.
- v1.2.0-alpha.2: slice colour resolution bug fix (raw CSS-var leaked
  into canvas fillStyle); demo gets ResizeObserver-backed responsive
  sizing via a `responsiveWidth(containerId, fallback)` helper.
- **v1.2.0-alpha.3: createBubbleChart on the axis kernel.**
  New BUBBLE_RENDERER. Each point becomes a circle with AREA
  proportional to a third dimension via sqrt scale (default; linear
  available). Pixel radii computed at extract time:
  r = sqrt(rMin^2 + t*(rMax^2 - rMin^2)). New seriesState fields rs
  (raw sizes) and prs (pixel radii) -- both null on non-bubble series,
  zero extra memory. extractBubbleData wraps extractSeriesData, adds
  size extraction + computeBubbleRadii in one pass. Hit-test signature
  on the axis kernel extended from (canvasX, primary, xScale, ctx) to
  (canvasX, canvasY, primary, xScale, ctx) -- line/area/bar tests ignore
  canvasY; bubble uses both for circle-containment with smallest-on-top
  tie-breaking on overlap. Bubble bundle 22 KB minified; tree-shake
  verified to drop all polar + bar + interp helpers. 10 new tests, 158
  total. Single-series only -- multi-series + per-point colour encoding
  land in v1.3.0.
- **v1.2.0-alpha.4 (current): createRadarChart on a third independent
  kernel.** Multi-axis polygon layout: N axes (min 3, typical 5-8)
  spoked from a shared center; each series is a polygon connecting one
  value-per-axis vertex. computeRadarGeometry precomputes cos/sin per
  axis into Float64 tables -- polygons, grid rings, and spokes all
  share them, zero per-frame trig. radarHitTest is nearest-vertex
  within 12 px across visible series (O(series * axes), trivial).
  Spoke labels auto-align based on angular position (cosA > 0.2 ->
  left-align, < -0.2 -> right-align, near-vertical -> center). Three
  drawing layers (grid -> polygons -> spokes+labels) as separate scene
  nodes for natural z-ordering. Auto-domain anchors at 0 when min/max
  ratio < 0.5 (scored-radar convention); explicit domain: [vMin, vMax]
  overrides. 18 new tests, 176 total. Radar bundle 13 KB minified --
  drops every axis-chart helper AND every polar-slice helper. The
  three kernels (axis, polar-slice, radar) are now strictly independent.
- v1.2.0: lock seven-chart API (line, area, bar, bubble, pie, donut, radar)
- v1.3.0: createBaseGridChart + createHeatmap (2D categorical,
  color-mapped); multi-series bubble + per-point colour encoding;
  @zakkster/lite-delaunay for O(log n) nearest-point hit-test on dense
  scatter/bubble clouds (> ~1000 points; sweepline Delaunay -> Voronoi
  dual extraction). Scatter chart rides on the same spatial index.
- v1.4.0: stacked bar, per-bar hover, rounded corners, SVG export
- v1.5.0: log scale, pan + zoom, legend virtualization

## License

MIT (c) Zahary Shinikchiev
