api reference
The KPI (stat-tile) view
createKPI and the stat tiles, the aggregations, formatting, baselines and delta, threshold bands, sparklines, and the keyed-diff surface a data router drives.
API reference › The KPI (stat-tile) view
The KPI (stat-tile) view
modules/kpi is an opt-in view of a dataset as a panel of stat tiles - each tile an aggregate over the routed rows: a sum, an average, a min/max, a count, a distinct count, or a host reducer. It is the fourth first-class viewer beside the grid, the kanban and the gantt, in the same shape: a separate bundle that adds no weight to a page that does not load it, changes nothing in grid core, and pulls in no dependency. A KPI panel is just another dataset viewer: it consumes data through the same keyed-diff contract a grid exposes, kpi.rows.apply({ add, update, remove }), so a Data Router can attach(value, kpi) and drive a KPI panel beside a grid, a kanban and a chart off one feed.
import { createKPI } from '@toclocoinc/lattice-grid/modules/kpi';
const kpi = createKPI(document.querySelector('#kpis'), {
rows, // or { grid } to read a live grid's rows
rowKey: 'id',
columns: 4, // responsive tile columns
tiles: [
{ id: 'total', label: 'Revenue', aggregation: 'sum', field: 'amount', format: 'currency' },
{ id: 'avg', label: 'Avg deal', aggregation: 'avg', field: 'amount',
format: { type: 'currency', decimals: 0 }, baseline: 5000 }, // delta vs a baseline
{ id: 'open', label: 'Open deals', aggregation: 'count',
filter: (r) => r.stage !== 'won',
thresholds: { warn: 10, critical: 25, direction: 'lowerIsBetter' } }, // good/warn/critical bands
],
onTileClick: ({ tile }) => drillInto(tile.id),
});
Each tile names an aggregation (sum, avg, min, max, count, countDistinct, or a custom reducer (rows, tile) => value), the field it reads, and an optional filter predicate. Formatting (number/currency/percent/compact, with decimals, currency and locale), a baseline for a delta, and semantic threshold bands (two cut points with a direction, or an explicit bands list) are all per tile; the band is a semantic name (good/warn/critical), separate from any accent colour. An optional sparkline plots a { x, y } series in sequence order. Each tile is a labelled <figure>, focusable and keyboard-activatable, its value announced; the sparkline transition respects prefers-reduced-motion.
Incremental, not recomputed. Each tile keeps a running accumulator, so a routed rows.apply delta adjusts only the rows it carries - an add contributes, a remove reverses, an update reverses the old row and contributes the new one - rather than re-reading the whole dataset per delta. The two bounded exceptions are honest: an extreme (min/max) removed at its current value triggers a rescan of that tile's own value multiset, and a custom reducer is recomputed over the (filtered) store because an arbitrary function has no inverse.
| Member | Description |
|---|---|
| createKPI(el, config) | Create a KPI panel. Pass a DOM element to render into, or null for a headless panel that computes the same tile model without a DOM. |
| rows.apply({ add, update, remove }) | The keyed-diff consumer contract a grid shares, so the panel is a drop-in Data Router target and updates each tile incrementally. Also rows.forEach and rows.count. |
| tiles() / tile(id) / value(id) | Every computed tile model, one tile by id (value, formatted, status, delta, deltaPercent, count, sparkline), or a tile's raw value. |
| setRows(rows) / refresh() | Replace the source rows, or re-seed and recompute. |
| getState() / setState(snapshot) | Serialise and restore the panel's row set, so a headless panel round-trips. |
| on(name, fn) / off(name, fn) | Events: tile:click, tile:dblclick, tile:contextmenu, and change (after every update). |
| destroy() | Empty the element and drop the listeners. The host still owns any bound grid. |
Interaction is light and host-driven. A tile emits tile:click (also from the keyboard) carrying the tile model, so a host can drill down or, in a demo, filter a routed grid - the wiring lives in the host, not the module. This is deliberately not a dashboard layout engine (that is the parked dashboard generator) and charting beyond a minimal sparkline belongs to the charts module.
Live, driven by a Data Router alongside a grid, executed
One feed fans out (overlap) to a KPI panel through the same keyed-diff contract a grid uses:
a snapshot seeds the tiles, then a delta removes the current max and the min/max rescans. Run headless on every build.
const { createKPI } = await import('../packages/modules/kpi/index.js');
const { createDataRouter } = await import('../packages/modules/data-router/index.js');
const kpi = createKPI(null, {
rowKey: 'id',
tiles: [
{ id: 'total', label: 'Revenue', aggregation: 'sum', field: 'amount', format: 'currency' },
{ id: 'max', label: 'Biggest', aggregation: 'max', field: 'amount' },
{ id: 'open', label: 'Open', aggregation: 'count', filter: (r) => r.stage === 'open',
thresholds: { warn: 1, critical: 3, direction: 'lowerIsBetter' } },
],
});
const router = createDataRouter({ key: 'kind', rowKey: 'id', overlap: true });
router.attach(kpi, 'deal'); // a KPI panel is a drop-in router target
router.load([
{ id: 'd1', kind: 'deal', amount: 100, stage: 'open' },
{ id: 'd2', kind: 'deal', amount: 250, stage: 'won' },
]);
const seeded = kpi.value('total') + ',' + kpi.value('max'); // 350,250
router.apply([{ op: 'delete', row: { id: 'd2' } }]); // removes the current max
const afterRemove = kpi.value('total') + ',' + kpi.value('max'); // 100,100 (max rescanned)
const band = kpi.tile('open').status; // 'good' - 1 open, lowerIsBetter
router.destroy();
return [seeded, afterRemove, band].join(' | ');