api reference
Statistics and units
grid.statistics and shadow columns, process capability, the statistic tile block, and unit and currency types of your own.
API reference › Statistics and units
grid.statistics
What the grid knows about its own numbers, and about how they have changed since the page loaded. Every figure is computed over the filtered rows, through the same column handles the totals row uses, so a median here and a median in the footer are the same number, by the same definition (R type 7).
grid.statistics.profile('margin');
// { column, rows, present, missing, distinct, min, max, mean, median,
// q1, q3, iqr, stddev, outliers, histogram: [{ from, to, count }, …] }
grid.statistics.profile('region'); // a categorical column
// { column, rows, present, missing, distinct, …numeric figures null…,
// histogram: [], topValues: [{ value, count, share }, …] }
grid.statistics.reduce('margin', 'p95'); // any registered kernel
grid.statistics.correlation('spend', 'revenue'); // Pearson's r, clamped to [-1, 1]
grid.statistics.weightedAverage('price', 'qty');
grid.statistics.covariance('spend', 'revenue');
grid.statistics.regression('spend', 'revenue'); // { slope, intercept, r2, stdError, n }
grid.statistics.spearman('spend', 'revenue'); // rank; one outlier cannot drag it
grid.statistics.kendall('spend', 'revenue'); // tau-b, null past 5,000 rows
grid.statistics.weightedQuantile('price', 'qty'); // the median by default
grid.statistics.series('price', { by: 'date', periodsPerYear: 252 });
// { volatility, annualisedVolatility, growth, maxDrawdown, maxDrawdownFrom,
// maxDrawdownTo, autocorrelation, upDays, downDays, … }
grid.statistics.capability('mm', { baseline: 20 });
// { cp, cpk, pp, ppk, sigmaWithin, sigmaOverall, outOfSpec, defectRate,
// limits: { centre, upper, lower, sigma }, violations: [{ index, rule }] }
grid.statistics.shadow('price', 'delta', 'R42'); // one row's shadow value
grid.statistics.rebase('price'); // "mark all": today's values become the baseline
grid.statistics.tracking(); // { columns, rows, forgotten }
The statistics tool panel is the end-user half of profile(): a column picker, the twelve figures and a histogram of the column's shape, all following the filters. Add it with toolPanel: { panels: ['columns', 'statistics'] }. It profiles categorical columns too: a text column shows its count, distinct count and Top values (each value with its count and share) instead of the numeric figures and the histogram it has none of.
The column header menu carries a Column statistics item that opens this panel seeded on the column it belongs to. It emits column:profile:open with { colId } rather than reaching into the dock, exactly as the header filter affordance emits column:filter:open; a mounted tool panel turns that into the open, seeded statistics panel.
The regression tool panel is its multi-column sibling: it fits the model you name and shows the coefficient table - each term's estimate ± standard error with its t and p - alongside R² and adjusted R², the variance-inflation factor per predictor, and the Breusch-Pagan heteroscedasticity flag, all over the filtered rows and computed by the one core engine (grid.statistics.regressionModel). Name the model on the panel: toolPanel: { panels: ['columns', { name: 'regression', props: { predictors: ['x1', 'x2'], response: 'y' } }] }. p-values are reported as numbers with a documented method, never a significance verdict.
The same fitted model can live in the data as shadow columns: shadow: { kind: 'fitPredicted', model: { predictors: ['x'], response: 'y' } }, and likewise fitResidual and fitInfluence - plus fitStdResidual, fitLeverage and fitCooksD, which surface the internally studentised residual, the hat-matrix leverage and Cook's distance the engine already computes. They are ordinary numeric/boolean cells - sortable, filterable, groupable, exportable - that read the fit by row key and follow the grid's filters (the model refits over the filtered rows); a row outside the fit reads null. fitInfluence flags Cook's D > 4/n by default (overridable with threshold), keeping "not influential" (false) and "cannot tell" (null) distinct.
It shows the twelve one-pass figures, then Shape (skewness, kurtosis, Jarque-Bera), Robust (trimmed and winsorized means, MAD, robust outliers), Concentration (Gini, HHI, entropy, evenness, top-3 share) and Capability where the column declares a spec. A section whose reductions all return null is left out rather than shown as a column of dashes.
Or put it in your own page. mountPanel takes no dock and does not create one: toolPanel may be off entirely, so a statistics readout can sit beside a chart, in your own sidebar, or in a settings dialog, at whatever size you give it. It repaints on the same events the rail does, so it stays in step with filters, edits and saved views without you subscribing to anything.
import { mountPanel } from '@toclocoinc/lattice-grid';
const stats = mountPanel({ grid, panel: 'statistics', container: sidebar });
stats.refresh(); // for a change the grid does not announce
stats.destroy(); // yours to call: the element belongs to your page
Any built-in panel works: columns, filters, views, quick, formatting, statistics, regression, compare, insights, as does a constructor of your own.
The insights panel
The insights tool panel is the on-screen half of the comparison analytics (): the API-only subsetVsPopulation(), datasetVsDataset(), capability() and compareGroups() rendered without you building any UI. It is opt-in - off unless you name it. Add it with toolPanel: { panels: ['columns', 'insights'] }.
It shows four things, all over the filtered rows: the columns of the current filtered subset ranked by effect size against the whole; the same ranking against a second grid when you pass one as toolPanel: { panels: ['insights'], /* config */ } with insights: { compareWith: otherGrid }; process capability for the chosen column where it declares a spec; and a two-group comparison - pick a column and a column to group by, and the panel shows the named test, its confidence interval and its effect size together.
The stance is enforced on screen. The effect size is never shown without its interval beside it; the test or method that produced a figure is always named, from the API's own method string; and nothing renders a significant flag, a verdict, a badge or a star - the p-value is shown as the plain datum it is, when it is shown at all. The panel adds no statistic of its own: every number it shows is the one grid.statistics returns.
createGrid(el, { toolPanel: { panels: ['columns', 'insights'] } });
// with a second dataset to rank this grid against:
createGrid(el, {
toolPanel: { panels: ['insights'] },
insights: { compareWith: otherGrid },
});
The reductions
Forty-one, all available to a totals row, to reduce() and to the profiling panel. Names are the same everywhere and the labels come from the message catalogue, so a grid in Polish reads in Polish.
| Group | Names |
|---|---|
| Basic | sum, avg, min, max, count, countValues, first, last, distinct, mode, range |
| Spread | variance, varianceP, stddev, stddevP, iqr, mad, sumSquares |
| Quantiles | median, p25, p75, p90, p95, p99 |
| Shape | skewness, kurtosis, jarqueBera: above 5.99 the column is not plausibly normal |
| Means | geomean, harmean, weightedAvg, trimmedMean, winsorizedMean |
| Outliers | robustOutliers: by the modified z-score, which an outlier cannot hide inside the way it inflates an ordinary one |
| Concentration | hhi, entropy, evenness, top3Share, top10Share, gini, the only group that reads a text column, because "how concentrated is this" is a question about categories |
| Positional | argmin, argmax |
Process capability
Declare the customer's tolerance on the column, and the capability figures, a control chart and any rule marking an out-of-tolerance cell all read the same limits.
columns: [{ field: 'mm', type: 'number', spec: { lower: 9.5, upper: 10.8, target: 10 } }]
Cp and Cpk use short-term variation, estimated from the moving range; Pp and Ppk use the overall standard deviation. The gap between them is the point: Cpk well above Ppk means the process drifted. Cp above Cpk means it is precise and aimed wrong, which needs a different fix from being too variable.
baseline fixes the control limits over the first N readings. Without it the limits are computed over everything (including whatever the process did wrong) so a step change pulls the centre line between the two levels and both halves land outside three sigma. Technically true, and useless for finding when it moved.
Seeing it
The statistics have chart types to match, in modules/charts. Each takes its numbers from this namespace rather than recomputing, so a coefficient in a matrix and the same one from the API cannot drift apart.
| Type | What it shows |
|---|---|
| correlogram | Every numeric column against every other, on a ramp centred at zero so the sign reads first. method: 'spearman' ranks instead; where the two disagree, the pair is related but not linearly. |
Sample quantiles against normal ones. Jarque-Bera says a column is not normal; this shows how, a heavy tail bends the ends, a skew bows the whole line. The reference runs through the quartiles, as R's qqline does, because a fitted line is dragged by the very tails you are inspecting. | |
| ecdf | The share at or below each value, as a step. No bins, so its shape is not partly a choice, and two overlay cleanly where two histograms fight. |
| lorenz | The curve a Gini is read off, against the diagonal a perfectly even column would trace. |
| control | An individuals chart: control limits from the moving range, the specification, and points breaking a Western Electric rule. The control limits are the process talking and the specification is the customer talking: conflating them is the classic error, so they are drawn differently. |
| histogram | curve: true overlays a kernel density estimate, which has no bin edges and so separates what is in the data from what is in the binning. |
| scatter | fit: true draws least squares per series with R² beside it. |
Values the grid maintains for you
Three of the ideas in this section are not standard grid vocabulary, so it is worth saying what they have in common before the detail. Each of them is a value the grid keeps up to date from data you already have, declared once rather than maintained by hand.
The alternative, in every application that needs one of these, is a parallel structure in the host: a copy of what each row looked like a moment ago, a rank recomputed on every tick, a cumulative total that has to be redone whenever the sort changes, and a dashboard panel running its own query beside the table. That code works for a while and then produces a number nobody can account for, usually because one part of it noticed a filter and another did not.
| Concept | What it is | Reach for it when |
|---|---|---|
| Shadow column | An extra column, declared against a real one, holding something the grid works out about it: how it has changed, or where it sits among the others. | You want “what was this an hour ago”, “how many places has it moved”, or “which decile is it in” as a column you can sort and filter on. |
| Running column | A cumulative value: the total, or the share of the total, by the time you reach this row. | You want a running balance, a cumulative percentage, or a Pareto curve down the page. |
| Derived grid | A whole second grid whose rows are built from the first: grouped, unnested, filtered, ranked or profiled. | You want a top-five panel, a breakdown by region, an exceptions list or a statistics summary beside the table, and it must never disagree with it. |
The line between a shadow and a running column is the sort order. A shadow is a function of the column: a row’s own history, or where its value sits among the others. Sort the grid differently and a rank is still the same rank. A running total is the opposite : it answers “how much by the time we reach this row”, and by the time is the order the rows are in, so re-sorting changes every value in the column. That is why they are declared separately rather than as two kinds of one thing.
A derived grid is a different scale of the same idea. A shadow adds a column to the rows you have; a derivation produces different rows altogether: one per sales person rather than one per sale. Because it is a source rather than a special kind of grid, the result sorts, filters, totals, themes and exports like any other, and can itself be the source of another.
All three read the rows the grid is currently showing, so a filter applied to the table moves the ranks, the running totals and every derived panel together. That is the property worth having: not that any one of them is clever, but that they cannot disagree.
Shadow columns
A shadow column is declared against another column and maintained by the grid. It has no field in the data and it is not a pure computed column either, because its value depends on what happened before. It is a real column throughout: sortable, filterable, totalled, grouped, exported, saved into a view, which is what makes "show me every circuit repriced more than twice this session, most-changed first" one gesture rather than a report.
columns: [
{ field: 'price', type: 'number' },
{ id: 'moved', title: 'Change', shadow: { of: 'price', kind: 'delta' } },
{ id: 'churn', title: 'Updates', shadow: { of: 'price', kind: 'updates' } },
{ id: 'run', title: 'Streak', shadow: 'streak' }, // shorthand: shadows the column beside it
]
| Kind | Value |
|---|---|
| updates | How many times the row's value has changed. Arrival is not a change, so a freshly loaded grid reads zero rather than one. |
| updatedAt | When it last changed, as a Date. |
| sinceUpdate | Milliseconds since it last changed. |
| delta | Current value minus the baseline. |
| deltaPercent | The same as a percentage. A change from nothing has no percentage and reads null rather than infinity. |
| rate | Change per second, from the last two readings. |
| history | The recent readings, oldest first. depth sets how many; the default is 20. Counts changes by default - see the time-windowed form below for a real time series. |
| firstValue | The baseline itself. |
| streak | Consecutive moves in one direction, signed. It resets on a turn, because "seven rises" means something and "seven changes" does not. |
A second family answers where the row sits among the others rather than what it did before. They share the same declaration and the same state, the tracker already holds every row's current value and its baseline, which is exactly what a rank and a rank change need.
| Kind | Value |
|---|---|
| rank | Competition rank, largest first: ties share the better rank and the next value skips, so two firsts are followed by a third. |
| rankAsc | The same ranking read from the other end. |
| rankChange | Places climbed since the baseline. Positive means climbed, even though the rank number itself falls: this is the "top movers" column. |
| percentile | The share of rows at or below this one, 0 to 100. |
| quartile | 1 to 4, agreeing with percentile: the 60th percentile is in the third quartile. |
| zScore | Deviations from the mean. A column with no spread reads null rather than zero. |
| shareOfTotal | The value over the column's total, as a percentage. A total of zero (a column of offsetting positions) reads null rather than a division by it. |
Running totals
A running column answers “how much by the time we reach this row”: a balance that accumulates down the page, or the share of the total accounted for so far. It is the column a Pareto chart is made of, and the one a finance report opens with.
It is declared separately from a shadow column, and the sort order is the reason. Every shadow is a function of the column (of a row's own history, or of where its value sits among the others) so it reads the same however the rows are arranged. A running total does not: sort the grid differently and every value changes, because the question is "how much by the time we reach this row", and by the time is the sort order.
columns: [
{ field: 'amount', type: 'number' },
{ id: 'cum', title: 'Running', running: { of: 'amount', kind: 'total' } },
{ id: 'share', title: 'Cumulative %', running: { of: 'amount', kind: 'percent' } },
]
Computed in one pass over the display rows and cached against that ordering, so a hundred thousand rows are walked once per sort rather than once per cell. A running column is not sortable. Sorting on one asks the sort to depend on its own output (the value is defined by the display order) so the column does not offer a sort unless its definition asks for one, and the query layer refuses such a sort with a warning rather than computing it. Sort by the column it runs over instead. Group headings and totals rows are skipped, a running total that counted a subtotal would double everything below it, and a row with no value carries the figure forward unchanged rather than resetting it.
History has two clocks: a count of changes, or a span of time
Plain history (above) is an event series: it records a reading only when the value changes, so a row that sits still never advances it. Bound to a cell.render sparkline ('line', 'area', 'column', 'winloss') that means a static row's sparkline freezes, then jumps when a change finally lands - while a title like "last 60s" keeps claiming a span the column never measured. Add a time window to make it a real time series instead:
{ id: 'spark', title: 'Last 60s', shadow: { of: 'price', kind: 'history', window: { kind: 'time', span: 60_000 }, depth: 20 } }
This is the same window: { kind, span } shape the rolling kinds below already accept - not a second spelling of it - and it changes what history means rather than adding a new kind: depth (20 here) is now how many buckets the span divides into, so this is sixty seconds as twenty three-second buckets. Each bucket is sampled once, at its close, as the row's last known value at that moment - carried forward from the previous bucket when nothing changed in between. A static row therefore draws a flat line that keeps advancing, one sample per bucket, and a change lands in the bucket it actually happened in rather than being appended at the end. The bucket clock runs on its own low-frequency timer (never a render loop), so the series keeps moving even while no data event ever reaches the column. window: { kind: 'count' } and { kind: 'session' } are refused for history - a plain count is already what depth means without a window, and a session has no fixed span to divide into buckets - and a caller who never sets window gets the original count-based behaviour, unchanged.
Positional kinds rank over every tracked row, not over the filtered set: a rank that changed as you filtered would make "the top ten movers" depend on what happened to be on screen, and the column would disagree with itself between two views of the same data. Pass scope: 'filtered' on the shadow spec to rank within what the filters left instead: both answers are legitimate, which is why it is a choice rather than a default.
Shadow state is keyed by row key, never by index: after any sort an index-keyed history would report one row's past against another row's present, and the wrong number would be sortable. Memory is capped at 200,000 tracked rows per column; past that the oldest are dropped and tracking().forgotten says how many, rather than a smaller number being reported as though it were the truth.
Rolling time-series columns
A total answers "how much"; a rolling total answers "how much lately, as the series ran" - the seven-day average that smooths a daily figure, the trailing sum, the change on the period before. These are rolling shadow kinds: real columns, sortable and filterable and exportable like any other, computed in one ordered pass and cached by row key. They compose the windowed-aggregate model the grid already uses for "the average lately" over a live stream, asked instead over a column arranged in a stated order.
The order is explicit and required - an orderBy column, never the screen sort, because a rolling figure defined by the current sort would change on every header click and a column sorted on its own rolling value would define itself. The window is the last span rows (count), the last span of the order axis (time), or the whole series so far (session). The first rows carry a partial window; that figure is still emitted, but a windowCoverage companion stamps how much of the window it actually covers, so a two-day average is never shown as a seven-day one. within chooses per-group (the default, partitioned by the grid's grouping) or across the whole dataset.
A rollingQuantile (a trailing median, a p95, set by q) is exact while the window is small and comes from a KLL sketch past an internal span cap and for a session window - where a windowApproximate companion reports which rows are approximate, so a sketched quantile is never presented as exact. At a million rows the single ordered pass stays well within the suite's budget (≈380ms for the window aggregates, ≈490ms for the exact rolling median, ≈420ms for the session sketch on the reference bench).
columns: [
{ field: 'day', type: 'date' },
{ field: 'sales', type: 'number' },
{ id: 'ma7', title: '7-day avg', shadow: { kind: 'rollingAvg', of: 'sales', orderBy: 'day', window: { kind: 'count', span: 7 } } },
{ id: 'cover', title: 'Coverage', shadow: { kind: 'windowCoverage', of: 'sales', orderBy: 'day', window: { kind: 'count', span: 7 } } },
{ id: 'p50', title: '30-day median', shadow: { kind: 'rollingQuantile', of: 'sales', orderBy: 'day', window: { kind: 'count', span: 30 }, q: 0.5 } },
{ id: 'ytd', title: 'Cumulative', shadow: { kind: 'cumulativeToDate', of: 'sales', orderBy: 'day' } },
{ id: 'delta', title: 'vs prev', shadow: { kind: 'periodOverPeriod', of: 'sales', orderBy: 'day' } },
]
A rolling window is a property of the series, so it is computed over every row before any filter: hiding rows with a filter narrows what you see, never what "the last seven" means. A missing reading is a gap, skipped rather than treated as a zero that would report a plunge and a rebound the series never made.
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// A short series, ordered by t: values 2,4,5,4,5.
const base = { of: 'v', orderBy: 't', within: 'all' };
const win = { kind: 'count', span: 3 };
const grid = createHeadlessGrid({
columns: [
{ field: 't', type: 'number' },
{ field: 'v', type: 'number' },
{ id: 'sum', shadow: { kind: 'rollingSum', window: win, ...base } },
{ id: 'avg', shadow: { kind: 'rollingAvg', window: win, ...base } },
{ id: 'cov', shadow: { kind: 'windowCoverage', window: win, ...base } },
{ id: 'med', shadow: { kind: 'rollingQuantile', window: win, q: 0.5, ...base } },
{ id: 'cum', shadow: { kind: 'cumulativeToDate', ...base } },
{ id: 'pop', shadow: { kind: 'periodOverPeriod', ...base } },
],
rows: [
{ id: 'r1', t: 1, v: 2 }, { id: 'r2', t: 2, v: 4 }, { id: 'r3', t: 3, v: 5 },
{ id: 'r4', t: 4, v: 4 }, { id: 'r5', t: 5, v: 5 },
],
rowKey: 'id',
});
const round4 = (x) => Math.round(x * 10000) / 10000;
const round2 = (x) => Math.round(x * 100) / 100;
return [
grid.rows.value('r3', 'sum'), // 2+4+5 = 11
round4(grid.rows.value('r3', 'avg')), // 11/3
round4(grid.rows.value('r5', 'avg')), // (5+4+5)/3
round2(grid.rows.value('r1', 'cov')), // 1/3 of the window filled
grid.rows.value('r5', 'cum'), // running total to the end
grid.rows.value('r2', 'pop'), // 4 - 2
grid.rows.value('r3', 'med'), // median of 2,4,5 = 4
].join('|');
Seasonal decomposition
Splitting a series into trend + seasonal + residual answers "what's the underlying trend with the weekly pattern removed?". It is classical decomposition - the same algorithm statsmodels.seasonal_decompose uses, verified against it in the reference suite - delivered as four shadow columns over the same ordered pass: tsTrend (a centred moving average), tsSeasonal (the repeating index), tsResidual (what the two leave behind), and tsCoverage.
The period is caller-declared and required - 7 for a weekly cycle in daily data, 12 for a monthly cycle in monthly data; there is no auto-detection in v1. The model is additive by default; decomposition: 'multiplicative' is a declared option that is undefined on a non-positive series (those rows report null, with a warning). The centred window runs off the ends, so the leading and trailing rows have no trend - they are partial edges, reported as null and stamped tsCoverage: 0 rather than emitted as if full.
columns: [
{ field: 'day', type: 'date' },
{ field: 'sales', type: 'number' },
{ id: 'trend', title: 'Trend', shadow: { kind: 'tsTrend', of: 'sales', orderBy: 'day', period: 7 } },
{ id: 'season', title: 'Weekly', shadow: { kind: 'tsSeasonal', of: 'sales', orderBy: 'day', period: 7 } },
{ id: 'resid', title: 'Residual', shadow: { kind: 'tsResidual', of: 'sales', orderBy: 'day', period: 7 } },
{ id: 'cover', title: 'Coverage', shadow: { kind: 'tsCoverage', of: 'sales', orderBy: 'day', period: 7 } },
]
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// A period-4 series: trend 10+i plus a season [2,-1,0,-1], so value = trend + season.
const season = [2, -1, 0, -1];
const base = { of: 'v', orderBy: 't', within: 'all', period: 4 };
const grid = createHeadlessGrid({
columns: [
{ field: 't', type: 'number' },
{ field: 'v', type: 'number' },
{ id: 'trend', shadow: { kind: 'tsTrend', ...base } },
{ id: 'season', shadow: { kind: 'tsSeasonal', ...base } },
{ id: 'resid', shadow: { kind: 'tsResidual', ...base } },
{ id: 'cover', shadow: { kind: 'tsCoverage', ...base } },
],
rows: Array.from({ length: 8 }, (unused, i) => ({ id: String(i), t: i, v: (10 + i) + season[i % 4] })),
rowKey: 'id',
});
return [
grid.rows.value('4', 'trend'), // centred MA recovers the trend: 14
grid.rows.value('4', 'season'), // the phase-0 seasonal index: 2
grid.rows.value('4', 'resid'), // nothing left over: 0
grid.rows.value('4', 'cover'), // interior row: full, 1
grid.rows.value('0', 'trend') === null ? 'null' : 'x', // partial edge: null, not invented
grid.rows.value('0', 'cover'), // edge stamped partial: 0
].join('|');
Exponential smoothing
Smoothing pulls the signal out of a noisy series. tsSmoothed is the fitted level - not a forecast of the future - from single exponential smoothing (smoothing: 'ses', the default) or Holt's level+trend (smoothing: 'holt'). The recursion matches statsmodels and is verified against it in the reference suite. Holt-Winters (seasonal) smoothing is deferred; seasonality is covered by decomposition above.
The smoothing factor is either caller-set (alpha, and beta for Holt) or fit by minimising the in-sample SSE when omitted - and the chosen value is reported, not hidden, by the tsSmoothingAlpha / tsSmoothingBeta companion columns.
columns: [
{ field: 'day', type: 'date' },
{ field: 'sales', type: 'number' },
{ id: 'level', title: 'Smoothed', shadow: { kind: 'tsSmoothed', of: 'sales', orderBy: 'day', smoothing: 'holt' } },
{ id: 'a', title: 'α', shadow: { kind: 'tsSmoothingAlpha', of: 'sales', orderBy: 'day', smoothing: 'holt' } },
{ id: 'b', title: 'β', shadow: { kind: 'tsSmoothingBeta', of: 'sales', orderBy: 'day', smoothing: 'holt' } },
]
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// SES at alpha 0.5 over 4,8,6,10: level runs 4, 6, 6, 8.
const base = { of: 'v', orderBy: 't', within: 'all', smoothing: 'ses', alpha: 0.5 };
const grid = createHeadlessGrid({
columns: [
{ field: 't', type: 'number' },
{ field: 'v', type: 'number' },
{ id: 'sm', shadow: { kind: 'tsSmoothed', ...base } },
{ id: 'a', shadow: { kind: 'tsSmoothingAlpha', ...base } },
],
rows: [4, 8, 6, 10].map((v, i) => ({ id: String(i), t: i, v })),
rowKey: 'id',
});
return [
grid.rows.value('1', 'sm'), // 0.5*8 + 0.5*4 = 6
grid.rows.value('3', 'sm'), // 0.5*10 + 0.5*6 = 8
grid.rows.value('0', 'a'), // the factor used, reported: 0.5
].join('|');
Stationarity (ADF)
Before you compare two series or detrend one, it helps to know whether it is stationary - reverting to a level or trend - or wandering with a unit root. grid.statistics.adf runs the Augmented Dickey-Fuller test and returns a scalar readout, not a per-row column: the statistic, the augmenting lag chosen by AIC, MacKinnon's critical values, an interpolated p-value (stamped approximate), and a plain-language verdict at the 5% level. The constant+trend regression and the AIC lag choice match statsmodels' adfuller, against which the statistic and lag are verified.
The lag search and what it costs. maxlag caps the number of augmenting lags the AIC search considers; left out, it is the Schwert rule ⌈12·(n/100)^0.25⌉ - 34 candidates on 7,000 rows - itself capped so the fixed sample keeps degrees of freedom. The candidates are nested, so the search builds one design matrix at the cap and reads every smaller candidate off it - one pass to accumulate the normal equations, then a small solve and a single residual pass per candidate, rather than a fresh fit each time. The default search over 7,000 rows is a matter of milliseconds. Setting maxlag narrows the search, never the arithmetic: the lag chosen, the statistic and the p-value are whatever the data says, and a cap that still contains the AIC-preferred lag returns exactly the same readout.
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// A random walk (a unit root): it wanders rather than reverting.
const walk = [0.138, -0.725, -1.26, -0.536, -0.267, 0.167, -0.765, -0.146, -0.311, -0.586,
0.376, -0.41, 0.282, 0.865, -0.024, 0.149, -0.2, -0.654, -1.338, -1.917, -1.832, -1.333,
-0.492, -1.259, -1.89, -2.145, -2.146, -1.297, -1.26, -0.716, -0.846, -1.206, -2.126,
-1.724, -0.945, -1.599, -1.316, -0.413, 0.304, 0.732, -0.257, 0.086, -0.572, -0.501,
-1.153, -1.186, -1.455, -1.607];
const grid = createHeadlessGrid({
columns: [{ field: 't', type: 'number' }, { field: 'v', type: 'number' }],
rows: walk.map((v, i) => ({ id: String(i), t: i, v })),
rowKey: 'id',
});
const adf = grid.statistics.adf({ of: 'v', orderBy: 't' });
return [adf.verdict, adf.usedLag].join('|'); // non-stationary, 0 lags
Autocorrelation (ACF / PACF)
grid.statistics.acf shows how far back a series depends on itself: the autocorrelation (ACF) and partial autocorrelation (PACF) arrays out to a maximum lag, each with the approximate ±1.96/√n white-noise band (stamped approximate) - a lag whose bar clears the band is evidence of real dependence. The estimators are the biased ACF and the Yule-Walker (Levinson-Durbin) PACF, matching statsmodels, verified in the reference suite. Lag 1 is the single source of truth: acf[1] is the same number statistics.series(...).autocorrelation reports, and pacf[1] === acf[1].
The correlogram is the arrays fed to a bar chart over explicit points, with the band as reference lines - reusing the existing chart primitives:
const { acf, bounds } = grid.statistics.acf({ of: 'sales', orderBy: 'day', maxlag: 20 });
createChart({
grid, container: '#acf', type: 'bar',
points: acf.map((v, lag) => ({ x: lag, y: v })),
reference: [{ value: bounds.upper }, { value: bounds.lower }, { value: 0 }],
});
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
// A deterministic AR(1): each reading leans 0.6 on the one before.
let s = 5; const rand = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296 - 0.5; };
const y = []; let prev = 0;
for (let i = 0; i < 200; i++) { const v = 0.6 * prev + rand(); y.push(v); prev = v; }
const grid = createHeadlessGrid({
columns: [{ field: 't', type: 'number' }, { field: 'v', type: 'number' }],
rows: y.map((v, i) => ({ id: String(i), t: i, v })),
rowKey: 'id',
});
const res = grid.statistics.acf({ of: 'v', orderBy: 't', maxlag: 6 });
const series = grid.statistics.series('v', { by: 't' });
return [
res.acf[0], // lag 0 is always 1
res.pacf[1] === res.acf[1], // the first partial equals the first acf
Math.abs(res.acf[1] - series.autocorrelation) < 1e-9, // lag 1 is the single source of truth
].join('|');
The statistic block
createStat draws the tile a dashboard opens with: a label, a value, its change
against a baseline, and a line saying what the comparison was. Two things make it worth using
rather than writing. It reads the grid, so it cannot disagree with the table
beneath it, a tile saying £4.2M above a table filtered to £1.8M is worse
than no tile, and that is what a hand-built tile does the first time somebody adds a filter.
And it formats through the column's own type: a stat over a
seconds column reads 42.1 ms, over a money column with an auto ladder
£1.2M, with nothing declared.
import { createStat } from '@toclocoinc/lattice-grid';
createStat({
grid, container: '#mrr',
title: 'Monthly recurring revenue',
of: 'mrr', fn: 'sum',
baseline: lastMonth,
footer: 'vs. last month',
});
| Key | Type | Description |
|---|---|---|
| grid | Grid | The grid to read. |
| container | Element | string | Required. An element, or a selector resolved against the grid's document. |
| title | string | The label above the value. Hidden when absent rather than left blank. |
| of | string | The column to reduce. Omit for count. |
| fn | TotalName | Any of the totals-row kernels: sum, avg, median, p95, distinct, gini and the rest. sum by default. |
| show | string | Report this column from the row holding the extreme, rather than the extreme itself: { of: 'sales', fn: 'max', show: 'rep' } is the name of the best rep. Needs min or max; no single row holds an average, so any other reduction is refused with a warning. |
| value | unknown | fn | A literal value (numeric or otherwise) or a function of the grid, instead of a reduction. |
| footer | string | fn | Text under the value, or a function of it. |
| baseline | number | fn | What the value is compared against. A zero baseline reports the absolute change and no percentage, because “up infinity per cent” is not a reading anyone can act on. |
| goodWhen | 'up' | 'down' | 'neither' StatGoodDirection | Whether a rise is good news, which decides the colour. up by default. Revenue up is green and error rate up is red; a tile that paints every rise green is misleading on half a dashboard. |
| scope | 'filtered' | 'all' | 'selected' StatFollowScope | Which rows feed the value. filtered by default; all for a tile that is deliberately a constant, such as the denominator a filtered number is a share of. |
| live | boolean | false stops the tile following the grid. refresh() still works, so a caller can drive it. |
| format | (value, grid) => string | Override the formatting the column's type would apply. |
| empty | string | Shown when there is no value. An em dash by default. |
| decimals | number | Fraction digits for a value whose reduction changed the unit. 2 by default. |
The column's formatter is borrowed only where the reduction leaves the unit alone. A Gini
coefficient over a money column is a ratio between 0 and 1, and rendering it as
$0.34 says it is thirty-four cents; counts, ratios and variances, which are
in units squared: fall back to a plain number.
Returns { element, value, refresh, destroy }. A misconfigured tile returns an inert
handle rather than throwing, so a dashboard with one bad tile still renders the other eleven.
Units of your own
Twenty-six unit systems ship: length, mass, pressure, data, bitrate, angle, temperature, flow and the rest. A family of your own takes three things, and all three are yours to set: the symbols, where the symbol sits relative to the number, and how the rungs relate to each other.
import { registerUnitSystem, defineUnit, createUnitType } from '@toclocoinc/lattice-grid';
// `factor` is how many base units one of these is. Exactly one must be 1.
registerUnitSystem('distance', [
defineUnit('mm', 0.001, ['millimetre', 'millimetres']),
defineUnit('cm', 0.01, ['centimetre', 'centimetres']),
defineUnit('m', 1, ['metre', 'metres']),
defineUnit('km', 1000, ['kilometre', 'kilometres']),
]);
createGrid(el, {
dataTypes: {
distance: createUnitType({ system: 'distance', unit: 'm', display: 'auto' }),
},
columns: [{ field: 'span', type: 'distance' }],
});
The factor values are the relationship between the rungs: there is no separate ladder to declare, and no ordering to get right, because the ladder is sorted by factor at load. A hand-ordered list of thirty units is one transposition away from an auto display that walks backwards, and that mistake is invisible in review.
| Option | Description |
|---|---|
| system | A built-in system, or one registered with registerUnitSystem. |
| unit | What the column stores. It need not be the system's base: the same ladder with unit: 'km' stores kilometres, and 50mm typed in becomes 0.00005. |
| display | 'auto' walks the ladder for the most readable rung, or name a symbol to fix it. |
| placement | 'prefix' puts the symbol in front: $1,200, and applies to input as well as display. Defaults to a suffix. |
| decimals | Fixed fraction digits; or minDecimals / maxDecimals, or significantFigures. |
| locale | Separators and grouping. Follows the grid's locale when unset. |
A unit given { auto: false } stays off the display: 'auto' ladder while remaining accepted on input and available as an explicit display. That is how imperial units sit beside metric ones without an auto readout jumping between the two.
The stored value is always a plain number in the column's own unit. Sorting, filtering, grouping, totals and the pivot all read that number and never the text, which is why 250mm sorts below 1.5 cm correctly rather than 1 sorting before 9. registerUnitSystem is global and throws on a duplicate name, so register each system once at startup rather than inside a component that may mount twice.
Compound display: 5 ft 11 in, 1 h 23 m
compound renders one stored number across an ordered subset of the system's units. It is display and parse only: the value stays a single base-unit number, so sort, filter, group and total are the same arithmetic they always were. The order is free - the units are sorted largest to smallest - and the smallest one carries any remainder. Parsing sums the parts, so a paste of 5 ft 11 in round-trips, and a single 71 in or a bare 6 still work.
const { formatUnit, parseUnit } = await import('../packages/core/src/columns/types/unit.js');
// A height column stored in metres, displayed as feet and inches.
const cfg = { system: 'length', unit: 'm', compound: ['ft', 'in'], locale: 'en-GB' };
const shown = formatUnit(1.8034, cfg); // across the two units
const stored = parseUnit('5 ft 11 in', cfg); // summed back to metres
const stable = formatUnit(parseUnit(shown, cfg), cfg) === shown; // round-trips
return `${shown} | ${stored} | ${stable}`;
The compound units need not include the stored unit, and any unit of the system is accepted on input: 71 in pasted into a feet-and-inches column is still 71 inches. Excel export and display: 'auto' share a rule here - a column of mixed-scale text is not summable in a spreadsheet, so the export uses the raw base number on the configured unit. The compound cell editor (mid-value keystrokes, roll-over between feet and inches, caret behaviour at a boundary) is a separate, later piece; this is the read-and-paste half.
Currency: an amount and a code
Currency is a real type, not a display format. Every other unit multiplies by a factor
fixed at load; a currency's “factor” is an exchange rate that moves, so it never
joins the unit factory. A value is an amount and a code - { amount: 10, code: 'USD' } is a different value from
{ amount: 10, code: 'EUR' }, and the code rides on every cell. The grid ships
and fetches no rates: the caller supplies a rate source, and a rate that is needed but absent
is surfaced loudly, never as zero. A footer refuses to add unlike currencies
unless a display currency and rates reconcile every value, the same stance temperature takes
for refusing a meaningless sum.
const { createCurrencyType, parseMoney, formatMoney, convertMoney, rateFunction, MISSING_RATE } =
await import('../packages/core/src/columns/types/currency.js');
// The caller owns the rates; the grid ships none. A missing one is loud, never zero.
const rates = { USD: 1, EUR: 0.92 };
const money = createCurrencyType({
code: 'USD', display: 'EUR', rates, rateBase: 'USD', decimals: 2,
nullDisplay: ' - ', missingRate: 'no rate', excel: '€#,##0.00', codes: ['USD', 'EUR'],
});
const rate = rateFunction(rates, 'USD');
const tenInEur = convertMoney(parseMoney('$10', { code: 'USD' }), 'EUR', rate);
const loud = formatMoney({ amount: 5, code: 'XYZ' }, money.currencyConfig).startsWith('no rate');
const marker = MISSING_RATE.length > 0;
return `${tenInEur.toFixed(2)}|${loud}|${marker}`;
| Option | Description |
|---|---|
| code | The default currency code for a bare numeric input. A number with its own symbol or code keeps that code. |
| display | The currency to render and total in. Omit to keep each cell in its own currency. |
| rates | The caller's rate source: a (from, to) => rate | null function, or a table of rates per unit of a common base. |
| rateBase | The code a rate table is denominated in. The cross rate is base-independent, so this documents the table's denomination for the reader. |
| missingRate | The loud marker rendered when a needed rate is absent. Defaults to MISSING_RATE. |
| decimals | Fixed fraction digits; omit for the currency's own convention. |
| nullDisplay | Text shown for an empty cell. |
| excel | An Excel number-format override for export. |
| codes | The code list the currency editor's picker offers. |
The stored value is always the amount and its own code. Sort, filter, group, copy and Excel export all read the underlying amount - converted to the display currency when rates allow, so £5 and $6 order by real value. Five ready-made types ship (currency, usd, eur, gbp, jpy); a mixed-currency column adds display and rates through createCurrencyType.