api reference
Charts API Reference
createChart, the chart types and their options, the axes, annotations and tooltips, and the geographic packs.
API reference › The charts module
All 24 pages Everything on one page → Developer guide →
The charts module
modules/charts draws forty-two chart types from the grid's own data. It is optional and imports nothing from the grid (the grid is handed in) so the bundle carries the drawing and none of the grid, and a page that never charts never loads it.
import { createChart } from '@toclocoinc/lattice-grid/modules/charts';
const chart = createChart({
grid, // the grid to read
container: '#revenue', // an element or a selector
type: 'bar',
x: 'region', // the category column
y: 'revenue', // the measure column
});
A chart reads the grid's filtered rows. Filter, sort or edit the grid and every chart bound to it redraws on the next frame: there is nothing to subscribe to and nothing to keep in step. A chart of rows the user cannot see would be describing a different data set.
Over a paged pushdown source, a chart aggregates through the engine, not the loaded window (GEO-5). The grouped types - line, step, area, bar, horizontalBar, waterfall, combo, pareto, heatmap, radar, funnel, stream, marimekko - ask source.aggregate() whenever the grid's source declares the pushdown group capability and the grid holds fewer rows than match: x and series become the GROUP BY, the measures its aggregates, and the grid's filter and quick search apply, so a bar totals the whole hundred-thousand-row relation rather than the hundred rows fetched so far. spec.aggregate ('auto' by default) forces 'engine' or 'client'; a forced 'engine' the source cannot serve is refused by name, never silently swapped. While a request is in flight the chart keeps its last drawing rather than going blank, and a burst of filter changes aborts the superseded request and coalesces to the one query the grid ends up on. A chart is never a mixture of the two - if any one measure would be computed client-side, every measure is - and chart.provenance() reports which happened, per measure, in the same engine/client shape source.aggregate() itself reports; the accessible summary says so too.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid } = await import('../packages/dom/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { document, root } = createTestDom({ width: 400, height: 300 });
const grid = createGrid(root, {
rowKey: 'id',
columns: [{ field: 'region' }, { field: 'revenue', type: 'number' }],
rows: [{ id: 1, region: 'North', revenue: 10 }, { id: 2, region: 'South', revenue: 20 }],
});
const container = document.createElement('div');
container.rect = { width: 400, height: 300, top: 0, left: 0 };
root.appendChild(container);
// A plain in-memory grid has no source.aggregate() to ask, so the chart reads
// the loaded rows exactly as it always has - provenance says client, and why.
const chart = createChart({ grid, container, type: 'bar', x: 'region', y: 'revenue' });
const p = chart.provenance();
return [p.source, p.pending, p.measures[0].computed, p.reason].join(' | ');
A measure can accumulate along the x order (cumulative: true,. y: { col, cumulative: true } (or, on a combo, a measures entry with the same flag) turns each category's own reduction into the sum of itself and every category before it - monthly amounts of 10, 20 and 30 draw 10, 30 and 60 - and restarts at the first category of every series a chart draws, so a series split or several measures never leak their totals into one another. Offered on bar, line, area and a combo's measures; not on waterfall, which already draws its own running total from the plain measure. The tooltip, the data labels and the axis all read the running value, because it is the measure's own bound y rather than a drawing-time overlay; over a pushdown source it runs on the engine's own grouped answer, never on a page of the source's raw rows.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid } = await import('../packages/dom/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { document, root } = createTestDom({ width: 400, height: 300 });
const grid = createGrid(root, {
rowKey: 'id',
columns: [{ field: 'month' }, { field: 'amount', type: 'number' }],
rows: [
{ id: 1, month: 'Jan', amount: 10 },
{ id: 2, month: 'Feb', amount: 20 },
{ id: 3, month: 'Mar', amount: 30 },
],
});
const container = document.createElement('div');
container.rect = { width: 400, height: 300, top: 0, left: 0 };
root.appendChild(container);
const chart = createChart({
grid, container, type: 'bar', x: 'month', y: { col: 'amount', cumulative: true },
});
return chart.data().series[0].points.map((p) => p.y).join(',');
The types
| Family | Types | Takes |
|---|---|---|
| Cartesian | line, step, area, bar, horizontalBar, waterfall, floatingBar, horizontalFloatingBar, scatter, bubble | x, y, optional series; a floatingBar takes y as a [lowCol, highCol] pair |
| Band | rangeArea | x and measures: [low, high]: the first measure is the lower edge and the second the upper edge, and the band is filled between them at each x. An optional third measure typed line ({ col: 'mean', type: 'line' }) is drawn over the band as the centre line. The y axis spans every measure, so it reaches the high edge. A rangeArea given y and y2, or fewer than two measures, says so by name and draws a plain area. |
| Comparison | populationPyramid, bullet | a populationPyramid takes x and y split by a series of two values, or x and two measures (left, right); a bullet takes x, y, optional target and ranges |
| Two axes | combo, pareto | x, measures |
| Distribution | histogram, boxplot | y alone |
| Matrix | heatmap | x, y, series |
| Part to whole | pie, donut, sunburst, treemap | x, y; or the grid's grouping, see below |
| Specialist | radar, gauge, funnel, candlestick, ohlc | varies; candlestick and ohlc take four measures in open, high, low, close order |
| Geographic | geomap | x as an ISO code, y as the value |
| Flow | sankey, chord, network | source, target, y; a network also takes nodes - see Network diagrams |
| Over time | stream, marimekko, violin, gantt | varies; gantt takes label, start, end |
A chart given data it cannot draw (a candlestick with three measures rather than four) says so on the chart rather than drawing nothing, because a chart that silently draws nothing is indistinguishable from one that is broken.
Hierarchical data. The part-to-whole types read nested input from the grid's own grouping, not from the spec: with grid.columns.group(['region', 'product']) in place, the tree is that grouping, one level per grouped column in that order, and x is not consulted; depth caps how many levels are read. On a flat grid, x is the single level. What each type draws of that tree: a pie or donut draws the top level; a sunburst draws every level as a ring, each segment its share of the segment inside it, and names a segment on any ring where the name fits, leaving it unnamed where it does not; a treemap nests: children inside their parent's tile, each branch with a header band naming it and padding round its children, to the depth the tree has. A child whose tile would be smaller than a line of text is not drawn and its parent's tile stands for it, so the levels drawn are the levels that can be read; a small group at the top level is always drawn, as a labelled tile with no children inside it. drill descends the tree on click, on either: clicking any tile or arc, at any depth, makes that node the root - a tile nested two levels down, or a segment on the outer ring, not only the top level - and the drill event carries the full path of labels from the root to it. ascend() comes back out a level at a time along the same path.
The spec
| Key | Type | Description |
|---|---|---|
| grid | Grid | Required. The grid to read. |
| container | Element | string | Required. Where to draw. |
| type | string | One of the thirty above. |
| x / y | string | Category and measure columns. x is one column id: an array there is not a nesting instruction and warns once, naming the option and what it accepts; nest by grouping the grid instead (see Hierarchical data above). |
| series | string | Splits the measure into one series per distinct value. |
| measures | object[] | {col, fn, type, axis}: several measures at once, each reduced by an aggregation. fn is one of sum, avg (alias mean), min, max, count, countValues, first, last; it defaults to sum. An fn that is none of these is a mistake, not a silent sum: it warns once, naming the value and the supported set, and falls back to sum so the chart still draws. |
| title | string | Drawn above the plot. |
| scheme | string | string[] | object | A named scheme, your own ordered colours, or a { name: colour } map. schemeNames() lists the built-in ones, including a colour-blind-safe palette. A bare array is { series: [...] } and colours series in measure order on every type. The map is what survives a filter: a pie or donut keys it on the category, a chart with series keys it on the series value, a single-series bar or column keys it on the x category, and a measure-based type (a bar or combo given several measures, a radar, a candlestick) keys it on the measure's title, falling back to its column. A name the map does not mention falls back to the ordered scheme by its position among the other unmapped names, so { Operational: 'green', Offline: 'red' } keeps "Operational" exactly as green whichever categories a filter leaves. A map and a palette array do not combine - the map wins, and the array is ignored once, with a warning. |
| ramp | string[] | ChartRampOptions | The colour ramp every ramp-coloured type fills by (heatmap, geomap, density cells, calendar, hexbin, hexmap, choropleth). [low, high] sets the two colours; { colours, scale } also sets the scale a value is placed by: 'linear', equal steps of value; 'log', equal ratios, so a city's 40,000-place centre and its 40-place suburbs both get a shade of their own, with zero and below at the bottom of the ramp and the legend labelled at powers of ten (1-2-5 inside two decades); 'quantile', five equal-count classes, the legend labelled at the four cut points. Unset, the chart uses 'log' when the positive values span more than 100:1 and says so once (chart.provenance().ramp reports it every draw), and 'linear' otherwise. A diverging ramp is always linear. |
| legend | boolean | object | position, isolate so a click shows only that series (what plain toggling makes a reader with eight series do in seven clicks), and values: true, which appends each entry's own total after its name and a thin space - a category's for pie and donut, a series' for a series legend - formatted by the measure column. |
| emphasis / seriesStyle | string[] / object | Weight, not just hue, for one series among many on a line, area or the line half of a combo/pareto - an equal-weighted index drawn thicker than the 24 tickers it averages, say. emphasis: ['Index'] names series by the same value scheme's map keys on: those draw with a heavier stroke, full opacity and on top; every other series draws thinner and muted. seriesStyle: { Index: { width: 4, dash: [6, 3] } } sets a series' own stroke width and/or dash pattern directly and wins over emphasis for the same name. The fixed emphasis/mute pair is themeable through --lattice-chart-emphasis-width and --lattice-chart-muted-opacity. |
| centre | object | A donut's own figure, drawn in its hole; a gauge reads label as its caption instead. { value?: 'total' | number | string, label? } - 'total' sums the slices actually drawn, so hiding a category from the legend moves the figure with them; a number is formatted the same way; a string is written through unchanged. label is a second line beneath it. Both are sized to fit the hole and never drawn past its edge. |
| bands / needle / ticks | object[] / boolean | object / number | array | A gauge's dial. bands shades the range behind the value, each { from, to, colour?, label? } clipped to min/max; needle draws a pointer from the centre instead of the filled value arc (true, or { width, pin }); ticks places tick values round the arc. |
| labels | boolean | object | Values beside each mark. position, format, minGap. A label that would overlap one already placed is dropped rather than drawn over it. horizontalBar draws them exactly as bar does: the figure sits past the end of the bar and, where that would spill past the plot's right edge, inside the bar instead. |
| axis | object | Titles, tick density and formatting per axis. |
| reference | object[] | Horizontal lines: {value, label}. |
| multiples | string | One chart per distinct value of this column, on a shared scale. |
| buckets | number | Histogram bins. Twelve by default. |
| canvas | boolean | number | Draw to canvas past this many points, for a dense scatter. |
| subtitle | string | A second line under the title. |
| footnote | string | A note under the plot, a source, a caveat, a unit. |
| emptyText | string | What to show when the binding produces nothing. Said rather than left blank, because an empty plot and a broken one look identical. |
| fit | boolean | 'line' | A least-squares line through a scatter or bubble chart, one per series. true draws it with its R²; 'line' draws the line alone. Only where the x axis is numeric: on a band scale a slope would be a slope through the order the categories happened to be listed in. |
| perRow | boolean | A scatter or bubble binds one mark per row by default only where every row's x is distinct; rows that tie on x share one point, reduced by the measure's aggregation. perRow: true turns that reduction off: every row keeps its own mark, however many share an x - the plot of a predictor with heavy ties (a measurement rounded to a few decimals over thousands of rows) draws the rows themselves rather than their sum at each tied x. A plain scatter with no flag keeps today's behaviour. |
| trend | boolean | string | ChartTrend | array | Trend and forecast overlays: a linear least-squares line, a movingAverage, or exponential smoothing, one per series. true draws a single linear trend; a method name or a { method, forecast, window, kind, alpha, beta } object configures one; an array draws several. The maths matches the core stats engine to the last digit (a parity test asserts it) but is computed locally to keep the charts bundle lean. For the linear method, forecast: n projects the line n steps past the data as a dashed forecast; a moving average and a smoothed level have no slope to project, so forecast is ignored for them and the fact is said in the accessible description. The trend layer (the fitted line, the forecast line, its band and the R² label) may extend past the plot into the chart's margin, but it is bounded by the chart box, the chart's own <svg>: a forecast that reaches past the chart's edge is cut off there rather than painted over the content beside it. |
| error | boolean | object | Whiskers showing the uncertainty in each mark, computed from the readings the chart can see behind it. { of } takes a symmetric margin from a column instead; { confidence } sets the level. A mark the chart sees only one value for gets none, and the chart says so. |
| stack | boolean | 'percent' | Stack the series rather than drawing them side by side. On a combo it stacks the bar measures; line and area measures stay outside the stack and are drawn after the columns. 'percent' stacks to 100%: a bar, horizontalBar, area or combo with two or more series draws each column as the whole plot height split into each series' share, the measure axis reads 0% to 100%, and the tooltip shows each series' share beside its real value. |
| curve | boolean | string | object | On a histogram, true overlays a kernel density curve, which shows which features are in the data and which are in the binning. On a line, an area or the line half of a combo/pareto, it names how the series connects its readings: 'linear' (default), 'monotone', 'catmullRom' or 'step'; { type, tension?, mode? } adds the Catmull-Rom tension (0..1) or the step's mode ('after', 'before', 'middle'); a map keyed by series label (or measure title on a combo) gives each its own. |
| startAngle / endAngle | number | A pie or donut's sweep, in degrees clockwise from twelve o'clock (0 is the top;. Defaults 0 and 360. A partial arc is sized to use the container's height, so -90 to 90 is a semi-circle that fills the box rather than half of it; labels and the legend follow the arc. |
| radius | object | A pie or donut's slice radii from a second measure: { col, fn?, min? }. The angle still comes from y; each slice's outer radius is scaled from col, area-true (fn: 'sqrt', the default) or 'linear', never below min (0..1) of the full radius. A donut keeps its hole; the tooltip shows both measures. |
| mark | 'lollipop' | Draw a bar or horizontalBar as lollipops - a stem from the baseline to the value with a dot at the value, instead of a solid column. Everything else the bar family does is unchanged: several measures sit side by side, sorting and the axis block apply, scheme forms colour the dots and stems, and the tooltip and grid binding read the same points. |
| ranges | (number | string)[] | A bullet's qualitative bands: the bands' upper edges, ascending, each a number (the same for every row) or a column name (per row). [60, 85, 100] shades the scale from its minimum to 60, 60 to 85 and 85 to 100 - the same reading the bullet cell renderer gives its bands. The actual is a bar from the scale's minimum to the value, the target (the target column) a marker across the band, and everything is clamped to the scale, exactly as in the cell. The value and the target read through their own columns' formats. |
| bullet | { orientation?, scale? } | A bullet's layout: orientation 'horizontal' (default, one row per KPI) or 'vertical'; scale 'shared' (default: every row against one scale and axis, so bars compare across KPIs) or 'row' (each KPI on its own scale, as a cell has, with no shared axis). min and max pin the scale's ends. |
| pyramid | { scale?, percent? } | A populationPyramid's options: scale 'shared' (default: one symmetric scale serves both halves, so equal bars are equal lengths) or 'independent' (each half scaled to its own largest bar); percent: true draws each bar as its share of the whole population and labels the axis in percent. The first half (the first series value, or the first measure) is drawn on the left. The axis labels are always absolute, never negative, and the tooltip reads the real value. |
| diverging | boolean | Colour a heatmap outward from zero rather than along a single ramp. |
| downsample | number | Reduce to at most this many points per series before drawing, keeping the extremes so a spike is not lost. |
| tooltip | boolean | false turns the hover tooltip off. |
| selection | boolean | Draw the grid's selected rows emphasised, and follow the selection as it changes. |
| drill | boolean | Clicking a group drills into it. |
| filterOnClick | boolean | Clicking a mark filters the grid to it. |
| size / maxRadius | string / number | Bubble charts: the column driving the radius, and the largest it may be drawn. The largest is also capped by the plot's own size, so a bubble can never outgrow the panel drawing it or draw past the plot's edge at the domain's own end. |
| min / max | number | Fix the measure axis rather than taking it from the data. |
| code / codeProperty | string | A geomap's ISO code column, and the property carrying the code in your shapes. |
| columns / method / values | string[] / string / boolean | Correlogram: which columns to correlate, by pearson, spearman or kendall, and whether to print the coefficients in the cells. |
| iterations | number | Network layouts: how many relaxation passes to run. |
| nodes | ChartNode[] | A network's nodes, named by you rather than inferred from the rows: { id, label, icon, x, y }. id matches a value in the source or target column; icon is any name in the grid's icon registry; x/y are fractions of the plot (0 to 1) and pin the node there, out of the force simulation. A node listed here that appears in no row is still drawn. See Network diagrams. |
| icon | string | The default glyph for a network node that names none of its own. Unset, an undeclared node is a plain disc. |
| linkWidth | number | Fix a network link's stroke width in pixels. Unset, width follows the link's value as a share of the heaviest link. |
| spec / baseline / rules / confidence | object / number / string / number | Control and capability charts: a tolerance overriding the column's own spec, how many leading readings fix the control limits, which rule set judges the violations (westernElectric or nelson), and the level for the capability interval. |
The ramp's scale. Every ramp-coloured type places a value along its ramp by ramp.scale: a map whose densest cell holds forty thousand places and whose quietest holds forty is coloured on a log scale unless you ask otherwise, so the forty-place cells are not all the palest shade. The legend labels the scale it drew.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { document, root } = createTestDom({ width: 600, height: 300 });
const container = document.createElement('div');
container.rect = { width: 600, height: 300, top: 0, left: 0 };
root.appendChild(container);
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'cell' }, { field: 'city' }, { field: 'places', type: 'number' }],
rows: [
{ id: 'a', cell: 'Centre', city: 'London', places: 40000 },
{ id: 'b', cell: 'Inner', city: 'London', places: 1000 },
{ id: 'c', cell: 'Outer', city: 'London', places: 40 },
],
});
const chart = createChart({
grid, container, type: 'heatmap', x: 'cell', y: 'places', series: 'city', legend: true,
ramp: { colours: ['#f7fbff', '#08306b'], scale: 'log' },
});
const ticks = [...container.querySelectorAll('.lat-chartview__legend-ramp-tick')].map((n) => n.textContent);
return [chart.provenance().ramp.scale, ticks.join(' ')].join(' | ');
Bars of every kind - 100% stacks, lollipops, floating bars, pyramids and bullets
stack: 'percent' stacks a bar, horizontalBar, area or combo to 100%: every column is the full height and the axis reads 0% to 100%. mark: 'lollipop' is a mark on bar and horizontalBar rather than a type of its own, because it changes only how a value is drawn - the slot, the baseline, the sort and the axis are the bar's. A floatingBar (and horizontalFloatingBar) is a type, because it changes what is drawn: its measure is a y: [lowCol, highCol] pair and the axis must cover every span, on a number or a date axis. A populationPyramid mirrors two halves (y split by a series of two values, or two measures) about a central axis, with absolute axis labels. A bullet is the bullet cell renderer's picture across rows: ranges are the bands' upper edges, the actual a bar from the scale's minimum, the target column a perpendicular marker, so a KPI reads the same in a cell and in a chart. Each follows the grid's filter and selection, takes every scheme form, has the accessible table and keyboard stops, and follows the light and dark themes.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { document, root } = createTestDom({ width: 600, height: 320 });
const container = document.createElement('div');
container.rect = { width: 600, height: 320, top: 0, left: 0 };
root.appendChild(container);
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [
{ field: 'month', type: 'text' },
{ field: 'committed', type: 'number', title: 'Committed revenue' },
{ field: 'assumed', type: 'number', title: 'Assumed extensions' },
],
rows: [
{ id: 'a', month: 'Oct', committed: 120000, assumed: 0 },
{ id: 'b', month: 'Nov', committed: 111000, assumed: 7000 },
],
});
// stack: 'percent' makes every column the same (100%) height, split by share.
const chart = createChart({
grid, container, type: 'bar', x: 'month', stack: 'percent',
measures: [
{ col: 'committed', fn: 'sum', title: 'Committed revenue' },
{ col: 'assumed', fn: 'sum', title: 'Assumed extensions' },
],
});
const bars = [...container.querySelectorAll('.lat-chartview__bar')];
const committed = bars.filter((b) => b.getAttribute('data-series') === '0');
const assumed = bars.filter((b) => b.getAttribute('data-series') === '1');
// November's column splits 111,000:7,000 into a 94.1%:5.9% share.
const novTotal = Number(committed[1].getAttribute('height')) + Number(assumed[1].getAttribute('height'));
const share = Math.round((Number(committed[1].getAttribute('height')) / novTotal) * 1000) / 10;
// October (assumed = 0) fills the same full height: both columns are 100%.
const same = Math.abs(Number(committed[0].getAttribute('height')) - novTotal) < 2;
grid.destroy();
return `${same} | ${share}%`;
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { document, root } = createTestDom({ width: 600, height: 300 });
const container = document.createElement('div');
container.rect = { width: 600, height: 300, top: 0, left: 0 };
root.appendChild(container);
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [
{ field: 'region', type: 'text' },
{ field: 'actual', type: 'number', title: 'Actual' },
],
rows: [
{ id: 'a', region: 'North', actual: 40 },
{ id: 'b', region: 'South', actual: 70 },
],
});
// mark: 'lollipop' swaps the bar family's solid column for a stem and a dot.
const chart = createChart({
grid, container, type: 'bar', x: 'region', y: 'actual', mark: 'lollipop',
});
const stems = container.querySelectorAll('.lat-chartview__lollipop-stem').length;
const dots = container.querySelectorAll('.lat-chartview__lollipop-dot').length;
const bars = container.querySelectorAll('.lat-chartview__bar').length;
// The higher value's dot sits higher up the plot (a smaller y).
const ys = [...container.querySelectorAll('.lat-chartview__lollipop-dot')].map((n) => Number(n.getAttribute('cy')));
grid.destroy();
return `${stems} stems, ${dots} dots, ${bars} bars | ${ys[1] < ys[0]}`;
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { document, root } = createTestDom({ width: 600, height: 300 });
const container = document.createElement('div');
container.rect = { width: 600, height: 300, top: 0, left: 0 };
root.appendChild(container);
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [
{ field: 'band', type: 'text' },
{ field: 'low', type: 'number' },
{ field: 'high', type: 'number' },
],
rows: [
{ id: 'a', band: 'A', low: 10, high: 30 },
{ id: 'b', band: 'B', low: 20, high: 50 },
],
});
// A floatingBar spans between a low and a high column: y is the [low, high] pair.
const chart = createChart({
grid, container, type: 'floatingBar', x: 'band', y: ['low', 'high'],
});
const bars = [...container.querySelectorAll('.lat-chartview__float')];
const heights = bars.map((b) => Number(b.getAttribute('height')));
grid.destroy();
return (heights[1] / heights[0]).toFixed(1);
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { document, root } = createTestDom({ width: 600, height: 300 });
const container = document.createElement('div');
container.rect = { width: 600, height: 300, top: 0, left: 0 };
root.appendChild(container);
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [
{ field: 'age', type: 'text' },
{ field: 'male', type: 'number', title: 'Male' },
{ field: 'female', type: 'number', title: 'Female' },
],
rows: [
{ id: 'a', age: '0-19', male: 40, female: 30 },
{ id: 'b', age: '20-39', male: 20, female: 25 },
],
});
// A populationPyramid mirrors two measures about a central axis on one
// symmetric scale (pyramid: { scale: 'independent' } gives each half its own).
const chart = createChart({
grid, container, type: 'populationPyramid', x: 'age',
measures: [{ col: 'male', fn: 'sum' }, { col: 'female', fn: 'sum' }],
});
const bars = [...container.querySelectorAll('.lat-chartview__pyramid-bar')];
const widths = bars.map((b) => Number(b.getAttribute('width')));
grid.destroy();
// Bars draw male first, then female: widths[0] is male 0-19, widths[2] female 0-19.
return `${bars.length} bars; male 0-19 is ${(widths[0] / widths[2]).toFixed(2)}x female`;
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { document, root } = createTestDom({ width: 600, height: 300 });
const container = document.createElement('div');
container.rect = { width: 600, height: 300, top: 0, left: 0 };
root.appendChild(container);
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [
{ field: 'kpi', type: 'text' },
{ field: 'actual', type: 'number', title: 'Actual' },
{ field: 'target', type: 'number', title: 'Target' },
],
rows: [
{ id: 'a', kpi: 'Revenue', actual: 70, target: 80 },
{ id: 'b', kpi: 'Margin', actual: 20, target: 25 },
],
});
// A bullet: ranges are the bands' upper edges (as the bullet cell's bands are),
// the actual a bar from the scale minimum and the target a perpendicular marker.
const chart = createChart({
grid, container, type: 'bullet', x: 'kpi', y: 'actual', target: 'target',
ranges: [50, 80, 100], min: 0, max: 100,
});
const bars = [...container.querySelectorAll('.lat-chartview__bullet-bar')];
const bands = container.querySelectorAll('.lat-chartview__bullet-band').length;
const targets = container.querySelectorAll('.lat-chartview__bullet-target').length;
const w = bars.map((b) => Number(b.getAttribute('width')));
grid.destroy();
return `${bands} bands, ${bars.length} bars, ${targets} targets; 70 vs 20 = ${(w[0] / w[1]).toFixed(1)}x`;
A reduction over no readings is a gap, not a zero.
sum, avg/mean, min, max,
first and last all answer null for a category whose
rows carry no value to reduce, so the line breaks and the bar is absent rather than dropping
to zero - a zero is a real reading, and drawing one where the data reported nothing
would show a plunge that never happened. count and countValues are
the deliberate exception: count tallies rows and countValues tallies
the values actually present, so both are honestly zero when that is the true answer.
countValues is the one to ask for when “none arrived” is the reading
you want drawn as zero rather than as a break in the line.
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { bindSeries } = await import('../packages/modules/charts/bind.js');
// 'a' has a reading; 'b' has a row, but the reading itself is absent.
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'day', type: 'text' }, { field: 'sales', type: 'number' }],
rows: [
{ id: 1, day: 'a', sales: 10 },
{ id: 2, day: 'b', sales: null },
],
});
// avg over no readings is null: 'b' is a gap in the line, not a zero.
const gap = bindSeries(grid, { x: 'day', y: { col: 'sales', fn: 'avg' } });
// countValues asks "how many arrived": honestly 0 for 'b', not null.
const none = bindSeries(grid, { x: 'day', y: { col: 'sales', fn: 'countValues' } });
grid.destroy();
return [gap, none].map((bound) => bound.series[0].points.map((p) => String(p.y)).join(',')).join(' | ');
A rolling axis.x.window that has aged past its data shows the empty
state, not a picture drawn off-plot. The window's domain ends at the wall clock
(see window under the axis options below), so a
feed that has gone quiet for longer than the window's span would otherwise have every mark
fall outside the plot, with the axes and legend still drawn as if the chart were healthy.
The chart shows its empty state instead and warns once per chart instance,
naming the span and how old the newest reading is, so a dead feed reads as no data rather
than as a chart that quietly stopped moving.
The x scale comes from the column's type, and nothing else. A temporal type - date, datetime,
timestamp or dateString - draws a time axis; a numeric type
draws a linear axis whatever its distinct count; every other type draws bands, one
per distinct value. A declared numeric or temporal column is therefore never demoted to a
band, which it used to be below thirteen distinct values - silently turning off
fit, band and everything else that needs a continuous x. Where the
column's type is not what you want, pin the scale with
axis: { x: { scale: 'band' | 'linear' | 'time' } }: 'band' is how a
numeric code column (a quarter, a rating, a star count) asks for its bands back, and
'time' or 'linear' lifts a column the grid types as
text onto a continuous axis. That last case is worth knowing about: a grid built
with no rows infers text and does not revisit it when rows
arrive, so a streamed date column binds bands where the same column beside a populated grid
binds time. The chart warns once when it bands a column whose values all read as dates or as
numbers, naming the column, its type and the option that overrules it. A band scale always
draws: a line, area or step on one is drawn through the band centres, and its labels are
thinned to the pitch the font can be read at rather than one per row
(axis.x.every overrides the count).
A date or time x runs oldest to newest, whatever the grid is sorted by. A chart whose x is a date, datetime, timestamp or
dateString column (or Date values, or axis: { x: { scale: 'time' } })
orders its points by x ascending, so a grid sorted newest-first no longer draws its axis
backwards. This covers line, area, step, bar, candlestick, the multi-measure types, and the
fan and decomposition chart modules. A category x keeps following
the grid's row order, which is the order you chose.
A candlestick or a bar bands its x by default, and sits on
the values themselves when you ask it to. These two are the marks
with a width, and a width has to come from somewhere: a band scale supplies one, and
a continuous axis has nothing to offer but the interval between the readings, which moves as
the data moves. So both stay banded whatever their column's type until
axis: { x: { scale: 'time' } } (or 'linear') says otherwise. On a
continuous axis each candle or bar is drawn centred on its own x value, one
sampling interval wide - the median gap between consecutive x values, so an
overnight or weekend hole does not widen every mark, less the type's usual padding.
axis.x.barWidth pins the width instead, in the axis's own units (milliseconds on
a time axis) rather than in pixels, because a pixel width would cover a different span of
time every time the domain moved.
Why it matters for a live pane. On a band scale the readings are spaced
evenly whatever their values, so a rolling axis.x.window can only move the
picture a whole slot at a time, when a period closes - which is what a trading terminal
shows as a pane that repaints in place and never scrolls. On a continuous axis the domain
ends at the wall clock, so between two candles every mark slides left by the same fraction of
a slot and the pane pans smoothly. Two more things follow from placing a mark at its value: a
candlestick price pane and a bar volume pane given the same
x, scale and window line up column for column, because
both put the same instant at the same pixel; and an interval with no reading leaves a
gap rather than closing up, which is the honest picture of a halt, a holiday
or a dropped message. The marks at each end of the domain are held inside the plot, so the
oldest and newest are drawn as part-marks rather than painted over the axis gutter.
axis: { y: { position: 'right' } } puts the measure axis on the right
instead - the layout an AG-style price pane uses. It moves
the axis line, its ticks and labels, and the plot's gutter with them, on any non-horizontal
cartesian type and on candlestick (a horizontal bar's y is its
category axis and has no measure side to move). On a dual-axis chart, setting it on
y swaps y2 to the opposite edge, since the two can never share one
side.
A continuous x axis takes the domain you give it.
axis.x.min and axis.x.max fix the visible domain exactly, on a time
or linear x as they always have on y and y2, for every cartesian
type - line, area, step, scatter,
combo, and bar or candlestick once
axis.x.scale has put them on one. On a time axis both are epoch
milliseconds. Readings outside the pinned domain are clipped by the plot,
not dropped from the data, so the tooltip, the hit test and the accessible table
still describe every row the chart was given; a pair that does not increase is refused with
one warning rather than drawing the axis backwards. This is the same domain the reader's own
brush: 'zoom' writes and the same one a zoom narrows inside, so a
pinned domain is the zoom's maximum extent rather than a rival to it.
A replay pane brings its own clock. A rolling
axis: { x: { window: { kind: 'time', span } } } ends at
Date.now(), which is right for a live feed and wrong for a recording: every
reading in a session captured yesterday is older than the window, so the chart correctly
draws nothing and says so. Name the pane's clock with
window: { kind: 'time', span, now: () => clockMs } - the same
now the Window class takes - and the window rolls against it
instead, including the “the newest value is N s old” check. It is read on every
draw, so a replay that advances its clock and calls chart.update({ axis }) per
tick scrolls exactly as a live feed does. A now that does not return epoch
milliseconds falls back to the wall clock and warns once.
The margin a chart leaves for its labels is measured from the labels. Where a chart names its rows down the left - a
correlogram's columns, a horizontalBar's categories, a
gantt's tasks, a forest's coefficients, a heatmap's
rows - the gutter is the widest name it will draw, capped at two fifths of the
chart. Past that cap a name is ellipsised and keeps the whole of itself as
aria-label, so a screen reader announces the real name and the glyphs still stop
inside the chart; no label is ever cut mid-glyph, and the gutter is re-measured whenever the
chart is resized. Widening the chart therefore gives the names more room, which is the thing
a fixed margin could not do. Where the left-hand gutter holds a measure axis the
room is estimated from five digits instead, because the numbers on it are not known until
after the plot has been laid out. margin: { left } is added to whatever the
labels need rather than competing with it.
A rule on the column colours the bar. A single-series
bar or horizontalBar - no series key - takes each mark's fill from the y column's own conditional-formatting rules,
the same styleFor a cell and a network link already
answer to, and the legend names the rules that fired instead of the one, unnamed series.
Without a rule on the column, or with series set, the bars keep the scheme
exactly as before - a rule and a series both mean “colour by something”,
and only one is read at a time.
A scheme can colour by name instead of by position. scheme: { Operational: 'green', Degraded: 'amber', Offline: 'red' }
keys on the category for a pie, donut or sunburst; on
the series value for any cartesian type with a series column; on the measure's
title (falling back to its column) for the measure-based types - a bar or
combo given several measures, a radar or a candlestick - so { 'Committed revenue': '#1f4d5c' } colours that measure on a combo
exactly as it does on a bar; and on the x category for a
single-series bar or horizontalBar - the same "no
series" case the rule colour above reads, and a rule still outranks the map where
both apply. A bare array - scheme: ['#1f4d5c', '#b4701a'] - is the shorthand for { series: [...] } and colours series in measure order
on every type, exactly as explicit. The point of the map is what an ordered
array cannot do: a category a filter removes never shifts the colour of the ones that stay,
because each name's colour comes from the map rather than from where it happens to sort. A name
the map does not mention falls back to the ordered scheme by its position among the other
unmapped names only. A map and an array palette are two ways of choosing the
same thing and do not combine - given both, the map wins and the array is ignored, once,
with a warning (chart:scheme:array-and-map).
legend: { values: true } writes the number beside the name. Each entry gets its own total appended after a thin space - the
category's for a pie or donut, the series' for a series legend - formatted by the measure column exactly as a cell in that column would be, so a
legend reading "Operational 42" and a filtered grid never disagree about what 42 means.
A donut can carry a figure in its own hole:
centre: { value: 'total', label: 'sites' }. 'total' sums the slices
actually drawn, so hiding a category from the legend moves the figure with the slices rather
than leaving it stale; a number is a caller's own figure, formatted the same way; a string is
written through unchanged, for a centre naming something a sum cannot. label is a
second line beneath the value. Both are sized to the hole a donut cuts - never a pie's,
which has none - and are shrunk together, proportionally, and truncated rather than
drawn past its edge.
A gauge can be a needle dial over threshold bands.
bands shades the range behind the value, clipped to min/max
and drawn in order; needle draws a pointer from the centre instead of the filled
value arc; ticks places values round the arc; and centre: { label }
names the reading’s caption. The figure itself is the measure, formatted by its own
column - a suffix % column reads 68%, not 68.
A value outside min/max is clamped on the arc while the tooltip keeps
the real number. With none of the new keys the arc dial is unchanged.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { document, root } = createTestDom({ width: 420, height: 420 });
const container = document.createElement('div');
container.rect = { width: 420, height: 420, top: 0, left: 0 };
root.appendChild(container);
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'name' }, { field: 'spi', type: 'number', title: 'SPI' }],
rows: [{ id: 'a', name: 'SPI', spi: 1.07 }],
});
const chart = createChart({
grid, container, type: 'gauge', x: 'name', y: 'spi', min: 0, max: 2,
bands: [
{ from: 0, to: 0.9, colour: '#e53935', label: 'Behind' },
{ from: 0.9, to: 1, colour: '#ffb300', label: 'At risk' },
{ from: 1, to: 2, colour: '#43a047', label: 'On track' },
],
needle: true, ticks: [0, 0.5, 1, 1.5, 2], centre: { label: 'SPI' },
});
const bands = container.querySelectorAll('.lat-chartview__gauge-band').length;
const needle = container.querySelector('.lat-chartview__gauge-needle').getAttribute('stroke');
const reading = container.querySelector('.lat-chartview__gauge-reading').textContent;
return [bands, needle, reading].join(' | ');
A gauge reads its measure’s own reduction.
y: { col, fn } reduces every row the gauge is bound to by fn - sum, avg (or mean), min, max,
median, count, countValues, first,
last, the same names every other chart accepts - so fn: 'avg'
reads the mean of the rows, not their total; with no fn the column’s own
default applies, as on every chart. The accessible summary names the reduction and the
figure: “Gauge chart of Load, Average: 30”. On the needle form the
reading and its caption sit in the dial’s opening rather than on the pin, so no needle
position, from min to max, is drawn across them.
A waterfall draws one step per category, and a closing total only when asked. Every category is a step from the running total, the last included.
waterfall: { total: true } adds a closing-total bar as an extra category after
the last one, standing on the baseline at the running total and labelled
“Total” (the catalogue’s chart.waterfallTotal); a string - waterfall: { total: 'Closing balance' } - labels it with that string.
The total is in the axis, the tooltip and the accessible table like any category. A waterfall
with a total bands its x axis.
A line or area can be smoothed or stepped.
curve: 'monotone' draws a cubic that never rises above or falls below the
readings it joins; 'catmullRom' is a spline whose tension you set;
'step' holds a value until the next reading (mode chooses where it
changes). Markers, the tooltip and hit-testing stay on the real readings, an area fills
under the curve, and a stacked area stays nested. Give curve a map keyed by
series label to curve one series and leave another straight. With no curve the
output is unchanged.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { document, root } = createTestDom({ width: 420, height: 420 });
const container = document.createElement('div');
container.rect = { width: 420, height: 420, top: 0, left: 0 };
root.appendChild(container);
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'day' }, { field: 'sales', type: 'number' }],
rows: [
{ id: 'a', day: 'Mon', sales: 3 }, { id: 'b', day: 'Tue', sales: 9 },
{ id: 'c', day: 'Wed', sales: 4 }, { id: 'd', day: 'Thu', sales: 8 },
],
});
const chart = createChart({
grid, container, type: 'line', x: 'day', y: 'sales', curve: 'monotone',
});
const d = container.querySelector('.lat-chartview__line').getAttribute('d');
return [d.includes('C'), d.startsWith('M')].join(' | ');
A pie or donut can be a partial arc.
startAngle and endAngle are degrees clockwise from twelve o’clock,
where 0 is the top, so startAngle: -90, endAngle: 90 is the upper half. A
partial arc is scaled to fill the container, which makes a semi-circle use the box’s
full height; labels, the legend and hit-testing follow the arcs. The default is the full
circle, unchanged.
A pie or donut can vary each slice’s radius.
radius: { col: 'margin' } keeps the angle from y and sets each
slice’s outer radius from a second measure, area-true by default
(fn: 'linear' for a plain proportion), with min as the smallest
share of the full radius. A donut keeps its hole, and the tooltip names both measures.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { document, root } = createTestDom({ width: 420, height: 420 });
const container = document.createElement('div');
container.rect = { width: 420, height: 420, top: 0, left: 0 };
root.appendChild(container);
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [
{ field: 'region' }, { field: 'revenue', type: 'number' },
{ field: 'margin', type: 'number' },
],
rows: [
{ id: 'a', region: 'North', revenue: 60, margin: 40 },
{ id: 'b', region: 'South', revenue: 40, margin: 10 },
],
});
const chart = createChart({
grid, container, type: 'donut', x: 'region', y: 'revenue',
startAngle: -90, endAngle: 90, radius: { col: 'margin' },
});
const slices = container.querySelectorAll('.lat-chartview__slice').length;
return slices;
An OHLC chart draws a candlestick’s inputs as bars.
type: 'ohlc' takes the same four measures (open, high, low, close)
and draws a high-low line with an open tick to the left and a close tick to the right,
coloured up or down as a candlestick is. It is a type of its own rather than a candlestick
style so that it can be switched to and from by type alone. volume,
the time axis, annotations and the drawing rail work as they do on a
candlestick.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { document, root } = createTestDom({ width: 420, height: 420 });
const container = document.createElement('div');
container.rect = { width: 420, height: 420, top: 0, left: 0 };
root.appendChild(container);
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [
{ field: 'day' }, { field: 'open', type: 'number' }, { field: 'high', type: 'number' },
{ field: 'low', type: 'number' }, { field: 'close', type: 'number' },
],
rows: [
{ id: 'a', day: 'Mon', open: 10, high: 14, low: 9, close: 13 },
{ id: 'b', day: 'Tue', open: 13, high: 15, low: 11, close: 12 },
],
});
const chart = createChart({
grid, container, type: 'ohlc', x: 'day', measures: ['open', 'high', 'low', 'close'],
});
return [container.querySelectorAll('.lat-chartview__ohlc-tick').length,
container.querySelectorAll('.lat-chartview__candle').length].join(' | ');
The chart
| Method | Returns | Description |
|---|---|---|
| update(spec) | void | Change any part of the spec and redraw. Keys you omit keep their values. |
| draw() | void | Redraw now, for a change the grid does not announce. |
| data() | object | What the chart last bound: series, categories and the rows behind them. |
| on(event, fn) | function | Returns its own unsubscribe. |
| ascend(levels?) | void | Up one level on a drillable hierarchy. |
| toSVG(opts?) | string | The chart as markup. |
| toPNG(opts?) | Promise<Blob> | scale: 2 for a retina still. |
| toCSV() | string | The bound data, for a reader who wants the numbers. |
| rebind(grid) | Chart | Point the chart at a replacement grid after the one it was bound to was destroyed - the chart keeps its spec and element, drops its subscriptions to the dead grid, subscribes to the new one and redraws. A chart left unbound draws nothing and warns once (chart:grid-destroyed) rather than throwing. Binding to the grid it already follows is a no-op. |
| destroy() | void | Yours to call: the element is in your page, not the grid's. |
| element | SVGElement | The chart's own root. |
A chart whose grid is destroyed - an htmx swap, a framework teardown - stays on the page but stops following it; a draw in that state draws nothing and warns once (chart:grid-destroyed) rather than throwing. chart.rebind(grid) points it at the replacement:
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { document, root } = createTestDom({ width: 600, height: 300 });
const container = document.createElement('div');
container.rect = { width: 600, height: 300, top: 0, left: 0 };
root.appendChild(container);
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'region' }, { field: 'charge', type: 'number' }],
rows: [{ id: 'a', region: 'emea', charge: 10 }, { id: 'b', region: 'amer', charge: 20 }],
});
const chart = createChart({ grid, container, type: 'bar', x: 'region', y: 'charge' });
grid.destroy(); // the chart un-binds: a draw would warn once, not throw
const replacement = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'region' }, { field: 'charge', type: 'number' }],
rows: [{ id: 'c', region: 'north', charge: 40 }, { id: 'd', region: 'south', charge: 50 }],
});
chart.rebind(replacement);
return [chart.data().categories.join(','), String(chart.data().rows)].join(' | ');
Clicking a chart
A chart emits click, hover, leave, focus, draw, drill, brush and legend - there is no point:click, point:hover or series:toggle. The common use is filtering the grid from a mark, which makes the pair two views of one selection rather than a chart beside a table. The click and hover payload is flat - { label, category, column, value, series, rowKeys, native, preventDefault }, with no point wrapper - where column is the grid column the mark filters on and category the value to filter it to.
chart.on('click', ({ column, category }) => {
grid.filters.set({ col: column, op: 'eq', value: category });
});
Simplest of all, set filterOnClick: true in the spec and the chart applies exactly that filter itself on the clicked mark. The click event still fires first, so a handler that calls preventDefault() on the payload takes the click over instead.
Chart a selected range
A user who drags out a block of cells - a text column and the numbers beside it - is asking a question a spreadsheet answers with one gesture: chart this. chartRange is that gesture. It reads the selected range, derives the chart from its shape, and returns the same live Chart createChart does, so nothing about it is a second kind of chart.
- The leading text column becomes the categories, and the numeric columns beside it become the measures. One number is a bar chart; several are a grouped bar. A block of pure numbers charts against the row position.
- Hidden and unreadable columns are never charted. The range is resolved against the columns the reader can actually see, so a chart never carries a value the grid itself would not show.
- It is bound to the range's own rows, filtered as the grid is filtered - the band the rectangle covers, not the whole sheet.
- The type is a sensible default you can change:
chart.update({ type: 'line' }), or passtypeup front.
The derivation is pure, so a menu can ask what a range would chart as - the type, the dimension, the measures - before anyone draws it. deriveRangeSpec answers that, and chartRange then draws exactly it. This block proves the shape rule on a real grid; drawing needs a container, shown below it.
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { deriveRangeSpec, canChartRange } = await import('../packages/modules/charts/index.js');
let charted = null;
const grid = createHeadlessGrid({
columns: [
{ field: 'region', type: 'text' },
{ field: 'revenue', type: 'number', total: 'sum' },
{ field: 'cost', type: 'number', total: 'sum' },
],
rows: [
{ id: 1, region: 'EMEA', revenue: 300, cost: 120 },
{ id: 2, region: 'AMER', revenue: 500, cost: 240 },
{ id: 3, region: 'APAC', revenue: 200, cost: 90 },
],
rowKey: 'id',
// Opt in, with the handler that draws - the page's link to modules/charts.
// The menu and Alt+F1 call it with the grid and the selected range.
rangeChart(grid, range) { charted = range; },
});
// The rectangle the user dragged: three rows, the text column and both numbers.
const range = { startRow: 0, endRow: 2, columns: ['region', 'revenue', 'cost'] };
// The leading text column is the dimension; the two numeric columns are the
// measures, so the default is a grouped bar (a combo of bar marks).
const plan = deriveRangeSpec(grid, { range });
// What the menu action does when the reader picks "Chart selection".
const handler = grid.get('rangeChart');
if (canChartRange(grid, { range })) handler(grid, range);
return [plan.x === 'region' ? 'Region' : plan.x,
plan.measures.join(','),
plan.type,
charted === range ? 'drawn' : 'no'].join('|');
Drawing is one more call. chartRange takes the container, derives the spec and returns the live chart - or null when the range has no number to plot. The cell menu offers Chart selection, and Alt+F1 triggers it from the keyboard, when the grid is configured with rangeChart. Because the charts module is optional and the grid draws no charts itself, the config carries the handler - a function, or { onChart }, called (grid, range) - which is where a page wires the two together:
import { chartRange } from '@toclocoinc/lattice-grid/modules/charts';
createGrid(el, {
columns, rows,
rangeChart(grid, range) {
// A grouped bar by default; pass `type` to draw it as something else.
const chart = chartRange(grid, { container: '#chart', range });
if (chart) chart.update({ scheme: 'colourblind' });
},
});
Regression diagnostics
A regression is not finished when it has coefficients; it is finished when the residuals
have been looked at. regressionPlots turns a fitted model - the one
grid.statistics.regressionModel returns - into ready chart specs, so the
diagnostic pictures are one call rather than a hand-assembled spec each. It reimplements no
charting and no statistics: the fit line’s confidence band is the module’s own ribbon
primitive fed by the model’s own interval, and the multicollinearity plot is the existing
correlogram paired with the model’s VIF.
The presets that map onto grid columns are returned as drawable specs: the fit with its
band, residuals-vs-fitted (over the fitPredicted and fitResidual
shadow columns), a QQ plot of the residuals, the multicollinearity correlogram, and - over the
fitStdResidual, fitLeverage and fitCooksD columns - residuals-vs-leverage, a bubble sized by Cook's distance. Scale-location
(√|standardised residual| vs fitted) is drawn from explicit points computed off the model,
since its y is a transform no column holds; the coefficient forest plot draws one
row per coefficient - its estimate with a confidence whisker and a line at zero - through the
explicit-bound error-bar primitive. A preset a given model cannot support (no multicollinearity
for one predictor, no band for several) is returned as a null spec carrying a machine-readable
reason rather than silently dropped.
Every preset bound over grid columns draws one mark per row:
the fit, residuals-vs-fitted and residuals-vs-leverage specs all carry perRow: true,
so rows that tie on the x column - common on a real predictor rounded to a few decimals over
thousands of rows - are not summed into one point. Without it a chart binds its default reduction,
and a fit drawn through the sums at each tied x reads nothing like the model it is meant to show:
its own R² disagrees with regressionModel's, and an influence plot sums every tied
row's Cook's distance into one oversized bubble. A bubble the sum could still draw too large for
its panel is capped by the plot's own size regardless (see maxRadius), so the largest
mark always stays inside the plot and is still the largest.
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { regressionPlots } = await import('../packages/modules/charts/index.js');
// x=1..5, y=2,4,5,4,5. Shadow columns carry the model's per-row diagnostics -
// fitted value, residual, standardised residual, leverage and Cook's D - so a
// diagnostic chart is a plain chart over columns.
const model = { predictors: ['x'], response: 'y' };
const grid = createHeadlessGrid({
columns: [
{ field: 'x', type: 'number' },
{ field: 'y', type: 'number' },
{ id: 'yhat', title: 'Fitted', shadow: { kind: 'fitPredicted', model } },
{ id: 'resid', title: 'Residual', shadow: { kind: 'fitResidual', model } },
{ id: 'sresid', title: 'Std residual', shadow: { kind: 'fitStdResidual', model } },
{ id: 'lev', title: 'Leverage', shadow: { kind: 'fitLeverage', model } },
{ id: 'cook', title: "Cook's D", shadow: { kind: 'fitCooksD', model } },
],
rows: [
{ id: 'r1', x: 1, y: 2 }, { id: 'r2', x: 2, y: 4 }, { id: 'r3', x: 3, y: 5 },
{ id: 'r4', x: 4, y: 4 }, { id: 'r5', x: 5, y: 5 },
],
rowKey: 'id',
source: { mode: 'memory', columnarBelow: 0 },
});
const { plots } = regressionPlots(grid, {
spec: model, fitted: 'yhat', residual: 'resid', stdResidual: 'sresid', leverage: 'lev', cooksD: 'cook',
});
return [
plots.fit.spec.type, // scatter, with fit:true and band
plots.fit.spec.band.points.length, // a band point per row
plots.residualsFitted.spec.type, // residual vs fitted, a scatter
plots.qq.spec.type, // a QQ plot of the residuals
plots.residualsLeverage.spec.type, // bubble, over the diagnostic columns
plots.residualsLeverage.spec.size, // sized by Cook's D
plots.scaleLocation.spec.type, // scatter, from explicit points off the model
plots.coefficientForest.spec.type, // forest, estimate + whisker per coefficient
].join('|');
Maps
A geomap takes an ISO code from one column and a value from another. Alpha-2, alpha-3 and numeric codes are all accepted, and continent codes draw a continent map without any outline data. Codes that match nothing are counted and reported on the chart rather than dropped, a map missing half its data looks exactly like a map of a world where half the data is zero. The full code tables are in CHART-CODES.md.
Real outlines: geometry packs
A pack is an optional module you import only if you draw that map. Real boundaries are tens to hundreds of kilobytes, so none of them are in the charts bundle and the grid still fetches nothing at runtime: you import the pack you want, exactly as you import an extension chart type, and hand it to shapes. Each pack is TopoJSON - quantised and delta-encoded, decoded by the chart - generated from the published source below by tools/build-geo-packs.mjs, and it carries its own provenance: the source URL, the version, the date it was retrieved, the licence, and the attribution line that licence requires.
import { pack } from '@toclocoinc/lattice-grid/modules/geo-world-110m';
createChart({ grid, container: '#map', type: 'geomap', code: 'iso', y: 'revenue',
shapes: pack }); // Equal Earth, fitted to the pack
The land itself is legible by default, in both themes. A pack's own outline fills from --lattice-chart-basemap-fill and strokes from --lattice-chart-basemap-stroke/-stroke-width, chosen to sit clear of the plot and of each other rather than fading into it; spec.basemap: { fill, stroke, strokeWidth } overrides any one of them per chart. Every type that draws a pack reads the same tokens: markermap, bubblemap, choropleth's own backdrop pack, hexmap and geomap.
No bundler? Every pack also ships UMD/CJS, exactly as
chart-markermap and the other extension chart types do: a plain <script
src> page reaches the pack from its own global rather than an import. Each
pack takes its own global - never merged into LatticeGrid - because all five export the
same two names (pack, default), and a page loading more than one would have
the second overwrite the first on a shared object.
<script src="lattice-grid.min.js"></script>
<script src="modules/charts.min.js"></script>
<script src="modules/chart-markermap.min.js"></script>
<script src="modules/geo-world-110m.min.js"></script>
<script>
LatticeGrid.createChart({ grid, container: '#map', type: 'markermap', lon: 'lng', lat: 'lat',
shapes: LatticeGridGeoWorld110m.pack });
</script>
| Module | Script-tag global | Regions | Source and licence | Size (gzipped) | Attribution required |
|---|---|---|---|---|---|
| modules/geo-world-110m | LatticeGridGeoWorld110m | 177 countries | Natural Earth 1:110m Admin 0, via world-atlas - public domain | 39 KB | None |
| modules/geo-world-50m | LatticeGridGeoWorld50m | 241 countries | Natural Earth 1:50m Admin 0, via world-atlas - public domain | 225 KB | None |
| modules/geo-us-states | LatticeGridGeoUsStates | 50 states + DC | US Census cartographic boundaries, via us-atlas - public domain | 36 KB | None |
| modules/geo-europe-nuts | LatticeGridGeoEuropeNuts | NUTS 0-2 | Eurostat GISCO NUTS 2021 1:20m - free re-use with attribution | 95 KB | © EuroGeographics for the administrative boundaries |
| modules/geo-uk | LatticeGridGeoUk | 9 regions, 361 local authorities, 650 constituencies | ONS Open Geography, generalised clipped - Open Government Licence v3.0 | 292 KB | Contains OS data © Crown copyright and database right 2026; Source: Office for National Statistics licensed under the Open Government Licence v.3.0 |
Joining. A pack is keyed by the code its source is published under and by the codes that source also knows: the world packs by ISO alpha-2, with alpha-3 and numeric accepted; geo-us-states by the two-letter USPS abbreviation, with the FIPS code accepted; geo-europe-nuts by NUTS id (DE, DE1, DE11); geo-uk by ONS code (E12000007). A region the pack does not know is reported as unmatched exactly as before.
Choosing a grain. geo-uk ships three layers in one module because they share a coastline: layer: 'regions' (the default), 'local-authorities' or 'constituencies'.
Two notes from the data, not from us. Natural Earth at 1:110m leaves out the micro-states - Singapore, Malta, Monaco have no outline at that scale - so bind country-level data to geo-world-50m if those matter. And geo-us-states places Alaska and Hawaii at their true longitudes rather than in the insets an Albers USA composite uses, so a map of all 51 spans the Pacific; the five US territories are left out of the pack for the same reason.
Projections
Every map names a projection, and a pack declares the right one for itself, so shapes: pack alone gives a sensible map. projection overrides it; projectionOptions passes parallels and centre to the two that take them. The drawn geometry is then fitted to the panel, so a map of the UK fills its box rather than sitting inside the whole globe's.
| Name | What it is | Use it for |
|---|---|---|
| equalEarth | Equal-area (Šavrič, Patterson & Jenny 2018) | A world map - the default. Areas are honest and the shapes are recognisable. |
| robinson | Compromise, tabulated | A world map where the poles matter more than area. |
| mercator | Conformal, cut at ±85.05° | Matching a web-map basemap. |
| albers | Conic equal-area, two standard parallels | A country or continent in the mid-latitudes - the US and Europe packs default to it. |
| transverseMercator | Conformal about a central meridian | A tall, narrow country. The UK pack defaults to it through 2°W, which is what stands Britain upright. |
| equirectangular | Longitude and latitude straight onto x and y | Back-compatibility: the projection every map here drew before 1.63. |
createChart({ grid, container: '#uk', type: 'geomap', code: 'lad', y: 'claims',
shapes: ukPack, layer: 'local-authorities' }); // transverse Mercator
createChart({ grid, container: '#us', type: 'geomap', code: 'state', y: 'sales',
shapes: usPack, projection: 'albers',
projectionOptions: { parallels: [29.5, 45.5], centre: [-96, 37.5] } });
The antimeridian is handled in the chart. A country whose outline crosses ±180° - Russia, Fiji, New Zealand's Chathams - is split there before it is projected, so it draws as the parts it is rather than as a band running the wrong way across the map. Antarctica is cropped until it carries a value, by its country code AQ as well as the continent code AN.
The module imports nothing from the grid: createChart is handed a grid rather than importing one. That is what keeps the charts bundle to the drawing, and it is why the grid must be created first, and why a chart cannot outlive it.
Network diagrams - icon nodes, links coloured by their value
A network draws the grid's rows as a graph: source and target name the two endpoint columns and y carries the value on the link between them. Three things make it a picture of your network rather than a generic hairball, and each is a fact only you have.
Nodes you name. nodes: [{ id, label, icon, x, y }] gives a node a glyph from the grid's own icon registry - a built-in name, or one you registered - and a label drawn beneath it. A node that appears in the rows but not in nodes takes the chart's icon default (a plain disc when there is none) and its own id as its label. A node listed in nodes that appears in no row is still drawn: a device with no links is a fact worth seeing. The glyphs are SVG paths from the same registry the cells paint from, so they are sharp at any chart size and take the chart's theme colours. This still works over a grid with no DOM of its own: createHeadlessGrid({ icons: {…} }) registers into that grid's own registry, and the chart resolves a node's icon from it exactly as it would on a rendered grid - a name neither it nor the built-ins hold still warns once, by name, rather than silently drawing a disc.
Positions you choose. x and y are fractions of the plot, measured from its top-left. A node giving both is pinned there and takes no part in the force simulation; everything else is laid out around it by the same deterministic relaxation as before, so “core on top, regions below” needs no hand-placed SVG. Half a position is not a position: a node with only x is laid out. A fraction outside 0 to 1 clamps to the edge of the plot rather than drawing where nobody can see it. Pinning one node never reshuffles the others - the layout's seeding draws for every node, pinned or not, precisely so that it cannot.
Colours from the rules you already wrote. Each link's stroke comes from the value column's own conditional-formatting rules, through grid.formatting.styleFor(col, value) - the colour order is background, then backgroundColor, then color; a gradient (a data bar, an icon set) is not a colour and is not read. A link no rule matches keeps the chart's default link colour. There is no chart-level threshold option, deliberately: a second place to say “red above 80” is a second place for the chart and the cell to disagree. The legend lists the rules that actually fired, with their own labels and their own swatches; a rule that matched nothing is not advertised. Change a rule and the links recolour on the next frame without the layout re-running, so nothing moves.
Links are undirected, and parallel links stay parallel. There are no arrowheads, and A,B is the same pair as B,A - a cable has two ends and no direction. Several rows between the same pair are drawn as several lines, side by side, offset perpendicular to the pair by 4 px and symmetric about it, in row order, each with its own value and its own colour. They are not summed: three circuits between two sites are three readings, and one line carrying 120% would be a number nothing measured. The tooltip on any one line names both endpoints and that line's own value, and the pointer picks out the line you are actually over rather than the pair. Width follows the value unless linkWidth fixes it.
Linked like every other chart. The graph is drawn from the grid's filtered rows and follows filter and sort. With selection: true, clicking a link selects its row and clicking a node selects every row it is an end of; the grid's selection then lights those links and nodes and dims the rest. A click handler still fires first and can preventDefault().
The picture, executed
Two core routers pinned across the top, three regional routers pinned below, two circuits between every pair, and one rule set on the load column doing all the colouring. Thirteen rows, thirteen lines, three colours, five glyphs, and a legend that names the three rules.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid } = await import('../packages/dom/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { document, root } = createTestDom({ width: 640, height: 420 });
// Two circuits between every core and every region: six pairs, twelve rows.
const rows = [];
for (const core of ['core-1', 'core-2']) {
for (const site of ['emea', 'amer', 'apac']) {
for (const [n, load] of [['a', 18], ['b', 92]]) {
rows.push({ id: `${core}/${site}/${n}`, from: core, to: site, load });
}
}
}
rows.push({ id: 'core-1/core-2/x', from: 'core-1', to: 'core-2', load: 61 });
const grid = createGrid(root, {
rowKey: 'id',
selection: 'multiple',
columns: [{ field: 'from' }, { field: 'to' }, { field: 'load', type: 'number' }],
rows,
// One rule set on the column. The cells and the links read it together.
formatting: {
load: [
{ id: 'ok', label: 'Healthy', when: { op: 'lt', value: 40 }, style: { background: '#107c41' } },
{ id: 'busy', label: 'Busy', when: { op: 'lt', value: 80 }, style: { background: '#f0b400' } },
{ id: 'hot', label: 'Saturated', when: { op: 'gte', value: 80 }, style: { background: '#a4262c' } },
],
},
});
const container = document.createElement('div');
container.rect = { width: 600, height: 400, top: 0, left: 0 };
root.appendChild(container);
const chart = createChart({
grid, container, type: 'network', source: 'from', target: 'to',
y: { col: 'load', fn: 'sum' }, selection: true,
nodes: [
{ id: 'core-1', label: 'Core', icon: 'square', x: 0.3, y: 0.15 },
{ id: 'core-2', label: 'Core', icon: 'square', x: 0.7, y: 0.15 },
{ id: 'emea', label: 'EMEA', icon: 'circleFilled', x: 0.2, y: 0.8 },
{ id: 'amer', label: 'AMER', icon: 'circleFilled', x: 0.5, y: 0.8 },
{ id: 'apac', label: 'APAC', icon: 'circleFilled', x: 0.8, y: 0.8 },
],
});
const find = (tag, cls) => [...container.querySelectorAll(tag)]
.filter((n) => (n.getAttribute('class') || '').includes(cls));
const at = (key) => find('circle', '__node').find((c) => c.getAttribute('data-node') === key);
const num = (el, name) => Number(el.getAttribute(name));
// The picture: two cores on one row, three regions on another below them.
const top = [at('core-1'), at('core-2')].map((c) => num(c, 'cy'));
const bottom = ['emea', 'amer', 'apac'].map((k) => num(at(k), 'cy'));
const rowsPinned = top[0] === top[1] && bottom.every((y) => y === bottom[0]) && bottom[0] > top[0]
&& num(at('core-1'), 'cx') < num(at('core-2'), 'cx');
// Every link its own line, coloured by the rule its value matched.
const edges = find('path', '__edge');
const stroke = (e) => ((e.getAttribute('style') || '').match(/stroke:\s*([^;]+)/) || [])[1];
const colours = [...new Set(edges.map(stroke))].sort();
// Two circuits between core-1 and emea, drawn side by side 4px apart.
const ends = (d) => d.match(/-?\d+(?:\.\d+)?/g).map(Number);
const pair = edges.slice(0, 2).map((e) => ends(e.getAttribute('d')));
const gap = Math.round(Math.hypot(pair[0][0] - pair[1][0], pair[0][1] - pair[1][1]));
// Five glyphs, drawn from the registry the host extended.
const glyphs = find('path', '__node-icon').length;
// The legend names the rules that fired, not a palette.
const legend = [...container.querySelectorAll('button')]
.filter((b) => (b.getAttribute('class') || '').includes('__legend-item'))
.map((b) => b.textContent).join(',');
chart.destroy();
grid.destroy();
return `${edges.length} links, ${colours.join('/')} | pinned ${rowsPinned} | gap ${gap} | ${glyphs} icons | ${legend}`;
Extension chart types - pay only for what you draw
The base charts bundle draws the built-in TYPES and nothing else. A new chart type is a separate, opt-in module a caller imports only if they use it, on the slim-core seam. Importing it self-registers the type with the base module through registerChartType; the base Chart consults that registry for any type it does not draw natively. Because the base never imports the extension, the base bundle does not grow for a type a caller never uses.
import '@toclocoinc/lattice-grid/modules/charts'; // the base
import '@toclocoinc/lattice-grid/modules/chart-ridgeline'; // opt in to one type
createChart({ grid, container: '#dist', type: 'ridgeline', x: 'segment', y: 'value' });
An extension declares { draw, bind?, freeform?, labelled? }. Its draw(ctx) receives the same context a built-in drawer gets - plot, bound, groups, scheme, typography, labels, grid, spec - plus ctx.helpers, the base's own toolkit (element factory, scales, axis drawers, mark pool, distribution kernels). So an extension imports nothing heavy from the base: it receives the toolkit and ships only its own geometry. registeredChartTypes() lists what is registered. Ridgeline (drawRidgeline) is the first: one kernel-density ridge per category, stacked and overlapping, over the distribution of a measure - the reading for "how did this distribution change across segments".
const charts = await import('../packages/modules/charts/index.js');
const ridge = await import('../packages/modules/chart-ridgeline/index.js');
// Importing the module self-registered the type against the base registry.
const registered = charts.registeredChartTypes().includes('ridgeline');
// registerChartType is idempotent by name, so re-registering is safe.
charts.registerChartType('ridgeline', { draw: ridge.drawRidgeline });
return [
registered,
charts.registeredChartTypes().includes('ridgeline'),
typeof ridge.drawRidgeline,
].join(' | ');
More lead-pick types ship the same way, each in its own opt-in module - the base bundle stays flat as they are added. Calendar heatmap (drawCalendar, type: 'calendar') lays a measure out value-by-day, GitHub-style, from a date column. Scatter-plot matrix (drawSplom/bindSplom, type: 'splom') crosses every pair of numeric columns. Hexbin (drawHexbin/bindHexbin, type: 'hexbin') bins a scatter into count-shaded hexagons so a million rows read as a density field. Each reads the grid through the public row API, so it follows the grid's filters and sort.
const charts = await import('../packages/modules/charts/index.js');
const cal = await import('../packages/modules/chart-calendar/index.js');
const splom = await import('../packages/modules/chart-splom/index.js');
const hex = await import('../packages/modules/chart-hexbin/index.js');
// Each import self-registered its type; the base bundle carries none of them.
const types = charts.registeredChartTypes();
return [
['calendar', 'splom', 'hexbin'].every((t) => types.includes(t)),
typeof cal.drawCalendar,
typeof splom.drawSplom, typeof splom.bindSplom,
typeof hex.drawHexbin, typeof hex.bindHexbin,
].join(' | ');
The model-evaluation and time-series lead picks ship the same way. ROC / PR / calibration (drawRoc/bindRoc, type: 'roc', curve: 'roc' | 'pr' | 'calibration') evaluates a classifier from a label and a score column, with the AUC. Fan / forecast (drawFan/bindFan, type: 'fan') draws history, a point forecast, and a widening prediction interval from y/forecast/lower/upper. Decomposition panel (drawDecomposition/bindDecomposition, type: 'decomposition') stacks the observed/trend/seasonal/residual components on one x axis - the companion to the grid's own time-series shadow columns.
const charts = await import('../packages/modules/charts/index.js');
const roc = await import('../packages/modules/chart-roc/index.js');
const fan = await import('../packages/modules/chart-fan/index.js');
const decomp = await import('../packages/modules/chart-decomposition/index.js');
const types = charts.registeredChartTypes();
return [
['roc', 'fan', 'decomposition'].every((t) => types.includes(t)),
typeof roc.drawRoc, typeof roc.bindRoc,
typeof fan.drawFan, typeof fan.bindFan,
typeof decomp.drawDecomposition, typeof decomp.bindDecomposition,
].join(' | ');
The comparison and ranking family ships the same way. Slope (drawSlope, type: 'slope') connects each series across two periods; dumbbell (drawDumbbell/bindDumbbell, type: 'dumbbell') shows a start/end gap per category; bump (drawBump, type: 'bump') plots rank-over-time; diverging (drawDiverging, type: 'diverging') grows bars from a central zero; parallel coordinates (drawParallel/bindParallel, type: 'parallel') draws one polyline per row across several numeric axes.
const charts = await import('../packages/modules/charts/index.js');
const slope = await import('../packages/modules/chart-slope/index.js');
const dumbbell = await import('../packages/modules/chart-dumbbell/index.js');
const bump = await import('../packages/modules/chart-bump/index.js');
const diverging = await import('../packages/modules/chart-diverging/index.js');
const parallel = await import('../packages/modules/chart-parallel/index.js');
const types = charts.registeredChartTypes();
return [
['slope', 'dumbbell', 'bump', 'diverging', 'parallel'].every((t) => types.includes(t)),
typeof slope.drawSlope,
typeof dumbbell.drawDumbbell, typeof dumbbell.bindDumbbell,
typeof bump.drawBump,
typeof diverging.drawDiverging,
typeof parallel.drawParallel, typeof parallel.bindParallel,
].join(' | ');
When parallel coordinates is given a colourBy column it colours each line by its category - but a colour with no key is a code, so the chart now emits a legend of those categories, one entry per category in the order the colours were assigned, exactly the shape every other coloured type returns. The base draws it and wires the click, so a click on a category toggles it off through the same hide-a-series gesture the rest of the module has, and the drawer skips a hidden category's lines. With no colourBy there is nothing to key and no legend is drawn. The categories the key is built from are the ones bindParallel returns as groups:
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { bindParallel } = await import('../packages/modules/chart-parallel/index.js');
const cats = ['red', 'green', 'blue'];
const rows = Array.from({ length: 9 }, (unused, i) => ({ id: `R${i}`, a: i, b: i % 4, grp: cats[i % 3] }));
const grid = createHeadlessGrid({
columns: [{ field: 'a', type: 'number' }, { field: 'b', type: 'number' }, { field: 'grp', type: 'text' }],
rows, rowKey: 'id',
});
// The colourBy categories, in colour order - each becomes a legend entry whose
// index picks the same colour its lines use.
const bound = bindParallel(grid, { columns: ['a', 'b'], colourBy: 'grp' });
grid.destroy();
return `key ${bound.groups.join(',')}`;
A least-squares forecast on the trend overlay (trend: { method: 'linear', forecast: n }) no longer draws a bare dashed line: it shades the uncertainty band around the projection. By default that is the Student-t prediction band (a future observation); band: 'confidence' shades the narrower mean-response band the fitted line's own doubt describes, and band: false leaves the bare line. The band widens as the line runs further past the data - the honest shape, since a projection is least certain where it reaches furthest - and confidence (default 0.95) sets its level. It is the exact interval the core forecast kernel reports for the linear method, computed locally in the charts bundle (never imported, for the bundle reason the trend maths already is) and asserted equal to the engine's to the last digit:
const { forecast } = await import('../packages/core/src/index.js');
const { linearTrend } = await import('../packages/modules/charts/trendline.js');
const ys = [2, 5, 6, 9, 11, 12];
const pairs = ys.map((y, i) => ({ x: i, y }));
// The trend overlay's forecast band, three steps ahead at 95%…
const overlay = linearTrend(pairs, 3, { confidence: 0.95 });
// …is the core forecast kernel's linear prediction band, to the last digit.
const engine = forecast(ys, { method: 'linear', horizon: 3, confidence: 0.95 });
const b = overlay.band.points[3];
const e = engine.points[2];
return `match ${b.lower === e.lower && b.upper === e.upper}; conf ${overlay.band.confidence}`;
The hierarchy, flow and geographic remainder ships the same way - treemap, sunburst, funnel, radar, sankey, chord and network are already built in, so the new opt-in modules are: icicle (drawIcicle, type: 'icicle', drawn from the grid's group tree), waffle (drawWaffle, type: 'waffle'), alluvial (drawAlluvial/bindAlluvial, type: 'alluvial'), arc diagram (drawArc/bindArc, type: 'arc'), bubble map (drawBubbleMap/bindBubbleMap, type: 'bubblemap') and hexbin map (drawHexMap/bindHexMap, type: 'hexmap'). The two maps place lon/lat directly, so they need no outline data and fetch nothing.
A marker or bubble map with no basemap still shows where it is. Without a shapes pack, markermap and bubblemap draw the same schematic continent outline a geomap draws without one - keyless, shipped in the charts module, fetching nothing - under the points, through the same placement the points take, so it is fitted to the data's own extent; it takes the geomap's plain empty tone (--lattice-chart-empty) and is held inside the plot. Each outline is a .lat-chartview__geo-reference path carrying its continent code in data-code; Antarctica is drawn only when a point lies south of 60°S. With a shapes pack, or on a globe, nothing changes: the pack's own outline is the basemap. outline: false switches the reference outline off and keeps the blank plane.
const charts = await import('../packages/modules/charts/index.js');
const icicle = await import('../packages/modules/chart-icicle/index.js');
const waffle = await import('../packages/modules/chart-waffle/index.js');
const alluvial = await import('../packages/modules/chart-alluvial/index.js');
const arc = await import('../packages/modules/chart-arc/index.js');
const bubblemap = await import('../packages/modules/chart-bubblemap/index.js');
const hexmap = await import('../packages/modules/chart-hexmap/index.js');
const types = charts.registeredChartTypes();
return [
['icicle', 'waffle', 'alluvial', 'arc', 'bubblemap', 'hexmap'].every((t) => types.includes(t)),
typeof icicle.drawIcicle, typeof waffle.drawWaffle,
typeof alluvial.drawAlluvial, typeof alluvial.bindAlluvial,
typeof arc.drawArc, typeof arc.bindArc,
typeof bubblemap.drawBubbleMap, typeof bubblemap.bindBubbleMap,
typeof hexmap.drawHexMap, typeof hexmap.bindHexMap,
].join(' | ');
Polar charts - a Nightingale rose, a radial bar and a polar scatter
Three opt-in modules draw a measure as a distance from a centre and a category (or a time, or a bearing) as an angle. Each is imported after the base charts module, registers its type by name, and is paid for only by the page that imports it. Each follows the grid's filter, answers a hover with a tooltip, takes the keyboard (one tab stop, the arrows between marks) and carries the accessible data table every chart has, and its marks, axes and legend use the same colour tokens, so they read in the light and dark themes alike. A scheme given as a bare array or as a name map colours them like every other type. The options that are particular to them are set in one polar object on the spec; startAngle, endAngle, max, min, stack, size and series mean what they mean elsewhere.
Polar area / Nightingale rose (modules/chart-polararea, drawPolarArea; type: 'polarArea', with the aliases 'nightingale' and 'rose') gives every x category the same angle and draws the wedge's radius from y. The default scale is area-true: the eye reads a wedge by its area, so the radius is the square root of the value and a value four times another reaches twice as far; polar: { scale: 'linear' } draws the radius from the value directly, for a reader who accepts the exaggeration. The radial axis labels the values in the column's own format - its rings bunch towards the edge on the square-root scale, which is the scale being drawn. With a series column or measures, stack: true stacks the series within each wedge (the classic Nightingale form, each segment's cumulative value taken through the scale so the areas stay true); without it each series gets its own sub-wedge. startAngle and endAngle (degrees clockwise from twelve o'clock) open the rose into a fan, and max pins the outer ring.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 600, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
// Importing the module registers `polarArea` (and `nightingale`, `rose`);
// its drawer is drawPolarArea.
const polarArea = await import('../packages/modules/chart-polararea/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'month', type: 'text' }, { field: 'deaths', type: 'number' }],
rows: [
{ id: 'jan', month: 'Jan', deaths: 100 }, { id: 'feb', month: 'Feb', deaths: 400 },
{ id: 'mar', month: 'Mar', deaths: 900 }, { id: 'apr', month: 'Apr', deaths: 1600 },
],
});
const chart = createChart({ grid, container: panel, type: 'polarArea', x: 'month', y: 'deaths' });
// Each wedge carries its angle range and its reach, in degrees and pixels.
const wedges = [];
const walk = (node) => {
for (const child of node.children || []) {
if (String(child.getAttribute('class') || '').includes('__slice')) wedges.push(child);
walk(child);
}
};
walk(chart.element);
const span = (w) => Number(w.getAttribute('data-end')) - Number(w.getAttribute('data-start'));
const reach = wedges.map((w) => Number(w.getAttribute('data-radius')));
chart.destroy();
// 100, 400, 900 and 1600 are 1, 4, 9 and 16 times the first: the radii are 1, 2, 3 and 4.
return `${wedges.length} wedges of ${span(wedges[0])} degrees | radius ratios ${reach.map((r) => (r / reach[0]).toFixed(2)).join(' ')} | ${typeof polarArea.drawPolarArea}`;
Radial bar (modules/chart-radialbar, drawRadialBar; type: 'radialBar') draws one ring per x category, innermost first, each an arc from startAngle (default 0, twelve o'clock) whose sweep is value / max of the full sweep. The full sweep is endAngle - startAngle and defaults to 270 degrees; a faint full track behind each ring is that whole sweep, so the ring reads against what it is a part of (polar: { track: false } hides them). max defaults to the largest value, so the longest ring fills its track; set it to read against a target. The ring's label sits at the start of the ring, in the open quarter. polar: { sort: 'asc' | 'desc' } orders the rings by value (smallest or largest innermost); unset keeps the grid's own order. With measures or a series column the measures are adjacent rings inside each category's band, or with polar: { multi: 'stacked' } end to end in one ring. Only the angle is an honest encoding - an outer ring is longer for the same sweep - which is why every ring shares one max and the tracks are drawn.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 600, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const radialBar = await import('../packages/modules/chart-radialbar/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'team', type: 'text' }, { field: 'score', type: 'number' }],
rows: [
{ id: 'a', team: 'A', score: 10 }, { id: 'b', team: 'B', score: 20 },
{ id: 'c', team: 'C', score: 30 }, { id: 'd', team: 'D', score: 40 },
],
});
// max is the largest score, 40, and the default sweep is 270 degrees.
const chart = createChart({ grid, container: panel, type: 'radialBar', x: 'team', y: 'score' });
const arcs = [];
const names = [];
const walk = (node) => {
for (const child of node.children || []) {
const cls = String(child.getAttribute('class') || '');
if (cls.includes('__radialbar-arc')) arcs.push(child);
if (cls.includes('__radialbar-label')) names.push(child.textContent);
walk(child);
}
};
walk(chart.element);
const sweep = (a) => Number(a.getAttribute('data-end')) - Number(a.getAttribute('data-start'));
chart.destroy();
return `sweeps ${arcs.map(sweep).join(' ')} | rings ${names.join(' ')} | ${typeof radialBar.drawRadialBar}`;
Polar scatter (modules/chart-polarscatter, drawPolarScatter, bindPolarScatter; type: 'polarScatter') places each row by an angle from the x column and a radius from y, with an optional size column (the dot's area), a series column (colour by category, with a legend) or polar: { colour: 'column' } (a number is drawn from the scheme's ramp, anything else by category). What the angle column means is stated with polar.angle rather than guessed, because the same number is a bearing in one chart and an hour in another. Every form is measured clockwise from twelve o'clock plus startAngle:
'degrees'- a numeric column of degrees; any value wraps into one turn, so 370 is 10.'category'- the distinct values, in the order they first appear, spread evenly round the circle, one spoke each.'hourOfDay'- a time column read as the time of day, midnight at the top; 06:30 is 6.5 / 24 of a turn.'dayOfWeek'- a time column read as its day of the week, Monday first, the time of day kept within its day.'month'- a time column read as its month, January first, the day of the month kept within it.
A time may be an epoch in milliseconds, a Date or a date string, and is read in the grid's timeZone when it has one and in UTC otherwise. Left unset the angle is 'degrees' for a numeric column, 'hourOfDay' for a datetime or timestamp column, 'dayOfWeek' for a date column and 'category' for anything else. A row whose value does not read as that angle (or whose radius is absent) is not drawn at an invented one: it is counted in chart.data().unplaced. The angular axis is labelled for the form chosen (degrees every 30, hours every three, the weekday and month names in the grid's locale, a label per category) and the radial axis in the radius column's format. angleOf(value, kind, { timeZone }) is the same reading, exported for a host that wants to place its own marks against the one convention.
The selection goes back to the grid as a cartesian scatter's does. With selection: true a click on a dot selects its row and the grid's selection dims the other dots; with brush: 'select' a drag across the plot selects every row whose dot lies in the dragged band (the brush event carries the rowKeys and can be vetoed with preventDefault()). A polar plot has no value range to filter by or to zoom into, so 'filter' and 'zoom' are refused by name rather than ignored.
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 600, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const polarScatter = await import('../packages/modules/chart-polarscatter/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
selection: { mode: 'multiple' },
columns: [{ field: 'bearing', type: 'number' }, { field: 'speed', type: 'number' }],
rows: [
{ id: 'a', bearing: 90, speed: 10 }, { id: 'b', bearing: 180, speed: 20 },
{ id: 'c', bearing: 370, speed: 30 }, { id: 'd', bearing: 'calm', speed: 0 },
],
});
const chart = createChart({
grid, container: panel, type: 'polarScatter', x: 'bearing', y: 'speed', selection: true,
polar: { angle: 'degrees' },
});
// Where each dot landed: its bearing clockwise from the top, from the centre.
const centre = (() => {
let found = null;
const walk = (node) => {
for (const child of node.children || []) {
if (child.getAttribute('data-cx') !== null) found = child;
walk(child);
}
};
walk(chart.element);
return { x: Number(found.getAttribute('data-cx')), y: Number(found.getAttribute('data-cy')) };
})();
const dots = [];
const walk = (node) => {
for (const child of node.children || []) {
if (String(child.getAttribute('class') || '').includes('__point')) dots.push(child);
walk(child);
}
};
walk(chart.element);
const bearing = (d) => {
const dx = Number(d.getAttribute('cx')) - centre.x;
const dy = Number(d.getAttribute('cy')) - centre.y;
return Math.round(((Math.atan2(dx, -dy) * 180) / Math.PI + 360) % 360);
};
// A click on a dot selects its row in the grid.
const plot = panel.querySelector('.lat-chartview__plot');
plot.rect = { left: 0, top: 0, width: 600, height: 400 };
plot.dispatchEvent(new TestEvent('click', {
clientX: Number(dots[1].getAttribute('cx')), clientY: Number(dots[1].getAttribute('cy')),
}));
// A time column can be read as an angle too: noon is half way round the day.
const hour = polarScatter.angleOf(Date.UTC(2024, 0, 1, 6), 'hourOfDay');
const bound = polarScatter.bindPolarScatter(grid, { x: 'bearing', y: 'speed', polar: { angle: 'degrees' } });
const selected = grid.selection.keys().join(',');
chart.destroy();
return `bearings ${dots.map(bearing).join(' ')} | hours ${hour} | selected ${selected} | unplaced ${bound.unplaced} | ${typeof polarScatter.drawPolarScatter}`;
3D scatter (modules/chart-scatter3d)
3D scatter (drawScatter3d/bindScatter3d, type: 'scatter3d') places each row at (x, y, z) - each a numeric column or a number, in metres or any Cartesian unit - as a marker you orbit, zoom and pan. color is a fixed colour or { field, stops, domain? }: stops is a list of [t, colour] pairs (Plotly's colorscale shape, any number of stops), and domain pins the ramp's ends to a fixed ceiling so assessments stay comparable. size is a pixel radius or a numeric column, shape is circle, diamond or square, and hover is a column or a (row) => text function shown as free text. A second series can mark one point - a tower at the origin - in its own colour, name and shape, beside the ramp legend. The marks are painted on a canvas, so a few thousand points orbit and zoom without stutter.
const charts = await import('../packages/modules/charts/index.js');
const scatter3d = await import('../packages/modules/chart-scatter3d/index.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'x', type: 'number' }, { field: 'y', type: 'number' }, { field: 'z', type: 'number' }, { field: 'reading', type: 'number' }],
rows: [{ id: 'a', x: 1, y: 2, z: 3, reading: 10 }, { id: 'b', x: 4, y: 5, z: 6, reading: 20 }],
});
// Importing the module registers `scatter3d`; the binding reads x/y/z off the rows.
const bound = scatter3d.bindScatter3d(grid, { x: 'x', y: 'y', z: 'z', color: { field: 'reading', stops: [[0, '#2ecc71'], [1, '#e74c3c']] } });
grid.destroy();
return [charts.registeredChartTypes().includes('scatter3d'), typeof scatter3d.drawScatter3d, typeof scatter3d.bindScatter3d, bound.points.length].join(' | ');
Flow, funnel and timeline charts - a directed chord, a pyramid, a pictorial, a serpentine and a spiral
Five more ways to read the same rows. Each of the four new types is its own opt-in module on the registry seam: import modules/charts, then the one you draw, and it registers itself (chart-pyramid, chart-pictorial, chart-serpentine, chart-spiral; each ships ESM, UMD and CJS builds like the other extension types). All four follow the grid - a filter redraws them from the rows left - read the scheme as a bare array or a title map, show a tooltip, take roving keyboard focus over their marks, and write the accessible data table; with selection: true a click on a mark selects the rows behind it in the grid, and the grid's selection dims the marks it leaves out.
Directed chord (directed)
The chord keeps its flows keyed by direction: A to B and B to A are two ribbons, never one. directed: true draws them as such - each ribbon leaves its source arc at the full width of its value and arrives narrower (style: 'taper', the default, narrows to 40% of the source width) or closes into an arrow head whose tip sits on the ring (style: 'arrow'). A node's arc is what it sends (arc: 'out', the default; a node that only receives keeps a small floor so it stays on the ring) or what it sends plus what it receives (arc: 'inout'). Colours follow the source, the tooltip names the direction (A → B) and the value, a node's tooltip gives what it sends (→) and receives (←), and the accessible table lists the flows. Unset, the chord draws exactly as before. A ribbon's data-sweep is its width at the source in degrees: the quantity that is proportional to the value.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 600, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const flows = [['A', 'B', 30], ['B', 'A', 10], ['A', 'C', 20], ['C', 'B', 15], ['B', 'C', 5]];
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'from', type: 'text' }, { field: 'to', type: 'text' }, { field: 'v', type: 'number' }],
rows: flows.map(([from, to, v], i) => ({ id: 'r' + i, from, to, v })),
});
// `directed: true` keeps A to B and B to A as two ribbons: each leaves its source
// arc at the width of its value and narrows toward its target.
const chart = createChart({ grid, container: panel, type: 'chord', source: 'from', target: 'to', y: 'v', directed: true });
const sweep = {};
for (const node of panel.querySelectorAll('path')) {
if (node.getAttribute('data-link')) sweep[node.getAttribute('data-link')] = Number(node.getAttribute('data-sweep'));
}
chart.destroy();
grid.destroy();
return `A to B is ${(sweep['A->B'] / sweep['B->A']).toFixed(1)}x B to A | ${Object.keys(sweep).length} ribbons`;
Pyramid (modules/chart-pyramid)
type: 'pyramid' takes the funnel's input - x the stage, y the measure - and draws the stages as stacked slices of one triangle, the first on top: apex up by default, stages: { inverted: true } for apex down. A slice's height is proportional to its value; stages: { area: true } makes its area proportional instead (the slice boundaries then sit where the triangle's cumulative area reaches each share). gap is the space between slices (default 2 px; both readings are exact with gap: 0). labels places each slice's stage, value and share 'inside', 'beside' (with a leader), 'auto' (the default: inside where the text fits) or false (the legend then names the stages). Not populationPyramid, which is two mirrored bar sets.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 600, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { drawPyramid, bindPyramid } = await import('../packages/modules/chart-pyramid/index.js');
const stages = [['Visit', 1000], ['Signup', 600], ['Trial', 300], ['Paid', 100]];
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'stage', type: 'text' }, { field: 'n', type: 'number' }],
rows: stages.map(([stage, n], i) => ({ id: 's' + i, stage, n })),
});
// Importing the module registers `pyramid`; slice heights follow the values.
const bound = bindPyramid(grid, { x: 'stage', y: 'n' });
const chart = createChart({ grid, container: panel, type: 'pyramid', x: 'stage', y: 'n' });
const heights = [...panel.querySelectorAll('path')]
.filter((n) => n.getAttribute('data-stage') !== null)
.map((n) => Number(n.getAttribute('data-height')));
chart.destroy();
grid.destroy();
return `${typeof drawPyramid} | ${bound.stages.length} stages | ${heights.map((h) => (h / heights[0]).toFixed(2)).join(' ')}`;
Pictorial (modules/chart-pictorial)
type: 'pictorial' tells quantity with a picture. pictorial: { mode: 'fill' } (the default) splits one silhouette into horizontal bands sized by each stage's value; mode: 'repeat' draws an isotype row of icons per category, one icon standing for unit (a round number that fits the widest row when unset), the last one clipped to the fraction left over. shape is an icon name from the grid's icon registry (grid.icons) or an SVG path string. A path string is path data only - commands, numbers and separators; markup, quotes, url( and any word are refused, and the chart falls back to the built-in figure and says so once (the chart:*:notice:* warning). Bands and the partial icon are cut with clip-path: inset() against the shape's own measured bounding box.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 600, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { drawPictorial, bindPictorial } = await import('../packages/modules/chart-pictorial/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'stage', type: 'text' }, { field: 'n', type: 'number' }],
rows: [{ id: 'a', stage: 'Visit', n: 35 }, { id: 'b', stage: 'Signup', n: 22 }, { id: 'c', stage: 'Trial', n: 12.5 }],
});
// One icon is 5 people; 12.5 is two and a half icons, the last one clipped.
const chart = createChart({
grid, container: panel, type: 'pictorial', x: 'stage', y: 'n',
pictorial: { mode: 'repeat', shape: 'M0 0H10V10H0Z', unit: 5 },
});
const icons = [...panel.querySelectorAll('g')]
.filter((n) => n.getAttribute('data-icons') !== null)
.map((n) => n.getAttribute('data-icons'));
// A path string is path data only: markup is refused and the built-in figure drawn.
const refused = bindPictorial(grid, { x: 'stage', y: 'n', pictorial: { shape: '<svg onload=alert(1)>' } });
chart.destroy();
grid.destroy();
return `${typeof drawPictorial} | ${icons.join(' ')} icons | refused ${refused.shape.rejected} so ${refused.shape.source}`;
Serpentine timeline (modules/chart-serpentine)
type: 'serpentine' folds a long time axis into serpentine.rows rows (unset: from the plot's size) joined by half-circle turns, each row running the opposite way to the one above. Time runs along the whole path, turns included, so an event's distance round the path is proportional to its date everywhere, and an event on a turn sits on the curve. x is the date column for points; start and end give spans, which follow the path round the turns; label names an event, series colours by a column, and calendar ticks run along the path (serpentine: { ticks: false } removes them). Rows with no readable date are left off and said once.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 600, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { drawSerpentine, bindSerpentine } = await import('../packages/modules/chart-serpentine/index.js');
const rows = [];
for (let i = 0; i < 12; i++) {
rows.push({ id: 'e' + i, at: new Date(Date.UTC(2026, i, 10)).toISOString().slice(0, 10), name: 'Event ' + i });
}
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'at', type: 'date' }, { field: 'name', type: 'text' }],
rows,
});
// A year folded into three rows joined by half-circle turns: time runs along the
// whole path, so an event on a turn is as far round it as its date says.
const bound = bindSerpentine(grid, { x: 'at', label: 'name' });
const chart = createChart({ grid, container: panel, type: 'serpentine', x: 'at', label: 'name', serpentine: { rows: 3 } });
const markers = [...panel.querySelectorAll('circle')].filter((n) => n.getAttribute('data-t') !== null);
const onTurn = markers.filter((n) => n.getAttribute('data-turn') === 'true').length;
chart.destroy();
grid.destroy();
return `${typeof drawSerpentine} | ${bound.events.length} events | ${markers.length} markers | ${onTurn} on a turn`;
Spiral timeline (modules/chart-spiral)
type: 'spiral' winds time onto an Archimedean spiral with one turn per spiral.period - 'day', 'week' (from Monday) or 'year' (calendar periods, UTC), or a duration such as '28d', '12h' or a number of milliseconds - so the same point of every period lands on one angle, a turn further out each time. x is the date column and y an optional value. mode: 'points' (default; dots sized by y), 'bars' (a radial bar whose barBy: 'length' or 'thickness' is the value) or 'spans' (start/end, a stroke along the spiral for however many turns it takes). There is one tinted band per period, labelled with its start while there are few enough, and angular labels for the period's subdivisions: hours for a day, weekdays for a week, months for a year, quarters of a custom period. Phase 0 is twelve o'clock and time runs clockwise.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 600, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { drawSpiral, bindSpiral } = await import('../packages/modules/chart-spiral/index.js');
const rows = [];
for (let day = 0; day < 4; day++) {
for (const hour of [6, 18]) {
rows.push({ id: day + '-' + hour, at: new Date(Date.UTC(2026, 2, 2 + day, hour)).toISOString(), v: hour });
}
}
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'at', type: 'datetime' }, { field: 'v', type: 'number' }],
rows,
});
// One turn per day: 06:00 on every day is on the same ray, a turn further out.
const bound = bindSpiral(grid, { x: 'at', y: 'v' });
const chart = createChart({ grid, container: panel, type: 'spiral', x: 'at', y: 'v', spiral: { period: 'day' } });
const dots = [...panel.querySelectorAll('circle')].filter((n) => n.getAttribute('data-angle') !== null);
const sixAm = dots.filter((n, i) => i % 2 === 0).map((n) => n.getAttribute('data-angle'));
const bands = [...panel.querySelectorAll('path')].filter((n) => n.getAttribute('data-period') !== null).length;
chart.destroy();
grid.destroy();
return `${typeof drawSpiral} | ${bound.events.length} readings | 06:00 at ${[...new Set(sixAm)].join(',')} degrees | ${bands} bands`;
Word cloud - words sized by how much there is of them
modules/chart-wordcloud registers wordCloud. It reads either a word column in x with a value in y (one word per distinct value, sized by the sum of y), or a free-text column in text, which it splits into words and counts - lower-cased (lowerCase), with the built-in English stop words removed (stopWords: true by default there; false or your own array also work), and words below minCount left out. A word's font size is the square root of its weight, mapped onto minSize-maxSize (12 and 48 pixels).
Words are placed on a seeded Archimedean spiral from the centre, biggest first, each at the first spot where it is inside the plot and clear of every word already placed, so no two word boxes overlap, and the same data and seed always draw the same picture. rotate takes 0, a list such as [0, 90], or 'random'. Colour comes from the scheme (a bare array or a { word: colour } map) or from a colour column.
A word that does not fit is never dropped silently. chart.provenance().dropped counts the words that were wanted and not drawn (droppedBy splits them into beyond maxWords and no room), and the same count is written under the picture and in the accessible table. Clicking a word filters the grid to the rows that contain it - whole words only, so cat
does not match concatenate
- or, with selection: true, selects them; filterOnClick: false turns the click off. The cloud follows the grid's own filters. The pieces are exported for a host that wants them on their own: bindWordCloud(grid, spec) reads the words and counts, tokenise(text) splits text, layoutWords(words, plot, opts) is the spiral placement (opts.measure takes your own text-width function; in a browser the chart measures with the real font), sizeFor(weight, lo, hi, minSize, maxSize) is the square-root size rule, wordFilter(bound, spec, word) is the filter a click applies, seededRandom(seed) is the generator rotate: 'random' draws from, and STOP_WORDS is the built-in list.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 800, height: 500 });
const panel = document.createElement('div');
panel.rect = { width: 800, height: 500, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
// Importing the module registers `wordCloud`; its drawer is drawWordCloud.
const cloud = await import('../packages/modules/chart-wordcloud/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'comment', type: 'text' }],
rows: [
{ id: 'a', comment: 'The quick brown fox jumps over the lazy dog' },
{ id: 'b', comment: 'A fast brown dog and a quick fox' },
{ id: 'c', comment: 'Lazy lazy afternoon for the dog' },
],
});
// Free text: every comment is split into words and counted; "the", "a" and
// "for" are dropped by the built-in stop list.
const chart = createChart({ grid, container: panel, type: 'wordCloud', text: 'comment', seed: 3 });
const words = chart.data().words.map((w) => w.word + ' ' + w.weight);
const drawn = chart.provenance();
// Tokenising on its own, with no chart:
const split = cloud.tokenise("Don't panic, 42 times!").join('|');
// The pieces on their own: the binding, the spiral placement, the size rule, the click's
// filter, the seeded generator and the stop list.
const bound = cloud.bindWordCloud(grid, { text: 'comment' });
const laid = cloud.layoutWords(bound.words.slice(0, 3), { left: 0, top: 0, right: 400, bottom: 300 },
{ minSize: 12, maxSize: 40, rotate: 0, seed: 1 });
const parts = [
cloud.sizeFor(25, 1, 81, 10, 50),
cloud.seededRandom(7)() === cloud.seededRandom(7)(),
cloud.STOP_WORDS.includes('the'),
cloud.wordFilter(bound, {}, 'dog').op,
laid.placed.length + '/' + laid.unplaced.length,
];
return [words.slice(0, 3).join(', '), 'dropped ' + drawn.dropped, split, typeof cloud.drawWordCloud, parts.join(' ')].join(' / ');
Venn - two or three sets, with the counts to prove it
modules/chart-venn registers venn. Membership is either boolean columns (sets: ['admin', 'billing']: a row is in a set when its column is true) or one multi-value column (set: 'tags', an array or text split by separator, with members naming the sets to compare). Each circle's area is proportional to its set's size and the distance between circles is fitted to how many rows are in both. For two sets the fit is exact (chart.provenance().venn.error is 0); three circles cannot always give all seven regions their own proportional area, so the layout is a deterministic best fit and provenance().venn.error reports the largest gap, over the regions, between a region's share of the drawn union and its share of the rows, with each region's own in regionErrors.
Every region's count is counted off the grid's own (filtered) rows and written in it; rows in no set are counted apart (chart.data().nonMembers) and printed under the picture. Clicking a region filters the grid to exactly the rows of that region - in those sets and in none of the others - or, with selection: true, selects them; filterOnClick: false turns the click off. bindVenn(grid, spec) reads the membership and counts the regions, distanceFor(r1, r2, overlap) solves the centre distance for an overlap area, regionFilter(bound, mask) is the filter a click applies, and MAX_SETS is 3.
More than three sets are refused by name. Four circles cannot each have a region of their own, and a picture that dropped one would invent the answer, so the chart shows its empty state and says once why: a venn chart draws two or three sets and was given 4
. The way out it points at is an UpSet-style chart: group the grid by the membership columns and draw a bar chart of the row count per combination, which scales to any number of sets.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 600, height: 420 });
const panel = document.createElement('div');
panel.rect = { width: 600, height: 420, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
// Importing the module registers `venn`; its drawer is drawVenn.
const venn = await import('../packages/modules/chart-venn/index.js');
// Who has which role: 30 admins only, 20 billing only, 12 both, 5 neither.
const rows = [];
const add = (n, admin, billing) => { for (let i = 0; i < n; i++) rows.push({ id: 'r' + rows.length, admin, billing }); };
add(30, true, false); add(20, false, true); add(12, true, true); add(5, false, false);
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'admin', type: 'boolean' }, { field: 'billing', type: 'boolean' }],
rows,
});
const chart = createChart({ grid, container: panel, type: 'venn', sets: ['admin', 'billing'] });
// Every region's count is taken from the rows; the two-set overlap is exact.
const bound = chart.data();
const counts = bound.regions.map((r) => r.names.join(' + ') + ' ' + r.count).join(', ');
const fit = chart.provenance().venn;
const [a, b] = venn.fitCircles(bound).circles;
const lens = venn.lensArea(a.r, b.r, Math.hypot(b.x - a.x, b.y - a.y));
const again = venn.bindVenn(grid, { sets: ['admin', 'billing'] });
return [
counts, 'outside ' + bound.nonMembers, 'exact ' + fit.exact, 'overlap ' + Math.round(lens),
'total ' + again.total, 'touch ' + venn.distanceFor(3, 4, 0),
'filter ' + venn.regionFilter(bound, 3).conditions.length, 'max ' + venn.MAX_SETS,
].join(' / ');
Bar chart race - the top N at each moment, animated
modules/chart-barrace registers barRace: x is the category, time the time column (a year, a date, an ISO string) and y the value. Every distinct time is a frame; the chart draws the top topN (10) categories of the frame the clock stands at, and between two frames the values and the ranks are interpolated, so a bar grows, shrinks and slides past its neighbour instead of jumping. A category with no row at a frame between two it does have is interpolated across the gap; before its first and after its last row it is absent and grows in from, or shrinks out to, below the board.
The time is written large behind the bars and there is a real control strip beside the plot: a play / pause button, a speed button (0.5× to 4×), a scrubber (an <input type="range">, so the arrow keys, Home and End work and it is announced with the frame's name) and the current time. Under prefers-reduced-motion: reduce (or reducedMotion: true) playback steps a whole frame at a time and a scrub lands on the nearest frame: nothing is tweened. A click on a bar selects that category's rows at the frame, and Select frame rows
selects every row the visible bars stand for. The race reads the grid's rows, so it follows the grid's filters. autoplay, speed, loop and format are the other options; bindBarRace(grid, spec) is the binding on its own, with ranking(frame) and barsAt(position) for the numbers behind the picture.
One time across charts. The grid's own scrubber moves the grid through its change log, which is not a column of the data, so the race runs on its own clock: createFrameClock() makes one, and passing the same clock as clock to this chart and to a motion chart makes them play, pause and scrub together; clock.subscribe lets a host move its own filter with it. Cost: binding is one pass over the rows and each frame's ranking is computed the first time it is needed and kept, so scrubbing never re-ranks - 200 categories over 100 frames bind in about 250 ms (mostly reading the cells) and rank all 100 frames in about 5 ms. The tween is one small function, tween in charts/frames.js, which the general chart animation engine will take over.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 800, height: 500 });
const panel = document.createElement('div');
panel.rect = { width: 800, height: 500, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
// Importing the module registers `barRace`; its drawer is drawBarRace.
const race = await import('../packages/modules/chart-barrace/index.js');
const sizes = { A: [100, 120, 130, 140], B: [80, 110, 150, 190], C: [60, 70, 80, 90], D: [10, 20, 30, 200] };
const rows = [];
for (const [name, values] of Object.entries(sizes)) {
values.forEach((pop, i) => rows.push({ id: name + i, country: name, year: 2000 + i, pop }));
}
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'country', type: 'text' }, { field: 'year', type: 'number' }, { field: 'pop', type: 'number' }],
rows,
});
// One clock; handing the same one to a motion chart would make the two play as one.
const clock = race.createFrameClock({ reducedMotion: false, schedule: { request: () => 0, cancel: () => {} } });
const chart = createChart({
grid, container: panel, type: 'barRace', x: 'country', time: 'year', y: 'pop', topN: 3, clock,
});
const bound = chart.data();
const order = (frame) => bound.ranking(frame).map((e) => e.category).join('');
// Halfway between 2001 and 2002, A and B are crossing: both half way between ranks 0 and 1.
const mid = Object.fromEntries(bound.barsAt(1.5).map((b) => [b.category, b]));
clock.seek(2003);
const painted = [...panel.querySelectorAll('rect')].filter((n) => n.getAttribute('data-category')).map((n) => n.getAttribute('data-category')).join('');
const again = race.bindBarRace(grid, { x: 'country', time: 'year', y: 'pop', topN: 3 });
return ['2000 ' + order(0), '2003 ' + order(3), 'A value ' + mid.A.value + ' rank ' + mid.A.rank, 'scrubbed to ' + painted,
'frames ' + again.frames.labels.length].join(' / ');
Motion chart - bubbles moving through time
modules/chart-motion registers motion: one bubble per entity, placed by two numeric columns (x, y), sized by size (radius by the square root, so area follows the value) and coloured by colour (one scheme colour per distinct value; a legend click hides a category), gliding through the frames of time. Between two frames position and size are interpolated; an entity with no row at a frame between two it does have is interpolated across the gap, and is absent before its first and after its last row.
The axes are fixed over the whole run - the extents of every reading - so a bubble moving is the only thing that moves. scale: { x: 'log', y: 'linear' } picks each axis; a log axis labels 1-2-5 of each power of ten, and a reading at or below zero cannot sit on it, so it is left out and counted in chart.provenance().dropped. Trails: trails: true draws the path so far of every entity the grid has selected (a click on a bubble selects that entity's rows, so clicking one gives it a trail), and trails: ['Chad', 'Peru'] names entities outright. Hover shows that entity's values at the frame on screen; click selects its rows in the grid; with selection: true the other bubbles dim.
The controls, the large time label, the reduced-motion behaviour (whole frames, nothing tweened) and the clock are the bar chart race's: pass the same createFrameClock() to both as clock and they play, pause and scrub as one. The chart reads the grid's rows, so it follows the grid's filters; bindMotion(grid, spec) is the binding on its own, with positionsAt(position) for every entity's reading at a frame.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 800, height: 500 });
const panel = document.createElement('div');
panel.rect = { width: 800, height: 500, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
// Importing the module registers `motion`; its drawer is drawMotion.
const motion = await import('../packages/modules/chart-motion/index.js');
const data = {
Aland: { region: 'north', income: [100, 200, 400], life: [50, 55, 60], pop: [10, 12, 14] },
Cland: { region: 'south', income: [10, 20, 40], life: [40, 50, 60], pop: [1, 2, 3] },
};
const rows = [];
for (const [country, d] of Object.entries(data)) {
for (let i = 0; i < 3; i++) {
rows.push({ id: country + i, country, year: 2000 + i, region: d.region, income: d.income[i], life: d.life[i], pop: d.pop[i] });
}
}
const grid = createHeadlessGrid({
rowKey: 'id',
selection: { mode: 'multiple' },
columns: [
{ field: 'country', type: 'text' }, { field: 'year', type: 'number' }, { field: 'region', type: 'text' },
{ field: 'income', type: 'number' }, { field: 'life', type: 'number' }, { field: 'pop', type: 'number' },
],
rows,
});
const clock = motion.createFrameClock({ reducedMotion: false, schedule: { request: () => 0, cancel: () => {} } });
const chart = createChart({
grid, container: panel, type: 'motion',
entity: 'country', time: 'year', x: 'income', y: 'life', size: 'pop', colour: 'region',
scale: { x: 'log' }, trails: true, clock,
});
// The reading of every country at a frame, and half way between two frames.
const at = (p) => chart.data().positionsAt(p).map((r) => r.entity + ' ' + r.x + ',' + r.y).join('; ');
grid.selection.set(['Aland0']); // selecting a country gives it a trail
clock.seek(2001.5);
const trail = panel.querySelectorAll('polyline').length;
const alone = motion.bindMotion(grid, { entity: 'country', time: 'year', x: 'income', y: 'life' });
return [at(0), 'half ' + at(0.5), 'trails ' + trail, 'dropped ' + chart.provenance().dropped, motion.logTicks(10, 1000).join(' '),
'readings ' + alone.positionsAt(0).length].join(' / ');
Map markers - a figure per location, coloured by its own rule
modules/chart-markermap registers markermap: one marker per row, placed by lon/lat over a geometry pack's outlines, showing the row's label and its value beside the dot. Two things come from the grid rather than from the chart, and that is the whole point of the type. The number is the value column's own formatted cell text, so a percentage, a currency or a unit reads on the map exactly as it reads in the table. The colour is whatever grid.formatting.styleFor(valueColumn, value) returns for that row - the rule's background, or its color where it sets no background - so a red / amber / green availability wall is one rule set on one column plus one chart configuration. There is deliberately no chart-level thresholds option and no colour column: the rules are the one source, and a legend lists the rules that actually fired, with each rule's own swatch.
With shapes it draws the pack's regions underneath, through the pack's own projection, and pans and zooms exactly as a geomap of that pack does; without shapes the markers fall back to the projection alone. A row whose coordinates are absent, non-numeric or outside ±180 / ±90 draws no marker and is counted in chart.data().unplaced, which the map also writes under itself. Labels are deconflicted by trying four positions in a fixed order - right of the dot, then left, then above, then below - and a label with nowhere to go is dropped rather than overprinted; labels: false turns them all off on a dense map and leaves the tooltip, which carries the name, the value, the coordinates and the status. With selection: true a click on a marker selects that row in the grid, and the grid's selection emphasises the marker.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 700, height: 460 });
const panel = document.createElement('div');
panel.rect = { width: 700, height: 460, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
// Importing the module registers `markermap`; its drawer is drawMarkerMap.
const markermap = await import('../packages/modules/chart-markermap/index.js');
const { pack } = await import('../packages/modules/geo-world-110m/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [
{ field: 'site', type: 'text' }, { field: 'lng', type: 'number' },
{ field: 'lat', type: 'number' }, { field: 'avail', type: 'number', format: '0.00%' },
],
rows: [
{ id: 'ldn', site: 'London', lng: -0.13, lat: 51.5, avail: 0.9995 },
{ id: 'syd', site: 'Sydney', lng: 151.2, lat: -33.87, avail: 0.9991 },
{ id: 'fra', site: 'Frankfurt', lng: 8.68, lat: 50.11, avail: 0.9962 },
{ id: 'nyc', site: 'New York', lng: -74.0, lat: 40.71, avail: 0.9805 },
],
});
// Three rules on the column. Nothing below repeats a threshold or a colour.
grid.formatting.add('avail', { when: { op: 'gte', value: 0.999 }, style: { background: '#1b7f3b' }, label: 'Healthy' });
grid.formatting.add('avail', { when: { op: 'gte', value: 0.99 }, style: { background: '#c8a415' }, label: 'Watch' });
grid.formatting.add('avail', { when: { op: 'lt', value: 0.99 }, style: { background: '#c0392b' }, label: 'Breached' });
const chart = createChart({
grid, container: panel, type: 'markermap',
lon: 'lng', lat: 'lat', label: 'site', value: 'avail', shapes: pack,
});
// What was painted: a fill per marker, and the text beside each dot.
const fills = [];
const labels = [];
const walk = (node) => {
for (const child of node.children || []) {
const cls = String(child.getAttribute('class') || '');
if (cls.includes('markermap-dot')) fills.push(child.getAttribute('fill'));
if (cls.includes('data-label')) labels.push(child.textContent);
walk(child);
}
};
walk(chart.element);
// The binder is public too, for a host that wants the placed rows itself.
const bound = markermap.bindMarkerMap(grid, { lon: 'lng', lat: 'lat', label: 'site', value: 'avail' });
chart.destroy();
return `${fills.join(' ')} | ${labels.join(' / ')} | unplaced ${bound.unplaced}`;
Maps from a geometry column
markermap, bubblemap and a third type, choropleth (modules/chart-choropleth), can all place a row from one geometry column instead of a lon/lat pair: geometry: '<col>'. Giving both is refused by name and draws nothing - a row is placed by one or the other. A Point is placed at its own coordinates; a LineString, a Polygon or any Multi* kind is placed at its centroid (planar, in degrees, the same reading ST_Centroid gives). A cell the column refused at ingest, or whose bounding box leaves the globe, is counted - in chart.data().unplaced, in the note the map draws under itself and in the accessible table - never drawn at 0,0.
choropleth is the one of the three that needs no geometry pack at all: it draws each row's own Polygon/MultiPolygon straight from the column, filled by y through the chart's ramp (the same ramp/legend every ramp-coloured type uses); shapes is an optional pack drawn underneath as a backdrop. A Point row on a choropleth draws as a marker instead, filled the same way - which is what lets one dataset mix point sites and polygon territories on one map, as the example below does. A shape with many vertices is simplified for drawing, at the projection's pixel tolerance, by a hand-written Douglas-Peucker; the simplified ring exists only in the drawing - the cell, the export and the filters all keep every vertex. All three types honour the grid's filters (a filtered-out row disappears), pan and zoom, and take the drawing rail anchored in longitude and latitude - over mercator or equirectangular, the two projections with a written inverse; any other projection refuses the rail by name rather than build one that cannot place a drag.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 700, height: 460 });
const panel = document.createElement('div');
panel.rect = { width: 700, height: 460, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// The geometry column type, then the base charts module, then the extension.
await import('../packages/modules/geometry/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
// Importing the module registers `choropleth`; its drawer is drawChoropleth
// and its binder, for a host that wants the placed rows itself, bindChoropleth.
const choropleth = await import('../packages/modules/chart-choropleth/index.js');
// A square GeoJSON Polygon literal, one sales territory.
function zone(x, y, s, id, sales) {
return {
id, name: id.toUpperCase(), sales,
site: { type: 'Polygon', coordinates: [[[x, y], [x + s, y], [x + s, y + s], [x, y + s], [x, y]]] },
};
}
// Ten point rows - sites - and three polygon rows - territories -
// in the SAME geometry column: a choropleth draws a Point as a marker and
// anything with area as its own shape, both filled on the one ramp.
const rows = [];
for (let i = 0; i < 10; i++) {
rows.push({
id: `p${i}`, name: `Site ${i}`, sales: 5 + i,
site: { type: 'Point', coordinates: [i, i] },
});
}
rows.push(zone(0, 20, 3, 'zoneA', 12), zone(4, 20, 3, 'zoneB', 28), zone(8, 20, 3, 'zoneC', 44));
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [
{ field: 'name', type: 'text' },
{ field: 'site', type: 'geometry' },
{ field: 'sales', type: 'number' },
],
rows,
});
const chart = createChart({
grid, container: panel, type: 'choropleth', geometry: 'site', y: 'sales', label: 'name',
});
// What was painted: a marker per Point row, a shape per polygon row.
const points = [];
const features = [];
const walk = (node) => {
for (const child of node.children || []) {
const cls = String(child.getAttribute('class') || '');
if (cls.includes('choropleth-point')) points.push(child.getAttribute('data-row'));
if (cls.includes('choropleth-feature')) features.push(child.getAttribute('data-row'));
walk(child);
}
};
walk(chart.element);
const unplaced = chart.data().unplaced;
// The binder is public too, and agrees with what was drawn.
const bound = choropleth.bindChoropleth(grid, { geometry: 'site', y: 'sales', label: 'name' });
const fns = [typeof choropleth.drawChoropleth, typeof choropleth.bindChoropleth];
chart.destroy();
return `${points.length} points, ${features.length} polygons | unplaced ${unplaced} | fns ${fns[0] === 'function'} ${fns[1] === 'function' && bound.features.length === 13}`;
A map over a paged relation asks the engine for its view, and caps what it draws (GEO-6)
Over a paged pushdown source, a map draws the rows in its view, not the grid's page. A markermap, bubblemap or choropleth beside a grid holding 200 rows of 35,000 draws the 35,000 - or as many as are in view - not the 200. When the grid holds fewer rows than match, the map asks the source itself: a count through source.aggregate(), then the rows through the adapter's own execute - the grid's filter and quick search applied, no range window, capped at spec.viewportCap rows (default 20,000) - and, once the reader has panned or zoomed, a condition for the box on screen: a withinBbox on a geometry column (pushed as ST_CoveredBy by a spatial duckdbAdapter), or a between pair on lon/lat. The fetched rows are the map's own: nothing is injected into the grid, which keeps its page. A pan or a zoom asks again, aborting the request it supersedes, and the map keeps drawing its last answer under the new view until the next one lands. spec.aggregate governs this exactly as it governs a grouped chart: 'auto' asks only when the grid holds a window, 'engine' asks whenever the source can and is refused by name when it cannot, 'client' never asks. A projection without an invertible pair of scales (equalEarth, robinson, the conics) cannot name its box, so the map asks for the whole filtered relation, still capped.
Past the cap, density rather than a random subset. When more rows than viewportCap fall inside the view, a marker or bubble map draws square density cells, each filled by its count through the chart's ramp with a ramp legend: over a paged source the engine counts them (GEO-2's spatial buckets, { col, bucket: 'grid', size }, so a geometry column), and over rows the browser already holds the chart counts the same cells itself - 35,000 opaque bubbles at world scale are a blob whichever side counted them. The cell size follows the box the map is about to draw - the view, else a box the grid's filters hold on the map's column, else the data's own extent - so that box's longer side is 24 to 47 cells across (a power of two of degrees; a box is never binned at the world's size once one is known), and zooming in until the view holds no more than the cap returns to the rows. When the box the map draws changes by more than 2× in either axis, narrower or wider, the map refits its projection to the new box before drawing, so a few streets' points spread across the plot rather than bunching in the middle of a city's frame: a box set on the map from outside (a withinBbox through grid.filters.set() with viewportFilter on), or, at the initial view, the extent of the rows the grid's filters now keep (which also refits when it moves off the frame). A smaller change, and every pan, keeps the frame and moves the map under it. “Reset view” returns to the fit of the data. A choropleth has no honest density form, and an engine map that cannot bin (a lon/lat map, or an adapter without the grid bucket) has none either: past the cap each is refused by name (chart:viewportCap:over:*) and draws none of its rows, with the reason under the map. chart.provenance().viewport reports the rows drawn, how many matched the view, the cap, whether it was binned and how many cells, why nothing was drawn, the box, and whether the engine or the browser counted; the accessible summary says which, for example “12,480 points in this view, fetched from the data engine”.
A globe - the built-in maps on an orthographic projection
projection: 'orthographic' (alias 'globe') draws a choropleth, markermap or bubblemap as the hemisphere that faces the viewer, rather than a flat map: it is an option on the existing types, not a new module. Features on the far side are not drawn and cannot be hit; a polygon that crosses the horizon is cut there and closed along it, so land runs right to the edge of the disc. rotate: [lon, lat] is the point at the centre of the disc (default [0, 0]); drag turns the globe, wheel, + and - zoom in on it, Shift+arrows turn it from the keyboard and 0 returns it to rotate. There is no inertia, so nothing moves after the pointer lets go and reduced-motion preferences have nothing to switch off. The sphere's outline is drawn unless sphere: false; graticule: true (or { step }) draws the meridians and parallels, clipped at the horizon like everything else.
fitTo: 'selection' turns the globe to the middle of the rows the grid selects each time the selection changes, and chart.fitToSelection() does it once; it returns false when there is nothing to turn to. With viewportFilter the visible hemisphere is the filter: one withinDistance condition on the geometry column, a quarter of the Earth's circumference from the centre of the disc, rewritten as the globe turns, so every other viewer of the grid shows exactly the rows on the visible side. A globe that places rows by lon/lat has no single column to write that condition on, says so once and writes nothing. A globe has no annotate rail, because it has no pair of longitude/latitude axes to anchor one in.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 600, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
await import('../packages/modules/chart-markermap/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'site' }, { field: 'lng', type: 'number' }, { field: 'lat', type: 'number' }],
rows: [
{ id: 'ldn', site: 'London', lng: -0.13, lat: 51.5 },
{ id: 'fra', site: 'Frankfurt', lng: 8.68, lat: 50.11 },
{ id: 'nyc', site: 'New York', lng: -74.0, lat: 40.71 },
{ id: 'syd', site: 'Sydney', lng: 151.2, lat: -33.87 },
{ id: 'tok', site: 'Tokyo', lng: 139.7, lat: 35.7 },
],
selection: { mode: 'multiple' },
});
// Facing Europe: Sydney and Tokyo are on the far side, so they are not drawn.
const chart = createChart({
grid, container: panel, type: 'markermap', lon: 'lng', lat: 'lat',
projection: 'orthographic', rotate: [10, 20],
});
const drawn = () => {
const rows = [];
const walk = (node) => {
for (const child of node.children || []) {
if (String(child.getAttribute('class') || '').includes('markermap-dot')) rows.push(child.getAttribute('data-row'));
walk(child);
}
};
walk(chart.element);
return rows.sort().join(' ');
};
const facing = drawn();
chart.update({ rotate: [140, 10] });
const rotated = drawn();
chart.update({ rotate: [10, 20] });
grid.selection.set(['syd']);
const turned = chart.fitToSelection() ? (chart.draw(), drawn().includes('syd') ? 'syd' : 'missed') : 'no';
chart.destroy();
return `facing: ${facing} | after rotate: ${rotated} | turned to selection: ${turned}`;
A hierarchy as a graph, as circles, or as a tree - forceTree, pack, tree
Three opt-in chart modules draw a hierarchy, each as its own import so a page pays only for the one it uses: modules/chart-forcetree registers forceTree, modules/chart-pack registers pack and modules/chart-tree registers tree. All three read the hierarchy the way every hierarchy chart reads it - from a parentId column (each row names its parent by that parent's id column, or by row key when no id is given), from a path column (Europe/France/Paris, split on pathSeparator, default /), or from the grid's own grouping. A row whose parent is empty, unknown or leads back to itself hangs off the root, so one bad row costs its place in the tree and never the drawing. y sizes the nodes (a branch is the sum of its leaves); with no y a row counts one. Colour is by depth, or by a column with colourBy, and scheme takes a bare array or a title map as on every chart. Every one follows the grid's filter, so a hidden row leaves the picture; with selection: true a click selects the rows at or beneath the node, and a selection made on the grid dims the nodes that hold none of it. The keyboard walks the nodes with the arrow keys and Enter does what a click does, every node is announced with its path, and the accessible data table lists every node as CEO › Engineering › Web.
forceTree is a force-directed graph whose only links are parent to child: the root is pinned at the centre and the rest settle around it under the network chart's force layout. It settles in a bounded number of ticks - iterations, 220 for the first layout and 90 when a fold or a drag resumes it - and then stops; nothing keeps simulating behind the chart. A click folds or opens a branch; a node can be dragged and stays where it is dropped. pack is circles inside circles: a leaf's area is proportional to its value, a branch is the circle that holds its children, siblings never overlap (padding sets the gap) and the packing is deterministic, so the same rows draw the same picture. A click on a branch - or on a leaf, which goes to the branch that holds it - zooms into it, and a click on the circle that fills the view zooms back out; names are written inside the circles they fit. tree is a tidy (Reingold-Tilford) node-link tree: orientation is 'top-down' (the default), 'left-right' or 'radial', linkShape is 'straight', 'elbow' (the default) or 'curved', and cluster: true draws a dendrogram with the leaves in one row at the deepest level. No two nodes at a level overlap, a click folds or opens a branch and the layout is recomputed, expandDepth starts the deeper branches closed, and labels: true writes each node's value after its name. A tree too big for the plot is drawn to fit and can be zoomed: the wheel and +/- zoom, a drag pans, 0 resets (panZoom: false leaves them to the page).
A fold, an unfold and a zoom are animated, unless the reader prefers reduced motion - then they jump - or animate: false.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { drawForceTree } = await import('../packages/modules/chart-forcetree/index.js');
const { drawPack } = await import('../packages/modules/chart-pack/index.js');
const { drawTree } = await import('../packages/modules/chart-tree/index.js');
const { document, root } = createTestDom({ width: 600, height: 420 });
const panel = () => {
const div = document.createElement('div');
div.rect = { width: 600, height: 420, top: 0, left: 0 };
root.appendChild(div);
return div;
};
// An org chart: each row names its boss. The three types register when their modules are imported.
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'name' }, { field: 'boss' }, { field: 'heads', type: 'number' }],
rows: [
{ id: 'ceo', boss: '', name: 'CEO', heads: 1 },
{ id: 'eng', boss: 'ceo', name: 'Engineering', heads: 6 },
{ id: 'web', boss: 'eng', name: 'Web', heads: 9 },
{ id: 'data', boss: 'eng', name: 'Data', heads: 5 },
{ id: 'ops', boss: 'ceo', name: 'Operations', heads: 4 },
{ id: 'infra', boss: 'ops', name: 'Infra', heads: 7 },
{ id: 'supp', boss: 'ops', name: 'Support', heads: 3 },
{ id: 'cfo', boss: 'ceo', name: 'CFO', heads: 2 },
],
});
const spec = { grid, parentId: 'boss', label: 'name', y: 'heads', animate: false };
// What was drawn: the circles (and the lines between them) in a chart.
const drawn = (chart, cls) => {
const out = [];
const walk = (node) => {
for (const child of node.children || []) {
if (String(child.getAttribute('class') || '').split(' ').some((c) => c.endsWith('__' + cls))) {
out.push({ x: +child.getAttribute('cx'), y: +child.getAttribute('cy'), r: +child.getAttribute('r'), branch: String(child.getAttribute('class')).includes('hbranch') });
}
walk(child);
}
};
walk(chart.element);
return out;
};
const click = (host, at) => {
const plot = host.querySelector('.lat-chartview__plot');
plot.rect = { left: 0, top: 0, width: 600, height: 420 };
plot.dispatchEvent({ type: 'click', clientX: at.x, clientY: at.y, bubbles: true });
};
// forceTree: eight nodes and seven parent-to-child links; a click folds Engineering away.
const hostA = panel();
const force = createChart({ ...spec, container: hostA, type: 'forceTree' });
const forceNodes = drawn(force, 'hnode').length;
const forceLinks = drawn(force, 'hlink').length;
click(hostA, drawn(force, 'hnode')[1]); // Engineering, the first child of the root
const folded = drawn(force, 'hnode').length;
// pack: five leaves; a click on the biggest (Web) zooms into the branch that holds it.
const hostB = panel();
const pack = createChart({ ...spec, container: hostB, type: 'pack' });
const circles = drawn(pack, 'hnode');
const leaves = circles.filter((c) => !c.branch).length;
click(hostB, circles.filter((c) => !c.branch).sort((a, b) => b.r - a.r)[0]);
const zoomed = drawn(pack, 'hnode').length;
// tree: a dendrogram lines the leaves up, so the CFO is level with the engineers it sits above;
// a plain tree leaves the CFO one level down, where its depth puts it.
const level = (chart) => { const c = drawn(chart, 'hnode'); return Math.round(c[7].y) === Math.round(c[2].y); };
const hostC = panel();
const dendrogram = createChart({ ...spec, container: hostC, type: 'tree', cluster: true, panZoom: false });
const hostD = panel();
const plain = createChart({ ...spec, container: hostD, type: 'tree', panZoom: false });
const aligned = level(dendrogram);
const notAligned = level(plain);
click(hostD, drawn(plain, 'hnode')[1]);
const treeFolded = drawn(plain, 'hnode').length;
[force, pack, dendrogram, plain].forEach((chart) => chart.destroy());
const drawers = [drawForceTree, drawPack, drawTree].every((fn) => typeof fn === 'function');
return `forceTree: ${forceNodes} nodes ${forceLinks} links, folded to ${folded} | pack: ${leaves} leaves, zoomed to ${zoomed} circles | tree: dendrogram aligns the leaves ${aligned}, plain tree ${notAligned}, folded to ${treeFolded} nodes`;
A crosshair - a cursor that follows the pointer, and the same cursor on every chart in a group
cursor: true puts a vertical hairline on a line, step, area, rangeArea, bar, horizontalBar, waterfall, scatter, bubble, candlestick, ohlc or combo chart that follows the pointer and snaps to the nearest reading in the data, not the nearest pixel. A label at the axis end of the hairline words the x as the axis does (its axis.x.format, else the column's format.pattern and time zone, else the full-precision date or number), and the tooltip lists every series' value at that x - open, high, low and close on a candlestick. The object form changes any of it: y: true adds a horizontal hairline that follows the pointer with its own label in the measure axis's format (and the right axis's, on a dual-axis chart); snap: 'none' puts the hairline under the pointer and labels the value there; labels: false drops the labels; tooltip: 'single' lists only the series nearest the pointer and tooltip: false lists none.
Groups share the cursor by value, not by pixel. Charts that name the same cursor.group show the cursor together: hovering one at 14:05 puts a hairline at 14:05 on every other, wherever 14:05 falls in that chart's own plot, with the hairline and axis labels on every member, and the tooltip on one: the chart under the pointer, or the one cursor.set was called on, so the members' tooltips never stack on top of each other. cursor.groupTooltips: 'all' gives every member that has a reading at the shared x its own tooltip, at its own hairline and in its own host, as a group showed them in 1.86.0 (they overlap when the charts share a place); the default is 'pointer'. The option is read on each chart, so a follower that sets 'all' shows its tooltip even when the chart driving the group says 'pointer', and tooltip: false still shows none anywhere. Any other value warns once (chart:*:cursor:groupTooltips) and is treated as 'pointer'. A category axis matches the category by key; a time or number axis matches the value and, with snap: 'nearest', lands on that chart's nearest reading. A chart whose range does not cover the value shows nothing rather than pinning the cursor to its edge. The charts may be bound to different grids and have different ranges. Zoom is not shared unless asked: set cursor.zoom: true (and zoom: true) on each member and a zoom, pan or reset of one is applied to the others, reported there as a zoom event with reason: 'group'.
Keyboard and screen readers. While the chart has focus, ← and → (↑ and ↓ on a horizontalBar) move the cursor one reading, Home and End go to the first and last, and Esc removes it; the other pair of arrows still walks the marks. Every deliberate move (keyboard, set(), row hover) is read out through a polite live region as the shared tooltip's text - “Tue: one 11, two 110, three 1,001” - while a sweeping pointer is not, so a mouse user with a screen reader is not read a sentence per pixel.
A tooltip never outlives its chart. The hover tooltip is hosted on document.body (or the shadow root, or tooltipHost), outside the chart root. If the host removes the chart's container from the document without calling destroy(), an open tooltip is hidden and removed as soon as the chart root leaves the document; destroy() still removes it directly.
Drive it, and listen to it. chart.cursor.set(x) puts the cursor at a category, number or date (false when the chart cannot place it), chart.cursor.clear() takes it away and chart.cursor.get() says where it is. cursor:move carries x, label, values (each series' text and raw number) and a source of pointer, keyboard, api, row or group; cursor:leave says what took it away. cursor.rowHover: true moves the cursor to the x of the grid row the pointer is over (through the grid's cell:mouseover), and a mouse-out removes it. A chart redrawing under a held cursor keeps it at the same x. Asked for on any other type, or on small multiples, cursor warns once (chart:*:cursor:type, chart:*:cursor:multiples) and draws none.
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const mount = () => {
const panel = document.createElement('div');
panel.rect = { width: 600, height: 400, top: 0, left: 0 };
root.appendChild(panel);
return panel;
};
const readings = (xs) => createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 't', type: 'number' }, { field: 'cpu', type: 'number' }],
rows: xs.map((t, i) => ({ id: 'r' + i, t, cpu: t * 2 })),
});
// Two charts of one incident: different grids, different ranges, one group.
const a = createChart({
grid: readings([1, 2, 3, 4, 5, 6, 7, 8]), container: mount(), type: 'line', x: 't', y: 'cpu',
axis: { x: { scale: 'linear' } }, cursor: { group: 'incident' },
});
const panelB = mount();
const b = createChart({
grid: readings([5, 6, 7, 8, 9, 10, 11, 12]), container: panelB, type: 'line', x: 't', y: 'cpu',
axis: { x: { scale: 'linear' } }, cursor: { group: 'incident' },
tooltipHost: panelB, // b's tooltip host, to read it back: a follower shows no tooltip (default host: document.body)
});
const heard = [];
b.on('cursor:move', (e) => heard.push(e.source + '@' + e.x));
a.cursor.set(6); // b follows by value
const followed = b.cursor.get().x;
const rows = panelB.querySelectorAll('.lat-chartview__tooltip-row').length;
a.cursor.set(2); // b does not cover 2
const outside = b.cursor.get();
// A grid row drives the cursor, and so do the arrow keys.
const names = createHeadlessGrid({
rowKey: 'id', columns: [{ field: 'k' }, { field: 'v', type: 'number' }],
rows: ['a', 'b', 'c', 'd'].map((k, i) => ({ id: 'n' + i, k, v: i + 1 })),
});
const c = createChart({ grid: names, container: mount(), type: 'bar', x: 'k', y: 'v', cursor: { rowHover: true } });
names.emit('cell:mouseover', { key: 'n3' });
const hovered = c.cursor.get().x;
c.cursor.clear();
const plot = c.element.querySelector('.lat-chartview__plot');
for (let i = 0; i < 3; i++) plot.dispatchEvent(new TestEvent('keydown', { key: 'ArrowRight', bubbles: true }));
const keyed = c.cursor.get().x;
// Both hairlines, unsnapped, under a pointer; and a zoom carried across a group.
const panelF = mount();
const free = createChart({
grid: readings([1, 2, 3, 4]), container: panelF, type: 'line', x: 't', y: 'cpu',
axis: { x: { scale: 'linear' } }, cursor: { x: true, y: true, snap: 'none' },
});
free.element.querySelector('.lat-chartview__plot').dispatchEvent(new TestEvent('pointermove', { clientX: 300, clientY: 200 }));
const hairlines = panelF.querySelectorAll('.lat-chartview__cursor-line').length;
const zoomed = (grid) => createChart({
grid, container: mount(), type: 'line', x: 't', y: 'cpu', zoom: true,
axis: { x: { scale: 'linear' } }, cursor: { group: 'zoomed', zoom: true },
});
const z1 = zoomed(readings([1, 2, 3, 4, 5, 6]));
const z2 = zoomed(readings([1, 2, 3, 4, 5, 6]));
z1.zoom.set([2, 4]);
const carried = z2.state().zoom.x.range.join('-');
for (const chart of [a, b, c, free, z1, z2]) chart.destroy();
return `b follows ${followed} via ${heard[0]} | b at 2: ${outside === null ? 'nothing' : 'shown'} | rows ${rows} | row hover ${hovered} | keys ${keyed} | hairlines ${hairlines} | zoom ${carried}`;
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const mount = () => {
const panel = document.createElement('div');
panel.rect = { width: 600, height: 400, top: 0, left: 0 };
root.appendChild(panel);
return panel;
};
const readings = () => createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 't', type: 'number' }, { field: 'cpu', type: 'number' }],
rows: [1, 2, 3, 4].map((t, i) => ({ id: 'r' + i, t, cpu: t * 2 })),
});
const open = () => [...document.querySelectorAll('.lat-chartview__tooltip')]
.filter((n) => n.getAttribute('hidden') === null).length;
// Two charts in one group. By default only the chart cursor.set was called on shows a tooltip;
// groupTooltips: 'all' gives each chart with a reading at that x its own.
const pair = (group, groupTooltips) => [1, 2].map(() => createChart({
grid: readings(), container: mount(), type: 'line', x: 't', y: 'cpu',
axis: { x: { scale: 'linear' } }, cursor: { group, groupTooltips },
}));
const one = pair('only-pointer', 'pointer');
one[0].cursor.set(3);
const pointer = open();
for (const chart of one) chart.destroy();
const both = pair('every-chart', 'all');
both[0].cursor.set(3);
const all = open();
for (const chart of both) chart.destroy();
return `pointer: ${pointer} tooltip | all: ${all} tooltips`;
Drag a mark to edit the row behind it
editable: true lets a reader change the data by moving the chart. Drag a bar’s end, a line or area point, a scatter point, either end of a floating bar or the edge of a pie or donut slice, and the value is written back to the grid - through the same door a cell edit takes, not through a copy the chart keeps. While the mark is held the chart shows the value it would write, formatted by the measure’s column (a currency column reads £1,250); on release it issues the edit with grid.edit.setCells() and the gated 'user' origin. So everything that guards a cell guards the mark: the column’s validation, a beforeEdit handler’s veto, edit.enabled (a read-only column, a read-only row), the undo stack, change tracking and router write-back. The chart then redraws from the grid’s cell:changed, which is also what happens when the same edit is made in the table or undone with grid.edit.undo().
An edit the grid refuses springs the mark back to where the data has it and says why in a status line under the plot: the grid’s own validation message (“Must be at most 100.”), the reason a beforeEdit handler gave to preventDefault(reason) (“That change was not applied: frozen by finance”), or a plain “That change was not applied.” for a read-only cell. The chart raises edit when the grid accepted a write and editRejected (with reason and message) when it did not.
Pass an object to say more: axis is 'y' (the measure, the default), 'x' (a scatter or bubble point on a numeric x axis) or 'xy' (both, which writes two cells in one undo step); min and max limit what can be written; snap rounds a drag to a quantum (true rounds to step, a number rounds to that), where an unsnapped drag is continuous to about a pixel’s worth; step is the keyboard increment. A drag may run past the end of the axis - the tallest bar sits at the top of its plot - and the axis rescales after the edit. A pie or donut slice is dragged by its leading edge and writes the slice’s value so that the redrawn edge lies under the pointer: the slice takes its share from every other slice at once, since a pie’s angles are shares of the whole.
A mark that is several rows is not draggable. A bar that sums a category has no single cell to write, so its handle is drawn inert (dashed, aria-disabled) and an attempt is answered, by name, in the console (chart:editable:aggregated) and in the status line - never by quietly doing nothing. editable.distribute says how a new total is shared out: 'proportional' scales every row by one factor (equal shares when the total was zero) and 'first' puts the whole difference on the first row. It needs a measure reduced with sum or avg (another reduction is refused by name, chart:editable:distribute), writes the rows in one undo step, and keeps none of them if the grid refuses any one: a total shared across only some of its rows is not a thing anyone meant.
The keyboard and touch. Every handle is a focusable slider with one tab stop for the lot. On a vertical chart Up and Down change the value by step (Shift takes ten) and Left and Right move to the neighbouring mark; on a horizontal bar the two pairs swap; on an 'xy' scatter the arrows move along x and y and Page Up and Page Down move between marks. Enter commits through the same path as a drag, Escape puts the value back, and Home and End go to the first and last mark. A finger drags like a pointer: the handles turn off the browser’s own touch panning, and the grab area is larger for touch. A drag does not also count as a click, so it never filters or drills the chart.
What cannot be dragged, said by name. A stacked or percent chart has no single value at the end of a mark, small multiples and the other chart types have no handles, a floating bar on a time axis is edited in the grid, and a chart of more than 2,000 marks draws handles on the first 2,000 (chart:editable:type, chart:editable:axis, chart:editable:cap). The handles are not part of an export: toSVG() leaves them out.
const { createTestDom, TestEvent, flushFrames } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 700, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 700, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [
{ field: 'region' },
{ field: 'sales', type: 'number', edit: { enabled: true }, validation: { max: 100 } },
],
rows: [
{ id: 'n', region: 'North', sales: 10 },
{ id: 'e', region: 'East', sales: 40 },
{ id: 's', region: 'South', sales: 80 },
],
});
// Every bar's end is a handle; the keyboard moves it by `step` and Enter commits.
const chart = createChart({ grid, container: panel, type: 'bar', x: 'region', y: 'sales', editable: { step: 5 } });
const handles = () => {
const found = [];
const walk = (node) => {
for (const child of node.children || []) {
if (String(child.getAttribute('class') || '').includes('edit-handle')) found.push(child);
walk(child);
}
};
walk(chart.element);
return found;
};
const key = (node, name) => node.dispatchEvent(new TestEvent('keydown', { key: name }));
const status = () => {
let text = '';
const walk = (node) => {
for (const child of node.children || []) {
if (String(child.getAttribute('class') || '').includes('edit-status')) text = child.textContent;
walk(child);
}
};
walk(chart.element);
return text;
};
// The grid answers on the next turn, and the chart redraws from it on the next frame.
const settle = async () => { await new Promise((done) => setImmediate(done)); flushFrames(); };
let east = handles()[1];
for (let i = 0; i < 3; i++) key(east, 'ArrowUp');
key(east, 'Enter');
await settle();
const wrote = grid.rows.value('e', 'sales');
// A beforeEdit handler vetoes it: the grid keeps 55 and the chart says why.
const off = grid.on('beforeEdit', (event) => event.preventDefault('frozen by finance'));
east = handles()[1];
key(east, 'ArrowDown');
key(east, 'Enter');
await settle();
const vetoed = `vetoed: ${status()}, still ${grid.rows.value('e', 'sales')}`;
off();
// The column's own validation speaks for itself.
east = handles()[1];
for (let i = 0; i < 10; i++) key(east, 'ArrowUp');
key(east, 'Enter');
await settle();
const invalid = `invalid: ${status()}, still ${grid.rows.value('e', 'sales')}`;
// One undo restores the grid, and the chart follows.
grid.edit.undo();
chart.destroy();
return `keyboard wrote ${wrote} | ${vetoed} | ${invalid} | undone: ${grid.rows.value('e', 'sales')}`;
Gradient and pattern fills, and images as markers
Anywhere a chart takes a colour for a filled mark, it takes a fill object. A bar, an area, a pie or donut slice, a treemap cell, a band, and a map's land (basemap.fill) accept { gradient: 'linear', angle, stops: [[0, c1], [1, c2]] }, { gradient: 'radial', stops } or { pattern: 'hatch' | 'crosshatch' | 'dots' | 'grid', colour, background, size, angle }. The angle of a linear gradient is in CSS degrees (180, the default, runs top to bottom; 90 left to right); a hatch defaults to 45 degrees and a size of 8 pixels. Put one in a scheme array (scheme: [{ gradient: 'linear', stops }, '#d55e00']), in a title map (scheme: { Offline: { pattern: 'hatch', colour: '#a4262c' } }) or in a registered scheme, exactly where a colour would go; per row, fill takes a column id, { col } or a function of the point - what it answers is a fill object, a pattern name drawn in the series colour ('hatch') or a CSS colour, and nothing leaves the series colour. A per-row fill applies to the bar family, lollipops, scatter and bubble points, and outranks a conditional-formatting rule colour.
Definitions are written once per chart, and cannot clash. Each chart owns one <defs>, every id carries a per-chart prefix (lattice-fill-<chart>-<n>), and a mark's fill is url(#id) #solid: the colour after the reference is the SVG paint fallback, so an export that cannot carry definitions paints the solid - a gradient's first stop, a pattern's colour - rather than nothing. Legend and tooltip swatches show the fill itself, as CSS. Gradients and patterns are for filled marks: a stroke or a text fill takes the solid fallback, because a gradient across a hairline has no bounding box to paint, so a line series given a gradient draws in its first colour. An area drawn with a fill object is drawn at full opacity, since the fill carries its own. A fill object with no usable stops, or a pattern the module does not know, is said once (chart:fill:invalid:*) and drawn as a solid colour.
A pattern is the colour-blind-safe option. scheme: 'patterns' is the default hues each in its own texture - hatch, dots, crosshatch, a reverse hatch, a grid - so a series is told apart by pattern as well as hue, and survives a greyscale print.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 600, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'team' }, { field: 'hours', type: 'number' }, { field: 'look' }],
rows: [
{ id: 'a', team: 'Platform', hours: 32, look: 'hatch' },
{ id: 'b', team: 'Data', hours: 18, look: null },
{ id: 'c', team: 'Mobile', hours: 25, look: 'dots' },
],
});
// A gradient for the series, and a texture for the rows that name one.
const chart = createChart({
grid, container: panel, type: 'bar', x: 'team', y: 'hours',
scheme: [{ gradient: 'linear', stops: [[0, '#0072b2'], [1, '#56b4e9']] }],
fill: 'look',
});
const find = (tag) => {
const found = [];
const walk = (node) => {
for (const child of node.children || []) {
if (String(child.tagName).toLowerCase() === tag) found.push(child);
walk(child);
}
};
walk(chart.element);
return found;
};
const bars = find('rect').filter((n) => String(n.getAttribute('class') || '').includes('__bar'));
const paints = bars.map((n) => n.getAttribute('fill'));
const defined = find('lineargradient').length + find('pattern').length;
chart.destroy();
return `${bars.length} bars | ${defined} definitions | the plain bar falls back to ${paints[1].split(') ')[1]}`;
Pictures where a dot would be. marker: { image, size } draws an image at each point of a line, scatter or bubble chart, and at each site of a markermap. image is an icon name from the grid's own registry (the same icons a network node's icon uses, including anything the host adds with registerIcon), a URL, { col } to read either per row, or a function of the point. A URL is loaded once per page and remembered; until it has loaded, and if it never does, the point keeps the default dot - a failure is said once, by URL (chart:marker:image-failed:*), and never leaves a gap. Only http, https, blob, relative and data:image/… URLs are drawn; javascript: and any other data: are refused by name (chart:marker:unsafe-url:*) and the point keeps its dot. size is in pixels, 6 to 64 (default 16). On a line every reading wears the marker. A markermap hit-tests the picture's box rather than a circle; the tooltip of a cartesian chart still resolves by category. An image marker on an exported SVG keeps its URL, so a standalone file shows it only where that URL can be fetched.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 600, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 600, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'x', type: 'number' }, { field: 'y', type: 'number' }, { field: 'logo' }],
rows: [
{ id: 'a', x: 1, y: 4, logo: 'https://example.com/a.png' },
{ id: 'b', x: 2, y: 7, logo: 'javascript:alert(1)' }, // refused: keeps its dot
{ id: 'c', x: 3, y: 5, logo: 'https://example.com/c.png' },
],
});
const chart = createChart({
grid, container: panel, type: 'scatter', x: 'x', y: 'y',
marker: { image: { col: 'logo' }, size: 20 },
});
const count = (test) => {
let n = 0;
const walk = (node) => {
for (const child of node.children || []) { if (test(child)) n++; walk(child); }
};
walk(chart.element);
return n;
};
const pictures = count((n) => String(n.tagName).toLowerCase() === 'image');
const dots = count((n) => String(n.getAttribute('class') || '').includes('__point'));
chart.destroy();
return `${pictures} pictures, ${dots} default dot`;
A hierarchy as fitted Voronoi cells - voronoiTreemap
The opt-in module modules/chart-voronoi registers voronoiTreemap: a treemap whose cells are the regions of a weighted (power) Voronoi diagram instead of rectangles. The shape - a circle by default, or any convex polygon given as clip: [[x, y], …] in any units, y down, scaled to fit the plot (a non-convex outline is filled as its convex hull, and fewer than three usable points fall back to the circle) - is split into one cell per top-level branch, each branch's cell into one per child, down to the leaves. It reads the hierarchy as every hierarchy chart does: a parentId column, a path column or the grid's grouping, with y sizing the leaves (a row counts one with no y) and the grid's filter applied.
The areas are fitted, not approximated. Each level starts from seeded sites spread over its shape, relaxes them towards the centres of their cells (which keeps the cells round), and then fits the weights by damped Newton steps until every cell is within the tolerance of its target area; each level is fitted to a share of the tolerance, so that compounded down the tree every leaf is drawn within tolerance - 1% by default - of its value's share of the whole. iterations sets the relaxations per level (10 by default; more rounds the cells off further and never changes how closely the areas fit), and seed the starting sites: the same rows and seed draw the same picture to the last digit. The fitting runs on the main thread, once per change of the data: the layout is kept against a signature of the values and options, so a resize, a theme change or any other redraw only rescales it. Measured on the release machine, 500 leaves take about 300 ms flat and about 100 ms as 20 branches of 25 in Chrome (the first draw, fit included), and about 320 ms and 90 ms in Node; a hierarchy keeps each level small, which is what keeps it fast.
A leaf takes its top-level branch's colour, so the branches read as regions (colourBy colours by a column instead, and scheme takes a bare array or a title map); the border between two cells is drawn in the background colour and is thicker the higher up the tree they part, so the gap between two departments is wider than the gap between two teams, in either theme. A leaf is named at its centre when the whole name fits inside its cell. The tooltip names the cell with its path and its share, the arrow keys walk the cells and announce each with its path, the data table lists every cell as Engineering › Web, and with selection: true a click (or Enter) selects the rows at or beneath the cell while a selection made on the grid dims the cells that hold none of it. The drawing reports what the fitting reached as fit: the worst cell's error, whether every level met its tolerance, and the milliseconds it took.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const { drawVoronoiTreemap } = await import('../packages/modules/chart-voronoi/index.js');
const { document, root } = createTestDom({ width: 600, height: 420 });
const panel = () => {
const div = document.createElement('div');
div.rect = { width: 600, height: 420, top: 0, left: 0 };
root.appendChild(div);
return div;
};
// Head count by team, as a path column. Importing the module registers voronoiTreemap.
const grid = createHeadlessGrid({
rowKey: 'id',
selection: { mode: 'multiple' },
columns: [{ field: 'team' }, { field: 'heads', type: 'number' }],
rows: [
{ id: 'web', team: 'Engineering/Web', heads: 9 },
{ id: 'data', team: 'Engineering/Data', heads: 5 },
{ id: 'infra', team: 'Operations/Infra', heads: 7 },
{ id: 'supp', team: 'Operations/Support', heads: 3 },
{ id: 'cfo', team: 'Finance', heads: 2 },
],
});
const spec = { grid, type: 'voronoiTreemap', path: 'team', y: 'heads', selection: true };
// The cells that were drawn: each leaf's outline, read back off the path data.
const leaves = (chart) => {
const out = [];
const walk = (node) => {
for (const child of node.children || []) {
const cls = String(child.getAttribute('class') || '');
if (cls.includes('__vcell') && !cls.includes('__vbranch')) {
const xy = String(child.getAttribute('d')).match(/-?[\d.]+/g).map(Number);
let twice = 0;
let cx = 0;
let cy = 0;
for (let i = 0; i < xy.length; i += 2) {
const j = (i + 2) % xy.length;
const cross = xy[i] * xy[j + 1] - xy[j] * xy[i + 1];
twice += cross;
cx += (xy[i] + xy[j]) * cross;
cy += (xy[i + 1] + xy[j + 1]) * cross;
}
out.push({ d: child.getAttribute('d'), area: Math.abs(twice) / 2, x: cx / (3 * twice), y: cy / (3 * twice) });
}
walk(child);
}
};
walk(chart.element);
return out;
};
// Every leaf's area is its share of the whole, to within 1%.
const host = panel();
const chart = createChart({ ...spec, container: host });
const cells = leaves(chart);
const whole = cells.reduce((sum, c) => sum + c.area, 0);
const values = [9, 5, 7, 3, 2];
const worst = Math.max(...cells.map((c, i) => Math.abs(c.area / whole - values[i] / 26) / (values[i] / 26)));
// The same rows and seed draw the same picture.
const again = createChart({ ...spec, container: panel() });
const same = leaves(again).map((c) => c.d).join() === cells.map((c) => c.d).join();
// A click on a cell selects the rows beneath it.
const plot = host.querySelector('.lat-chartview__plot');
plot.rect = { left: 0, top: 0, width: 600, height: 420 };
plot.dispatchEvent({ type: 'click', clientX: cells[0].x, clientY: cells[0].y, bubbles: true });
[chart, again].forEach((c) => c.destroy());
const drawer = typeof drawVoronoiTreemap === 'function';
return `${cells.length} leaves, worst area error under 1%: ${worst < 0.01} | same rows, same picture: ${same} | click selects ${grid.selection.keys().join()} | drawer ${drawer}`;
Drill down. With drill: true, a click on a group - any cell that sits inside a branch of the level on show - redraws the chart as a Voronoi treemap of that branch's children alone, re-fitted to the whole shape within the same tolerance and grown out of the cell that was clicked. Deeper levels drill again. A breadcrumb (All › Engineering › …) names the path: click a crumb, or press Escape or Backspace, to go up a level. A cell that is a direct child of the level on show with nothing inside it is a leaf, and a click on it still selects its rows with selection: true. From the keyboard, Enter on a focused branch outline drills into it and Enter on a leaf selects. The drill event fires with the path from the top ({ path: ['Engineering'], label: 'Engineering' }, and an empty path on the way back to the top); the chart's accessible name and data table describe the level on show, with paths from the top. Drilling is the chart's own view: it never filters the grid. The new level grows out of the clicked cell over the chart's animation, and jumps under reduced motion or animation: false; going up always jumps.
const { createTestDom } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
await import('../packages/modules/chart-voronoi/index.js');
const { document, root } = createTestDom({ width: 600, height: 420 });
const host = document.createElement('div');
host.rect = { width: 600, height: 420, top: 0, left: 0 };
root.appendChild(host);
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'team' }, { field: 'heads', type: 'number' }],
rows: [
{ id: 'web', team: 'Engineering/Web', heads: 9 },
{ id: 'data', team: 'Engineering/Data', heads: 5 },
{ id: 'infra', team: 'Operations/Infra', heads: 7 },
{ id: 'supp', team: 'Operations/Support', heads: 3 },
],
});
// drill: true makes a click on a group zoom into it; animation: false jumps.
const chart = createChart({ grid, container: host, type: 'voronoiTreemap', path: 'team', y: 'heads', drill: true, animation: false });
const plot = host.querySelector('.lat-chartview__plot');
plot.rect = { left: 0, top: 0, width: 600, height: 420 };
const events = [];
chart.on('drill', (d) => events.push(d.path.join('/') || 'top'));
// The cells drawn, and their centres, read off the path data.
const cells = () => [...host.querySelectorAll('path.lat-chartview__vcell')]
.filter((el) => !String(el.getAttribute('class')).includes('__vbranch'))
.map((el) => {
const xy = String(el.getAttribute('d')).match(/-?[\d.]+/g).map(Number);
let x = 0;
let y = 0;
for (let i = 0; i < xy.length; i += 2) { x += xy[i]; y += xy[i + 1]; }
return { x: x / (xy.length / 2), y: y / (xy.length / 2) };
});
const before = cells().length;
// Click a cell inside Engineering: the chart redraws as Engineering's two teams.
const first = cells()[0];
plot.dispatchEvent({ type: 'click', clientX: first.x, clientY: first.y, bubbles: true });
const inside = cells().length;
// Escape goes back up.
host.querySelector('svg').dispatchEvent({ type: 'keydown', key: 'Escape', bubbles: true });
const after = cells().length;
chart.destroy();
return `${before} cells, drilled to ${inside} | events ${events.join(', ')} | back to ${after}`;
Responsive rules - a chart that changes shape at its container’s breakpoints
A chart that reads well at 640 px is cluttered at 300: the legend eats the width, the axis titles crowd the plot, the ticks collide. responsive is a list of rules, each a size condition and the options to lay over the chart while it holds: { maxWidth?, minWidth?, maxHeight?, minHeight?, set }. The sizes are the chart’s own container, watched as it resizes, not the viewport - so a chart in a narrow dashboard panel or layout window on a wide screen takes the narrow rules. Bounds are inclusive pixels, a rule needs every bound it gives, rules apply in order and a later rule wins. set takes any chart option; plain objects (legend, axis) merge key by key and anything else replaces. title, subtitle and footnote are built once and do not respond.
When a rule starts or stops matching the chart redraws with the merged options, and with none matching the base options are back exactly: the rules are always laid over what you set, never over the previous result, so a chart resized a thousand times is the chart resized once. Options you change later with chart.update() become the new base and the rules re-apply on top. A rule that is already active holds for 4 px past its edge, so a container sitting on a boundary does not flicker. Each change raises responsive after the redraw, with the rules now active ({ active: [{ index, auto, ...bounds }], width, height }), and chart.responsive reads them at any time.
responsive: 'auto' is a ready-made set: below 400 px the legend moves to the bottom, axis titles are hidden and the value axes take three ticks; below 250 px the legend goes too. It never switches on what you turned off (legend: false and axis: false stay off), and an explicit list of tick values is kept. The built-ins are ordinary rules listed first, so they are overridable: responsive: ['auto', { maxWidth: 400, set: { legend: { position: 'top' } } }] is the defaults with your own rule on top, and your rule wins.
const { createTestDom, triggerResize, flushFrames } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 1600, height: 900 });
const panel = document.createElement('div');
panel.rect = { width: 600, height: 360, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'q', type: 'text' }, { field: 'region', type: 'text' }, { field: 'sales', type: 'number' }],
rows: [
{ id: 'a', q: 'Q1', region: 'North', sales: 40 }, { id: 'b', q: 'Q1', region: 'South', sales: 25 },
{ id: 'c', q: 'Q2', region: 'North', sales: 55 }, { id: 'd', q: 'Q2', region: 'South', sales: 35 },
],
});
const changes = [];
const chart = createChart({
grid, container: panel, type: 'bar', x: 'q', y: 'sales', series: 'region',
axis: { x: { title: 'Quarter' }, y: { title: 'Sales' } },
responsive: 'auto',
});
chart.on('responsive', (e) => changes.push(e.active.length));
const look = () => {
const legend = panel.querySelector('.lat-chartview__legend');
const titles = panel.querySelectorAll('.lat-chartview__axis-title').length;
return legend.children.length ? `${legend.getAttribute('data-position')} ${titles} titles` : 'no legend';
};
const resize = (width) => {
panel.rect = { width, height: 360, top: 0, left: 0 };
triggerResize();
flushFrames();
};
const wide = look();
resize(380);
const narrow = look();
resize(230);
const tiny = look();
resize(600);
const back = look();
const events = changes.length;
// Your own rule after 'auto' wins over the built-in one. The second rule is a
// short, wide strip and does not match here: every bound it gives must hold.
chart.update({
responsive: [
'auto',
{ maxWidth: 400, set: { legend: { position: 'top' } } },
{ minWidth: 1000, minHeight: 100, maxHeight: 200, set: { legend: false } },
],
});
resize(380);
const override = panel.querySelector('.lat-chartview__legend').getAttribute('data-position');
chart.destroy();
return `600px: ${wide} | 380px: ${narrow} | 230px: ${tiny} | back: ${back} | events ${events} | rule override: ${override}`;
Animation - an entry that grows in, and updates that move rather than redraw
A chart plays its marks in when it first draws and plays every change in the grid from the old drawing to the new. animation is on by default: bars and columns grow from the baseline, lines and areas draw left to right, pie and donut slices sweep open, and points scale and fade in. When the rows change - a filter, a sort, a row update, live data - the marks interpolate from where they were to where they now belong, matched by identity (a series and a category, a slice’s label path), not by position: a bar that stays moves and grows rather than being redrawn, a bar whose category arrives grows in, a bar whose category leaves shrinks out, and the axis re-ticks smoothly, each tick label moving to where its number now sits. animation: false draws the final state at once.
// The defaults: a 400 ms ease-out entry and update.
createChart({ grid, container: '#sales', type: 'bar', x: 'region', y: 'sales' });
// Slower, linear, and no entry; or no animation at all.
createChart({ grid, container: '#trend', type: 'line', x: 'month', y: 'sales',
animation: { duration: 800, easing: 'linear', entry: false } });
createChart({ grid, container: '#static', type: 'pie', x: 'region', y: 'sales', animation: false });
The object form takes duration (milliseconds, default 400; 0 is no play), easing ('linear', 'ease-in', 'ease-out', 'ease-in-out' or a function of 0..1), entry and update (each true by default) and budget. A value the chart cannot read is named once in the console (chart:*:animation:invalid) and the default plays in its place.
The end state is the same document. A play writes the final drawing first, puts each mark back where it was, and plays it forward; at the end every attribute it touched is written back exactly as the unanimated draw wrote it, so an animated chart and the same chart with animation: false end identical, attribute for attribute. Everything that reads the chart’s data - the tooltip, the hit test, the crosshair, the drag handles, the accessible table, chart.data() - reads the final drawing from the moment the draw returns; only the paint is in between, so a crosshair or a handle never sits at a stale position. toSVG() and toPNG() always export the final picture, finishing a play in progress.
Who gets no movement. prefers-reduced-motion: reduce, an automated browser (navigator.webdriver, which Playwright, Puppeteer, Selenium and headless Chrome set, so an end-to-end test reads final geometry straight after a draw), a jsdom or happy-dom test DOM (which name themselves in the user agent), and a render with no window (Node, a server, the test DOM) all draw the final state at once. So does a resize or a zoom, which is the reader moving the view rather than the data changing. chart.provenance().animation says what the last draw did and why: { animated, reason, marks, budget }, where reason is 'entry', 'update', 'disabled', 'reduced-motion', 'automation', 'no-window', 'own-motion', 'budget' or 'view-change'.
Live data does not queue, and big charts do not stutter. A draw that lands mid-play retargets from where the marks are on screen - no jump back to either end - and there is only ever one play per chart, so a feed at ten updates a second shows marks heading for the latest data rather than working through a backlog. Above budget marks (default 2,000) a draw is written at its final state at once and provenance().animation.reason is 'budget': the per-frame cost of moving every mark grows with the number of marks, and the budget is set where a frame stays well inside its 16 ms in a real browser.
One engine for every type. Every chart in a window plays on one requestAnimationFrame loop, and every in-between value goes through one interpolation. The built-in types are animated by mark kind - a rect’s box, a circle’s centre and radius, a line’s ends, a slice’s angles, a path’s numbers when its commands match (a line that gains a point is drawn at its new shape at once rather than morphed through shapes the data never had), a label’s position. An extension type adopts it by drawing its marks through the toolkit’s Pool with a stable key (pool.next(tag, className, key)) or data-series/data-point: the base then animates it with no code of its own. A type that plays its own motion - the bar chart race, the motion chart, the hierarchy folds - declares animates: true in registerChartType and plays on the same loop and interpolation, handed to it as ctx.helpers.frameLoop and ctx.helpers.tween. Those types honour animation: false too: a fold jumps and a race steps whole frames.
The example runs in Node against a stand-in window whose frames it steps by hand, to read the play frame by frame: the entry starts from the baseline and is half grown half way through, and a change to East shrinks the same element rather than replacing it.
const { createTestDom, flushFrames } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
// A stand-in window whose frames are stepped by hand, so the play can be read frame by frame.
// In a page the chart uses the real one; with no window at all it draws the final state.
let now = 0;
let queue = [];
const view = Object.create(globalThis, { navigator: { value: { webdriver: false } } });
view.requestAnimationFrame = (fn) => queue.push(fn);
view.cancelAnimationFrame = () => {};
view.matchMedia = () => ({ matches: false });
const step = (ms) => {
for (let t = 0; t < ms; t += 16) {
now += 16;
const due = queue;
queue = [];
for (const fn of due) fn(now);
}
};
const { document, root } = createTestDom({ width: 600, height: 300 });
document.defaultView = view;
const panel = () => {
const el = document.createElement('div');
el.rect = { width: 600, height: 300, top: 0, left: 0 };
root.appendChild(el);
return el;
};
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'region' }, { field: 'sales', type: 'number' }],
rows: [
{ id: 'n', region: 'North', sales: 40 },
{ id: 'e', region: 'East', sales: 100 },
{ id: 's', region: 'South', sales: 70 },
],
});
// The same chart twice: one plays, one draws its final state at once.
const spec = { grid, type: 'bar', x: 'region', y: 'sales' };
const played = createChart({ ...spec, container: panel(), animation: { duration: 400, easing: 'linear' } });
const still = createChart({ ...spec, container: panel(), animation: false });
const bars = (chart) => [...chart.element.querySelectorAll('.lat-chartview__bar')];
const east = (chart) => bars(chart).find((b) => b.getAttribute('data-pool-key').endsWith(':East'));
const share = () => Math.round((100 * east(played).getAttribute('height')) / east(still).getAttribute('height'));
const same = () => bars(played).map((b) => b.getAttribute('height')).join() === bars(still).map((b) => b.getAttribute('height')).join();
// The entry: the bars grow from the baseline.
const entry = played.provenance().animation.reason;
step(16);
const start = share();
step(200);
const half = share();
step(400);
const settled = same();
// A change in the grid plays from the old drawing: East shrinks, it is not redrawn.
const element = east(played);
grid.rows.apply({ update: [{ id: 'e', sales: 50 }] });
flushFrames();
step(16);
const update = played.provenance().animation.reason;
step(200);
const shrinking = share() > 100 && east(played) === element;
step(400);
const budget = played.provenance().animation.budget;
const result = `${entry}: ${start}% then ${half}% then settled ${settled} | ${update}: East shrinking ${shrinking}, settled ${same()} | budget ${budget}`;
played.destroy();
still.destroy();
return result;
The three switches, run the same way: entry: false draws the first picture at once and still plays changes, update: false plays the entry and draws every change at once, and a budget below the number of marks means the chart never plays.
const { createTestDom, flushFrames } = await import('../packages/dom/src/renderer/testdom.js');
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
// A stand-in window, as above: frames that run only when asked.
let queue = [];
const view = Object.create(globalThis, { navigator: { value: { webdriver: false } } });
view.requestAnimationFrame = (fn) => queue.push(fn);
view.cancelAnimationFrame = () => {};
view.matchMedia = () => ({ matches: false });
const frame = () => { const due = queue; queue = []; for (const fn of due) fn(performance.now()); };
const { document, root } = createTestDom({ width: 600, height: 300 });
document.defaultView = view;
const panel = () => {
const el = document.createElement('div');
el.rect = { width: 600, height: 300, top: 0, left: 0 };
root.appendChild(el);
return el;
};
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'region' }, { field: 'sales', type: 'number' }],
rows: [
{ id: 'n', region: 'North', sales: 40 },
{ id: 'e', region: 'East', sales: 100 },
{ id: 's', region: 'South', sales: 70 },
],
});
const chart = (animation) => createChart({ grid, container: panel(), type: 'bar', x: 'region', y: 'sales', animation });
// entry: false draws the first picture at once; update: false draws every change at once;
// a budget of 2 marks is less than three bars, so this chart never plays.
const noEntry = chart({ entry: false });
const noUpdate = chart({ update: false });
const tight = chart({ budget: 2 });
const first = [noEntry, noUpdate, tight].map((c) => c.provenance().animation.reason);
grid.rows.apply({ update: [{ id: 'e', sales: 50 }] });
flushFrames();
frame();
const second = [noEntry, noUpdate, tight].map((c) => c.provenance().animation.reason);
for (const c of [noEntry, noUpdate, tight]) c.destroy();
return `first draw: ${first.join(', ')} | after a change: ${second.join(', ')}`;
Drawing on a chart - a tool rail anchored in the data
The declarative annotations above are written by whoever built the dashboard and state a standard the data is judged against. annotate is the other half: it gives whoever is reading the chart a tool rail to state an observation on it - this trend, this level, this retracement, this note. Set annotate: true for the whole rail on the left, or an object to narrow it: tools picks which buttons appear (always in rail order, so two charts never read differently), rail is left, top or none (the tools and the pointer behaviour without the strip, for a host driving them from its own toolbar), and readOnly paints what is in the state and refuses every change - including the rail, because a strip of buttons that do nothing is worse than no strip.
Eleven tools: select/move, trend line (two points), horizontal line and vertical line (one value, labelled with it), parallel channel (two parallel lines and a fill), Fibonacci retracement (0, 23.6, 38.2, 50, 61.8, 78.6 and 100 % between two points, each level labelled with its own value), arrow, text, callout (a label with a leader to a reading), note (a pin), and delete. Available on the cartesian types - line, step, area, rangeArea, bar, horizontalBar, waterfall, scatter, bubble - and on combo and candlestick; asking for it on any other type warns and draws no rail rather than silently doing nothing.
Every drawing is anchored in the chart’s own data coordinates, never in pixels. An anchor is { x, y } where x is a category, a number or a date exactly as the x scale reads one and y is a measure reading, and the pixels are recomputed from the live scales on every draw. So a line drawn across a spike stays across that spike when the container resizes, when a rolling window advances, when the grid is filtered and when the theme changes. On a category axis the pointer resolves to the nearest band - the category the reader was pointing at - and on a horizontal bar the projection follows the swapped axes, so the same drag anchors to the same readings either way round.
Everything drawn lives in chart.state() as plain JSON and comes back through chart.setState() as the same drawings; restoring is silent, because reloading a saved view is not an edit anybody made. Each change is reported as annotation:added, annotation:changed or annotation:removed with the item, subscribed through chart.on(…). An individual item may carry readOnly: true, which lets a reader select it and see what it is without being able to move, edit or delete it. Keyboard: the rail is one tab stop with the arrows, Home and End moving within it, every button carries its accessible name from the message catalogue, Escape cancels the tool in use and Delete removes the selection.
chart.setState({
annotations: [
{ kind: 'trendLine', anchors: [{ x: '2024-01-02', y: 118 }, { x: '2024-03-08', y: 154 }] },
{ kind: 'horizontalLine', anchors: [{ x: 0, y: 140 }] }, // a level; the label is its value
{ kind: 'callout', text: 'gap up', readOnly: true,
anchors: [{ x: '2024-02-19', y: 133 }, { x: '2024-02-26', y: 160 }] },
],
});
chart.on('annotation:added', ({ item }) => save(item)); // item.kind, item.anchors, item.text
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 640, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 640, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const grid = createHeadlessGrid({
rowKey: 'day',
columns: [{ field: 'day', type: 'number' }, { field: 'price', type: 'number' }],
rows: Array.from({ length: 20 }, (v, i) => ({ day: i, price: 100 + i * 4 + (i % 3) * 6 })),
});
const reported = [];
const chart = createChart({
grid, container: panel, type: 'line', x: 'day', y: 'price',
annotate: { tools: ['select', 'trendLine', 'fibonacci', 'delete'], rail: 'left' },
});
chart.on('annotation:added', (e) => reported.push(e.item.kind));
const plot = panel.querySelector('.lat-chartview__plot');
plot.rect = { left: 0, top: 0, width: 640, height: 400 };
// Press the trend-line tool and drag across the plot, exactly as a reader does.
const rail = panel.querySelector('.lat-chartview__annotate-rail');
const trend = [...rail.children].find((b) => b.getAttribute('data-tool') === 'trendLine');
trend.dispatchEvent(new TestEvent('click'));
for (const [type, x, y] of [['pointerdown', 120, 300], ['pointermove', 520, 120], ['pointerup', 520, 120]]) {
plot.dispatchEvent(new TestEvent(type, { clientX: x, clientY: y }));
}
// The drawing is anchored in DATA, so the same readings survive a resize.
const saved = JSON.parse(JSON.stringify(chart.state()));
const [a, b] = saved.annotations[0].anchors;
const before = panel.querySelector('.lat-chartview__drawn-line').getAttribute('d');
panel.rect = { width: 960, height: 400, top: 0, left: 0 };
plot.rect = { left: 0, top: 0, width: 960, height: 400 };
chart.draw();
const after = panel.querySelector('.lat-chartview__drawn-line').getAttribute('d');
const kept = JSON.stringify(chart.state()) === JSON.stringify(saved);
// The same drawings on a second chart that nobody may change.
const board = document.createElement('div');
board.rect = { width: 480, height: 300, top: 0, left: 0 };
root.appendChild(board);
const locked = createChart({
grid, container: board, type: 'line', x: 'day', y: 'price', annotate: { readOnly: true },
});
locked.setState(saved);
const shown = board.querySelectorAll('.lat-chartview__drawn-line').length;
const rails = board.querySelector('.lat-chartview__annotate-rail') ? 'a rail' : 'no rail';
chart.destroy();
locked.destroy();
return `tools ${rail.children.length}; ${saved.annotations[0].kind} day ${a.x.toFixed(1)}-${b.x.toFixed(1)}; `
+ `${before} then ${after}; anchors kept ${kept}; reported ${reported.join(',')}; `
+ `read-only ${shown} drawn and ${rails}`;
Measuring on a chart - Δx, Δy, percentage and slope
measure is the chart's ruler. Set measure: true and a one-button rail appears (or, beside annotate, the same measure tool joins its rail even when annotate.tools left it out). Press the button, or M with the chart focused, then press, drag and release between two points: a band is drawn corner to corner with a readout - Δx (an elapsed time such as 3 d 4 h on a time axis, a count of categories stepped over on a category axis, the plain difference on a numeric one), Δy in the y axis's own format, the percentage change (left out when the start is zero) and the slope (Δy per day on a time axis, per category, or per x unit; left out when Δx is zero).
One measure button, three axes. The rail has a single measure button. measure: { axes: 'x' | 'y' | 'xy' } says what it measures: 'xy' (the default) is the point-to-point ruler described above; 'x' draws the x bracket and reads the span between its ends (a bar or point count, elapsed time, and the summed volume column on a candlestick); 'y' draws the y bracket and reads the signed change and percentage. The older tool names measure-x, measure-y and measure-xy are deprecated: listed in annotate: { tools: [...] } they become the one measure button with the matching axes and a one-time developer warning; saved items of those kinds restore and render unchanged, without a warning.
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 640, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 640, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const grid = createHeadlessGrid({
rowKey: 'day',
columns: [{ field: 'day', type: 'number' }, { field: 'price', type: 'number' }],
rows: Array.from({ length: 20 }, (v, i) => ({ day: i, price: 100 + i * 4 })),
});
const reported = [];
const chart = createChart({
grid, container: panel, type: 'line', x: 'day', y: 'price',
measure: { axes: 'y', snap: false }, // one button; reads the signed change and percentage
});
chart.on('annotation:added', (e) => reported.push(e));
const plot = panel.querySelector('.lat-chartview__plot');
plot.rect = { left: 0, top: 0, width: 640, height: 400 };
const buttons = [...panel.querySelectorAll('[data-tool]')].map((b) => b.getAttribute('data-tool'));
panel.querySelector('[data-tool="measure"]').dispatchEvent(new TestEvent('click', {}));
const send = (type, x, y) => plot.dispatchEvent(new TestEvent(type, { clientX: x, clientY: y }));
send('pointerdown', 100, 300);
send('pointermove', 400, 120);
send('pointerup', 400, 120);
const [added] = reported;
return `buttons ${buttons.join()}; ${added.item.kind}; change ${Math.round(added.readout.change)}`;
Both ends snap to the nearest data point within 24 pixels; measure: { snap: false } keeps exactly what the pointer is over. On a dual-axis chart the axis under the start point is the one measured - a press in the left half of the plot reads the left axis, in the right half the right - and the readout is worded in that axis's format. A measurement is transient until pinned: Escape or the next drag clears it. Press Enter, or click on the band, to pin it (or set pin: true to pin on release); a pinned measurement is an ordinary drawing of kind measure with two data anchors - it follows zoom and pan, lives in chart.state() and is reported by annotation:added with a readout (dx, dxKind, duration, change, changePercent, slope, slopeUnit).
The keyboard has the whole tool. With the chart focused, M turns it on and puts a start marker at the reading nearest the middle; Left and Right step along the x positions, Up and Down along the readings at one x; Enter fixes the start, the arrows then move the end, and Enter again pins; Escape clears it and a second Escape leaves the tool. Each step is spoken through a polite live region (Measurement: Δx 3 d 4 h, Δy -30 (-25%), Slope -9.474 per day), the same text the band is labelled with. The readout labels are catalogue keys (chart.annotate.measure.*); English only until translated.
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 640, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 640, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const start = Date.UTC(2026, 0, 1);
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'at', type: 'datetime' }, { field: 'price', type: 'number', total: 'sum' }],
rows: [100, 120, 90, 150, 130, 160].map((price, i) => ({ id: `r${i}`, at: new Date(start + i * 76 * 3600000), price })),
});
const reported = [];
const chart = createChart({
grid, container: panel, type: 'line', x: 'at', y: 'price',
axis: { x: { scale: 'time' } }, measure: { snap: true, pin: false },
});
chart.on('annotation:added', (e) => reported.push(e));
const plot = panel.querySelector('.lat-chartview__plot');
plot.rect = { left: 0, top: 0, width: 640, height: 400 };
const press = (key) => plot.dispatchEvent(new TestEvent('keydown', { key, target: plot }));
// With the chart focused: M, an arrow to the start, Enter, an arrow to the end, Enter.
press('m');
press('ArrowLeft');
press('Enter');
press('ArrowRight');
press('Enter');
const lines = [...plot.querySelectorAll('text')]
.filter((n) => String(n.getAttribute('class') || '').includes('__drawn-label'))
.map((n) => n.textContent);
const [added] = reported;
return `${lines.join(' | ')}; pinned ${chart.state().annotations.length} ${added.readout.dxKind} ${added.readout.change}`;
Freehand drawing on a chart - the pen tool
The rail's pen draws a freehand stroke: press, drag, release. The stroke is stored in data coordinates - each point is an { x, y } reading (the x value; the y on the axis the stroke was drawn over, and on a dual-axis chart the side it started on, stored as axis: 'right') - so it re-projects exactly on a zoom, a pan, a resize and a responsive rule. The points are simplified on release (Ramer-Douglas-Peucker, within one pixel of what was drawn) and the path is smoothed when painted. Touch and stylus input draw the same way as the mouse. A stroke is selected with the select tool, moved as one piece, deleted like any drawing, saved in chart.state() and restored by chart.setState(), and raises annotation:added, annotation:changed and annotation:removed.
annotate: { pen: { colour, width } } sets the colour (a plain CSS colour value) and width in pixels (up to 24) for new strokes; the defaults follow the theme, light and dark. A stroke carries its own colour and width in the state. In the accessible table each stroke is listed as Freehand drawing from x0 to x1. There is no keyboard drawing - a freehand stroke is a pointer gesture, so the tool is not driven from the keyboard; the list in the table is how a reader who cannot see it is told it is there.
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 640, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 640, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const grid = createHeadlessGrid({
rowKey: 'day',
columns: [{ field: 'day', type: 'number' }, { field: 'price', type: 'number' }],
rows: Array.from({ length: 20 }, (v, i) => ({ day: i, price: 100 + i * 4 })),
});
const reported = [];
const chart = createChart({
grid, container: panel, type: 'line', x: 'day', y: 'price',
annotate: { pen: { colour: '#c0392b', width: 4 } },
});
chart.on('annotation:added', (e) => reported.push(e.item.kind));
const plot = panel.querySelector('.lat-chartview__plot');
plot.rect = { left: 0, top: 0, width: 640, height: 400 };
panel.querySelector('[data-tool="pen"]').dispatchEvent(new TestEvent('click', {}));
const wave = Array.from({ length: 40 }, (v, i) => [80 + i * 12, 200 + Math.round(Math.sin(i / 3) * 40)]);
const send = (type, p) => plot.dispatchEvent(new TestEvent(type, { clientX: p[0], clientY: p[1] }));
send('pointerdown', wave[0]);
wave.slice(1).forEach((p) => send('pointermove', p));
send('pointerup', wave[wave.length - 1]);
const [stroke] = chart.state().annotations;
const inData = stroke.anchors.every((a) => a.x >= 0 && a.x <= 19);
return `${stroke.kind} ${stroke.width}px ${stroke.colour}; simplified ${stroke.anchors.length < wave.length}; data x ${inData}; reported ${reported.join()}`;
Zooming a chart - five controls, the wheel, and a rolling window
brush: 'zoom' lets a reader drag a range out of a chart. It then leaves them there: no controls, no wheel, no pan, and no way back but a full reset. zoom is the whole interaction instead. zoom: true is the defaults; the object form narrows them: axes is x (the default), y or xy; buttons is hover (the default), always or none; wheel and pan switch those two gestures off; step is how much of the visible span one press of a control is worth (0.25 by default, so zoom in shows three quarters of what was shown and a pan moves by a quarter of it); and min is the narrowest the view may go, as a share of the full extent.
Five buttons - zoom in, zoom out, pan left, pan right, reset - fade in at the bottom edge of the plot on hover and on keyboard focus. The wheel zooms about the pointer, so the reading under it stays under it; dragging a zoomed plot pans it; on a touch screen a two-finger pinch zooms about the point between the fingers (the plot takes touch-action: none while it can zoom, so the page does not take the pinch); + and - zoom while the chart itself is focused, 0 resets, and so does a double-click. A configured brush keeps the drag - brushing is an explicit opt-in and panning is the resting gesture - so adding zoom to a chart that already brushes takes nothing away. Available on the cartesian types - line, step, area, rangeArea, bar, horizontalBar, waterfall, scatter, bubble, candlestick, ohlc - on combo, and on heatmap, whose columns zoom like a bar chart’s categories (its rows are a band with no measure behind them, so axes other than x warns there); asking for it on any other type warns and adds no controls rather than silently doing nothing. A time or numeric x zooms a range of values; a category x - and a bar or candlestick over dates, which draws them in bands - zooms to a contiguous run of categories; axes: 'xy' on a scatter or bubble zooms both axes, and a horizontal bar pans its categories with a vertical drag, the way they run.
A zoom composes with a rolling axis.x.window: the window is the maximum extent and the zoom is a range inside it. A view sitting at the newest end keeps the newest reading at the right edge as rows arrive and the window advances - which is what somebody watching a live feed wants - and a view the reader has panned back from stays exactly where they left it rather than being dragged forward. On a category axis the zoom counts categories, because the gaps between two bands are not values; the range is then stated as two of the chart’s own categories.
The view lives in chart.state() beside the drawings, as plain JSON that survives JSON.stringify, and comes back through chart.setState() - silently, because reloading a saved view is not a zoom anybody performed. Every move is reported as zoom with the range now drawn and what moved it (button, wheel, key, pan, pinch, reset, api, group or filter), and chart.zoom.set(range) and chart.zoom.reset() drive the same view from a host’s own toolbar. Every control is a real button carrying its accessible name from the message catalogue, the strip is one tab stop with the arrows moving within it, and a control that cannot act - pan on an un-zoomed chart, zoom in at min - is disabled rather than silently dead.
createChart({
grid, container, type: 'candlestick', x: 'at', measures: ['open', 'high', 'low', 'close'],
axis: { x: { window: { kind: 'time', span: 300000 } } }, // five minutes is the most you can see
zoom: { axes: 'xy', buttons: 'hover', step: 0.2 }, // ...and the zoom narrows that
});
chart.on('zoom', ({ x, y, reason }) => save({ x, y })); // reason: button | wheel | key | pan | reset | api
chart.zoom.set([start, end]); // or { x: [...], y: [...] }
chart.zoom.reset();
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 640, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 640, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const grid = createHeadlessGrid({
rowKey: 'day',
columns: [{ field: 'day', type: 'number' }, { field: 'price', type: 'number', total: 'sum' }],
rows: Array.from({ length: 21 }, (v, i) => ({ day: i, price: 100 + i * 4 })),
});
const moves = [];
const chart = createChart({
grid, container: panel, type: 'line', x: 'day', y: 'price',
zoom: { axes: 'x', buttons: 'always', wheel: true, pan: true, step: 0.25, min: 0.05 },
});
chart.on('zoom', (e) => moves.push(e.reason));
const plot = panel.querySelector('.lat-chartview__plot');
plot.rect = { left: 0, top: 0, width: 640, height: 400 };
// The x axis's own tick labels, read back off the SVG: what is drawn, not what
// the state says is drawn.
const axis = () => {
const ticks = [...plot.querySelectorAll('text')]
.filter((n) => String(n.getAttribute('class') || '').includes('lat-chartview__tick'))
.map((n) => ({ text: n.textContent, x: Number(n.getAttribute('x')), y: Number(n.getAttribute('y')) }));
const baseline = Math.max(...ticks.map((t) => t.y));
const row = ticks.filter((t) => t.y === baseline).sort((a, b) => a.x - b.x);
return `${row[0].text}..${row[row.length - 1].text}`;
};
const full = axis();
// Press the zoom-in button, exactly as a reader does.
const bar = panel.querySelector('.lat-chartview__zoom-bar');
const press = (name) => [...bar.children]
.find((b) => b.getAttribute('data-zoom') === name)
.dispatchEvent(new TestEvent('click'));
press('in');
const closer = axis();
press('left');
const panned = axis();
// A wheel notch a quarter of the way across magnifies about that reading.
press('reset');
plot.dispatchEvent(new TestEvent('wheel', { deltaY: -120, clientX: 185, clientY: 200 }));
const [from, to] = chart.state().zoom.x.range;
const share = ((5 - from) / (to - from)).toFixed(2);
// The view is plain JSON, and it restores into a second chart of the same data.
const saved = JSON.parse(JSON.stringify(chart.state()));
const board = document.createElement('div');
board.rect = { width: 640, height: 400, top: 0, left: 0 };
root.appendChild(board);
const copy = createChart({ grid, container: board, type: 'line', x: 'day', y: 'price', zoom: true });
const seen = [];
copy.on('zoom', (e) => seen.push(e.reason));
copy.setState(saved);
copy.draw();
const same = JSON.stringify(copy.state().zoom) === JSON.stringify(saved.zoom);
chart.destroy();
copy.destroy();
return `axis ${full} then ${closer} then ${panned}; day 5 at ${share} of the plot; `
+ `moves ${moves.join(',')}; restored ${same} silently ${seen.length === 0}`;
The grid following the zoom - viewportFilter on a cartesian chart
A zoom on its own only changes the picture. viewportFilter - the same option a map uses to filter its grid to the box on screen - makes the grid follow the visible range: once a pan or zoom settles (debounce, 150 ms by default), the chart writes one condition to grid.filters, so the table, a KPI and every other chart over the same grid show exactly the rows inside the view. It needs zoom (without it the option warns, chart:*:viewportFilter:zoom, and writes nothing) and is off by default.
- Time and numeric x:
betweenthe first and last reading in view, inclusive, written as the grid stores them. A view sitting at the newest end is writtengteits first reading instead, so a live feed’s new rows still reach the grid.viewportFilter.columnwrites the range to another column thanx. - Category x (and a bar, candlestick or heatmap over dates, drawn in bands):
inthe visible categories. - Scatter and bubble zoomed in y (
axes: 'xy'), when each point is one row: a secondbetweenon the measure column. A y zoom anywhere else - a line’s y is a total over the rows at one x, and so is a scatter’s when rows share an x - filters by x alone and says so once (chart:*:viewportFilter:y). SetperRow: trueon the spec to keep every row its own point instead of summing the ties, the way the regression diagnostic plots do over a predictor with heavy ties.
One condition per axis, owned by the chart. Each is its own single-column branch of the filter tree, so the column’s filter menu shows it and its Clear removes it, and each carries meta: { owner: 'chart-view', chart, axis }. The next pan or zoom replaces it; zooming back out to everything, reset, viewportFilter: false and chart.destroy() remove it. The rule for a filter you set yourself: the chart only ever replaces or removes a condition carrying its own mark. A condition you put on the same column - from the menu, the tool panel or filters.set - stays exactly as you wrote it and is ANDed with the chart’s, so the grid shows the rows both admit; when the chart’s condition goes, yours is still there. (The column menu edits a column’s filter as a whole, so clearing or replacing the column there removes the chart’s condition too, which resets the chart’s zoom.)
Two-way, and undoable. The condition is a filter like any other: grid.history.undo() takes it off and redo() puts it back. Clear it in the grid - the menu, the tool panel, filters.clear(), an undo - and the chart resets its zoom; an undo or a redo that puts one of the chart’s conditions back moves the chart’s view to it. Both are reported as zoom with reason: 'filter', and carried to a cursor.group with cursor.zoom like any other zoom. Grouped charts keep zooming together; a chart over the same grid narrows because the grid did.
const line = createChart({
grid, container, type: 'line', x: 'day', y: 'price',
zoom: true,
viewportFilter: true, // or { debounce: 300, column: 'tradeDate' }
});
line.on('zoom', ({ x, reason }) => { // reason 'filter' when the grid moved the chart
status.textContent = `${grid.rows.count()} rows in ${x[0]}..${x[1]}`;
});
const { createTestDom, flushFrames } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 640, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 640, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const grid = createHeadlessGrid({
rowKey: 'day',
columns: [{ field: 'day', type: 'number' }, { field: 'price', type: 'number', total: 'sum' }],
rows: Array.from({ length: 21 }, (v, i) => ({ day: i, price: 100 + i * 4 })),
});
const count = () => { let n = 0; grid.rows.forEach(() => { n++; }); return n; };
const settle = async () => { await new Promise((r) => setTimeout(r, 5)); flushFrames(); };
const chart = createChart({
grid, container: panel, type: 'line', x: 'day', y: 'price',
zoom: true, viewportFilter: { debounce: 0 },
});
const reasons = [];
chart.on('zoom', (e) => reasons.push(e.reason));
const before = count();
chart.zoom.set([5, 10]);
await settle();
const followed = count();
const [first, last] = grid.filters.get().conditions[0].value;
// A filter of your own on the same column is kept, and ANDed with the chart's.
const yours = { col: 'day', op: 'notIn', value: [7] };
grid.filters.set({ op: 'and', conditions: [...grid.filters.get().conditions, yours] });
await settle();
const kept = grid.filters.get().conditions.some((c) => c.col === 'day' && c.op === 'notIn');
const both = count();
// Clear the whole filter in the grid: the chart resets its zoom.
grid.filters.set(null);
flushFrames();
const gone = chart.state().zoom === undefined;
await settle();
chart.destroy();
return `rows ${before} then ${followed} (days ${first}..${last}); yours kept ${kept} and ANDed ${both}; `
+ `cleared in the grid: zoom gone ${gone}, reason ${reasons.at(-1)}, rows ${count()}`;
A price-and-volume pane - candles over volume on one x axis
A trading screen reads price candles with the volume traded in each period underneath, in one chart: one x axis, one window, one zoom, one tooltip. volume on a candlestick draws exactly that - a price pane and a volume pane in one svg - rather than two stacked charts that share an x and a window and have to be kept in step by hand. volume names the column holding each period’s traded volume, summed per period; volumePane.height is the volume pane’s share of the plot’s height, 0.25 by default.
Both panes are drawn on one x scale, so a candle and its volume bar share an x by construction, and the x axis is drawn once, under the volume pane. Everything that moves the x axis moves both panes together: a rolling axis.x.window, a pinned axis.x.min/max, the zoom’s buttons, wheel and pan, and chart.zoom.set(). Each volume bar takes its candle’s rise or fall colour - the scheme’s positive and negative - and the volume axis starts at zero on the same side as the price axis (axis.y.position). The tooltip, and the hover and click events, read both panes at the hovered period.
The price axis is still the chart’s y axis. A drawing is anchored in price, a y zoom narrows the price pane, and chart.state() holds the drawings and the zoom exactly as it does for a candlestick without volume. The volume column is bound as the fifth measure, after open, high, low and close, so it appears in chart.data() and in the accessible table, and a measurer’s volume figure sums it. volume on any other chart type, or naming a column the grid does not have, warns once and draws no pane. A candlestick without volume is drawn exactly as before.
createChart({
grid, container, type: 'candlestick', x: 'at',
measures: [{ col: 'open', fn: 'first' }, { col: 'high', fn: 'max' }, { col: 'low', fn: 'min' }, { col: 'close', fn: 'last' }],
volume: 'volume', // summed per period, drawn under the candles
volumePane: { height: 0.25 }, // a quarter of the plot
axis: { x: { scale: 'time', window: { kind: 'time', span: 1800000 } }, y: { position: 'right' } },
zoom: true, annotate: true, // both act on the one shared x axis
});
const { createTestDom, TestEvent } = await import('../packages/dom/src/renderer/testdom.js');
const { document, root } = createTestDom({ width: 640, height: 400 });
const panel = document.createElement('div');
panel.rect = { width: 640, height: 400, top: 0, left: 0 };
root.appendChild(panel);
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createChart } = await import('../packages/modules/charts/index.js');
const grid = createHeadlessGrid({
rowKey: 'day',
columns: ['day', 'open', 'high', 'low', 'close', 'volume'].map((field) => ({ field, type: 'number' })),
rows: Array.from({ length: 10 }, (v, i) => ({
day: i, open: 100 + i, high: 104 + i, low: 98 + i,
close: i % 3 ? 103 + i : 99 + i, // every third day falls
volume: 1000 + i * 250,
})),
});
const chart = createChart({
grid, container: panel, type: 'candlestick', x: 'day',
measures: ['open', 'high', 'low', 'close'],
volume: 'volume', volumePane: { height: 0.25 },
zoom: true,
});
const plot = panel.querySelector('.lat-chartview__plot');
plot.rect = { left: 0, top: 0, width: 640, height: 400 };
// Read back off the SVG: what is drawn, not what the options say.
const marks = (name) => [...plot.querySelectorAll('rect')]
.filter((n) => String(n.getAttribute('class')).split(' ').includes(`lat-chartview__${name}`));
const lined = () => {
const candles = marks('candle');
const bars = marks('volume');
const same = candles.every((c, i) => bars[i] && c.getAttribute('x') === bars[i].getAttribute('x'));
return `${candles.length} candles over ${bars.length} bars${same ? ', aligned' : ', MISALIGNED'}`;
};
const before = lined();
const priceBottom = Math.max(...marks('candle').map((n) => Number(n.getAttribute('y')) + Number(n.getAttribute('height'))));
const volumeTop = Math.min(...marks('volume').map((n) => Number(n.getAttribute('y'))));
const falls = marks('volume').filter((n) => n.getAttribute('data-direction') === 'down').length;
// One zoom narrows both panes.
chart.zoom.set([3, 6]);
const zoomed = lined();
// One tooltip reads both panes at the hovered day.
const first = marks('candle')[0];
const x = Number(first.getAttribute('x')) + Number(first.getAttribute('width')) / 2;
plot.dispatchEvent(new TestEvent('pointermove', { clientX: x, clientY: 100 }));
// The tooltip is hosted on document.body, not in the panel.
const tip = [...document.body.querySelectorAll('.lat-chartview__tooltip-row')].map((row) => row.textContent).join(' | ');
chart.destroy();
return `${before}; volume under price ${volumeTop > priceBottom}; ${falls} falling bars; `
+ `zoomed ${zoomed}; tooltip ${tip}`;
Type reference
Generated from the type declarations, so it always matches the release. Each surface lists its properties, its methods and the events it raises as three tables; an option or value type lists its members once.
The charts module
ChartTypeDefinition
The definition an extension chart type registers. `draw` receives the base drawing context - `plot`, `bound`, `groups`, `scheme`, `typography`, `fontSize`, `labels`, `grid`, `spec`, `doc` - plus `ctx.helpers`, the base's own toolkit of primitives (element factory, scales, axes, mark pool, distribution kernels), and appends its marks to the layer groups. `bind` optionally supplies the bound data (default: the by-series binder); `freeform` lays the chart out without axis gutters; `labelled` declares that `labels` applies.
| Property | Type | Description |
|---|---|---|
| freeform | boolean | Set it when the type lays itself out across the whole frame instead of drawing inside the axis gutters. (optional) |
| labelled | boolean | Whether the spec's `labels` option applies to this type. Default false. (optional) |
| hierarchical | boolean | Set it when the type draws a hierarchy: the base binds the grid's grouping, a `parentId` column or a `path` column into one tree and hands it to `draw` as `bound.root`, 0001652). (optional) |
| count | boolean | With `hierarchical`: a row counts one when the spec names no `y`, so a hierarchy with no measure still has a size. (optional) |
| animates | boolean | Set it when the type plays its own motion (a race, a fold): the base's animator then leaves its marks alone. A type that leaves it unset is animated by the base - its keyed `rect`, `circle`, `line`, `path` and `text` marks move, grow in and shrink out like a built-in type's. `ctx.helpers.frameLoop` and `ctx.helpers.tween` are the engine's own frame loop and interpolation, for a type that plays its own. (optional) |
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| draw | (ctx: object) => object | ctx: object | => object | Draws the type. It is handed the same context a built-in drawer gets - the plot rectangle, the bound data, the SVG groups, the scheme, the typography and the spec - plus `helpers`, the base's own element, scale, axis and pool primitives, and returns what it drew. |
| bind | (grid: Grid, spec: ChartSpec) => object | grid: Gridspec: ChartSpec | => object | Turns the grid and spec into the bound data `draw` receives. Omitted, the base's by-series binder is used. The bound data may carry `notices` - `{ id, message }` each - which the chart says once, by name, as a developer-facing warning. (optional) |
ChartRole
One data channel a chart type binds, for a generated editor: the stable channel name, the kind of field it takes, and the flags an editor reads. Carries no human label - that is the consuming UI's own i18n.
| Property | Type | Description |
|---|---|---|
| role | string | The channel name (`x`, `y`, `series`, `measures`, `size`, …). |
| accepts | 'dimension' | 'measure' | 'field' | The kind of field it binds. |
| path | string | The spec-options path the field is written to, when it differs from `role`. (optional) |
| required | boolean | Whether the type cannot draw without it. (optional) |
| repeatable | boolean | Whether more than one field may be bound. (optional) |
| aggregated | boolean | Whether it carries an aggregation (a measure). (optional) |
| bucketable | boolean | Whether a date in it may be bucketed by period. (optional) |
| aggPath | string | The sibling path an aggregation is written to, when it is not stored with the field (a KPI tile's `aggregation`). (optional) |
| entryKey | string | For a repeatable role whose entries are objects, the key the field is written to (a grid column's `field`). (optional) |
| entryOptions | ChartOption[] | The options each entry carries, named by their path inside the entry (a grid column's `layout.width`). (optional) |
| orderable | boolean | Whether the entries' order is meaningful, so an editor offers to move one earlier or later. (optional) |
ChartOption
One formatting option a chart type honours, for a generated editor: the dotted spec path and the control's machine-readable type, enum or range.
| Property | Type | Description |
|---|---|---|
| name | string | The dotted path into the chart spec (`title`, `axis.x.title`). |
| type | 'string' | 'boolean' | 'number' | 'enum' | 'list' | The control kind; `list` is an ordered list of strings, such as a palette's colours. |
| values | Array<string | boolean> | The choices, for an enum (a choice may be `true`, as `stack: true` is). (optional) |
| default | string | boolean | number | What the chart does when the option is unset, so a control shows the effective value. (optional) |
| format | 'number' | 'date' | 'colour' | What a string or list holds, when it is a format mask or a colour rather than free text. (optional) |
| min | number | The least a number may be. (optional) |
| max | number | The most a number may be. (optional) |
| step | number | The number's step. (optional) |
ChartEditorModel
The full generated-editor model for a chart type.
| Property | Type | Description |
|---|---|---|
| type | string | The chart type. |
| roles | ChartRole[] | Its data channels, in editor order. |
| options | ChartOption[] | Its formatting options, in editor order. |
| aggregations | ReadonlyArray<string> | The aggregations a measure role offers. |
Chart
Properties
| Property | Type | Description |
|---|---|---|
| element | HTMLElement | The chart's root element - the wrapper the chart built inside the container, which holds the heading, the SVG, the legend and the accessible table. The `<svg>` is a descendant of it, not this element. (read-only) |
| zoom | ChartZoomApi | Drive the chart's zoom from a host's own controls: `set` shows a range and `reset` shows everything again, each raising `zoom` and returning whether the view moved. Calling either on a chart with no `zoom` warns rather than silently doing nothing. (read-only) |
| cursor | ChartCursorApi | The host's handle on the crosshair: `set(x)`, `clear()` and `get()`. See {@link ChartSpec.cursor}. (read-only) |
| responsive | ChartActiveRule[] | The `responsive` rules matching the chart's container right now; empty when none match or the chart has none. (read-only) |
Methods
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| draw | (): void | - | void | Redraw now. |
| update | (spec: Partial<ChartSpec>): void | spec: Partial<ChartSpec> | void | Change the spec and redraw; unnamed keys keep their values. |
| data | (): object | null | - | object | null | The data the chart last bound. |
| provenance | (): ChartProvenance | - | ChartProvenance | How the figures the chart is drawing were computed (GEO-5): by a pushdown source's engine over the whole matching relation, or from the rows the grid has loaded - never a mixture - with the reason for a client chart and each measure's split in the shape `source.aggregate()` reports it. See {@link ChartSpec.aggregate}. |
| ascend | (levels?: number): void | levels?: number | void | Go up one level, on a drillable hierarchy. |
| fitToSelection | (): boolean | - | boolean | Turn a globe (`projection: 'orthographic'`) to the middle of the grid's selected rows. Returns true when it turned; false on a flat map, an empty selection or a selection with no position. See {@link ChartSpec.fitTo}. |
| on | (event: ChartEventName, handler: (payload: ChartEventPayloads[ChartEventName]) => void): () => void | event: ChartEventNamehandler: (payload: ChartEventPayloads[ChartEventName]) => void | () => void | Register an event handler; returns a function that unsubscribes. A handler that throws is reported to the console and the rest still run. What each event carries is {@link ChartEventPayloads}; the handler is declared with the widest of them, so narrow on the name inside it. |
| emit | (event: ChartEventName, payload?: object): object | event: ChartEventNamepayload?: object | object | Fire an event at the subscribers and at the matching `on<Event>` in the spec, and return the payload the handlers saw - which is how a caller reads back what a handler changed. |
| toSVG | (opts?: object): string | opts?: object | string | The chart as standalone SVG markup, empty string before the first draw. Pass `{ inlineStyles: true }` to copy the computed styles onto a clone, which is what an SVG loaded as an image needs to look like the chart on screen. |
| toPNG | (opts?: { scale?: number; background?: string }): Promise<Blob | null> | opts?: { scale?: number; background?: string } | Promise<Blob | null> | Rasterise the chart through the browser, so the picture is the one it drew. Styles are inlined first, and a white background is painted unless `background` says otherwise; `scale` defaults to the device pixel ratio. Resolves to null where there is no canvas or `Image`. |
| toCSV | (): string | - | string | The numbers the chart is drawing, as CSV: one column per series, one row per category (or label and total, for a hierarchy). Empty string before the first draw. |
| state | (): ChartState | - | ChartState | The chart's own view state: everything a reader has drawn on it, as plain JSON that survives `JSON.stringify` and comes back through {@link Chart.setState} as the same drawings. |
| setState | (state: ChartState | ChartAnnotationItem[]): void | state: ChartState | ChartAnnotationItem[] | void | Restore a state produced by {@link Chart.state}. Silent - reloading a saved view is not an edit, so it raises no `annotation:added`. A chart that has not drawn yet holds the drawings until it has. |
| rebind | (grid: Grid): Chart | grid: Grid | Chart | Point the chart at a replacement grid after the one it was bound to was destroyed. The chart keeps its spec and element, drops its subscriptions to the dead grid, subscribes to the new one and redraws from it. Binding to the grid it already follows is a no-op, so a re-bind pass over the whole page is safe after every swap. A destroyed chart cannot be re-bound. |
| destroy | (): void | - | void | Stop following the grid, disconnect the resize observer, stop any rolling-window timer, remove the chart's element and drop every listener. Calling it twice is harmless. |
Events
| Event | When | Payload | Cancellable |
|---|---|---|---|
| click | A mark was clicked, before the chart filters, drills or selects on it; cancellable. | ChartClickEvent | yes |
| hover | The pointer moved onto a mark and its tooltip was shown. | ChartDatumEvent | no |
| leave | The pointer left every mark and the tooltip was hidden. | ChartEvent | no |
| focus | The keyboard moved onto a mark, which has just been described for a screen reader. | ChartFocusEvent | no |
| draw | A draw finished, at the settled plot size; a draw that showed the empty state raises nothing. | ChartDrawEvent | no |
| drill | The chart descended into a hierarchy, or `ascend()` came back up. | ChartDrillEvent | no |
| brush | A range was dragged out on an axis, before the chart zooms or filters on it; cancellable. | ChartBrushEvent | yes |
| legend | A legend entry was clicked and the hidden set changed. | ChartLegendEvent | no |
| annotation:added | A reader finished drawing an annotation on the chart, or restored none. | ChartAnnotationEvent | no |
| annotation:changed | A drawn annotation was moved, re-anchored or its text edited. | ChartAnnotationEvent | no |
| annotation:removed | A drawn annotation was deleted. | ChartAnnotationEvent | no |
| zoom | The visible range changed: a control, the wheel, a key, a pan, a reset or the host API. | ChartZoomEvent | no |
| cursor:move | The crosshair moved: the pointer, the keyboard, the host API, a grid row or another chart in its group. | ChartCursorMoveEvent | no |
| cursor:leave | The crosshair went away: the pointer left the plot, Escape, `cursor.clear()` or the group's pointer left. | ChartCursorLeaveEvent | no |
| edit | A mark was dragged (or keyed) to a new value and the grid accepted the edit. | ChartEditEvent | no |
| editRejected | A drag or key edit was refused by the grid, and the mark went back. | ChartEditEvent | no |
| responsive | The `responsive` rules matching the container changed, and the chart redrew with them. | ChartResponsiveEvent | no |
ChartSpec
Properties
| Property | Type | Description |
|---|---|---|
| responsive | 'auto' | Array<'auto' | ChartResponsiveOptions> | Option changes at container breakpoints. An array of `{ maxWidth?, minWidth?, maxHeight?, minHeight?, set }` evaluated against the chart's own container (not the viewport) in order, later rules winning; `'auto'` is the built-in rules (below 400 px: legend at the bottom, axis titles hidden, three value-axis ticks; below 250 px: no legend), and an array may include `'auto'` and then override it. When a rule starts or stops matching the chart redraws with the merged options and, with none matching, the base options come back. A rule that is within 4 px of its edge keeps its state, so a container on a boundary does not flicker. Options passed to `update()` become the new base. Raises `responsive`. (optional) |
| grid | Grid | The grid the chart draws. It reads the grid's filtered rows and redraws when they change, so the chart follows the table without being told to. |
| container | Element | string | Where to draw: an element, or a CSS selector resolved against the grid's document. The chart builds its own root inside it. |
| type | ChartType | ChartExtensionType | (string & {}) | Which chart to draw. A name the base bundle does not know is looked up in the extension registry, so an opt-in chart module's type works here once imported. The built-in names and every shipped extension name are suggested; a host's own `registerChartType` name is also accepted as a plain string. |
| x | string | Array<string | { field?: string; col?: string }> | The category column. On the hierarchical types (pie, donut, sunburst, treemap) an array of column names - or `{ field }` / `{ col }` objects - names the hierarchy's levels top down. (optional) |
| y | string | [string, string] | ChartYMeasure | The measure column, for the types that take one. On a `floatingBar` or `horizontalFloatingBar` it is the low/high pair of columns the bar spans between: `[lowCol, highCol]`. The object form names its own reduction and, on `bar`, `line`, `area` and the measures of a `combo`, whether it runs as a running total. (optional) |
| series | string | Splits the measure into one series per distinct value. (optional) |
| rows | object[] | ((grid: Grid) => object[]) | The exact rows to chart, overriding the grid's own walk - an array, or a function returning one at draw time. `chartRange` uses it to bind a chart to the band of rows a selected range covers rather than the whole grid. Only rows the grid holds are charted - the objects `grid.rows.get(i)` and `grid.rows.byKey(key)` return - because every value is read back through the grid by the row's key. Any other object is left out and named once in a `[lattice]` warning (`chart:rows:not-grid-rows`); if none of the entries is a grid row the chart is empty rather than falling back to every row. (optional) |
| perRow | boolean | Turns off the reduction a `scatter` or `bubble` binds by default: without it, rows that tie on `x` share one point, reduced by the measure's aggregation (`sum` unless the column says otherwise) - right for a chart *of* the grid's totals, wrong for one meant to show the rows themselves, where tied x's are common (a regression diagnostic plotted against a predictor rounded to a few decimals, for one) and the reduction draws a point nothing in the data is. `perRow: true` draws one mark per row instead, however many share an x. Not the same option as {@link ChartSpec.aggregate} below, which decides *where* a reduction runs, not whether one runs at all. A `scatter` or `bubble` normally thins its marks to roughly one per horizontal pixel once a series outgrows the plot, so a dense cloud does not overplot itself; `perRow` turns that off too, up to 20,000 rows, so a diagnostic plot of a few thousand real, tied rows draws every one of them whatever the pane's width. Past 20,000 the usual density thinning resumes - a chart of hundreds of thousands of rows must still render - but the reduction keeps whichever rows are the largest by `|y|`, by `x` or by `size` regardless of the bucket they would otherwise land in, so a diagnostic's outliers are never the ones a thinned draw drops. The fit line, its confidence band and the R² beside it are computed from every bound row either way, never from whatever a draw happened to thin down to - an explicit {@link ChartSpec.downsample} included. (optional) |
| keepRows | Array<string | number> | Row keys that thinning must always keep drawn. `regressionPlots` fills it with the model's own outliers (top residual, Cook's D and leverage rows, and every flagged row) so a thinned diagnostic chart never drops them. Ignored unless the scatter is thinned. (optional) |
| aggregate | 'auto' | 'engine' | 'client' | Where the chart's aggregate is computed (GEO-5). `'auto'`, the default, asks the grid's pushdown source - `source.aggregate()`, with x (and `series`) as the `GROUP BY` and the measures as its aggregates, the grid's filter and quick search applied - when the source's adapter declares the `group` capability and the grid holds only a window of the matching rows; otherwise it reduces the loaded rows, as a chart always has. `'engine'` asks the source whenever it can, and is refused by name (a `[lattice]` warning and `provenance().refused`) when it cannot; `'client'` always reduces the loaded rows. One chart is never a mixture: if any measure would be computed client-side, every one is. Applies to the grouped types (`line`, `step`, `area`, `bar`, `horizontalBar`, `waterfall`, `combo`, `pareto`, `heatmap`, `radar`, `funnel`, `stream`, `marimekko`); the source's `aggregates` config must route the reductions to the engine. {@link Chart.provenance} reports which happened and why. (optional) |
| measures | ChartMeasure[] | Several measures at once, for combo and candlestick. (optional) |
| volume | string | A candlestick's volume pane: the column holding each period's traded volume, summed per period. The candlestick then draws a price pane and a volume pane in one svg, on one x scale with one x axis at the bottom, so `axis.x.window`, `axis.x.min`/`max`, `zoom`, `annotate` and the measurers act on both panes together, and one tooltip reads both. Each volume bar takes its candle's up or down colour, and the volume axis sits on the price axis's side. The column is bound as the fifth measure, so it appears in `chart.data()` and the accessible table and is the volume a measurer sums. Any other chart type, or a column the grid does not have, warns and draws no pane. (optional) |
| volumePane | ChartVolumePaneOptions | The volume pane's geometry, when `volume` is set. (optional) |
| source | string | Endpoints, for sankey, chord and network. (optional) |
| target | string | The column naming the link's destination, beside `source`. On a `bullet` it is instead the column holding each row's target, drawn as a marker across the bar. (optional) |
| label | string | Row label and dates, for gantt; also the column naming each event of a `serpentine` timeline. (optional) |
| start | string | The start-date column, for a gantt chart and for the spans of a `serpentine` or `spiral` timeline. (optional) |
| end | string | The end-date column, for a gantt chart and for the spans of a `serpentine` or `spiral` timeline. (optional) |
| title | string | A heading above the plot, drawn in the figure's caption alongside any `subtitle`. (optional) |
| scheme | ChartSchemeName | string | Array<string | ChartFill> | Record<string, string | ChartFill> | ChartScheme | A named scheme, an array of colours, a `{ [name]: colour }` map, or a full {@link ChartScheme} object - the shape every built-in scheme already is, so a host can override the ramps and semantic colours alongside the palette in one value. The built-in names are {@link ChartSchemeName} (`'default'`, `'bright'`, `'earth'`, `'mono'`, `'patterns'`); a name registered with `registerScheme` is a plain string alongside them. An array entry or a map value may also be a {@link ChartFill} - a gradient or a pattern - and `'patterns'` is the built-in scheme that tells series apart by texture as well as hue, the colour-blind-safe option. The map is the form that survives a filter: a pie or donut keys it on the category (`x`) value, a chart with a `series` column keys it on the series value, and a single-series bar or column - no `series` - keys it on the `x` category instead. On the measure-based types - a bar or combo given several `measures`, a `radar` and a `candlestick` - the map keys on the measure's `title`, falling back to its column, so `{ 'Committed revenue': '#1f4d5c' }` colours that measure on a combo the same way it does on a bar. A bare array (`scheme: ['#1f4d5c', '#b4701a']`) is the shorthand for `{ series: [...] }` and colours series in measure order on every type, exactly as explicit. A name absent from the map falls back to the ordered scheme by its position among the *other unmapped* names, so `{ Operational: 'green' }` keeps every other category exactly as it was whichever ones a filter removes - an ordered array reassigns every colour after the gap. A map and an array `palette` do not combine; given both, the map wins and the array is ignored, once, with a warning. A {@link ChartScheme} object and a map are told apart by content: an object naming at least one of `series`/`sequential`/`diverging`/`positive`/`negative` is a scheme override, not a map of category names to colours. (optional) |
| fill | string | { col: string } | ChartFill | ((point: { label: string; x: unknown; y: unknown; rowKey: unknown }) => ChartFill | string | null | undefined) | A fill per mark, read per row on the bar family, lollipops, scatter and bubble points, and on the slices of a `pie`, `donut` or `sunburst` and the tiles of a `treemap`, whose legend swatches wear the same fill; a column id or `{ col }` whose cell holds a {@link ChartFill}, a pattern name (`'hatch'`, drawn in the series colour) or a CSS colour; or a function of the point returning the same. Nothing (null, an empty cell) leaves the mark in its series colour. A fill object here applies to every mark. It outranks a conditional-formatting rule colour and a `scheme` name map. (optional) |
| marker | ChartMarkerOptions | Draw pictures where a `line`, `scatter`, `bubble` or `markermap` would draw dots. See {@link ChartMarkerOptions}. On a `line` every reading wears the marker; a cartesian chart's tooltip still resolves by category until the pointer is over a picture, which then hits that point; a `markermap` hit-tests the picture's box. (optional) |
| ramp | [string, string] | string[] | ChartRampOptions | A shorthand that overrides only the resolved scheme's sequential ramp - the colour a heatmap, calendar, hexbin or hexmap's magnitude comes from - leaving the palette, the diverging ramp and the semantic colours as the scheme (named or default) already set them. Two colours, low value then high; a longer array is read by its first and last stop. The object form, {@link ChartRampOptions}, also takes the `scale` a value is placed along the ramp by - `'linear'`, `'log'` or `'quantile'`, chosen from the values when unset. (optional) |
| emphasis | string[] | Draw these series (by their own value, the same name `scheme`'s map keys on) with a heavier stroke, full opacity and on top of the rest; every other series draws thinner and muted. Applies to `line`, `area` and the line half of `combo`/`pareto`. The fixed style pair is themeable through `--lattice-chart-emphasis-width` and `--lattice-chart-muted-opacity`. An entry in {@link ChartSpec.seriesStyle} for the same name wins over this. (optional) |
| seriesStyle | Record<string, { width?: number; dash?: number[] }> | An explicit stroke width and/or dash pattern for a named series, keyed the same way {@link ChartSpec.emphasis} is. Wins over `emphasis` for a series named in both. Applies to `line`, `area` and the line half of `combo`/`pareto`. (optional) |
| legend | boolean | { position?: 'top' | 'bottom' | 'left' | 'right'; isolate?: boolean; values?: boolean; title?: string; } | Show the series legend. The object form places it, and `isolate` lets a click on a legend entry show that series alone. `values: true` appends each entry's own total - a category's for pie and donut, a series' for a series legend - formatted by the measure column, after the name and a thin space. `title` labels a continuous colour-ramp legend (heatmap, calendar, hexbin, hexmap); ignored by a series legend, which names each series instead. (optional) |
| centre | { value?: 'total' | number | string; label?: string } | The figure a donut draws in its own hole; ignored by every other type, which has no hole to draw into. `value: 'total'` sums the slices actually drawn - a legend click that hides a category moves the figure with them - formatted by the measure column; a number is a caller's own figure, formatted the same way; a string is written through unchanged. `label` is a second line beneath it. Both are sized to fit the hole and are never drawn past its edge. A `gauge` reads the same object differently: `label` names the reading's caption (the figure itself is the measure, formatted by its column), and `value` is unused. (optional) |
| bands | { from: number; to: number; colour?: string; label?: string }[] | Threshold bands behind a gauge's value, clipped to `min`/`max` and drawn in order. `colour` takes any form `scheme` accepts; `label`, when given, is drawn in the legend rather than on the arc. (optional) |
| needle | boolean | { width?: number; pin?: number } | Draw a gauge as a needle dial rather than a filled value arc. `true` takes the defaults; the object form sets the needle's stroke width in pixels (`width`) and the central pin's radius as a fraction of the gauge radius (`pin`). With a needle the value arc is not drawn, and the needle takes the colour of the band the value falls in - or the series colour without bands. The reading and its caption move off the pin into the dial's opening, where no needle position crosses them. (optional) |
| ticks | number | unknown[] | Tick values drawn round a gauge's arc. Values outside `min`/`max` are dropped. (optional) |
| labels | boolean | ChartLabels | Print the value beside each mark. `true` takes the defaults; the object form sets the position, the format and the minimum gap. Only the chart types that support labels honour it. (optional) |
| axis | { x?: string | ChartAxis; y?: string | ChartAxis; y2?: string | ChartAxis; right?: string | ChartAxis; } | Per-axis configuration. Each side is a title string or an object of `{ title, min, max, ticks, format, grid, labels }`. `y2` (or `right`) configures the second measure axis of a dual-axis or combo chart; a dual-axis chart labels both axes by default so it cannot silently mislead. (optional) |
| brush | boolean | 'filter' | 'zoom' | 'select' | { mode: 'filter' | 'zoom' | 'select'; axis?: 'x' | 'y' | 'y2' } | Dragging across the plot. `true` or `'filter'` writes a range condition into the grid as the chart's own: it carries `meta: { owner: 'chart-brush', chart }`, the next brush replaces it and nothing else, and every other filter - one the user set on the same column included - is kept and ANDed with it; one `filters.set`, so one undo step. Escape on the focused chart, or a drag over nothing, takes the brush's condition off and leaves the rest. `'zoom'` changes only this chart's own domain; `'select'` selects the rows under the drag. The object form names which axis the drag acts on - `axis: 'y'` or `'y2'` brushes a value axis, which on a dual-axis chart must say which one it means. With `'filter'`, a value-axis brush writes a `between` on the measure column behind that axis - the right axis's measure for `y2` - and only when each mark there is one row's own value: one measure column on the axis, a reduction that keeps a single value (sum, mean, median, min, max, first, last; not a count) and no mark standing for several rows. Otherwise it warns once (`chart:*:brush:aggregate`, `chart:*:brush:measures`) and writes nothing. (optional) |
| font | object | The type scale, as a base size in pixels or an object naming any of the roles (`size`, `small`, `title`, `axisTitle`, `family`, `weight`). Anything left out is derived from the base, so setting one size rescales the chart rather than leaving one label out of step. The base defaults to 12. (optional) |
| margin | number | { top?: number; right?: number; bottom?: number; left?: number } | Space in pixels around the plot: a number for every side, or an object for the ones it names. It is the floor the axis-label gutters are added to, so widening the left margin buys room beyond what the labels already needed. Defaults to 8. (optional) |
| fit | boolean | 'line' | A least-squares line through a scatter or bubble chart, one per series. `true` draws the line and its R²; `'line'` draws the line alone. Only where the x axis is numeric: on a band scale the positions are categories in an arbitrary order, and a slope through them would be a slope through the order they happened to be listed in. (optional) |
| trend | boolean | ChartTrendMethod | ChartTrend | Array<ChartTrendMethod | ChartTrend> | Trend and forecast overlays: a least-squares line, a trailing moving average, or exponential smoothing, drawn over a line, area or scatter chart. `true` draws a single linear trend; a method name or a {@link ChartTrend} object configures one; an array draws several. The maths matches the core stats engine to the last digit - the same least-squares fit, rolling window and exponential recursions - but is computed locally in the charts module rather than imported, because the in-tree bundler does not tree-shake and the import would inline the whole statistics closure; a test asserts the parity. A `forecast` count projects the linear line that many steps past the data, drawn dashed so it never reads as a reading; a moving average and a smoothed level have no slope to project, so `forecast` is ignored for them and the fact is stated in the accessible description rather than faked. (optional) |
| band | (RegressionBand & { line?: boolean }) | null | A pointwise confidence band, drawn as a varying-width ribbon beneath the fit line. Fed by a fitted model's own interval - the `band` from {@link StatisticsApi.regressionModel}, or as produced by {@link regressionPlots} - so the ribbon and the diagnostics report the one computation rather than a slope redrawn here. `line: false` suppresses the band's own centre line, for a chart that already draws the fit with `fit`. Only where the x axis is numeric, for the same reason `fit` is. (optional) |
| points | { x: number | string; y?: number; label?: string; size?: number; lower?: number; upper?: number; key?: string; }[] | An explicit point set, bypassing the by-column binder: a cartesian chart whose values are not a grid column - a scale-location plot's √|standardised residual|, a coefficient forest's per-coefficient estimate - hands its points in directly. Each is `{x, y}` with an optional `label`, `size` (a bubble's third channel) and `lower`/`upper` (interval bounds the error-bar primitive reads). Numeric `x` throughout gives a continuous axis. (optional) |
| error | boolean | { of?: string; confidence?: number } | Whiskers showing the uncertainty in each mark. `true` computes a confidence interval from the readings behind the mark; `of` takes a symmetric margin from another column instead. (optional) |
| reference | { value: number; label?: string; axis?: 'left' | 'right'; colour?: string }[] | Reference lines, placed on the chart's value axis: horizontal on a bar, line, area or candlestick, vertical on a `horizontalBar`, whose value axis runs left to right. On a candlestick they span the price pane (not the volume pane) and sit above the gridlines and below the candles. On a dual-axis bar or line chart (see {@link ChartMeasure.axis}) a line naming `axis: 'right'` is placed on the right-hand scale, so it means what the right axis says rather than landing at the same number on the scale it does not belong to. `colour` is any CSS colour (the current text colour when omitted). (optional) |
| annotations | ChartAnnotation[] | The declarative annotation layer: reference and target lines, shaded bands and callouts, each naming the axis it reads and each described into the accessible table as a sentence. A value may be a constant or `compute`d from the data it annotates, so it follows the chart as the grid is filtered. (optional) |
| annotate | boolean | ChartAnnotateOptions | Add a drawing rail to a cartesian chart, so a reader can mark it up: trend lines, horizontal and vertical levels, parallel channels, Fibonacci retracements, arrows, text, callouts and notes. `true` is the whole rail on the left; the object form narrows the tools, moves or hides the strip, or makes the chart read-only. Every drawing is anchored in data, lives in {@link Chart.state}, and is reported by `annotation:added`, `annotation:changed` and `annotation:removed`. Accepted on the cartesian types (line, step, area, rangeArea, bar, horizontalBar, waterfall, scatter, bubble) and on combo and candlestick; any other type warns and draws no rail. (optional) |
| zoom | boolean | ChartZoomOptions | Let a reader look closer. `true` is the whole thing with its defaults: a toolbar of five buttons - zoom in, zoom out, pan left, pan right, reset - fading in at the bottom edge of the plot on hover and on keyboard focus, the wheel zooming about the pointer, a drag panning the visible range, `+` and `-` while the chart is focused, and a double-click resetting. The object form narrows all of that. The view composes with a rolling `axis.x.window`: the window is the maximum extent, the zoom is a range inside it, and a view at the newest end keeps the newest reading at the right edge as rows arrive. It lives in {@link Chart.state}, is reported by `zoom`, and is drivable through {@link Chart.zoom}. Accepted on the cartesian types (line, step, area, rangeArea, bar, horizontalBar, waterfall, scatter, bubble, candlestick, ohlc), on combo and on heatmap (its columns only); any other type warns and adds no controls. A time or numeric x zooms a range of values; a category x (and a heatmap's columns) zooms to a contiguous run of categories. On a touch screen a two-finger pinch zooms about the point between the fingers, and `0` resets from the keyboard. With {@link ChartSpec.viewportFilter} the grid follows the visible range. (optional) |
| cursor | boolean | ChartCursorOptions | A crosshair. `true` draws a vertical hairline that follows the pointer, snapped to the nearest reading in the data, labelled at the axis in the axis's own format, with a tooltip listing every series' value at that x. The object form ({@link ChartCursorOptions}) adds a horizontal hairline (`y`), turns snapping off, picks the tooltip's form, and puts the chart in a `group` whose members share the cursor by x value - time or category, matched by value rather than pixel, across charts bound to different grids - with optional zoom sync and `rowHover` to follow the grid's row under the pointer. The arrow keys along the category axis, Home, End and Escape drive it while the chart has focus, and every move is read out through a polite live region. Drive it from the host with {@link Chart.cursor}; it reports `cursor:move` and `cursor:leave`. Accepted on the cartesian types (line, step, area, rangeArea, bar, horizontalBar, waterfall, scatter, bubble, candlestick, ohlc) and on combo; any other type warns and draws no cursor. (optional) |
| buckets | number | Bins for a histogram; the default is twelve. (optional) |
| diverging | boolean | A diverging colour ramp, for heatmap and geomap. (optional) |
| shapes | unknown | Country outlines, for a geomap drawing countries rather than continents. Either GeoJSON, an object of code to SVG path data, or a geometry {@link GeoPack} imported from an optional `modules/geo-*` package - as the pack itself, or as `{ pack: id }` once its module has been imported and registered. On a `markermap` or `bubblemap` the pack is the basemap; without one they draw the geomap's schematic continent outline under their points, fitted to the data's extent. (optional) |
| codeProperty | string | Which GeoJSON feature property carries the region code that the rows are matched against. Defaults to `iso_a2`. Unused when `shapes` is a map of code to path data. (optional) |
| lon | string | The longitude column, for the types that place a row by where it is rather than by a code: `markermap`, `bubblemap`, `hexmap` and `choropleth`. Degrees east, -180 to 180; a row outside that, or with no reading, is left off the map and counted. Given together with {@link ChartSpec.geometry}, both are refused by name - a row is placed by one or the other. (optional) |
| lat | string | The latitude column, beside {@link ChartSpec.lon}. Degrees north, -90 to 90, on the same terms. (optional) |
| geometry | string | A `geometry` column (the opt-in `modules/geometry`, GEO-3) to place each row from, instead of {@link ChartSpec.lon}/{@link ChartSpec.lat} - on `markermap`, `bubblemap` and `choropleth`. A Point row is a marker (or a `choropleth` feature) at its own coordinates; a LineString, Polygon or Multi* row is placed at its planar centroid, in degrees, the same one the column's own type reports. `choropleth` draws each Polygon/MultiPolygon row as its own shape, filled by {@link ChartSpec.y} through the chart's ramp; a Point row on a `choropleth` draws as a marker instead, filled the same way. A cell the column refused at ingest, or whose bounding box leaves the globe, is counted rather than drawn at the origin. Given together with `lon`/`lat`, both are refused by name. (optional) |
| viewportFilter | boolean | ChartViewportFilterOptions | Turn a map's own pan and zoom into a filter on its grid, GEO-4): whenever the reader pans or zooms `markermap`, `bubblemap` or `choropleth` (a drag, the wheel, or the keyboard - the same gestures {@link ChartSpec.geometry}/`shapes` already draw a pack through), the chart writes ONE condition to `grid.filters` for the box now on screen, replacing the previous one rather than stacking it, so every other viewer over the same grid - another chart, a KPI, the table - narrows with the map, and a pushdown source does the narrowing in the engine. A `geometry`-column map writes a single `withinBbox`; a `lon`/`lat` map writes a pair of `between` conditions, one per column, joined by `and`. `true` is the whole feature at its default 150 ms debounce, on the column the map already places rows from; the object form narrows either. Two-way: a matching condition set from outside - a saved view, a host, a sibling panel on the same column - pans and zooms the map to it instead of being overwritten. "Reset view" and destroying the chart both remove the condition; a map with nothing to pan (no `shapes` and no `geometry` column) never writes one. On a cartesian chart with {@link ChartSpec.zoom} the grid follows the chart's visible range instead: once a pan or zoom settles (the same debounce), the chart writes ONE condition per zoomed axis to `grid.filters` - `between` the first and last reading in view on a time or numeric x (`gte` the first when the view is at the newest end, so rows that arrive later still reach the grid), `in` the visible categories on a category x - each as its own single-column branch, so the column's filter menu shows it and its Clear removes it. A scatter or bubble zoomed in y, with one row per point, adds a `between` on the measure column; a y zoom anywhere else filters by x alone and warns. The condition carries `meta: { owner: 'chart-view', chart, axis }`, and only a condition carrying this chart's mark is ever replaced or removed: a condition the user set on the same column is kept and ANDed with it. Zooming back out to everything, reset, turning the option off or destroying the chart removes it. It is a filter like any other: undoable, and two-way - clear it in the grid and the chart resets its zoom, undo or redo it and the chart's view follows, each reported as `zoom` with `reason: 'filter'`. Without `zoom` it warns and writes nothing. Ignored on any other type. (optional) |
| viewportCap | number | The most rows a `markermap`, `bubblemap` or `choropleth` draws for one view (GEO-6); default 20,000. Over a grid on a paged pushdown source that holds only a window of its rows, the map asks the source's engine for the rows inside its current view - the grid's filters and quick search, plus a `withinBbox` on a `geometry` column or a `between` pair on `lon`/`lat` for the box once the reader has panned or zoomed - through the adapter's `execute`, capped at this many rows, instead of drawing the grid's page; the grid keeps its page and the map its own set, a pan or zoom asks again (aborting the request it supersedes), and the last drawing stays up until the answer lands. {@link ChartSpec.aggregate} governs this as it governs a grouped chart. When more rows than the cap fall inside the view - from the engine, or from rows the browser holds - a marker or bubble map draws square density cells instead, counted by the engine (GEO-2's spatial `buckets`, `geometry` columns only) or in the browser, at a size derived from the zoom; zooming in until the view holds no more than the cap returns to the rows. A `choropleth`, and an engine map that cannot bin (a `lon`/`lat` map, or an adapter without the `grid` bucket), is refused by name past the cap and draws none of its rows. {@link Chart.provenance} reports it as `viewport`, and the accessible summary says which happened. (optional) |
| value | string | The measure a `markermap` writes beside each dot and colours it by. Its text is the column's own formatted cell text and its colour is whatever the column's conditional-formatting rules give that value, so a map and the table beside it say the same thing about the same number. (optional) |
| layer | string | Which layer of a multi-layer geometry pack to draw - the UK pack, for instance, ships `regions`, `local-authorities` and `constituencies` together. Ignored for a single-layer pack. (optional) |
| projection | MapProjection | ((lon: number, lat: number) => [number, number]) | The map projection a geomap draws through: `'equalEarth'` (the default for a world), `'robinson'`, `'mercator'`, `'equirectangular'`, `'albers'`, `'transverseMercator'`, `'orthographic'` (alias `'globe'` - the visible hemisphere of the sphere, on `choropleth`, `markermap` and `bubblemap`; see {@link ChartSpec.rotate}), or a projection function of the caller's own `(lon: number, lat: number) => [number, number]`. Left unset, a geometry pack draws through the projection it declares. (optional) |
| rotate | [number, number] | Where a globe is facing: `[longitude, latitude]` in degrees of the point at the centre of the disc, for `projection: 'orthographic'` (alias `'globe'`) on `choropleth`, `markermap` and `bubblemap`. Default `[0, 0]`. Dragging turns the globe away from it and `0` (or the reset control) turns it back. Ignored by every flat projection. (optional) |
| sphere | boolean | Whether a globe draws the outline of the sphere. Default true; `false` leaves only the land and the markers. Ignored by every flat projection. (optional) |
| fitTo | 'selection' | Turn a globe to what is selected: `'selection'` rotates a `projection: 'orthographic'` map to the middle of the rows the grid selects each time the selection changes. {@link Chart.fitToSelection} does the same on demand. (optional) |
| projectionOptions | { parallels?: [number, number]; centre?: [number, number] } | Parameters for the projections that take them: `parallels` and `centre` for `albers`, `centre` for `transverseMercator`. (optional) |
| graticule | boolean | { step?: number } | A lon/lat reference grid under a geomap's regions, off by default part 2). Only drawn over a geometry pack's fitted projection - the schematic continents have no fitted projection to draw one against (a globe draws it always). `step` is the spacing between lines in degrees (default 30). (optional) |
| basemap | { fill?: string | ChartFill; stroke?: string; strokeWidth?: number } | Override the land a `shapes` pack draws with, one colour or width at a time: `fill` and `stroke` as any CSS colour, `strokeWidth` in pixels. Unset, each takes its theme token - `--lattice-chart-basemap-fill`, `--lattice-chart-basemap-stroke`, `--lattice-chart-basemap-stroke-width` - whose defaults are legible against the plot in both themes without a host doing anything. Applies to every type that draws a `shapes` pack: `markermap`, `bubblemap`, `choropleth`'s own backdrop pack, `hexmap` and `geomap`. (optional) |
| outline | boolean | Whether a `markermap` or `bubblemap` with no `shapes` pack draws the schematic continent outline under its points. On by default; `false` keeps the blank plane. Ignored with a `shapes` pack (the pack is the basemap), on a globe, and by every other type. (optional) |
| multiples | string | One chart per distinct value of this column. (optional) |
| canvas | boolean | number | Draw to canvas past this many points. (optional) |
| downsample | number | How many points to reduce a dense line or scatter series to before drawing. Reduction keeps the first point, the last, and the ones that carry the outline. Unset, the target is the plot's width in pixels; a series is only reduced once it exceeds half again that target. (optional) |
| emptyText | string | What to show when there is nothing to draw. Defaults to the grid's own translated empty-chart message. (optional) |
| subtitle | string | A second line under the title. (optional) |
| footnote | string | A note under the plot, a source, a caveat, a unit. (optional) |
| tooltip | boolean | `false` turns the hover tooltip off. (optional) |
| tooltipHost | HTMLElement | Where the hover tooltip node lives. Defaults to `document.body`, so a host that blurs, transforms or clips the chart cannot hide it; a chart mounted inside a shadow root defaults to that shadow root instead, so the root's stylesheets still reach the tooltip (a transform or filter on the shadow host then moves it: give an element outside to host it there). It is removed when the chart is destroyed. (optional) |
| selection | boolean | Draw the grid's selected rows emphasised, and follow the selection. A click on a mark that stands for rows selects them: a marker on a marker map, a network's node or link, an extension's items, a `pie`, `donut` or `sunburst` slice, a `treemap` tile (every row at or beneath it), and a `chord` ribbon or arc. Marks the selection leaves out are dimmed. (optional) |
| drill | boolean | Clicking a group drills into it. (optional) |
| filterOnClick | boolean | Clicking a mark filters the grid to it: an `eq` on the mark's column, as the chart's own condition. It carries `meta: { owner: 'chart-click', chart }`; a click on another mark replaces it and nothing else, every other filter - one the user set on the same column included - is kept and ANDed with it, and a second click on the same mark, or Escape on the focused chart, takes it off again. One `filters.set` each, so one undo step. (optional) |
| parentId | string | The hierarchy types - `forceTree`, `pack`, `tree`, `voronoiTreemap` - read a hierarchy from a column that names each row's parent: the column holds the parent's `id`. A row with no parent, an unknown parent or a parent that leads back to itself hangs off the root, so a bad row costs its place in the tree and never the drawing. Without `parentId` or `path` the hierarchy is the grid's own grouping. (optional) |
| id | string | The column a `parentId` refers to: the row's own identity. Defaults to the row key. Used by the hierarchy types. (optional) |
| path | string | The hierarchy types read a hierarchy from one column holding each row's whole path (`Europe/France/Paris`), split on `pathSeparator`; rows that share a start share a branch. (optional) |
| pathSeparator | string | What `path` is split on. Default `'/'`. (optional) |
| colourBy | 'depth' | string | How the hierarchy types colour a node: `'depth'` (the default - one colour per level) or a column id, in which case a node takes the value that column has on its first row and the legend names the values. (optional) |
| expandDepth | number | `tree` and `forceTree`: how many levels start open. A node beyond it starts folded; a click folds or opens a branch, 0001655). Default: every level. (optional) |
| orientation | 'top-down' | 'left-right' | 'radial' | `tree`: which way the levels run - `'top-down'` (the default), `'left-right'` or `'radial'`. (optional) |
| linkShape | 'straight' | 'elbow' | 'curved' | `tree`: the shape of the line from a node to its parent. Default `'elbow'`. (optional) |
| cluster | boolean | `tree`: draw a dendrogram - the leaves in one row at the deepest level, each branch over the middle of its children. (optional) |
| panZoom | boolean | `tree`: drag to pan, the wheel and `+` / `-` to zoom, `0` to reset. On by default; `false` leaves the wheel and the drag to the page. (optional) |
| padding | number | `pack`: the gap between sibling circles in pixels. Default 3. (optional) |
| clip | 'circle' | [number, number][] | `voronoiTreemap`: the shape the cells fill - `'circle'` (the default) or a polygon as `[x, y]` points in any units, y down as on screen, scaled to fit the plot. A polygon is filled as its convex hull; fewer than three usable points fall back to the circle. (optional) |
| tolerance | number | `voronoiTreemap`: the largest error any leaf's area may keep, as a share of the area its value should have. Default 0.01, under 1%. (optional) |
| animate | boolean | The hierarchy types animate a fold, an unfold or a zoom unless the reader prefers reduced motion; `false` turns the animation off whatever they prefer, 0001653, 0001655). (optional) |
| editable | boolean | ChartEditable | Drag a mark to edit the row behind it. `true` drags along the value axis; an object says which axis, the step, the limits and what to do with a mark that is several rows (see {@link ChartEditable}). Works on a line, step or area point, a bar or horizontal bar's end, both ends of a floating bar, a scatter or bubble point and a pie or donut slice's edge. Dragging shows the live value, formatted by the measure's column; releasing writes it with `grid.edit.setCells` and the gated `'user'` origin, so the column's validation, a `beforeEdit` veto, `edit.enabled` and read-only columns and rows, the undo stack, change tracking and router write-back apply exactly as they do to a cell edit. A refused edit puts the mark back and shows the grid's own message under the plot; the chart redraws from the grid's `cell:changed`, never from the dragged number. Marks are focusable sliders for the keyboard (arrows by `step`, Enter commits, Escape cancels). A mark that stands for several rows is not draggable unless {@link ChartEditable.distribute} is set. <small>(optional)</small> (optional) |
| animation | boolean | ChartAnimationOptions | How the chart plays its marks in and between draws. `true` (the default) plays an entry on the first drawing - bars grow from the baseline, lines draw left to right, slices sweep, points scale in - and plays every change in the grid from the old drawing to the new, keyed by series and category so a bar that stays moves rather than being redrawn. `false` draws the final state at once. The object form ({@link ChartAnimationOptions}) sets the duration, the easing, entry and update apart, and the mark budget. Reduced motion, an automated browser (`navigator.webdriver`), a jsdom or happy-dom test DOM, a headless render and an export always get the final state at once, and the end state is identical to the unanimated drawing, attribute for attribute. A draw that lands mid-play retargets from where the marks are; nothing queues. A resize or a zoom draws at once. <small>(optional)</small> (optional) |
| stack | boolean | 'percent' | Stack the series rather than drawing them side by side. On a `combo` it stacks the bar measures; line and area measures stay outside the stack on the same scale and are drawn after the columns. `'percent'` stacks to 100%: a `bar`, `horizontalBar`, `area` or `combo` with two or more series draws each column as the whole plot height, split into each series' share of the column, and the measure axis runs 0 to 100 rather than 0 to the tallest total. A column that mixes signs has no meaningful "share of the whole", so shares are taken against the sum of the parts' absolute values; the tooltip still reads the real value, with its share beside it. (optional) |
| curve | boolean | ChartCurve | ChartCurveOptions | Record<string, ChartCurve | ChartCurveOptions> | On a histogram, `true` overlays a kernel density curve. On a `line`, an `area` (and the line half of a `combo`/`pareto`), it names how the series connects its readings: a {@link ChartCurve} name (`'linear'`, `'monotone'`, `'catmullRom'`, `'step'`), a {@link ChartCurveOptions} object (`{ type, tension, mode }`), or a map keyed by series label - the same name {@link ChartSpec.emphasis} keys on - giving each series its own curve. Markers, tooltips and hit-testing stay on the real readings whatever the curve. (optional) |
| startAngle | number | A pie or donut's first slice, in degrees clockwise from twelve o'clock, 0 being the top. Default 0; a partial circle uses the container's height rather than half of it. A `polarArea`, `radialBar` or `polarScatter` reads it as where its angular range starts. (optional) |
| endAngle | number | A pie or donut's last slice, in degrees clockwise from twelve o'clock. Default 360. A `polarArea` reads it the same way; a `radialBar` reads it as the end of its full track, 270 degrees from the start by default. (optional) |
| radius | ChartRadiusOptions | Drive a pie or donut's slice radii from a second measure, the angle still coming from `y`: `{ col, fn?, min? }`. `fn` is `'sqrt'` (area-true, default) or `'linear'`; `min` is the smallest share of the full radius a slice may shrink to. The donut's hole is respected, the tooltip shows both measures, and the legend is unchanged. (optional) |
| polar | ChartPolarOptions | Options for the opt-in polar chart types `polarArea` (alias `nightingale`, `rose`), `radialBar` and `polarScatter`, 0001650, 0001651). Ignored by every other type. `startAngle`, `endAngle`, `max`, `min`, `stack`, `size` and `series` read as they do elsewhere; the rest are in {@link ChartPolarOptions}. (optional) |
| mark | 'lollipop' | Draw a `bar` or `horizontalBar` as lollipops rather than solid columns: a stem from the baseline up to the value, with a dot at the value. Everything else the bar family does is unchanged - several measures sit side by side, sorting and the `axis` block apply, `scheme` forms colour the dots and stems, and the tooltip and grid binding read the same points. Reuses the bar code path rather than a second implementation, which is why it is a mark on the bar type and not a type of its own. (optional) |
| ranges | (number | string)[] | A `bullet` chart's qualitative bands: the bands' upper edges in ascending order, each either a number (the same for every row) or the name of a column holding that edge per row. `[60, 85, 100]` shades the scale from its minimum to 60, 60 to 85 and 85 to 100 - the same reading the `bullet` cell renderer gives its `bands`. Ignored by every other type. (optional) |
| bullet | { orientation?: 'horizontal' | 'vertical'; scale?: 'shared' | 'row' } | A `bullet` chart's layout. `orientation` is `'horizontal'` (the default: one row per KPI, the value running along x) or `'vertical'`. `scale` is `'shared'` (the default: every row is read against one scale and one axis, so bars compare across KPIs) or `'row'` (each KPI has its own scale, as a bullet cell has, and no shared axis is drawn). `min` and `max` on the chart pin the scale's ends. (optional) |
| waterfall | { total?: boolean | string } | A `waterfall`'s options. A waterfall draws one step per category, the last included. `total` adds a closing-total bar as an extra category after the last one, drawn from the baseline to the running total: `true` labels it with the catalogue's word for a total (`Total`), a string labels it with that string. With no `total` there is no closing bar. A waterfall with a total bands its x axis. Ignored by every other type. (optional) |
| pyramid | { scale?: 'shared' | 'independent'; percent?: boolean } | A `populationPyramid`'s options. `scale` is `'shared'` (the default: one symmetric scale serves both halves, so equal bars are equal lengths) or `'independent'` (each half is scaled to its own largest bar). `percent: true` draws each bar as its share of the whole population and labels the axis in percent. The axis labels are always absolute. (optional) |
| directed | boolean | ChartDirectedOptions | Draw a `chord` as a *directed* flow: A to B and B to A stay two ribbons, each wide at its source arc and narrowing (or ending in an arrow tip) at its target; an arc's length is what the node sends, or with `arc: 'inout'` what it sends plus receives; colours follow the source and the tooltip names the direction and the value. `true` takes the defaults; an object is {@link ChartDirectedOptions}. Unset, the chord draws exactly as it always has. (optional) |
| stages | ChartStagesOptions | A `pyramid` chart's options: {@link ChartStagesOptions}. The pyramid takes the funnel's input - `x` the stage, `y` the measure - and is the funnel's stages as slices of one triangle (not `populationPyramid`). (optional) |
| pictorial | ChartPictorialOptions | A `pictorial` chart's options: {@link ChartPictorialOptions}. `x` is the category and `y` the measure. (optional) |
| serpentine | ChartSerpentineOptions | A `serpentine` timeline's options: {@link ChartSerpentineOptions}. `x` is the date column for points, or `start` and `end` for spans; `label` names an event and `series` colours by a column. (optional) |
| spiral | ChartSpiralOptions | A `spiral` timeline's options: {@link ChartSpiralOptions}. `x` is the date column and `y` an optional value; `start` and `end` give the spans of `mode: 'spans'`; `series` colours by a column. (optional) |
| measure | string | boolean | ChartMeasureOptions | A string is an alias for `y`, where "the measure" reads better than "the y axis". `true` or {@link ChartMeasureOptions} instead turns on the point-to-point measure tool on its own, with a one-button rail (or alongside `annotate`'s rail, adding the tool to it): drag between two points, or press `M` and use the arrows with the chart focused, for a band with Δx (a duration on a time axis, a count of categories on a category axis, the difference on a numeric one), Δy in the y axis's own format, the percentage change and the slope. Ends snap to data points; on a dual-axis chart the axis under the start point is the one measured. A measurement is transient (cleared by Escape or the next drag) until pinned with Enter, a click on it, or `pin: true`; a pinned one is data-anchored, in {@link Chart.state}, and reported by `annotation:added` with its readout. Accepted on the same chart types as `annotate`. (optional) |
| size | string | Bubble charts: the column driving the radius, and the largest it may be. (optional) |
| maxRadius | number | The largest bubble radius in pixels. Clamped to between 6 and 28, and 22 when unset; the smallest bubble is always 3. Also capped by the plot's own size, at a sixth of its shorter side, so a caller cannot ask for a bubble bigger than the panel drawing it; a mark a scaled radius would still draw past the plot's edge - the point with the most leverage is as likely to sit at the domain's own end as anywhere else - is shrunk further, just enough to stay inside the clip. (optional) |
| min | number | Fix the measure axis rather than taking it from the data. (optional) |
| max | number | Fix the top of the measure axis rather than taking it from the data. (optional) |
| code | string | A geomap's ISO code column. An alias for `x`. (optional) |
| columns | string[] | Correlogram: which columns to correlate, how, and whether to print them. (optional) |
| method | CorrelationMethod 'pearson' | 'spearman' | 'kendall' | Which correlation a correlogram computes. `spearman` uses the rank coefficient; anything else - including `kendall`, which is not implemented - uses Pearson. The figures come from the grid's own statistics, so the matrix cannot disagree with them. (optional) |
| values | boolean | Print each coefficient inside its correlogram cell. On by default, and dropped anyway where the cells are too small for the text to fit; set `false` to leave the matrix as colour alone. (optional) |
| iterations | number | Network layouts: how many relaxation passes to run. On a `forceTree`, the ticks the layout settles for - 220 for the first layout and 90 when a fold or a drag resumes it unless set; the ticks it used are reported by the drawing. On a `voronoiTreemap`, the relaxations each level gets before its last fit of the weights - 10 unless set; more rounds the cells off further and never changes how closely the areas fit. (optional) |
| nodes | ChartNode[] | The nodes of a `network`, named by the host rather than inferred from the rows: an icon per device, a label, and a position the layout must honour. A node listed here that appears in no row is still drawn. A node in the rows that is not listed here takes the chart's `icon` default and its own id as its label. (optional) |
| icon | string | The default glyph for a `network` node that names none of its own: any name in the grid's icon registry (see {@link Grid.icons}). Unset, a node with no icon is a plain disc. (optional) |
| linkWidth | number | A `network` link's stroke width in pixels, fixed. Unset, width follows the link's value as a share of the heaviest link, as it always has. (optional) |
| spec | { lower?: number; upper?: number; target?: number } | Control and capability charts: a tolerance overriding the column's own `spec`, how many leading readings fix the control limits, which rule set the violations are judged against, and the level for the capability interval. (optional) |
| baseline | number | How many leading readings fix a control chart's limits. The rest of the series is then judged against them, which is how a step change shows up as a run of violations instead of dragging the centre line to the middle. Unset, the whole series sets the limits. (optional) |
| rules | ControlChartRuleSet 'westernElectric' | 'nelson' | Which run-rule set a control chart's violations are judged against. Defaults to `westernElectric`. (optional) |
| confidence | number | The level of the capability interval printed with a capability chart, as a fraction (0.95 for 95%). (optional) |
| text | string | A `wordCloud`'s free-text column: every row's text is split into words and counted, instead of reading one word per row from `x`. (optional) |
| stopWords | boolean | string[] | A `wordCloud`'s stop words: `true` (built-in English), `false`, or your own list. Tokenising only; defaults to `true` there. (optional) |
| lowerCase | boolean | A `wordCloud` folds case before counting. Default `true` when tokenising `text`, `false` for a word column in `x`. (optional) |
| minCount | number | A `wordCloud` leaves out words whose total is below this. Default 1. (optional) |
| maxWords | number | A `wordCloud` draws at most this many words, the biggest first; the rest are counted in `provenance().dropped`. Default 150. (optional) |
| minSize | number | A `wordCloud`'s font size in pixels for the smallest word. Default 12. (optional) |
| maxSize | number | A `wordCloud`'s font size in pixels for the biggest word. Default 48. (optional) |
| rotate | number | number[] | 'random' | A `wordCloud`'s angles in degrees: one angle, a list to choose among, or `'random'`. Default 0. (optional) |
| seed | number | A `wordCloud`'s placement seed, or a `voronoiTreemap`'s starting sites; the same data and seed draw the same picture. Default 1. (optional) |
| colour | string | The column whose value colours each mark of a `wordCloud` or `motion` chart. (optional) |
| sets | string[] | A `venn`'s membership as boolean columns, two or three of them. (optional) |
| set | string | A `venn`'s membership as one multi-value column (an array, or text split by `separator`). (optional) |
| members | string[] | A `venn` over `set`: which values are the sets. Default: every distinct value (refused above three). (optional) |
| separator | string | A `venn` over `set` holding text: the character that separates the values in a cell. (optional) |
| time | string | The time column of a `barRace` or `motion` chart: each distinct value is a frame. (optional) |
| entity | string | A `motion` chart's entity column: one bubble per distinct value. (optional) |
| topN | number | A `barRace`'s number of bars. Default 10. (optional) |
| autoplay | boolean | An animated chart starts playing when drawn. (optional) |
| speed | number | An animated chart's playback speed in multiples of normal. Default 1. (optional) |
| loop | boolean | An animated chart starts again at the end of its run. (optional) |
| reducedMotion | boolean | An animated chart's reduced-motion override; unset, `prefers-reduced-motion` decides. (optional) |
| clock | FrameClock | An animated chart's clock. Pass one {@link FrameClock} to a `barRace` and a `motion` chart and they play, pause and scrub together. (optional) |
| trails | boolean | string[] | A `motion` chart's trails: `true` draws the path so far of every entity the grid has selected, an array of entity names draws those. (optional) |
| scale | { x?: 'linear' | 'log'; y?: 'linear' | 'log' } | A `motion` chart's axis scales, `'linear'` or `'log'` each. A log axis leaves out readings at or below zero and counts them in `provenance().dropped`. (optional) |
Methods
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| format | (value: number) => string | value: number | => string | A `barRace`'s value-to-text function for the bar ends and the axis. (optional) |
| onAnnotationAdded | (payload: ChartEvent) => void | payload: ChartEvent | => void | Inline handlers for the chart's events: `on` plus the event name with each word capitalised, colons dropped, so `annotation:added` is `onAnnotationAdded` and `cursor:move` is `onCursorMove`. One-word events are `onClick`, `onHover`, `onDraw` and so on. Called before the `chart.on` subscribers; `chart.on` keeps taking the event name as written. (`on` + the name with only its first letter capitalised, `onAnnotation:added`, is still read.) (optional) |
| onAnnotationChanged | (payload: ChartEvent) => void | payload: ChartEvent | => void | Inline handler for `annotation:changed`. (optional) |
| onAnnotationRemoved | (payload: ChartEvent) => void | payload: ChartEvent | => void | Inline handler for `annotation:removed`. (optional) |
| onCursorMove | (payload: ChartEvent) => void | payload: ChartEvent | => void | Inline handler for `cursor:move`. (optional) |
| onCursorLeave | (payload: ChartEvent) => void | payload: ChartEvent | => void | Inline handler for `cursor:leave`. (optional) |
Events
No events.
RegressionModel
A fitted multi-predictor linear model and its diagnostics.
| Property | Type | Description |
|---|---|---|
| method | string | Which fit produced the model: `ols`, `wls` or `robust`. |
| coefficients | RegressionCoefficient[] | The fitted coefficients, the intercept first, then one per predictor in the order given. |
| r2 | number | The share of the response's variance the model accounts for, clamped to 0..1 - weighted for a weighted fit. 1 when the response has no variance at all. |
| adjR2 | number | R² penalised for the number of predictors, so adding a predictor that earns nothing lowers it. |
| n | number | How many rows the fit used. A row missing the response, any predictor, or (for a weighted fit) a positive weight is left out of the fit. Fewer rows than coefficients gives no model at all rather than a fitted one. |
| df | number | Residual degrees of freedom, n − p. |
| sigma2 | number | Residual variance, RSS ÷ df. |
| fitted | number[] | The model's prediction for each row used, in the order of `rows`. |
| residuals | number[] | Observed minus fitted for each row used - what the model did not explain. |
| leverage | number[] | Hat-diagonal leverage per row. |
| cooksD | (number | null)[] | Cook's distance per row; null where it cannot be computed. |
| vif | number[] | Variance-inflation factor per predictor; Infinity when exactly collinear. |
| heteroscedasticity | Heteroscedasticity | null | The Breusch-Pagan test of whether the residual spread depends on the predictors. Null when the auxiliary regression could not be fitted. |
| band | RegressionBand | null | The pointwise confidence band for the mean response. Present only for a single-predictor fit - the charted fit-line case - and null otherwise. |
| weights | number[] | null | Per-row weights actually used (robust/WLS), or null for OLS. |
| predictors | string[] | The predictor column ids the model was fitted on, in the order the coefficients follow. |
| response | string | The column id the model predicts. |
| rows | number[] | The physical rows the diagnostics are aligned to, in order. |
RegressionCoefficient
One fitted coefficient, with the uncertainty around it.
| Property | Type | Description |
|---|---|---|
| name | string | `(intercept)` or the predictor's column id. |
| estimate | number | The fitted coefficient itself. |
| stdError | number | The standard error of the estimate - the square root of the coefficient's variance, from the residual variance and the design matrix. |
| t | number | estimate ÷ standard error. |
| p | number | Two-sided Student-t p-value; a number with a documented method, not a verdict. |
| lower | number | null | The Wald confidence interval at the model's confidence level - the whiskers a coefficient forest plot draws. Null when there is no residual degree of freedom to form a critical value. |
| upper | number | null | The top of the Wald interval, null on the same terms as `lower`. |
ChartScheme
A full colour scheme, the object every built-in `SCHEMES` entry is and the shape `registerScheme` and `setDefaultScheme` store whatever they are given as: a categorical palette plus the two magnitude ramps and the two semantic colours. Declaring it is what was missing - a TypeScript host could not write `{ series, sequential }` for `scheme` or `registerScheme` although the runtime already accepted and used exactly that shape.
| Property | Type | Description |
|---|---|---|
| series | Array<string | ChartFill> | The categorical palette, one colour or {@link ChartFill} per series. |
| sequential | [string, string] | string[] | The sequential ramp's ends, low value then high - a heatmap, calendar, hexbin or hexmap's magnitude colour. (optional) |
| diverging | [string, string, string] | The diverging ramp's three stops: low, the neutral midpoint, high. (optional) |
| positive | string | The colour for a positive or gain reading (a waterfall, a candlestick). (optional) |
| negative | string | The colour for a negative or loss reading. (optional) |
FrameClock
The clock the animated chart types (`barRace`, `motion`) run on. `createFrameClock()` from either module makes one; handing the same clock to several charts as `spec.clock` gives them one time.
Properties
| Property | Type | Description |
|---|---|---|
| min | number | The earliest time any registered chart has, in the time column's own units (epoch milliseconds for dates). (read-only) |
| max | number | The latest. (read-only) |
| time | number | Where the clock stands. (read-only) |
| fraction | number | How far through the run, 0 to 1. (read-only) |
| playing | boolean | Whether it is playing. (read-only) |
| speed | number | The speed in multiples of normal. (read-only) |
| reduced | boolean | Whether motion is reduced, so playback steps whole frames. (read-only) |
Methods
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| seek | (time: number): FrameClock | time: number | FrameClock | Move to a time, clamped to the run. |
| seekFraction | (fraction: number): FrameClock | fraction: number | FrameClock | Move to a fraction of the run, 0 to 1. |
| step | (by?: number): FrameClock | by?: number | FrameClock | Move by whole frames; negative goes back. |
| play | (): FrameClock | - | FrameClock | Start playing; from the end, from the beginning. |
| pause | (): FrameClock | - | FrameClock | Stop where it is. |
| toggle | (): FrameClock | - | FrameClock | Play, or pause when playing. |
| setSpeed | (speed: number): FrameClock | speed: number | FrameClock | Set the speed multiple. |
| subscribe | (listener: (clock: FrameClock) => void): () => void | listener: (clock: FrameClock) => void | () => void | Be told whenever the clock changes; returns a function that stops listening. |
| destroy | (): void | - | void | Stop and let go of every listener. |
Events
No events.