@zakkster/lite-charts
v1.24.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.
Maintainers
Readme
@zakkster/lite-charts
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.24.0 -- chart chrome (minor). title / subtitle stack
centered above the plot and caption takes the bottom-right attribution
line on every axis-kernel chart, each bumping its side's DEFAULT margin
only when that margin side is unset. Fonts derive from the axis font
(bold title one size class up); colors ride labelColor and the theme
refresh. Fail-closed doors before any allocation, including subtitle
without title.
583/583 tests pass plus a torture/stress gate (npm run torture).
New in v1.24.0:
- Chart chrome.
title/subtitlerender canvas-centered above the plot (top-margin bumps 24px/16px whenmargin.topis unset);captionright-aligns at the plot edge on the bottom-most line (14px bump), pushing anxTitleup one line when both are present -- without a caption the v1.23.0xTitleposition is byte-identical (test-pinned). Fonts derive fromfontat construction (title bold +4px, subtitle +1px, caption -1px; a px-less base font falls back unchanged). All three recolor onrefreshTheme(), export to SVG, and die with the chart.'', non-strings, and an orphansubtitlethrow at construction with zero nodes allocated. Non-axis kernels ignore the keys; per-kernel chrome, per-element style objects, and wrapping are named refusals. 11 new tests (572 -> 583), 3 reversion proofs, torture A29 (chrome redraw within 2 B/op of a chrome-less control).
New in v1.23.0:
- Tick-format callbacks.
xTickFormat/yTickFormat((value: number) => string) on every axis-kernel chart, applied at the cold axis rebuild. A time x-axis passes the raw epoch-ms value; band (category) axes ignore the callback (the horizontal bar's bottom VALUE axis honorsxTickFormat). Non-function configs throw at construction with nothing allocated; a callback that returns junk or throws surfaces as amount()error on the first rebuild and fail-safes (labels hide, no throw mid-gesture) on later ones. - Axis titles.
xTitle/yTitlenon-empty strings; the y title reads bottom-to-top via a rotated scene node. A title adds 18px to its side's DEFAULT margin only when that side is not set explicitly. Titles track resize, recolor onrefreshTheme(), export to SVG, die with the chart. - Axis theme fix.
refreshTheme()now re-fires the axis/grid scene-node color bindings through a trackedaxisThemeVersionsignal -- previously every axis-kernel chart repainted its axis chrome in the stale mount-time colors after a theme switch. 14 new boundary tests (558 -> 572), three measured reversion proofs, torture tier A28.
New in v1.22.0:
- Heatmap
refreshTheme(). The grid kernel captures its theme-affected color specs (colorsramp endpoints,labelColor,highlightStroke,rowColumnHighlightFill,valueLabelColor) before mount resolves them, so a theme switch re-resolves the original--vartokens and recomputes the cell/label color arrays (Charts.d.tsalready declared the method onChart-- the implementation now matches). ThevalueLabelColor: 'auto'per-cell contrast path survives repeated refreshes; headless or unresolvable tokens fail closed to#888; unmounted charts no-op. 6 new boundary tests (552 -> 558), one measured reversion proof.
New in v1.21.0:
- Error bars / confidence bands. Per-series (chart-level default)
errorBars: { lo, hi }(absolute per-row accessors or SoAlos/hisarrays) or{ value }(symmetric magnitude,lo = y - v/hi = y + v; mutually exclusive withlo/hi).band: falsedraws whiskers (vertical bar +capWidthcaps),truedraws a filled ribbon,'both'draws both; a NaN/null gap splits the ribbon into runs.colordefaults to the series color,bandFillto that color at ~0.15 alpha. Projects through a log y-axis (a non-positive bound self-skips), clips to the plot rect, and emits to SVG. A per-row null/NaNlo/hi/valueself-skips that point (+null === 0gated -- never a 0-anchored bar); a bad config throws at construction before any signal is allocated. A chart withouterrorBarsis byte-identical. New torture A27 (whisker + band gesture storm within 2 B/op of a no-errorBars control; the cold resolve fires once per data/theme change, never per frame).
New in v1.20.0:
- Brush v2.
brushModifierselects the gesture modifier (default'shift', resolved cold to one predicate; an unknown value throws at construction). Each gesture-driven commit gainsidsBySeries-- a commit-time per-series snapshot, one slot per series,nullfor a hidden or empty series (visibility read untracked, never recomputed on a later toggle); the primaryidsis unchanged. The horizontal-bar brush gains a band multi-select: a modifier+click TOGGLES the clicked band in/out (modifier+drag still replaces with a contiguous range), toggling the last band off clears;bandsmay be NON-contiguous,bandMin/bandMaxare the HULL, and the overlay draws one rect per contiguous run (byte-identical to the one-rect path for a single run).setBrush({ bands })validates each key against the categories and re-derives the hull. Fixed:setBrush({ xMin: null })on a vertical brush now throws instead of coercing to bound 0.
New in v1.19.0:
- Candlestick / OHLC chart.
createCandlestickChart(config)-- wickh..l(1px centered), bodyo..c, up/down fills (#16a34a/#dc2626defaults;wickdefaults to the per-candle body color). Body width is the MEDIAN adjacent ts delta in pixels xbodyRatio(default 0.7, clamp(0,1]), recomputed cold per data/scale change -- one missing or off-schedule bar never resizes the rest. Timestamps ride raw doubles internally, so minute bars at 2026 epochs draw exactly. Corrupt rows (null/NaN fields,h < max(o,c),l > min(o,c), equal or descendingts) refuse the whole series --mount()throws; a corrupt post-mount swap draws nothing and a healthy re-swap recovers.yScale: { type: 'log' }projects each of o/h/l/c independently. Doji (o === c) paints a 1px tick. The tooltip carries four O/H/L/C rows.shading(weekends / sessions / overnight / holidays / early close) is the shippedcreateTimeLineChartengine, byte-identical. Candles sit at TRUE time -- weekend gaps show as gaps (index-compact slots are a named-trigger candidate, see the roadmap).
New in v1.18.0:
- Cluster outlines on scatter.
outlines: { index: createClusterIndex(N), groupKey, alpha?, stroke?, strokeWidth?, fill?, fillOpacity?, dash? }partitions the primary series' rows by the rawgroupKeyvalue and strokes one boundary per group: the convex hull whenalphais absent, or the concave alpha shape (alpha= a radius in pixel units, finite> 0;Infinityis refused -- omitalphafor the hull) when set. The fourth injection rung afterspatialIndex,cellsandfield: geometry comes from the injected factory (lite-charts imports nothing), per-group handles are rebuilt cold on every data/scale change on thepostProjectseam, and the per-frame draw walks prebuilt pooled loops only. Multi-loop alpha shapes (split clusters) and interior hole loops both draw; an optional translucent fill runs one path per group, so hole loops carve out under the nonzero fill rule. Rows whose key is missing/nullbelong to no group; groups with fewer than 3 usable points (or none under the alpha) silently draw markers only; more than 64 distinct groups is a fail-closed layer fault. Construction throws before any owned signal on a badindex,groupKey, oralpha; junk styles fall back.exportSVGemits Z-closed paths per loop.
New in v1.17.0:
- Contour lines over the field raster.
field.contours: { levels, color?, width?, dash? }sweeps the injected triangulation for iso-value crossings (exact for the piecewise-linear interpolant -- a planar field's contour is exactly straight) and strokes them between the raster and the cells/markers, inside the plot clip, at 0 B/frame.levelsis a count (1..32lines spread evenly strictly inside the finite sampled range -- they re-derive on every pan/zoom, so a panned-out outlier never pins them) or an explicit array of values (sorted, deduped; out-of-range values legally draw nothing). Segments are precomputed cold into a pooled grow-only buffer on the same postProject refresh as the raster; the per-frame draw walks prebuilt geometry only. Styles fall back (junkcolor/width/dashnever throw); badlevelsthrow at construction before any owned signal. The contour pass is a third independent fault domain: it reuses the field's mesh handle, skips silently when the raster has nothing to show, and a fault in it never touches the raster or cells.exportSVGemits the stroked paths between the field rects and the cell polygons.locate/barycentricremain unconsumed -- no delaunay-side change was needed.
New in v1.16.0:
- Injected field-raster layer.
field: { index: createFieldIndex(N), value, gridW?, gridH?, colors?, colorFn?, opacity? }: the primary series' per-point scalarvalueis rasterized into a smooth interpolated background heatmap (the third injection rung afterspatialIndexandcells), drawn UNDER the cells and markers inside the plot clip at 0 B/frame. Built over pixel-space points and re-sampled cold on every data/scale change (samepostProjectseam as cells), so pan/zoom never serves a stale or affine-wrong field. OnesampleFieldper refresh fills a pooled grid; per-cell colors are precomputed cold.gridW/gridHintegers in[8, 256](default64 x 48);colors[low, high]hex ramp orcolorFn(v, vMin, vMax)(extents over FINITE cells only);opacitydefault0.5. Independent fault domain fromcells(each has its owntry/catch, error slot, and handle disposal). SVG export parity (one<rect>per finite cell). Construction throws before any owned signal: non-objectfield, missing/non-functionindex, missingvalue, or non-integer/out-of-capsgridW/gridH. See "Field raster" below for wiring.
New in v1.15.0:
- Horizontal legend virtualization. A virtualized legend at
position: 'top' | 'bottom'scrolls a single non-wrapping row along X. Supplylegend.width(viewport) +legend.itemWidth(fixed row width, both positive integers); the adapter receives{ count, itemWidth, width, overscan, renderRow, horizontal: true }. Size keys are orientation-exclusive --height/itemHeighton top/bottom, orwidth/itemWidthon left/right, throw at construction. Left/right virtualization is unchanged and byte-identical. - Early-close holidays.
shading.holidaysentries may be{ ts, closeMinutes }: on that UTC day every session clamps to close atcloseMinutes(1..1439) instead of its normal close, fusing forward like a whole-day holiday's band. Doors that throw at construction: object withoutts; badts;closeMinutesnull/non-integer/outside 1..1439; a duplicate UTC day across all entries; an early close on a weekday with no open session; and an early close on a day carrying an overnight evening session. Whole-day (number) entries are byte-identical to v1.14.0. - Earlier fail-closed legend validation. Bad legend config (virtualize junk,
orientation-exclusive size key) now throws with zero owned signals allocated.
When both the chart-type options and the legend are invalid, the
initOptserror wins.
New in v1.14.0:
- Fat hover.
hitTolerance: 'nearest'(scatter only): the whole plot becomes each point's hit region -- ideal for sparse scatters. Finite per-query cap (plot diagonal), identical on the linear and indexed hit paths; any other string throws at construction. - Voronoi cell layer.
cells: { index: createCellIndex(N), colorKey?, fillOpacity?, stroke?, strokeWidth? }: pixel-space bbox-clipped cells, rebuilt with the projection through the spatial-index lifecycle (pan/zoom never serves a stale or affine-wrong cell), drawn under the markers at 0 B/frame, with a free hover-cell highlight off the crosshair. Degenerate clouds draw markers with no cells; construction validation throws before any signal allocation. SVG export parity. - See "Fat hover + Voronoi cells" below for wiring.
New in v1.13.0:
- Overnight sessions.
{ openMinutes: 1320, closeMinutes: 1260, days: [0,1,2,3,4] }(Globex-style, opens Sun-Thu 22:00 UTC) now works: the session is split at the UTC midnight seam into an evening + next-day morning half, so the single-cursor band sweep is structurally unchanged and the seam never shades a sliver inside the open span.daysis the weekday the session OPENS.close === openstill throws (a 24h session is{ 0, 1440 }). shading.holidays. Epoch-ms dates, each truncated to its UTC day start; the whole day is closed and fuses with adjacent gap bands into one range. Works with or withoutsessions. Fail-closed:[],nullentries (null is not epoch 0), non-integers, strings, andDateobjects all throw -- passDate.UTC(...)values.- See "Overnight sessions + holidays" under the Time-series section.
New in v1.12.0:
legend.virtualize.(host, opts) => ({ dispose })-- the adapter owns row creation/position/height; lite-charts owns row contents (renderRowre-reads swatch colour + visibility per bind, so recycled rows never show stale state), toggling, and a11y (role/aria-pressed/tabindex). Withlegend.height(required),legend.itemHeight(default 28), andlegend.overscan(default 2). See "Virtualizing a tall legend" below.- Fail-closed doors. Non-function
virtualize,position: 'top'|'bottom', a missing/null/non-positive-integerheight, or a factory returning nodispose()all throw (construction or mount) with nothing attached;virtualize: falseor absent keeps the byte-identical eager legend. @zakkster/lite-virtualadded as an OPTIONAL peer (mirrorslite-delaunay); never imported.
New in v1.11.0:
- Session-calendar shading.
shading: { sessions: [{ openMinutes: 570, closeMinutes: 960 }] }(UTC minutes-from-midnight, days default Mon-Fri) shades everything outside trading hours. Bands ride the annotation layer as in v1.10.0 -- zero per-frame cost, exportSVG-visible, composing with userannotations. Overnight sessions (close < open) throw as unsupported; every malformed field throws at construction. See "Time-series line chart" below. - Fail-closed
xScale.type.createTimeLineChart({ xScale: { type: 'log' } })now throws; previously the conflicting type was silently overridden.
New in v1.10.0:
createTimeLineChart(config).createLineChartwith time-first defaults:xScale.typeforced to'time'(a plain numericxkey infers'linear'otherwise),panBoundsdefaulting to'data', and an optionalshadingconfig. See "Time-series line chart" below.- Weekend shading.
shading: true | 'weekends' | { fill? }shades every Sat 00:00 -> Mon 00:00 UTC span in the data domain. Bands are plain{ type: 'range', axis: 'x' }annotations, so they compose with anyannotationsyou pass and export throughexportSVG; omitshadingfor a plain time line at zero cost. Fail-closed: anull/non-finite extent bound emits no bands (never epoch 0), and SoA{ xs, ys }data is shaded like AoS.
New in v1.9.0:
- Horizontal bars brush.
createBarChart({ orientation: 'horizontal', brush: true }). A shift-drag selects a value range (screen-X, the value axis under the swap) crossed with a band set (screen-Y);chart.brush()returns{ valueMin, valueMax, bandMin, bandMax, bands, ids }--bandsthe selected category keys,idsthe matching primary-series rows. The vertical brush keeps its{ xMin, xMax, yMin, yMax, ids }shape. Zero per-frame draw allocation is preserved (aswapAxes ? ...selection at each brush call site; the pure brush helpers are byte-identical). Fail-closed: an empty-category chart commitsnull(never an undefined band), and alogvalue axis still throws at construction. See "Horizontal bar brush" below.
New in v1.8.0:
- Horizontal bars pan, zoom, and grid.
createBarChart({ orientation: 'horizontal', pan: true, zoom: true, grid: true }). Under the axis-role swap the value axis is on screen-X, so a horizontal drag pans the value domain and the wheel zooms it around the cursor value; the band (category) axis stays pinned.view.yMin/view.yMaxaddress the value axis. Zero per-frame draw allocation is preserved (a new gesture-timeswapAxes ? ...selection at each call site; the linear kernels are untouched). Still fail-closed: horizontal +brush(a value-range + band-ids payload is a separate future cut) and horizontal + alogvalue axis both throw at construction. See "Horizontal bar interactions" below.
New in v1.7.0:
annotationson axis-kernel charts. An array -- or() => Annotation[]-- ofline(a rule at a data value on the x or y axis),range(a shaded band between two values on one axis),point, andtextmarks, each pinned to DATA coordinates. They re-project on every scale change, so they track pan / zoom and work on log axes (a value<= 0on a log axis simply does not draw -- fail-closed, exactly like the series).color/fillaccept--css-vartokens and re-resolve onrefreshTheme. Rendered above the series and below the crosshair; present inexportSVG. Zero per-frame allocation, and the whole layer is absent whenannotationsis unset. See "Annotations" below.
New in v1.6.1:
- Mixed-sign log domains pan and zoom cleanly. When
min <= 0, max > 0on atype: 'log'axis (x or y), the data-domain snapshot used for pan/zoom bounds is floored to the same positive value the render path uses, so the first gesture no longer produces a NaN (frozen) view. The no-positive-extent case still throws at mount. Symmetric on both axes; draw path byte-unchanged.
New in v1.6.0:
xScale: { type: 'log' }-- base-10 log on the x-axis for any axis-kernel chart. Ticks vialite-axis.logTicks(decade boundaries);map(x <= 0)is NaN, so line / area break segments and markers skip. Pan and zoom operate in log space -- the interaction plumbing was already symmetric with the y-axis, so this wiring was mostly enabling what the kernels already supported. Fail-closed: throws at mount on a non-positive x-domain, and at construction on a bar (band) or time x-axis (a scale is one type). See "Log scale" below.
Inherited from v1.5.0 (+ the v1.5.1 patch):
centerLabeloncreateDonutChart-- a number rendered in the donut hole as apointer-events:noneDOM overlay (not canvas text), so its font resizes itself. Font size is owned by CSSclamp(): the chart writes the hole radius and the label's digit count as custom properties on data/resize only (sub-Hz), and more digits shrink the number to stay inside the ring -- zero per-frame JS, nomeasureText. Acceptsboolean | string | (() => string) | { text, format, subLabel, color, font, minFontSize, maxFontSize }. Fail-closed: throws on a chart with no hole (a pie, orinnerRadius0). See "Donut center label" below.orientation: 'horizontal'oncreateBarChart-- category band on the Y axis (category 0 at top), bars growing from the value baseline along X. The vertical draw path is byte-identical (horizontal selects a peer draw function once at setup). Fail-closed subset for this cut:horizontalcombined withpan/zoom/brush/ a valuegrid/ a log value axis throws at construction. See "Horizontal orientation" below.- Log-scale point projection fix (v1.5.1 patch).
scaleSeriesToPixelsprojectedyScale: { type: 'log' }points with linear math, throwing line / area / scatter / bubble points off-canvas (the axis and ticks were correct; only the points were wrong). The projection loop is now log-aware on both axes; the all-linear path is byte-identical. Present since y-log shipped in v1.4.1. (This is what made the v1.6.0 x-log projection correct.)
Inherited from v1.4.0 (+ the v1.4.1 patch):
yScale: { type: 'log' }-- log scale on the y-axis for any axis-kernel chart. Base-10 log; ticks vialite-axis.logTicks. See "Log scale" below.pan: true+zoom: true-- pointer-drag pans (cursor-anchor convention); wheel zooms around the cursor.chart.viewis a reactive{ xMin, xMax, yMin, yMax }accessor intentionally symmetric withlite-camera-max's camera signal for future lite-gl drop-in. See "Pan + zoom" below.brush: true-- shift+drag rectangle selection.chart.brushemits{ xMin, xMax, yMin, yMax, ids }for cross-chart linking. Coexists with pan/zoom via modifier routing. See "Brushing" below.- Four allocation traps closed (audit): heatmap quantile
Float32Array pool;
_parseRGBLikeindexOf scan;charBufToStringviaapply; SVG path chunks for 100k+ point export. No public API changes; pre-existing leaks in v1.2-1.3 code, fixed silently. - Log-aware pan/zoom (v1.4.1 patch).
pan/zoomon ayScale: { type: 'log' }chart now run their arithmetic in log space (_applyPanLog/_applyZoomLog) with a domain floor; before, the first gesture drove the domain wrong or negative (findings LC-01..LC-05).updateLogScalethrows on a non-positive domain. The same log-space pan/zoom now applies toxScale: { type: 'log' }as of v1.6.0 (see "New in v1.6.0" above).
Inherited from v1.3.0:
chart.exportSVG()on every chart. Safe at 100k+ points (the SVG path-chunks audit fix above protects againstRangeError: Invalid string lengthon big exports).
Inherited from v1.2.0:
chart.destroy()on every kernel; terminal counterpart tounmount(). Zero residue across 30 mount+destroy cycles.- Heatmap row + column highlights, quantile color binning, and auto-contrast value labels.
Inherited from earlier alphas:
createHeatmapon the fourth kernel (alpha.3); ~11 KB minified on its own, the smallest of the nine bundles.createScatterChart(alpha.1); reuses the spatial-index foundation withk = 1.- Multi-series bubble + per-point color via
colorKey, global size domain across visible series (alpha.2). - Pluggable spatial-index (
SpatialIndex/SpatialIndexFactory) for O(log n) hit-test on dense point clouds (alpha.0).@zakkster/lite-delaunay1.1.0+ ships a conformingcreateSpatialIndex-- see "Spatial-index hit-testing" below.
Inherited from v1.1.0: bar layout polish -- stacked bars, rounded corners, per-bar hover tint. All opt-in.
See ROADMAP.md for the development history and the forward plan.
Install
npm i @zakkster/lite-charts @zakkster/lite-signal @zakkster/lite-scene @zakkster/lite-axisHello 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 --> DrawFnThe 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 callInternally, 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 attachedSynchronized 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 -- opt-in -- windowable for hundreds of series (see
Virtualizing a tall legend).
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.
Virtualizing a tall legend (v1.12.0)
A legend with hundreds of series puts one DOM node per series into layout.
legend.virtualize hands the row windowing to an external adapter so only a
bounded set of rows is ever mounted. lite-charts never imports a windowing
library -- you pass the factory. Wire it to @zakkster/lite-virtual's
mountList (your import), or any windowing primitive with the same shape:
import { createLineChart } from '@zakkster/lite-charts';
import { effect } from '@zakkster/lite-signal';
import { mountList } from '@zakkster/lite-virtual'; // YOUR import, not ours
// mountList wants (host, scope, opts) with a lite-element-shaped scope
// ({ effect, on, onCleanup }) and viewport/render key names; lite-charts
// hands the factory (host, opts) and wants { dispose } back. This bridge
// closes the gap -- its collected teardowns become the one dispose().
const virtualize = (host, opts) => {
const cleanups = [];
const scope = {
effect: (fn) => { const stop = effect(fn); cleanups.push(stop); return stop; },
on: (el, ev, fn, o) => {
el.addEventListener(ev, fn, o);
cleanups.push(() => el.removeEventListener(ev, fn, o));
},
onCleanup: (fn) => cleanups.push(fn),
};
mountList(host, scope, {
count: opts.count,
itemHeight: opts.itemHeight,
viewport: opts.height,
overscan: opts.overscan,
render: opts.renderRow,
});
return {
dispose: () => {
for (let i = cleanups.length - 1; i >= 0; i--) cleanups[i]();
cleanups.length = 0;
},
};
};
createLineChart({
series: manySeries, // hundreds of rows
legend: {
position: 'right', // must be 'left' or 'right'
height: 320, // required: scroll-viewport px
itemHeight: 28, // fixed row height (default 28)
overscan: 2, // off-viewport rows (default 2)
virtualize, // -> { dispose }
},
});One styling note: the pooled rows are absolutely positioned, so the legend
host has no intrinsic width -- give .lite-charts-legend-virtual an explicit
width (e.g. flex: 0 0 200px) or it collapses beside the canvas.
The factory is called once at mount with (host, opts):
| opts field | Type | Meaning |
|---|---|---|
| count | number | Total series count (>= 0). |
| itemHeight | number | Fixed row height in px (> 0 integer). |
| height | number | Viewport height in px (> 0 integer). |
| overscan | number | Extra rows rendered per edge (>= 0 integer). |
| renderRow | (rowEl, idx) => void | Bind a pooled row to series idx. |
The adapter owns row creation, position, and height; it calls renderRow for
each visible (and overscan) row. renderRow -- owned by lite-charts -- writes
the row's children, data-lc-idx, role/aria-pressed/tabindex, swatch
colour, opacity, and label text, re-reading colour and visibility on every call
so a recycled row never carries stale state. A single delegated click listener
on the host toggles the series under the clicked data-lc-idx.
The factory must return { dispose: function }, or mount throws with nothing
attached (chart.legend stays null). Every invalid config -- a non-function
virtualize, position: 'top' | 'bottom', a missing/null/non-positive-integer
height, or an invalid itemHeight / overscan -- throws at construction,
before any signal is allocated. virtualize: false (or absent) uses the eager
path unchanged.
Focus does not survive scroll-out. Rows are pooled and recycled, so a row that scrolls out of the window is rebound to a different series -- keyboard focus on it is lost. This is an inherent trade-off of virtualization.
Series visibility
Each series has a Signal<boolean> exposed on chart.seriesVisibility[i].
Toggling it has three effects:
- The series stops rendering (line/area, crosshair marker, tooltip row).
- 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. - 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.
Error bars + confidence bands (v1.21.0)
Opt in per series (or chart-level as a default) on createLineChart,
createAreaChart, createScatterChart. Each point gets a lower/upper
bound, drawn as a whisker (vertical bar + caps), a filled ribbon, or both.
// Absolute lo/hi per row.
createLineChart({
data: [{ x: 0, y: 5, lo: 4, hi: 6.5 }, { x: 1, y: 6, lo: 5.2, hi: 6.8 }],
errorBars: { lo: 'lo', hi: 'hi' }, // whiskers, caps 3px
});
// Symmetric magnitude -- lo = y - e, hi = y + e.
createLineChart({
data: rows,
errorBars: { value: 'stderr', band: 'both' }, // ribbon + whiskers
});
// Per series: bars on one series only.
createLineChart({
series: [
{ name: 'model', data: pred, errorBars: { lo: 'p05', hi: 'p95', band: true } },
{ name: 'actual', data: obs }, // no bars
],
});errorBars fields:
| Field | Meaning |
| --- | --- |
| lo, hi | Absolute lower/upper accessors or keys (AoS). Mutually exclusive with value. |
| value | Symmetric magnitude: lo = y - v, hi = y + v. |
| band | false whiskers only (default), true filled ribbon, 'both'. |
| color | Whisker stroke. Default = the series color. |
| width | Whisker stroke px. Default 1, clamped to (0, 8]. |
| capWidth | Cap half-extent px. Default 3, [0, 32]; 0 = no caps. |
| bandFill | Ribbon fill. Default = the series color at ~0.15 alpha. |
SoA data supplies parallel los / his typed arrays alongside xs / ys
instead of accessors. Bounds project through the y-scale (log-safe -- a
non-positive bound self-skips) on the annotation cold-resolve/hot-project
split, so a pan/zoom frame re-maps at 0 B and the raw columns re-resolve
only on a data or theme change. A NaN/null gap splits the ribbon into runs.
Fail-closed: a per-row null/NaN lo/hi/value self-skips that
point -- never a bar anchored at 0 (+null === 0 is gated). A malformed
errorBars (junk shape, value together with lo/hi, bad
width/capWidth/band/color) throws at construction before any signal
is allocated.
Not for: bar charts (the grouped/stacked offset math is a separate cut), decimated high-N series (bars draw per raw point -- intended for low-N), and horizontal-bar layouts. Box plots and stacked area are later statistical cuts.
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. As of v1.22.0 the heatmap / grid
kernel is covered as well -- its precomputed cell ramp is recomputed on
refreshTheme(); earlier versions required a re-create. As of v1.23.0
the axis-kernel chrome (spine, ticks, labels, gridlines, axis titles)
genuinely recolors too: earlier versions repainted those scene nodes in
their stale mount-time colors after a theme switch.
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 callchart.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. |
| orientation | 'vertical' \| 'horizontal' | 'vertical' | 'horizontal' puts the category band on Y (v1.5.0). Any other value throws. |
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 (v1.1.0) flips the grouped layout with stack: true;
each series gets per-category stackBottoms / stackTops in data space
from a postExtract hook, and the y-domain rolls up to the cumulative
total.
Horizontal orientation (v1.5.0)
createBarChart({
orientation: 'horizontal',
series: [{ name: 'Downloads', color: '--c-primary', data: [
{ x: 'TypeScript', y: 1840 },
{ x: 'Python', y: 1620 },
{ x: 'Rust', y: 940 },
]}],
});orientation: 'horizontal' swaps the category band onto the Y axis
(category 0 at top, reusing the heatmap y-band convention) and grows each
bar from the value baseline along X; the value axis moves to the
bottom and category labels sit right-aligned on the left. Rankings and
long category labels read better this way than rotated under a vertical
axis.
The vertical draw path is byte-identical to v1.4.1 -- horizontal
selects a peer draw function (makeHBarDrawFn) and a pixel-range swap
once at setup, so there is no per-frame branch and a vertical chart pays
nothing. Proven by a SHA-256 hash-parity test over the five hot draw
functions.
Fail-closed subset for this cut. orientation: 'horizontal' combined
with pan, zoom, brush, a value-axis grid, or a log value axis
throws at construction rather than half-wiring the interaction (those
land in a later 1.5.x). crosshair().snapPixelX reports the band-axis
pixel when horizontal.
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):
"sideEffects": falsein package.json- Every renderer is a separate top-level
const - Renderers don't reference each other (no spread inheritance -- shared
methods are top-level
consts) - Pure test helpers live on a separate
_testHelpersexport, 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.
Donut center label (v1.5.0)
A number in the donut hole, rendered as a pointer-events:none DOM
overlay (a sibling of the canvas, not canvas text or a scene node) so
it resizes itself and never touches the per-frame slice draw:
import { signal, createDonutChart } from '...';
const data = signal([
{ label: 'Search', value: 4200 },
{ label: 'Direct', value: 2800 },
]);
createDonutChart({
data,
innerRadius: 0.55,
centerLabel: {
text: () => data().reduce((s, d) => s + d.value, 0).toLocaleString(),
subLabel: 'Total',
},
});- Config:
boolean | string | (() => string) | { text, format, subLabel, color, font, minFontSize, maxFontSize }.centerLabel: truedefaultsformatto the total of visible slices; a string or accessor is shorthand for{ text }.textandsubLabelare static-or-signal -- an accessor makes the label reactive. - Font size is owned by CSS. The overlay is fixed at mount to
font-size: clamp(var(--cl-min), calc(var(--cl-fit) / var(--cl-digits)), var(--cl-max)). On data/resize only (sub-Hz) the chart writes--cl-fit(hole radius),--cl-digits(label length), and the min/max floor and cap. More digits shrink the number; it tracks the donut as it resizes; there is zero per-frame JS and nomeasureText. The overlay box is constrained to the hole's inscribed square so text cannot overflow the ring. - Fail-closed. Throws at construction on a chart with no hole (a pie,
or a resolved
innerRadiusof 0), and ifminFontSize > maxFontSize. - SVG export parity.
chart.exportSVG()emits an equivalent centered<text>from the same fit formula (plus a second<text>for the sub-label).exportPNGdoes not include the overlay -- it is atoDataURLof the canvas only, by design.chart.centerLabelexposes the overlay element (null when not configured). - Requires a mount target with a parent (the overlay is absolutely
positioned against the chart container); a plain donut without
centerLabelstill mounts anywhere.
Spatial-index hit-testing (v1.2.0)
Past roughly a thousand points, the linear-scan hover hit-test on bubble and
scatter charts starts to cost real time per pointer move. The spatialIndex
config option accepts a factory matching the SpatialIndexFactory contract;
lite-charts never imports a spatial library -- you pass the factory, same
dependency direction as legend.virtualize. @zakkster/lite-delaunay 1.1.0+
exports createSpatialIndex, which matches the contract directly -- no
adapter needed:
import { createBubbleChart } from '@zakkster/lite-charts';
import { createSpatialIndex } from '@zakkster/lite-delaunay'; // YOUR import, not ours
createBubbleChart({
data: denseRows, // thousands of points
// One factory per chart; maxPoints >= your largest series point count.
spatialIndex: createSpatialIndex(20_000),
spatialIndexThreshold: 1000, // default; linear scan below it
});The factory is (pxs, pys, n) => index, called lazily at the first hit-test
once a series crosses spatialIndexThreshold points, cached across queries,
and disposed on every data change and on unmount. The index it returns:
| Member | Contract |
|---|---|
| findNearest(qx, qy, k, maxDistSq, outIndices, outDistSq) | Write up to k nearest original indices (by squared pixel distance, filtered to <= maxDistSq) into the caller-owned buffers; return the count written (0..k). Zero allocation per query. |
| dispose() | Free/recycle whatever the build acquired. |
lite-charts queries with k = 8 and post-filters by disc containment plus
smallest-radius tie-break, so overlapping bubbles resolve to the visually
topmost point -- identical semantics to the linear-scan path. NaN points
(log-scale projections, missing data) never come back from a conforming
index, and a chart below the threshold, or with no factory configured, stays
on the byte-identical linear-scan path.
Fat hover + Voronoi cells (v1.14.0)
Two scatter-only features that share one idea: a point's Voronoi cell is the region of the plot closer to it than to any other point.
Fat hover turns that region into the hit target. On a sparse scatter,
pixel-perfect aim inside a small hitTolerance disc is fussy; hitTolerance:
'nearest' instead snaps the hover to the closest point no matter how far the
cursor is:
import { createScatterChart } from '@zakkster/lite-charts';
createScatterChart({
data: sparseRows,
hitTolerance: 'nearest', // snap to the closest point, always
});The query is capped per-hover at the plot diagonal squared -- a finite bound
that is semantically "everywhere inside the plot" yet always terminates the
spatial index's grid walk (an unbounded Infinity is an untested input to a
third-party grid). It works identically on the linear and indexed paths. A
number keeps the classic disc behavior byte-for-byte; any other string throws
at construction.
Cell tessellation draws the cells themselves -- the classic
station-map/coverage view where every point owns a colored polygon. Like
spatialIndex, the polygon geometry comes from an injected factory;
lite-charts imports no tessellation library. @zakkster/lite-delaunay
1.2.0+ exports createCellIndex, which matches the CellIndexFactory
contract directly:
import { createScatterChart } from '@zakkster/lite-charts';
import { createCellIndex } from '@zakkster/lite-delaunay'; // YOUR import, not ours
createScatterChart({
data: stations,
hitTolerance: 'nearest', // fat hover pairs naturally
cells: {
index: createCellIndex(20_000), // maxPoints >= largest series
colorKey: 'zone', // raw per-point fill (no coercion)
fillOpacity: 0.35,
stroke: '#ffffff', strokeWidth: 1, // optional cell boundaries
},
});The cell layer is one scene node drawn UNDER the markers, inside the plot
clip, with a free hover-cell highlight (the crosshair's snapIdx cell is
stroked in its own color). The factory is (pxs, pys, n) => index; the index
it returns:
| Member | Contract |
|---|---|
| cell(i, bx0, by0, bx1, by1, outXY) | Write cell i's polygon, clipped to the axis-aligned bbox, into the caller-owned outXY as interleaved [x0,y0,x1,y1,...]; return the vertex count (0 => absent cell: NaN site, degenerate input, or no bbox intersection). Throws (never truncates) on overflow. Zero allocation per call. |
| dispose() | Free/recycle whatever the build acquired. |
Cells are pixel-space -- Voronoi is not affine-invariant under anisotropic
x/y scaling, so data-space cells would be wrong hover regions. lite-charts
hands the factory the same projected pxs/pys it hands spatialIndex and
rebuilds the tessellation (cold) on every data or scale change, so pan and
zoom can never serve a stale or distorted cell. The per-frame draw walks
prebuilt packed arrays at 0 B/frame. A degenerate cloud (all-collinear or
coincident points) draws markers with n
