npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zakkster/lite-charts

v1.1.0

Published

Reactive, zero-GC chart library. Signal-native data, scales, and dimensions; 60fps at 100k points; zero allocations in steady-state render. Built on @zakkster/lite-scene.

Readme

@zakkster/lite-charts

npm version Zero-GC sponsor npm bundle size npm downloads npm total downloads TypeScript lite-signal peer Dependencies license

Reactive, zero-GC chart library. Signals for data, dimensions, theme. 100k points at 60fps with sub-frame budget. Built on @zakkster/lite-scene (Canvas2D scene graph), @zakkster/lite-signal (reactive core), and @zakkster/lite-axis (tick generation). Three peer deps. ESM-only. ~1100 lines single file. MIT.

Status: v1.1.0 — nine chart types across four independent kernels. The headline addition is bar-chart layout polish (stacked bars, rounded corners, per-bar hover tint, all opt-in). This release also lands several features originally scoped for the v1.2.0 alpha train that proved stable enough to ship together:

  • createScatterChart — bubble's simpler sibling on the same axis kernel; constant marker, no third dimension.
  • createHeatmap on a new createBaseGridChart kernel (10.5 KB minified, the smallest of the nine bundles). Two band scales, flat Float32Array cell storage, Uint8Array presentMask for sparse data, per-cell color strings precomputed at extract for zero-allocation draws. Default linear-RGB ramp; colorFn for custom mappings.
  • Multi-series bubble with per-point color via colorKey and a global size domain across visible series.
  • Pluggable spatial-index (SpatialIndex / SpatialIndexFactory) for O(log n) hit-test on dense point clouds. @zakkster/lite-delaunay is the intended default but optional.

231/231 tests pass. See CHANGELOG.md for the full release contract and ROADMAP.md for the forward plan.

Install

npm i @zakkster/lite-charts @zakkster/lite-signal @zakkster/lite-scene @zakkster/lite-axis

Hello World

import { signal } from '@zakkster/lite-signal';
import { createLineChart } from '@zakkster/lite-charts';

const data = signal([
    { t: new Date('2026-01-01'), v: 100 },
    { t: new Date('2026-02-01'), v: 142 },
    { t: new Date('2026-03-01'), v: 88 },
    { t: new Date('2026-04-01'), v: 175 },
]);

const chart = createLineChart({
    data,
    x: 't',
    y: 'v',
    width: 800,
    height: 400,
    color: '#3b82f6',
});

chart.mount(document.getElementById('chart-container'));

// Mutate the signal anywhere -- the chart redraws automatically.
setTimeout(() => {
    data.update((rows) => [...rows, { t: new Date('2026-05-01'), v: 210 }]);
}, 1000);

The chart inferred the time scale from the Date probe, auto-fitted the y-domain with 5% padding, and threaded a reactive signal end-to-end. No explicit re-render needed.

Why lite-charts

| Concern | lite-charts | Chart.js | uPlot | D3 | |---|---|---|---|---| | Reactive data binding | First-class signals | Imperative .update() | Imperative .setData() | Manual selection re-bind | | 100k points | 1.4 ms / 4.7 ms p95 (CPU) | Drops frames | OK | Hand-rolled | | Zero-GC steady state | Yes (slab-based) | No | Mostly | No | | Bundle (min+gz) | ~6 KB (alpha est.) | 78 KB | 40 KB | 70+ KB | | Render substrate | Canvas2D via lite-scene | Canvas2D | Canvas2D | SVG / Canvas | | API style | Vega-Lite middle ground | Imperative config | Hand-tuned | Composable primitives | | Twitch Extension fit | Yes (1MB / 3s budget) | No | Yes | No |

Built specifically for performance-critical environments: dashboards that stream telemetry, live trading interfaces, game HUDs, monitoring overlays, Twitch Extensions. Where Chart.js works fine until you hit 5k points and a ~3MB transitive dep graph, lite-charts is engineered to scale to 100k points in a 1MB bundle without GC pauses.

Architecture

graph TD
    User[User config + data signal] --> Constructor[createLineChart]
    Constructor --> Normalize["Normalize: data shorthand -> series[]"]
    Normalize --> Accessors[Build accessors x/y]
    Accessors --> InferType[Infer x-scale type]
    InferType --> StateAlloc[Allocate SeriesState slabs]

    StateAlloc --> Mount[mount(container)]
    Mount --> Scene[createScene from lite-scene]
    Scene --> Effect1[Effect: width/height -> plotBounds]
    Scene --> Effect2[Effect: data -> SoA extract -> scale -> pixels]
    Scene --> Axes[buildAxis x2 / lite-axis ticks]
    Scene --> SeriesNodes[path nodes / one per series]
    SeriesNodes --> DrawFn[makeLineDrawFn closure]

    DrawFn --> PathSelect{"n > 2*cols?"}
    PathSelect -->|yes| Decimate["decimateMinMax kernel<br/>lifted from lite-canvas-graph"]
    PathSelect -->|no| Polyline[Direct polyline / NaN-aware]
    Decimate --> Stroke[ctx.stroke]
    Polyline --> Stroke

    Signal[Any signal write] --> LiteSignal[lite-signal sync flush]
    LiteSignal --> EffectsRun[Effects re-run]
    EffectsRun --> DirtyBridge[scaleVersion bump -> scene.markDirty]
    DirtyBridge --> SceneDraw[lite-scene drawAll / coalesced via _queued]
    SceneDraw --> DrawFn

The hot path (line render) is allocation-free: per-frame work is two O(n) scans (extract extents, project to pixels) plus the decimation kernel (O(plotWidth)) and a single ctx.stroke(). The axis update path allocates a small amount per re-layout (label strings, ephemeral props objects), but that runs only on data-domain or size changes, not every frame.

API Reference

createLineChart(config) -> chart

| Config key | Type | Default | Notes | |---|---|---|---| | data | Row[] | Signal<Row[]> | () => Row[] | {xs, ys} SoA | -- | Either data or series required. SoA fast path is zero-copy. | | series | SeriesConfig[] | Signal<SeriesConfig[]> | -- | Multi-series form. {name, data, color, lineWidth}. | | x | string | number | (row, i) => number | 'x' | Accessor key, array index, or function. Date is coerced to ms. | | y | string | number | (row, i) => number | 'y' | Same. | | width | number | Signal<number> | () => number | 800 | Static or reactive. | | height | number | Signal<number> | () => number | 400 | Same. | | margin | {top,right,bottom,left} | {16,24,32,56} | Pixel space reserved for axes. | | color | string | '#3b82f6' | Hex, css var (--my-token), or any CSS color string. | | lineWidth | number | 1.5 | Series stroke width in CSS pixels. | | background | string | null | null | Canvas fill before draw. | | dpr | number | devicePixelRatio | Override device pixel ratio. | | xScale | {type?, domain?} | inferred | type: 'linear' \| 'time'; domain: [min, max] to lock. | | yScale | {domain?, zero?, nice?} | nice + pad | zero: true forces 0 inclusion; nice: true adds 5% padding. | | axisColor | string | '#888888' | Axis spine + tick color. | | labelColor | string | '#444444' | Tick label color. | | font | string | '11px sans-serif' | Tick label font. | | interpolation | 'linear' | 'step' | 'step-after' | 'step-before' | 'step-mid' | 'monotone' | 'catmull-rom' | 'linear' | Path interpolation mode. Per-series override via SeriesConfig.interpolation. | | markers | boolean | {shape?, size?, fill?, stroke?, strokeWidth?, everyN?} | false | Marker dots at each sample. true = circle defaults. {everyN: 5} for dense data. | | grid | boolean | {x?, y?, color?} | false | Gridlines through the plot rect at each tick. true = both axes. Object form for per-axis + color override. | | crosshair | boolean | {color?, dash?} | true | Vertical line + per-series marker dots. false disables. | | tooltip | boolean | {background?, border?, format?} | true | Canvas-drawn box at the snapped x. false disables. | | legend | boolean | 'top'|'bottom'|'left'|'right' | {position?, container?} | 'bottom' | DOM-rendered legend with click-to-toggle. false disables. | | schedule | (fn) => void | requestAnimationFrame | Frame scheduler. Pass (fn) => fn() for sync (tests), queueMicrotask for headless batching. |

Chart methods

| Method | Returns | Notes | |---|---|---| | chart.mount(target) | chart | target is an HTMLElement (creates canvas inside) or HTMLCanvasElement. | | chart.unmount() | void | Disposes all effects, removes canvas if owned. Idempotent. | | chart.exportPNG({mimeType?, quality?}) | string (data URL) | Calls canvas.toDataURL. | | chart.redraw() | void | Force a redraw without changing data. | | chart.moveCrosshair(canvasX, canvasY) | void | Programmatic crosshair move. Snaps to nearest sample on the primary series. | | chart.hideCrosshair() | void | Hide crosshair + tooltip. Idempotent. | | chart.setSeriesVisible(idx, visible) | void | Toggle a series. Out-of-range indices are safe no-ops. | | chart.refreshTheme() | void | Re-resolve CSS-var colors and redraw. Call after a theme switch. |

Chart properties

| Prop | Type | Notes | |---|---|---| | chart.scene | Scene | null | The underlying lite-scene instance. | | chart.canvas | HTMLCanvasElement | null | The canvas being drawn into. | | chart.xScale | Scale | {type, dMin, dMax, rMin, rMax, map(v), invert(px)}. | | chart.yScale | Scale | Same shape. | | chart.xScaleType | 'linear' | 'time' | Resolved at construction. | | chart.plotBounds | Signal<number> | A version-counter signal; subscribe to react to size changes. | | chart.crosshair | Signal<CrosshairState> | Live {visible, snapIdx, snapDomainX, snapPixelX, mousePixelY}. Subscribe for synchronized small-multiples. | | chart.seriesVisibility | Signal<boolean>[] | One signal per series. Read in a reactive context to bind external UI; write to toggle. | | chart.legend | HTMLElement | null | The legend container, or null if legend: false or mounted into a bare canvas. |

Reactivity

Every config value (width, height, data, future color, etc.) accepts either a static value or a signal accessor. A signal is just a function:

const w = signal(800);
const chart = createLineChart({ data, width: w, height: 400 });
chart.mount(el);

// Later:
w.set(1200);  // chart resizes and rescales -- no manual redraw call

Internally, lite-charts wraps statics in constant accessors via a tiny helper, so the engine only ever calls functions. Zero overhead for static config; full reactivity for signal config. Same pattern as unref in Vue, toValue in Solid, etc.

Bring-your-own scheduling

The default schedule is requestAnimationFrame. In Node (tests, headless benches, SSR-adjacent workflows), pass an explicit schedule:

// Synchronous -- assertions can read ctx.calls immediately. Best for tests.
const chart = createLineChart({ ..., schedule: (fn) => fn() });

// Microtask-coalesced -- draws batch within a tick. Best for headless benches.
const chart = createLineChart({ ..., schedule: (fn) => queueMicrotask(fn) });

Tooltip + crosshair

On by default in v1.0.0-alpha.1. The crosshair vertical line snaps to the nearest sample on the primary series (binary search on sorted xs); markers on each additional series snap independently at the same domain x. The tooltip is canvas-drawn (no DOM overlay), so it remains headless-testable.

createLineChart({
    data,
    crosshair: { color: '#666', dash: [3, 3] },
    tooltip: {
        // String form: replaces the header, suppresses rows.
        format: (snap) => 'sample #' + snap.snapIdx,
        // Object form: customize both -- snap.rows is pre-filled with one row per series.
        // format: (snap) => ({ header: 'custom', rows: snap.rows }),
    },
});

// Disable per-feature
createLineChart({ data, crosshair: false });  // tooltip stays on
createLineChart({ data, tooltip: false });    // crosshair stays on
createLineChart({ data, crosshair: false, tooltip: false }); // no DOM listener attached

Synchronized crosshairs across small multiples

The chart.crosshair signal exposes live state. To synchronize the crosshair across multiple charts sharing an x-axis, write to one and forward to the others:

const c1 = createLineChart({ data: a, x: 't', y: 'cpu' });
const c2 = createLineChart({ data: b, x: 't', y: 'mem' });
c1.mount(el1); c2.mount(el2);

c1.crosshair.subscribe((state) => {
    if (state.visible) c2.moveCrosshair(state.snapPixelX, /* y irrelevant for sync */ 0);
    else c2.hideCrosshair();
});

Programmatic + testing API

chart.moveCrosshair(canvasX, canvasY) and chart.hideCrosshair() drive the same path as the DOM mousemove handler. Tests use these directly against the mock canvas (no event simulation needed). The mock canvas in test/harness.js doesn't implement addEventListener, so the DOM listener is skipped in headless contexts -- the programmatic API is the only way in.

Area chart (v1.0.0-alpha.2)

createAreaChart(config) shares everything with createLineChart -- same data shape, same accessors, same scales, same reactivity, same crosshair and tooltip -- and adds three options:

| Config key | Type | Default | Notes | |---|---|---|---| | 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. | | stroke | boolean | true | Whether to stroke the upper boundary of the fill. | | fillOpacity | number | 0.3 | Multiplied into globalAlpha before fill. The stroke draws at full alpha. |

import { createAreaChart } from '@zakkster/lite-charts';

const chart = createAreaChart({
    data: timeseries,
    x: 't', y: 'cpu',
    color: '#3b82f6',
    baseline: 0,        // fills from data line down to y=0
    fillOpacity: 0.25,
    stroke: true,       // crisp blue line on top of soft fill
});

Both render paths from line chart carry over: direct polyline-with-close for sparse data, decimated per-column for dense. The decimated path fills to the column's upper envelope (max), matching d3-area's default behavior; ribbon-style min-max area is a separate primitive in v1.1+.

Legend (v1.0.0-alpha.3)

Rendered as a DOM element (sibling of the canvas, inside an auto-created flex wrapper), so it's keyboard-accessible (each row is a <button> with aria-pressed), CSS-themable (.lite-charts-legend class on the container), and ready to drop a virtualizer into when v1.2 ships the lite-virtual integration. Click-to-toggle is wired by default.

createLineChart({
    series: [
        { name: 'CPU',  data: cpuRows },
        { name: 'Memory', data: memRows },
        { name: 'Disk', data: diskRows },
    ],
    x: 't', y: 'pct',
    legend: 'bottom',           // 'top' | 'bottom' | 'left' | 'right' | false
});

Position controls the auto-wrapper's flex direction:

  • 'bottom' / 'top' -> column wrapper (canvas above/below legend)
  • 'left' / 'right' -> row wrapper (canvas beside legend)

For custom DOM placement, pass an existing element via legend: { container: someEl } -- the legend appends into your element and the canvas stays put.

Series visibility

Each series has a Signal<boolean> exposed on chart.seriesVisibility[i]. Toggling it has three effects:

  1. The series stops rendering (line/area, crosshair marker, tooltip row).
  2. The y-domain rescales to fit only the visible series (matching Chart.js convention -- toggle reveals detail in the remaining data). Pass an explicit yScale: { domain: [...] } to lock the scale.
  3. The legend swatch + label dim (opacity: 0.4, aria-pressed=false).

You can toggle programmatically via chart.setSeriesVisible(idx, bool) or write directly to the signal:

chart.setSeriesVisible(0, false);
// or
chart.seriesVisibility[0].set(false);
// or
chart.seriesVisibility[0].update((v) => !v);

For a "show only this" pattern (alt-click), iterate:

const showOnly = (idx) => {
    chart.seriesVisibility.forEach((sig, i) => sig.set(i === idx));
};

Path interpolation (v1.0.0)

Seven modes. Default is 'linear' (the polyline). Three step variants for discrete data (telemetry, state machines, financial OHLC). Two smoothing modes for continuous data.

createLineChart({ data, interpolation: 'monotone' });

| Mode | Visual | When to use | |---|---|---| | 'linear' | Straight segments between samples | Default; honest about data resolution | | 'step' / 'step-after' | Horizontal then vertical | Sample held until the next reading (sensor readouts) | | 'step-before' | Vertical then horizontal | Sample took effect at the prior x (event-triggered transitions) | | 'step-mid' | Step at the midpoint of each segment | Symmetric staircase; useful for histogram-like data | | 'monotone' | Fritsch-Carlson cubic Hermite | Smooth without overshooting between samples. Best for noisy time-series. | | 'catmull-rom' | Uniform Catmull-Rom spline | Smooth through all samples. Aesthetic; can overshoot on irregular data. |

Per-series override:

createLineChart({
    series: [
        { name: 'CPU',    data: cpu,    interpolation: 'monotone' },
        { name: 'Events', data: events, interpolation: 'step-after' },
    ],
});

Decimation interaction: when n > 2 * plotWidth and the decimated render path activates, interpolation is ignored -- smoothing the per-column min/max envelope would be visually misleading. Interpolation only changes the direct path.

NaN handling: linear and step modes split on NaN (each contiguous run renders independently). Smoothing modes assume contiguous data; if you need gaps, use linear or step.

Markers (v1.0.0)

Marker dots at each sample point. Distinct from crosshair markers (those appear only on hover).

createLineChart({ data, markers: true });   // circle defaults

createLineChart({
    data,
    markers: {
        shape: 'diamond',
        size: 6,
        fill: '#3b82f6',
        stroke: '#ffffff',
        strokeWidth: 2,
        everyN: 1,
    },
});

Use everyN for dense series:

// 500-point series with markers every 10th sample -- legible without noise.
createLineChart({ data: dense, markers: { everyN: 10 } });

Decimation interaction: markers are suppressed when the decimated path runs (>2x plot width). They'd be unreadable.

Theme reactivity (v1.0.0)

Colors passed as '--token-name' get resolved against the container's computed style at mount. When you switch themes (dark mode, brand swap), call chart.refreshTheme() to re-resolve every CSS-var-driven color and trigger a redraw.

const chart = createLineChart({
    data,
    color: '--my-brand-primary',
    axisColor: '--my-text-muted',
});
chart.mount(el);

// On theme change:
document.documentElement.setAttribute('data-theme', 'dark');
chart.refreshTheme();

Hex / oklch / named colors pass through unchanged; only CSS-var tokens re-resolve. Legend swatches update too.

MutationObserver auto-detection is deliberately not bundled in v1.0.0. Which element to observe (container? <html>? <body>?), which attributes (class? data-theme? both?), and how to debounce all depend on the host app's theming convention. Wire your own observer to call chart.refreshTheme(), or pair it with whatever theme-change event your framework emits.

Bar chart (v1.1.0-alpha.0)

import { createBarChart } from '@zakkster/lite-charts';

// Single series:
const chart = createBarChart({
    data: [
        { x: 'Q1', y: 42 },
        { x: 'Q2', y: 58 },
        { x: 'Q3', y: 65 },
        { x: 'Q4', y: 78 },
    ],
    color: '--c-primary',
});
chart.mount(document.getElementById('chart'));

Multi-series renders grouped side-by-side at each category. Each bar takes a slice of the band centered on its series index (offsetX = (i - (count - 1)/2) * groupWidth):

createBarChart({
    series: [
        { name: 'Revenue',  data: [{x:'Q1',y:42}, {x:'Q2',y:58}, ...], color: '--c-primary' },
        { name: 'Expenses', data: [{x:'Q1',y:30}, {x:'Q2',y:35}, ...], color: '--c-amber' },
        { name: 'Profit',   data: [{x:'Q1',y:12}, {x:'Q2',y:23}, ...], color: '--c-cyan' },
    ],
});

| Config | Type | Default | Notes | |---|---|---|---| | baseline | number | 0 | Y value where bars anchor. Negatives extend downward. | | paddingInner | number | 0.15 | Gap between bands as fraction of step. d3 convention. | | paddingOuter | number | 0.1 | Padding at each end of the range as fraction of step. | | groupInnerPad | number | 0.08 | Inner gap between bars within a grouped slot. |

Hit detection is discrete. Unlike line/area which uses bisectNearest (O(log n) over the x array), bar uses bandScale.invert(canvasX) which is a single floor-division: Math.floor((px - origin) / step). The user is either inside a band or in a gap that snaps to the nearest band. O(1) regardless of category count.

Y-domain includes the baseline by default so bars don't visually float. Override with yScale: { domain: [...] } if you need a fixed window.

Stacked layout ships in v1.1.1. The current beta only supports grouped multi-series (which is the right default -- stacked introduces design choices around shared y-domain and tooltip ordering that are worth a dedicated session).

Tree-shakeable architecture (v1.2.0)

lite-charts is built on a tiny shared kernel that's parameterized by a renderer object per chart type:

const createBaseAxisChart = (config, renderer) => { /* shared scaffold */ };

const LINE_RENDERER = { extractData, makeDrawFn, hitTest, buildXAxis, ... };
const AREA_RENDERER = { ...AREA_specific };
const BAR_RENDERER  = { ...BAR_specific };

export const createLineChart = (config) => createBaseAxisChart(config, LINE_RENDERER);
export const createAreaChart = (config) => createBaseAxisChart(config, AREA_RENDERER);
export const createBarChart  = (config) => createBaseAxisChart(config, BAR_RENDERER);

createBaseAxisChart calls renderer methods polymorphically -- it never references any specific renderer by name. The bundler can statically prove which renderers are reachable from the entry import and drop the rest, along with all their renderer-specific helpers.

Measured bundle sizes (esbuild --bundle --minify, peer deps externalized):

| Entry | Bundle size | What's included | |---|---|---| | import { createLineChart } | 24 KB | Line renderer + interp helpers + decimation + shared axis kernel + auto-resize | | import { createAreaChart } | 25 KB | Area renderer + interp helpers + decimation + shared axis kernel + auto-resize | | import { createBarChart } | 25 KB | Bar renderer + bandScale + bar helpers + shared axis kernel + auto-resize + stack / rounded / hover (v1.1.0) | | import { createBubbleChart } | 25 KB | Bubble renderer + sqrt size scale + distance hit-test + axis kernel + auto-resize + spatial-index hook (v1.2.0-alpha.0) + multi-series + per-point color (v1.2.0-alpha.2) | | import { createScatterChart } | 22 KB | Scatter renderer + axis kernel + spatial-index hook (v1.2.0-alpha.1) | | import { createPieChart } | 13 KB | Slice renderer + polar kernel (no axes / scales / interp / decimation) + auto-resize | | import { createDonutChart } | 13 KB | Same as pie (shared renderer; only innerRadius default differs) | | import { createRadarChart } | 13 KB | Radar kernel (cos/sin tables, polygon draw, spokes, grid rings, vertex hit-test) -- zero axis/polar code | | import { createHeatmap } | 10.5 KB | Grid kernel (v1.2.0-alpha.3) -- two band scales, Float32 cells, Uint8 presentMask, precomputed cell colors. Zero axis / polar / radar code. | | All nine together | ~70 KB | Four kernels deduplicated; all renderers; shared utilities (resolveColor, ensureFloat32, mount/DPR, legend, auto-resize) shared once |

The v1.1.0 bar features (stacked layout, rounded corners, hover tint) add ~1.6 KB to the bar bundle (computeBarStacks, _roundRectPath, the per-bar tint overlay path). The kernel-level postExtract hook is a single null-check that minifies to a few dozen bytes; line / area / bubble bundles each pick up ~300 bytes for it. Pie / donut / radar are on different kernels and unaffected.

Auto-resize: omit width / height from the config and the chart observes its mount container, updating dimensions on container resize through the existing reactive graph:

// Reactive to container size, no demo helpers needed
createLineChart({ series: [...] }).mount(document.getElementById('chart'));

// Explicit static -- bypasses auto-observation
createLineChart({ series: [...], width: 800, height: 400 }).mount(canvas);

// Explicit reactive -- user-provided signal
createLineChart({ series: [...], width: mySignal }).mount(div);

Falls back gracefully (keeps default size) when ResizeObserver is unavailable. rAF-throttled so burst resize events coalesce into one re-extract per frame.

What gets dropped from the radar bundle: every axis-chart helper (xScale, yScale, axes, grid, decimation, interp, bisect, bandScale, makeLineDrawFn, makeBarDrawFn, makeBubbleDrawFn) and every polar-slice helper (extractSliceData, sliceHitTest, computeSliceGeometry, makeSliceDrawFn). What's kept: the precomputed cos/sin tables, polygon draw, spoke renderer with angle-aware label alignment, and 12-px nearest-vertex hit-test.

Requirements for tree-shaking to work (already in place):

  1. "sideEffects": false in package.json
  2. Every renderer is a separate top-level const
  3. Renderers don't reference each other (no spread inheritance -- shared methods are top-level consts)
  4. Pure test helpers live on a separate _testHelpers export, not on chart instance _internal -- production code never references it, so it gets dropped along with everything it transitively references

The same architecture extends to upcoming chart families:

// v1.3.0 -- pie family (no axes, polar coordinates)
const createBasePolarChart = (config, renderer) => { /* polar scaffold */ };
export const createPieChart   = (c) => createBasePolarChart(c, PIE_RENDERER);
export const createDonutChart = (c) => createBasePolarChart(c, DONUT_RENDERER);
export const createRadarChart = (c) => createBasePolarChart(c, RADAR_RENDERER);

// v1.3.0 -- scatter family (extends axis chart with size dimension)
export const createBubbleChart = (c) => createBaseAxisChart(c, BUBBLE_RENDERER);

// v1.4.0 -- heatmap (2D categorical grid)
const createBaseGridChart = (config, renderer) => { /* grid scaffold */ };
export const createHeatmap = (c) => createBaseGridChart(c, HEATMAP_RENDERER);

Each chart type added to the library costs nothing for users who don't import it. A dashboard that only needs line and bar charts gets a ~30 KB bundle even after pie, donut, radar, bubble, and heatmap ship.

Performance

All numbers are from bench/line-100k.mjs running on Node 22 against a mock canvas (so the GPU paint cost is not included -- see disclaimer below). Dataset is 100,000 points; canvas is 1600x800.

full update cycle (data -> draw)     p50 = 1.39 ms    p95 = 4.66 ms    p99 = 5.11 ms
decimation kernel only               p50 = 0.52 ms    p95 = 0.56 ms    p99 = 0.65 ms
draw only (cached data + scales)     p50 = 0.48 ms    p95 = 0.58 ms    p99 = 3.90 ms

CPU-bound fps ceiling at p95:

  • full cycle: 214 fps
  • draw only: 1735 fps

Both 60fps (16.67ms) and 120fps (8.33ms) frame budgets fit with material headroom on the CPU side.

Honest disclaimer. The bench runs against a recording mock canvas context (see test/harness.js). This measures the library's CPU work (scale math, decimation, canvas-call issuing) but does NOT measure real GPU paint cost. In a real browser, paint is additional and depends on GPU, DPR, blending, and what else is on the compositor. The 60fps-at-100k claim is meaningful only when CPU + GPU together fit under 16.67ms. This bench validates the CPU side. The browser bench (bench/browser/, coming in v1.0.1) measures real paint.

Zero-GC discipline

Hot-path allocations target: <100 bytes/cycle. Currently measured at ~270 bytes/cycle, attributable to:

  • {xs, ys} object literal allocated by the test rotor (~16 B)
  • niceYDomain returning a fresh [lo, hi] tuple (~40 B; fix in v1.0.1)
  • Axis label string concatenation via String.fromCharCode (~120 B for 20 labels; fix by switching to a shared Uint8Array and only stringifying when text actually changes)
  • Promise allocations from queueMicrotask-based draining

None of these touch the per-frame line render -- they're in the data/axis update path that fires only on actual changes. The line draw closure itself is fully allocation-free in steady state (verified by the decimateMinMax zero-GC test in test/charts.test.js).

What's measured, what isn't

| | Measured | Notes | |---|---|---| | Per-frame CPU work | YES | Bench p95 | | Decimation kernel zero-alloc | YES | Test asserts <100 bytes/call | | GPU paint cost | NO | Browser bench coming v1.0.1 | | Cold-start overhead | NO | Single-figure ms; not yet measured | | Bundle size min+gz | NO | Single-file ESM, ~6 KB estimated; not yet minified |

Capacity considerations

lite-charts builds on @zakkster/lite-signal, which pre-allocates a fixed-size arena for its reactive nodes (signals + effects). The default capacity is 1024 nodes, which fits a typical app with a few charts but can be exhausted on dashboards or demos with many simultaneous charts. If you see a CapacityError: nodes capacity (1024) exceeded, this is the cause.

Per-chart active node footprint (measured against the v1.1.0 implementation, on a chart with default options at typical sizes):

| Chart | Active nodes | |---|---| | createLineChart | ~43 | | createAreaChart | ~43 | | createBarChart | ~60 (3 series x 10 cats) | | createBubbleChart | ~46 | | createScatterChart | ~46 | | createPieChart | ~25 | | createDonutChart | ~25 | | createRadarChart | ~50 | | createHeatmap | ~5 |

The dominant cost on axis-kernel charts is the per-axis tick pool: each tick allocates a lineNode and a textNode (the label), each of which creates one lite-scene effect. At max tick count (12 per axis) that's ~24 effect nodes per axis x 2 axes = ~48 per chart. Heatmap is unusually cheap because the grid kernel renders cells through a single pathNode-per-layer rather than per-cell scene nodes.

Rule of thumb: the default 1024-node arena fits ~15-20 axis-kernel charts on a single page. Multiply by the headroom you want for safety.

Bumping the arena -- call setDefaultRegistry BEFORE constructing any chart:

import { createRegistry, setDefaultRegistry } from '@zakkster/lite-signal';

// 32k nodes -- comfortable headroom for dashboards or demos. The arena
// is a few tens of KB of memory, so this is cheap.
setDefaultRegistry(createRegistry({ maxNodes: 32768 }));

// ... THEN construct your charts:
import { createLineChart } from '@zakkster/lite-charts';
const chart = createLineChart({ /* ... */ });

Order matters: charts read the current default registry at construction time. Bumping after charts are already created doesn't help those charts. The lite-charts demo (demo/index.html) bumps to 32768 at the very top for this reason.

Mount/unmount note: each mount/unmount cycle leaves a small residue (~4 reactive nodes per chart, from construction-time signals that aren't disposed in unmount so the chart can be remounted). For apps that create and destroy many charts dynamically over a long session, that residue accumulates until the chart reference is dropped and the lite-signal arena slots become eligible for reclamation. A dedicated terminal-teardown chart.destroy() is on the roadmap for v1.3.

Roadmap

v1.1.0 ships nine chart types on four independent kernels with kernel-side auto-resize. See ROADMAP.md for the full forward plan and the development history that led here. Headlines:

| Version | Scope | |---|---| | v1.0.0 | Seven chart types, three kernels, auto-resize, 182 tests, full tree-shake verification. | | v1.1.0 (this release) | Bar polish (stacked, rounded corners, hover tint) plus the features prototyped over four internal alphas now landing in one go: pluggable spatial index for bubble hit-test (auto-engages ≥1000 points), createScatterChart (eighth type), multi-series bubble + per-point color + global size domain, and createHeatmap on a new createBaseGridChart kernel (10.5 KB minified, the smallest of the nine). 231 tests. | | v1.2.0 | Heatmap polish (per-row / per-column highlight on hover, quantile binning); doc + release notes. | | v1.3.0 | SVG export across all nine charts (mirrors every draw fn through SVG path commands; pixel-identical output). | | v1.4.0 | Log scale; pan + zoom; brushing primitives. | | v1.5.0 | Time-series specialized variants; legend virtualization via lite-virtual; annotation layer. |

Ecosystem

Part of the @zakkster/* zero-GC stack:

  • @zakkster/lite-signal -- reactive core (peer)
  • @zakkster/lite-scene -- Canvas2D scene graph (peer)
  • @zakkster/lite-axis -- tick generation (peer)
  • @zakkster/lite-canvas-graph -- the decimation kernel was lifted from here
  • @zakkster/lite-bvh / lite-aabb -- spatial tooltip backend for v1.2 scatter
  • @zakkster/lite-virtual -- legend virtualization for v1.2

License

MIT (c) Zahary Shinikchiev