api reference
KPI API Reference
createKPI: stat tiles over a dataset, the aggregations, baselines and delta, threshold bands, sparklines and the tile tree.
API reference › The KPI (stat-tile) view
All 29 pages Everything on one page → Developer guide →
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 follow 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: { style: '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. delta: 'absolute' | 'relative' | 'both' (default 'both') chooses what the movement line prints against the baseline - the difference alone, the percentage alone, or both together, because the percentage of a rate series (4.10 vs a 4.30 baseline) is meaningless and a host needs to keep the line without it. 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.
A panel with no data says so. A tile's status is good, warn, critical, null (no thresholds) - or unknown, which means the tile measured nothing. Three things cause that: the panel holds no rows at all; the tile's field names no column on the bound grid, so it never read a cell to reduce over; or the panel declares a maxAge and its feed has been silent for longer than that (below). That fourth status is decided from data presence before any threshold is consulted, because an aggregation over nothing returns the identity of its operation (sum and count return 0) and 0 is a number a threshold grades: under lowerIsBetter cut points it grades good, so an empty panel would otherwise read as a healthy one. An unknown tile reports a null value, renders the nullText placeholder rather than a zero, and is marked with a dashed edge and a visible “No data” caption that also forms part of its accessible name - the state is never carried by colour alone. Because unknown is an explicit status rather than a severity, anything rolling tiles up can surface “not measured” instead of inheriting a false green.
A measured zero is still a measurement. A tile whose filter matches none of the rows the panel does hold is a different thing: while the feed is fresh, no open incidents is genuinely good, so it reads 0 and is graded on its thresholds exactly as before.
A feed that went quiet is the other way to have nothing to report, and it is a question about time. A feed that stopped six hours ago still has its rows, all old, and every tile over them still computes a number: a Sev-1 count of 0 over rows that died yesterday and one over rows that arrived a second ago are the same zero, and under lowerIsBetter cut points both paint green. Tell the panel how fresh its data has to be and it tells them apart: createKPI(el, { maxAge: 60000, ageBy: 'observedAt', … }). maxAge (milliseconds) is how long the panel keeps grading what it holds after its feed last spoke; ageBy names the clock that is measured on - a column, a dotted path or a function of the row - and carries exactly the meanings the rolling time window gives the same two options. Past the window with nothing recent, every stat tile reports unknown with a null value whatever its own membership, each carries stale: true, and the tile captions itself “No recent data” (message key kpi.status.stale) with the same dashed edge and accessible-name treatment the empty panel's “No data” has. Grading resumes when the feed speaks.
Three properties of that window worth knowing before you set one. It is opt-in: a panel that declares no maxAge judges no freshness at all, because nobody has said what recent means. Omitting ageBy measures arrival instead - when rows last reached the panel, through rows.apply, setRows, or the bound grid announcing that its own rows changed - which is what a feed with no timestamp column has to be judged on; a filter, a sort, an expansion, a refresh() and a rolling window's eviction are not arrivals, so a live feed whose rows are all filtered out of the grid beneath the panel reports the empty panel's unknown (stale: false) rather than a silence, and an empty panel never goes stale. And the panel checks itself on a low-frequency timer while a window is declared, because a silence is the absence of events and nothing else on the panel would notice one; destroy() stops it. An ageBy that no row answers to grades as usual and names itself once rather than blanking the panel.
A grid-bound panel follows the grid. A panel given grid reads the grid's rows when it is built and every time what the grid shows changes: after a filter, a sort, a cell edit (edit.setCells or the keyboard), rows.apply or rows.load on the grid, and the grid's pipeline settle after an off-thread sort, the panel agrees with the grid beneath it without the host calling refresh(). It used to read the grid once, at bind, so a KPI rail beside a filtered grid kept showing the unfiltered numbers until the host wired refresh() to the grid's events themselves. The panel now subscribes to the same grid events a createStat tile follows, gated on the same pipeline-settle test, so a model:changed for an expand, a collapse or a page turn re-reads nothing - except on a grid whose rows are fetched (a paged or remote source), where a page or the exact total landing is followed too, so a panel built before the first page arrives shows it without a refresh() (GEO-6). One re-read per change, not one per event: the grid announces one change several ways (rows.apply fires six events; an edit fires one cell:changed per cell), so the re-read is queued on a microtask and every event of one synchronous turn collapses into one read of the grid - read the panel after the turn ends (after an await, or in its change event), or call refresh() for the numbers now, which re-reads in the same turn and replaces the queued follow rather than doubling it. On a sorted grid past the worker threshold a filter change is two re-reads (one at the end of the turn, one when the settle lands); on a synchronous grid it is one. The grid is the truth, so rows.apply and setRows on a bound panel are still refused, and destroy() stops following. Want a snapshot instead? Do not pass grid as the source: pass the rows (rows: snapshot), with grid alongside if the panel should still adopt the grid's type and density - a panel given both reads rows and follows nothing.
Over a paged pushdown source, a sum is the relation's sum (GEO-6). A grid-bound panel beside a grid holding one page of a large relation totals the relation, not the page. A sum, avg, min, max or countDistinct tile asks the grid's source.aggregate() whenever the grid holds fewer rows than match - one request for every such tile on the panel, the grid's filter and quick search applied - and reads the engine's figure over every matching row. The tile model says so (computed: 'engine'), and so does the tile's accessible name. While a new question is in flight the tile keeps its last engine figure, never the page's. count already counts every matching row and stays the panel's own. A tile the engine cannot answer - a filter function, a custom reducer, a field that is not a plain column, a source whose aggregates config keeps the statistic in the browser - keeps reducing the page for sum/avg/min/max, a [lattice] warning (kpi:engine-page:*) naming the tile and the reason. countDistinct is the one exception. A page's distinct count is not a partial reading of the relation's the way a page's sum is - the same values recur on pages that have not loaded, so a bigger page never converges toward the true count, and a demo reported 103 destinations for a relation of 354 with nothing on screen to say so. So a countDistinct tile the engine cannot answer shows no number at all rather than the page's, named instead by kpi:aggregate-unpushable:*.
A pivoted pushdown grid has no leaves to read. A pivot's terminal groups are pushed down and answered as aggregated cells - they carry no leaf rows at all - so a panel bound to a pivoted grid finds nothing under grid.rows.leavesOf, whether or not a heading is expanded. A sum, avg, min, max or countDistinct tile over a plain column with no filter function is unaffected: it asks the engine directly for the grand total over every matching row, exactly the same ungrouped query GEO-6 (and, for countDistinct) already asks for a windowed grid and exactly the number a scalar tile wants regardless of how the grid itself groups or pivots for display, and its model still says computed: 'engine'. A count, a custom reduction, or a tile whose filter is a host function has no pushed pivot query and no leaf row to fall back to: it reports unknown with a null value, exactly as an empty panel does, and a [lattice] warning (kpi:pivot-unsupported:*) names the tile and why - never a leaf-window figure.
A grid-bound filter reads the columns you project, and says so when it cannot. A panel bound to a grid does not see whole grid rows: it materialises a projection of each row through the grid’s own value pipeline, carrying the row key plus the fields the tiles declare. That is what keeps a refresh over a large grid cheap - and it used to mean a filter reading any other column saw undefined, matched nothing, and reported a confident 0 beside a grid full of rows that matched. Declare the extra columns with fields:
createKPI(el, {
grid,
fields: ['priority'], // project it, so the filter can read it
tiles: [
{ label: 'P1 jobs', agg: 'count', filter: (r) => r.priority === 'P1' },
],
});
Forget to, and the panel tells you rather than quietly reporting a zero: a read of a column the bound grid has but the projection does not resolves to the real cell and warns once - “the ‘P1 jobs’ tile's filter read priority, which is a column on the bound grid but is not projected - add it to fields” - keyed on the tile and the field, so one bad filter over a 100,000-row grid produces one line, not 100,000. undefined on its own is deliberately not the trigger: a blank cell in a column you did project is a legal value and stays silent, because a warning that fires on ordinary sparse data gets muted and then deleted. A tile field naming no column on the grid at all is a different fault, and that tile reports no data rather than a number: reducing over a column that does not exist gives sum and count a 0, which grades good under any lowerIsBetter threshold, so warning in the console while leaving a confident green zero on the dashboard would document the lie rather than fix it - and the reader of a dashboard is not reading the console. It reports the unknown status above, renders nullText, and contributes an explicit unknown to any roll-up. That is deliberately not the same case as a tile with a real field whose filter simply matches nothing: that tile measured, and its zero is still graded. The refusal is checked at first read rather than at bind time, because a dynamic grid's columns can arrive after the panel does, and the verdict is recomputed at every read, so a tile refused while the grid was still loading is measured again the moment its column lands. A panel over a plain rows array has whole rows already and none of this applies to it.
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, bar), or a tile's raw value. status is good, warn, critical, unknown (the panel holds no rows) or null (no thresholds configured); value is null whenever the tile measured nothing. |
| setRows(rows) / refresh() | Replace the source rows, or recompute every tile and re-render. What refresh() re-reads depends on where the rows come from: a grid-bound panel re-reads the bound grid (it follows the grid on its own, so this is for a host that wants the numbers in the same turn); a panel still on its config.rows re-reads that array (a host that mutated it in place sees the change); a routed panel - any row has arrived through rows.apply, typically from a Data Router attach - re-reads nothing: every tile is re-derived from the rows the panel holds and it shows exactly what it showed before, re-rendered. setRows puts a routed panel back on its configured rows. |
| nodes() / node(key) / visibleNodes() | The hierarchy, when tree resolves one: the top-level nodes with their children, one node by key at any depth, or just the nodes on screen. Each node carries label, level, tile (null on a synthesised level), status, rollup (the worst severity at or below it, never unknown), unknown (how many below it measured nothing) and items. Empty on a flat panel, where kpi.tree is false. |
| expand(key) / collapse(key) / toggle(key) | Open or close a branch. A key for a branch the panel does not (yet) hold is retained rather than dropped, so a delta that later introduces it finds it already open. |
| getState() / setState(snapshot) | Serialise and restore the panel's row set, so a headless panel round-trips, plus expanded (the open branch keys) on a hierarchical panel. A snapshot with no expanded key leaves expansion alone rather than resetting it. |
| on(name, fn) / off(name, fn) | Events: tile:click, tile:dblclick, tile:contextmenu, node:toggle (a branch opened or closed), and change (after every update). |
| rebind(grid) | Point the panel at a replacement grid after the one it was bound to was destroyed - the panel keeps its tiles and element, drops its subscriptions to the dead grid, subscribes to the new one and re-reads its rows. A panel left unbound shows nothing and warns once (kpi:grid-destroyed) rather than throwing. Binding to the grid it already follows is a no-op. |
| destroy() | Empty the element and drop the listeners. The host still owns any bound grid. |
A panel whose grid is destroyed - an htmx swap, a framework teardown - stays on the page but shows nothing; a refresh in that state reports nothing and warns once (kpi:grid-destroyed) rather than throwing. kpi.rebind(grid) points it at the replacement:
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const { createKPI } = await import('../packages/modules/kpi/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'qty', type: 'number' }],
rows: [{ id: 'a', qty: 2 }, { id: 'b', qty: 1 }],
});
const kpi = createKPI(null, { grid, tiles: [{ id: 'total', label: 'Total', aggregation: 'sum', field: 'qty' }] });
grid.destroy(); // the panel un-binds: a refresh would warn once, not throw
const replacement = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'qty', type: 'number' }],
rows: [{ id: 'c', qty: 30 }, { id: 'd', qty: 40 }],
});
kpi.rebind(replacement);
return String(kpi.value('total'));
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.
The clock tile: the device clock, not an aggregate
{ kind: 'clock', label, timeZone?, locale?, seconds?, date? } in tiles renders a tile that shows the current date and time instead of a figure - the date on one line, the time on the next, e.g. Mon, 21 Apr 2025 over 14:32:18 - styled like any other tile, so a panel of zone clocks beside a panel of figures reads as one visual system with no host CSS.
const kpi = createKPI(document.querySelector('#kpis'), {
tiles: [
{ kind: 'clock', label: 'London', timeZone: 'Europe/London' },
{ kind: 'clock', label: 'New York', timeZone: 'America/New_York', locale: 'en-US' },
{ id: 'open', label: 'Open deals', aggregation: 'count', filter: (r) => r.stage !== 'won' },
],
});
Where the time comes from. The device clock, read every second in exact alignment with the second boundary - not a fixed setInterval(1000), which drifts - so every clock tile on a panel ticks in the same repaint. One timer serves the whole panel, not one per tile: a panel of three zone clocks runs one shared interval, not three. The timer stops when the panel is destroy()ed or the document goes into the background (document.hidden) and resumes correctly - catching up immediately, then re-aligning - when the tab returns. A panel with no clock tile starts no timer at all.
Zone and format. timeZone is any IANA zone name; omitted, the tile shows the viewer's local time. A name Intl.DateTimeFormat does not recognise is reported through the usual [lattice] diagnostics warning, by name, and the tile falls back to local time rather than rendering nothing. The date and time are formatted for locale - the tile's own, else the panel's locale, else the browser's default - entirely through Intl.DateTimeFormat: a 24-hour clock where the locale uses one, 12-hour with an AM/PM marker where it does not, because that is the locale's own convention rather than a second option to set. seconds: false drops the seconds from the time line; date: false drops the date line entirely. Every formatter is built once, at tile resolution, and reused on every tick.
Inert everywhere a stat tile measures. A clock tile takes none of a stat tile's measurement options - aggregation, field, format, thresholds, bands, target, baseline, sparkline - because a tile that measures nothing has nothing for them to apply to. Supplying any of them is reported as a configuration warning by name and ignored: the tile still renders the clock, nothing else. It contributes nothing to the panel's totals, to a parent's rolled-up status in a tree (its own status is always null, never unknown - a clock tile is never “not measured”, it always has a reading) and nothing to what a host reads out of onChange beyond its own text. It otherwise behaves exactly like any other tile: it sits in tiles()/tile(id) and a tree alongside stat tiles, responds to columns, fires tile:click/tile:dblclick/tile:contextmenu, and its accessible name (aria-label) carries the same date and time text the two visible lines show.
A clock tile, two zones, executed
Structural assertions only - the clock reads the real device clock, so a doc example run on every build cannot pin a literal time without freezing it. The formatting itself is pinned for two locales and two zones with a fixed clock in the test suite. Run headless on every build.
const { createKPI } = await import('../packages/modules/kpi/index.js');
const kpi = createKPI(null, {
tiles: [
{ kind: 'clock', id: 'london', label: 'London', timeZone: 'Europe/London', locale: 'en-GB' },
// aggregation/thresholds are measurement options a clock tile refuses (warned, ignored):
{ kind: 'clock', id: 'ny', label: 'New York', timeZone: 'America/New_York', locale: 'en-US',
aggregation: 'sum', thresholds: { warn: 1, critical: 2 } },
],
});
const london = kpi.tile('london');
const ny = kpi.tile('ny');
// kind, status and bar are the same three neutral values on every clock tile,
// whatever was supplied for the ignored options above (String(), because
// Array#join renders null as '' rather than 'null'):
const shapes = [london.kind, ny.kind, String(london.status), String(ny.status), String(ny.bar)].join(' '); // clock clock null null null
// The date and time lines hold the shape the formatting spec promises:
const shaped = [
/^[A-Za-z]{3}, \d{2} [A-Za-z]{3,4} \d{4}$/.test(london.clock.date),
/^\d{2}:\d{2}:\d{2}(\s?[AP]M)?$/.test(ny.clock.time),
].join(' '); // true true
// The ignored `aggregation: 'sum'` never took effect:
const ignoredAgg = ny.aggregation; // clock
kpi.destroy();
return [shapes, shaped, ignoredAgg].join(' | ');
The KPI tree: top-level items that expand to the indicators beneath them
Set tree and the panel becomes a rail instead of a grid of tiles: a small number of top-level items, each expanding to the indicators underneath it, with the parent telling you at a glance whether anything below needs attention. Compute expands to psi and cpu; collapsed, it still shows you that one of them is in breach. tree is the opt-in: a dotted tile id is not a hierarchy on its own - with tree unset, the ids you see below would stay plain, unsplit tile ids and the panel would render every tile flat, warning once that setting tree is how the hierarchy is reached.
const kpi = createKPI(document.querySelector('#rail'), {
rows, rowKey: 'id',
tiles: [
{ id: 'compute.cpu', label: 'cpu', aggregation: 'max', field: 'cpu',
thresholds: { warn: 70, critical: 90, direction: 'lowerIsBetter' } },
{ id: 'compute.memory', label: 'memory', aggregation: 'avg', field: 'mem',
thresholds: { warn: 70, critical: 90, direction: 'lowerIsBetter' } },
{ id: 'network.latency', label: 'latency', aggregation: 'max', field: 'latency',
thresholds: { warn: 100, critical: 300, direction: 'lowerIsBetter' } },
],
tree: {}, // the dotted ids are the hierarchy
});
kpi.nodes()[0].rollup; // 'critical' - even with the branch shut
kpi.expand(kpi.nodes()[0].key); // open it
Where the shape comes from - two sources, in this order, once tree is set. Declared: tree: { path } or tree: { parentKey } over the tile specs, the same two shapes the grid's tree data and the tree-select editor already take, so a hierarchy you have configured once needs no second vocabulary. A path is the tile's own place, its own segment last - ['System','Compute','cpu'], exactly as ['EMEA','UK','Colchester'] is Colchester's path and not its parent's - and levels no tile represents are synthesised, so System and Compute appear without a tile of their own. Derived: with neither declared, the tile ids are split on separator (default .), so system.compute.cpu files itself - but only once tree is given an object ({} included); unset, a dot in an id means nothing and the panel warns once rather than building a hierarchy you never asked for. A panel whose ids carry no separator is flat and renders exactly as it always did even with tree set; tree: false, same as leaving it unset, keeps it flat whatever the ids look like, silently. A tile's field is never a source - a dot there already means a nested object property, and overloading it would make field: 'cpu.util' ambiguous.
No value rolls up; severity does. A parent shows no aggregated number. That is not a simplification: the running accumulators expose add/remove/value and no merge, so avg, countDistinct and a custom reducer cannot be composed from their children without rescanning, and a per-aggregation exception list would be a number that is right for a sum and wrong for an average. A parent that has a tile of its own still shows that tile's reading. What does roll up is the status: rollup is the worst severity at or below the node, across as many levels as you have, and it is what a collapsed branch reports.
Nothing measured is not good news, and it does not win the roll-up either. A leaf that measured nothing is unknown (see above), and unknown is deliberately excluded from rollup: ranking “not measured” as the worst thing beneath a parent would hide a real warning under it. It is surfaced separately instead - node.unknown counts the descendants that measured nothing, and the node renders it in words (“2 unknown”) and puts it in its accessible name. So neither way of being wrong is available: silence cannot read as green, and it cannot bury an amber.
Accessible by construction. The rail is a real role="tree" with the APG keyboard model - → opens a closed branch and otherwise steps into it, ← closes an open one and otherwise steps out to its parent, ↑/↓ walk what is visible, Home/End jump to its ends, Enter/Space activate - with a roving tabindex, and aria-level, aria-posinset and aria-setsize on every node, because a reader cannot count what a collapsed branch has left out of the DOM. Status carries a shape as well as a colour (a filled circle, a triangle, a square, a hollow circle), not one dot in three colours, and a parent's rolled-up status is in its accessible name: “Compute, 6 items, worst status critical”, announced as one string. Every phrase is a catalogue key: pass messages (any { t(key, params) }, including a grid's own) to translate the panel, and a key your catalogue lacks falls back to English rather than printing the key.
Every leaf carries a value and a meter. A rail exists to be read at a glance, and a column of numbers is not that, so each leaf draws a small fixed-scale bar beside its reading. The scale comes only from what the tile already declares - there is no new configuration key. A bands list states its own ends and is taken at its word; thresholds states two interior cut points and a lone target states one point, so in those the open end is anchored at the origin: { warn: 70, critical: 90 } measures 0-90, and target: 4000 measures 0 to the target. The scale is never derived from the data - a bar scaled to the values currently in the panel would mean something different on every refresh - so a tile with no bands, no thresholds and no target gets no meter at all, a value and nothing else. The meter is placed by exactly the mapping the grid's own conditional-formatting data bar uses, clamp((x - lo) / span), so a reading past the top of its scale fills the bar rather than overflowing it, and it is coloured by the leaf's own good/warn/critical status - reinforcing the shape glyph, never replacing it. A leaf that measured nothing draws a dashed empty outline with no fill element, which is deliberately not what a measured zero looks like (a solid track with a fill of no length): those are different facts. Each tile model carries the same numbers as bar ({ lo, hi, percent }, or null when no scale is declared) for a host that would rather draw its own. A row that gets no meter reserves the width of one, so the readings stay in a single column whether or not there is a bar beside them - the same reservation a leaf makes on the other side for the +/− control it does not have. A branch draws no meter, because no value rolls up to draw one; and a flat panel of tiles renders exactly the tiles it always did.
The panel follows the grid it belongs to, not the page. Pass grid and the panel takes that grid's resolved type and row rhythm: on a 16px page beside a grid painting its cells at 12.7px, a rail used to draw 16px text. Type reads --lat-kpi-* > --lattice-* > --lat-chrome-* > 13px and row height reads --lat-kpi-row-height > --lat-chrome-row-height > 22px. The --lat-chrome-* rung is mirrored off the mounted grid in JS (modules/shared/chrome.js) because a grid's density lands on the grid's own root, which is a descendant of a panel mounted beside it, and custom properties inherit downward only - no stylesheet can read it. Measured in Chrome: identical type and a 1.00× leaf-row-to-grid-row ratio at compact, comfortable and spacious. It re-mirrors on the grid's config:changed, so grid.set('density', …) moves the panel with it, and destroy() releases everything it wrote. A panel with no grid - a plain rows array, or the router-driven panel - mirrors nothing and renders at the module's own defaults, and a host's --lattice-font-size on an ancestor still out-ranks the mirror.
A heading says it opens. Each top-level item carries a + when shut and a − when open, in a small bordered box, on a row with a pointer cursor and a hover state - a disclosure triangle read as decoration rather than as a control. It is purely decoration: aria-expanded on the node is what states the same thing to a screen reader, so the marker is aria-hidden exactly as the status shape is, and the announced name is unchanged. A leaf keeps the marker's width as an empty spacer, so the status shapes and labels stay in one column whether or not the row beside them opens.
A collapsed branch costs data, not paint. Every node's status is computed whether or not you can see it - that is what makes the rail worth having - while a collapsed branch contributes no DOM at all. It is the same division the grid's grouping already makes between its totals walk and its display walk. Expansion is patched in place, keyed on the node, so a live routed feed does not throw a keyboard user off the node they are standing on.
A collapsed parent reports the red leaf underneath it, executed
Two subsystems from dotted tile ids, one of them in breach, with nothing expanded; then the same
rail with no data at all. tree is the opt-in - a dotted id alone does
not build a hierarchy, so this example sets tree: {} deliberately, the way any host must. Run
headless on every build.
const { createKPI } = await import('../packages/modules/kpi/index.js');
const fewerIsBetter = { warn: 70, critical: 90, direction: 'lowerIsBetter' };
const tiles = [
{ id: 'compute.cpu', label: 'cpu', aggregation: 'max', field: 'cpu', thresholds: fewerIsBetter },
{ id: 'compute.memory', label: 'memory', aggregation: 'avg', field: 'mem', thresholds: fewerIsBetter },
{ id: 'network.latency', label: 'latency', aggregation: 'max', field: 'latency',
thresholds: { warn: 100, critical: 300, direction: 'lowerIsBetter' } },
];
// `tree: {}` is the opt-in: without it, these dotted ids would stay flat tiles
// and the panel would warn once instead of building a hierarchy.
const kpi = createKPI(null, { rows: [{ id: 'h1', cpu: 94, mem: 40, latency: 12 }], rowKey: 'id', tree: {}, tiles });
const compute = kpi.nodes()[0];
// Nobody has opened it, and it reports the breach anyway.
const shut = [compute.label, compute.expanded, compute.rollup].join(' '); // compute false critical
const leaf = [compute.children[0].label, compute.children[0].status].join(' '); // cpu critical
// Nothing delivered: the parent reports the silence rather than a false green.
const quiet = createKPI(null, { rows: [], rowKey: 'id', tree: {}, tiles });
const silent = String(quiet.nodes()[0].rollup) + ' ' + quiet.nodes()[0].unknown; // null 2
return [shut, leaf, silent].join(' | ');
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(' | ');
An empty panel is unknown, a measured zero is not, executed
The same lowerIsBetter thresholds, three states: nothing delivered yet, rows delivered,
and a tile whose filter matches none of the rows the panel holds. Run headless on every build.
const { createKPI } = await import('../packages/modules/kpi/index.js');
const fewerIsBetter = { warn: 10, critical: 25, direction: 'lowerIsBetter' };
const kpi = createKPI(null, {
rows: [], rowKey: 'id',
tiles: [
{ id: 'errors', label: 'Errors', aggregation: 'count', thresholds: fewerIsBetter },
{ id: 'sev1', label: 'Sev-1', aggregation: 'count',
filter: (r) => r.severity === 1, thresholds: fewerIsBetter },
],
});
// Nothing has arrived: not a healthy zero, and no number to show.
const empty = kpi.tile('errors').status + ' ' + kpi.tile('errors').value; // unknown null
kpi.rows.apply({ add: [{ id: 'e1', severity: 3 }, { id: 'e2', severity: 2 }] });
const measured = kpi.tile('errors').status + ' ' + kpi.tile('errors').value; // good 2
// Its filter matched none of those rows - but the panel holds rows, so 0 is a reading.
const realZero = kpi.tile('sev1').status + ' ' + kpi.tile('sev1').value; // good 0
return [empty, measured, realZero].join(' | ');
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 KPI module
KPIThresholds
| Property | Type | Description |
|---|---|---|
| warn | number | The cut point between good and warning. With the default `higherIsBetter`, a value at or above it is good. |
| critical | number | The cut point between warning and critical. With `higherIsBetter`, a value at or above it (but below `warn`) is a warning and anything below it is critical. |
| direction | KpiThresholdDirection 'higherIsBetter' | 'lowerIsBetter' | Which way is good. `higherIsBetter` (the default) grades upwards from the cut points; `lowerIsBetter` mirrors them, so a small value is the healthy one. (optional) |
KPIBand
An explicit band: the `status` of the first band whose half-open `[min, max)` contains the value.
| Property | Type | Description |
|---|---|---|
| min | number | The lower bound, inclusive. Omitted, the band reaches down without limit. (optional) |
| max | number | The upper bound, exclusive. Omitted, the band reaches up without limit. (optional) |
| status | KpiStatus 'good' | 'warn' | 'critical' | The status a value inside this band reports. The first matching band in the list wins, so order them from the narrowest. |
KPISparkline
An optional sparkline series: the `y` field plotted in order of the `x` field (or insertion).
| Property | Type | Description |
|---|---|---|
| x | string | The field the points are ordered by. Omitted, the rows are plotted in the order the panel holds them. (optional) |
| y | string | ((row: KPIRow) => unknown) | The value plotted - a field name or a function of the row. Anything that is not a finite number is dropped, and a series with no finite points renders no sparkline. |
KPIStatTile
| Property | Type | Description |
|---|---|---|
| kind | 'stat' | Absent, or `'stat'`: the default tile kind. (optional) |
| id | string | A stable identity for the tile (defaults to the label, then the index). (optional) |
| label | string | The tile's accessible label. (optional) |
| aggregation | KPIAggregation | ((rows: KPIRow[], tile: object) => unknown) | The aggregation kind, or a reducer `(rows, tile) => value` for a custom tile. Over a grid on a paged pushdown source that holds only a window of its matching rows (GEO-6), a `sum`, `avg`, `min`, `max` or `countDistinct` tile is reduced by the engine - `source.aggregate()`, the grid's filters and quick search applied, one request for every such tile on the panel - rather than from the loaded page, and its model reads `computed: 'engine'` and its accessible name says so. `count` already counts every matching row and stays the panel's own. A tile with a `filter` function, a custom reducer, a field that is not a plain column, or a source whose `aggregates` config keeps the statistic in the browser keeps reducing the page - a `sum`/`avg`/`min`/`max` tile still shows that page figure, named once in a `[lattice]` warning (`kpi:engine-page`), because it is a real partial reading. A `countDistinct` tile is different: the same values can recur on pages that have not loaded, so a page's distinct count is not a partial answer the way a page's sum is, and the tile shows no number at all rather than one, named once (`kpi:aggregate-unpushable`). The panel follows the grid's page and total landing, so it needs no `refresh()` after the first page settles. (optional) |
| field | string | ((row: KPIRow) => unknown) | The field the aggregation reads (a path or accessor). Ignored by `count`. (optional) |
| format | KPIFormat | Value formatting. (optional) |
| target | number | A comparison target rendered alongside the value. (optional) |
| baseline | number | A baseline the tile's delta is measured against. (optional) |
| delta | DeltaMode 'absolute' | 'relative' | 'both' | What the movement line prints against `baseline`, F-FRED-G): `'absolute'` the difference alone, `'relative'` the percentage alone, `'both'` (the default, unchanged) both together. A rate series (4.10 vs 4.30) makes the percentage a percent-of-a-percent and meaningless, so `'absolute'` is how a host keeps the line without it. The arrow and its colour follow the sign of the difference either way. (optional) |
| thresholds | KPIThresholds | Threshold bands, either two cut points or an explicit band list. (optional) |
| bands | KPIBand[] | Explicit status bands (an alternative to `thresholds`). (optional) |
| sparkline | KPISparkline | string | A trend sparkline series. (optional) |
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| compute | (rows: KPIRow[], tile: object) => unknown | rows: KPIRow[]tile: object | => unknown | The reducer for a `custom` aggregation, when `aggregation` is the string `'custom'`. (optional) |
| filter | (row: KPIRow) => boolean | row: KPIRow | => boolean | A predicate limiting the rows this tile aggregates. (optional) |
KPIClockTile
A clock tile: the device clock, not an aggregate - the date on one line and the time on the next, ticking once a second from one shared panel timer. It takes none of a stat tile's measurement options (`aggregation`, `field`, `format`, `thresholds`, `bands`, `target`, `baseline`, `sparkline`): supplying any of them is reported as a configuration warning by name and ignored, because a tile that measures nothing has nothing for them to apply to.
| Property | Type | Description |
|---|---|---|
| kind | 'clock' | Discriminates a clock tile from an aggregate stat tile. |
| id | string | A stable identity for the tile (defaults to the label, then the index). (optional) |
| label | string | The tile's accessible label (e.g. the city or zone it names). (optional) |
| timeZone | string | Any IANA zone name (`'Europe/London'`). Omitted, the tile shows the viewer's local time. A name `Intl.DateTimeFormat` does not recognise is reported through the usual diagnostics warning and the tile falls back to local time rather than rendering nothing. (optional) |
| locale | string | The locale the date and time are formatted in - the tile's own, else the panel's `KPIConfig.locale`, else the browser's default. A 24-hour clock or a 12-hour one with an AM/PM marker follows from the locale itself (`Intl.DateTimeFormat`'s own convention), never a separate option. (optional) |
| seconds | boolean | Show the seconds on the time line. Default `true`. (optional) |
| date | boolean | Show the date line at all. Default `true`. (optional) |
KPITreeConfig
The hierarchy a KPI panel arranges its tiles into: a rail of top-level items that expand to the indicators beneath them, each parent highlighted with the worst status below it. `tree` is an opt-in: omitted or `false`, the panel is flat whatever its tile ids look like, and a dotted id seen with `tree` unset is reported once rather than silently turned into a hierarchy. Given any object (`{}` included), the shape is declared with `path` or `parentKey` - the same two shapes the grid's tree data and the tree-select editor take - over the **tile specs**, not the rows. With neither declared, one is derived by splitting the tile ids on `separator`, so `system.compute.cpu` files itself under Compute under System. A panel whose ids carry no separator stays flat even with `tree` set. A tile's `field` is never a source: a dot there already means a nested object property.
| Property | Type | Description |
|---|---|---|
| parentKey | string | ((tile: KPITile) => unknown) | The id of the tile this one sits under, or a reader for it. (optional) |
| orphans | 'root' | string | The heading tiles whose parent is not in the panel are gathered under. (optional) |
| separator | string | The separator a derived hierarchy splits a tile id on. Defaults to `.`. (optional) |
| expanded | true | string[] | Which branches start open: every one (`true`), or these node keys. (optional) |
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| path | (tile: KPITile) => (string | number)[] | tile: KPITile | => (string | number)[] | The tile's own place in the hierarchy, its own segment last. (optional) |
KPINodeModel
| Property | Type | Description |
|---|---|---|
| key | string | The node's stable identity: the tile id, or the path of a synthesised level. |
| id | string | null | The tile id, or null on a synthesised level. |
| label | string | What the node is called: the tile's own label, or the path segment a synthesised level came from. |
| level | number | Depth, 0 at the top level. |
| posinset | number | Its place among its siblings, from 1, and how many there are. |
| setsize | number | How many siblings the node sits among - the other half of the screen-reader's "3 of 7". |
| hasChildren | boolean | Whether the node has anything beneath it. A leaf is never expandable. |
| expanded | boolean | Whether the branch is currently open. Always false for a leaf; a collapsed branch's children are not rendered, because its worst status is already on the node that is. |
| children | KPINodeModel[] | The node's children, in order, each a full node model. |
| tile | KPITileModel | null | The node's own tile, or null on a synthesised level. |
| value | unknown | The node's own reading, from its tile. Null on a synthesised level - no value is invented for a branch from its children. |
| formatted | string | null | The node's reading as text, formatted by its tile. Null on a synthesised level. |
| status | KpiRollupStatus | null | The node's own status. |
| rollup | KpiStatus | null | The worst status at or below the node. Never `unknown`. |
| unknown | number | How many tiles at or below the node measured nothing. |
| items | number | How many tiles are at or below the node. |
KPITileModel
A computed tile, as it appears in the model.
| Property | Type | Description |
|---|---|---|
| id | string | The tile's identity - its configured `id`, else its label, else its index. |
| label | string | The tile's accessible name, as configured. |
| kind | KpiTileKind 'stat' | 'clock' | `'stat'` for an aggregate tile, `'clock'` for a clock tile. |
| aggregation | string | How the value was reduced: `sum`, `avg`, `min`, `max`, `count`, `countDistinct` or `custom`. |
| field | string | The row field the aggregation read. Undefined for a `count` or custom tile that names none, and on a clock tile. (optional) |
| value | unknown | For a clock tile, the read instant as epoch milliseconds. |
| formatted | string | For a clock tile, the date and time text joined by a space (the same text `clock.date` and `clock.time` carry separately). |
| clock | { date: string | null; time: string } | Present only on a clock tile: the date and time lines rendered separately. `date` is `null` when the tile was given `date: false`. (optional) |
| status | KpiRollupStatus | null | The tile's semantic band, or `unknown` when the tile measured nothing. `unknown` is decided from data presence before any threshold is consulted: an aggregation over nothing returns the identity of its operation (`sum` and `count` return 0), and 0 is a number a threshold grades, so without it an empty panel would report as a healthy one. Four things make a tile `unknown`: the panel holds no rows at all; the tile's `field` names no column on the bound grid, so it never read a cell to reduce over; the panel declares a `maxAge` and its feed has been silent for longer than that, so every row it could reduce over is older than the panel was told to trust (`stale` says which of these two this is); or it is a `countDistinct` tile over a windowed pushdown source whose engine could not be asked - a page's distinct count is not a usable partial reading, so none is shown. A tile whose `filter` matches none of the rows the panel *does* hold, while its feed is fresh, is none of them - it has measured a real zero and is banded normally. `null` means the tile has no thresholds or bands configured. |
| stale | boolean | Whether this tile's `unknown` is a **silence** rather than an emptiness: the panel holds rows, and none of them is inside the `maxAge` window it declared. False on every tile of a panel that declares no `maxAge`, on an empty panel (where "nothing has arrived" is the truthful reading), and always on a clock tile. |
| target | number | The tile's target, as configured. It also extends the meter's scale when it falls outside the bands. Undefined on a clock tile. (optional) |
| baseline | number | The comparison value the movement line is measured from, as configured. (optional) |
| delta | number | null | `value − baseline`. Null when the tile has no baseline or measured no number. |
| deltaPercent | number | null | The delta as a fraction of the baseline. Null when there is no delta, and when the baseline is zero - a percentage of nothing is not reported as infinity. |
| deltaFormatted | string | The delta rendered with the tile's own number format. Undefined when there is no delta. (optional) |
| deltaMode | DeltaMode 'absolute' | 'relative' | 'both' | What the movement line prints; see `KPIStatTile.delta`. Always present once `baseline` is. (optional) |
| count | number | How many of the panel's rows this tile's filter admitted - its own membership, which may be 0 while the panel holds rows. Always 0 on a clock tile. |
| computed | 'engine' | 'client' | Where a stat tile's value was reduced (GEO-6): `'engine'` by a paged pushdown source's engine over every matching row (see `KPIStatTile.aggregation`), `'client'` from the rows the panel holds. Absent on a clock tile. (optional) |
| sparkline | number[] | null | The tile's trend series, as the finite numbers it plots, in `x` order. Null when the tile declares no sparkline and when no row yielded a finite value. |
KPIEvent
The payload every tile event carries.
| Property | Type | Description |
|---|---|---|
| tile | KPITileModel | The tile model the event is about. |
| id | string | That tile's id, for a host that only needs to switch on it. |
| node | KPINodeModel | The tree node the tile sits in, on a hierarchical panel; absent on a flat one and on the keyboard route. (optional) |
| originalEvent | unknown | The DOM event behind this one - a click, double-click, context-menu gesture or the Enter/Space keypress that stands in for a click. (optional) |
KPIModel
The whole panel model, as `change` hands it over.
| Property | Type | Description |
|---|---|---|
| tiles | KPITileModel[] | Every tile model, in configured order. |
| nodes | KPINodeModel[] | The decorated tree, on a hierarchical panel only. (optional) |
KPINodeToggleEvent
`node:toggle`: a branch of a hierarchical panel was expanded or collapsed.
| Property | Type | Description |
|---|---|---|
| key | string | The key of the node that moved. |
| expanded | boolean | True when it was opened, false when it was closed. |
| node | KPINodeModel | The node, as the panel now holds it; undefined when the key names none. (optional) |
KPIChangeEvent
`change`: the panel rebuilt its model.
| Property | Type | Description |
|---|---|---|
| model | KPIModel | The model the panel now holds. |
KPIEventPayloads
What a handler receives, per KPI event.
| Property | Type | Description |
|---|---|---|
| tile:click | KPIEvent | The tile clicked, its id, its node on a tree panel, and the DOM event. |
| tile:dblclick | KPIEvent | The tile double-clicked, its id, its node on a tree panel, and the DOM event. |
| tile:contextmenu | KPIEvent | The tile the menu was asked for, its id, its node on a tree panel, and the DOM event. |
| node:toggle | KPINodeToggleEvent | Which branch moved and which way. |
| change | KPIChangeEvent | The rebuilt model. |
| tile:status | KPITileStatusEvent | Which tile moved, where from, where to, and what it now reads. |
KPITileStatusEvent
A tile crossed a threshold. The event an alarm system is fed from: one per transition, rather than the whole model on every refresh. `lattice-grid/modules/alarms` consumes exactly this.
| Property | Type | Description |
|---|---|---|
| id | string | The tile's identity - its configured `id`, else its label, else its index. |
| status | KpiRollupStatus | null | The status it now reads, or null when the tile declares no thresholds or bands. |
| previous | KpiRollupStatus | null | The status it held before this transition, or null when it had none - the first reading of a tile, or a tile that has just been added. |
| value | unknown | The tile's value at the transition, or null when it measured nothing. |
| at | number | When the transition was observed, as a millisecond timestamp. |
KPIConfig
KPI panel configuration. Theme: no `theme` of its own - it inherits. Its colours are the grid's `--lattice-*` tokens, so `data-theme="dark"` on any ancestor (a layout's `theme: 'dark'`, or `<html>`) darkens it with everything else, and the nearest `data-theme` wins. Load the grid stylesheet, which carries the tokens.
| Property | Type | Description |
|---|---|---|
| rows | KPIRow[] | The rows the tiles reduce over. Use this or `grid`; passing both leaves the panel on the array. (optional) |
| grid | unknown | A Lattice grid to follow instead of `rows`: the panel reads the grid's displayed rows and re-reads them whenever the grid settles, so it never disagrees with the table beneath it. On a bound panel `rows.apply` and `setRows` are ignored with a warning. (optional) |
| rowKey | string | ((row: KPIRow) => string | number) | Row identity (a field or fn, returning a string or number); default 'id'. Composite (`string[]`) keys are core-grid-only: a panel keys its own tile identity from one value, so there is nothing for an array to join into here. (optional) |
| fields | string[] | Extra columns of the bound `grid` to project onto the rows a tile `filter` sees, beyond the fields the tiles themselves declare. A grid-bound panel hands a filter a projection, not a whole grid row, so a filter over a column no tile names would otherwise read `undefined` and report a confident zero. Ignored on a panel over a plain `rows` array. (optional) |
| tiles | KPITile[] | The tiles to show, in display order: aggregate stat tiles, or clock tiles. (optional) |
| columns | number | How many tile columns to aim for. Tiles shrink to fit rather than overflow the host, so a narrow panel settles on fewer. Unset, the layout fits as many as the width allows. (optional) |
| ariaLabel | string | The panel's accessible name. Unset, the panel names itself - "KPI: {tile labels}", or "KPI" when there are no tiles - so its `role="group"` is never left unnamed. (optional) |
| nullText | string | The placeholder printed where a tile has no number - an unknown tile, or one whose aggregation returned nothing. Defaults to an em dash. (optional) |
| maxAge | number | How long, in milliseconds, the panel keeps grading what it holds after its feed last spoke. Past it, with nothing recent, every stat tile reports `unknown` with a null value whatever its own membership, so a silent feed cannot read as a healthy zero under `lowerIsBetter` cut points (or as an alarm under `higherIsBetter` ones); each tile carries `stale: true` and the panel captions it "No recent data". Grading resumes the moment the feed speaks again. The same word, and the same meaning, `SourceConfig.maxAge` carries for a rolling window. Unset, the panel judges no freshness at all: an empty panel still reports `unknown`, and everything else is graded however old it is. (optional) |
| ageBy | string | ((row: KPIRow) => unknown) | Which clock `maxAge` reads: a column id (or dotted path), or a function of the row, naming the data's own time - on a grid-bound panel it is projected onto the rows for you. Omitted, `maxAge` measures **arrival** instead: when rows last reached this panel, through `rows.apply`, `setRows`, or the bound grid announcing that its own rows changed. A filter, a sort, an expansion and a `refresh()` are not arrivals, so a live feed whose rows are all filtered out of view reports the empty panel's `unknown` rather than a silence. The same option, with the same two meanings, `SourceConfig.ageBy` has. Without `maxAge` it reads nothing and says so once. (optional) |
| locale | string | The default locale a clock tile formats in when the tile itself declares none; falls back to the browser's default. No effect on a stat tile, which takes its own `format.locale`. (optional) |
| tree | KPITreeConfig | false | Arrange the tiles as a hierarchy; unset or `false` keeps the panel flat. Opt-in: a dotted tile id is not a hierarchy until `tree` is set, and warns once while it is not. (optional) |
| messages | { t(key: string, params?: Record<string, unknown>): string } | The catalogue the panel's own text is read from. A panel routinely has no grid to borrow one off - two of its three input modes have none - so this is the first-class way to translate it. A grid's own `messages` satisfies the shape; a key it does not carry falls back to English. (optional) |
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| onTileClick | (event: KPIEvent) => void | event: KPIEvent | => void | Called when a tile is clicked (or activated from the keyboard), with the same payload as the `tile:click` event. Both fire. (optional) |
| onTileDblClick | (event: KPIEvent) => void | event: KPIEvent | => void | Called when a tile is double-clicked, alongside the `tile:dblclick` event. (optional) |
| onTileContextMenu | (event: KPIEvent) => void | event: KPIEvent | => void | Called on a tile's context-menu gesture, alongside the `tile:contextmenu` event. (optional) |
| onNodeToggle | (event: { key: string; expanded: boolean; node?: KPINodeModel }) => void | event: { key: string; expanded: boolean; node?: KPINodeModel } | => void | Called when a branch of a hierarchical panel opens or closes, with the node's key, its new state and the node model. Alongside the `node:toggle` event. (optional) |
| onChange | (event: { model: { tiles: KPITileModel[]; nodes?: KPINodeModel[] } }) => void | event: { model: { tiles: KPITileModel[]; nodes?: KPINodeModel[] } } | => void | Called after every recompute, with the freshly built model - the hook for mirroring the panel's numbers somewhere else. Alongside the `change` event. (optional) |
KPIRows
The keyed-diff consumer surface a KPI panel shares with a grid, so a Data Router routes to it directly.
| Property | Type | Description |
|---|---|---|
| count | number | How many rows the panel holds. This is the panel's emptiness test: with none, every stat tile reports `unknown` rather than a healthy zero. (read-only) |
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| apply | (change: { add?: KPIRow[]; update?: KPIRow[]; remove?: unknown[] }): void | change: { add?: KPIRow[]; update?: KPIRow[]; remove?: unknown[] } | void | Apply a keyed diff: `add` and `update` upsert a row by its key, `remove` drops one. Only the rows in the diff touch each tile's running total, so a live feed costs nothing per unchanged row. Ignored with a warning on a grid-bound panel. |
| forEach | (fn: (row: KPIRow, key: unknown) => void): void | fn: (row: KPIRow, key: unknown) => void | void | Visit every row the panel holds, with its key. |
KPI
A KPI / stat-tile panel: a grid of aggregate tiles over a dataset. It consumes data through the same keyed-diff `rows.apply` contract a grid exposes, so `dataRouter.attach(value, kpi)` drives it like any other viewer, updating each tile incrementally from the routed delta.
Properties
| Property | Type | Description |
|---|---|---|
| el | unknown | null | The element the panel renders into, or null for a headless panel. (read-only) |
| rowKey | string | ((row: KPIRow) => string | number) | The resolved row identity; see `KPIConfig.rowKey`. (read-only) |
| tree | boolean | Whether the panel renders as a hierarchy rather than a flat tile grid. (read-only) |
| rows | KPIRows | The keyed-diff consumer surface, the same shape a grid exposes - this is what makes a panel a Data Router target. |
Methods
| Method | Signature | Parameters | Returns | Description |
|---|---|---|---|---|
| tiles | (): KPITileModel[] | - | KPITileModel[] | Every tile model, in configured order. |
| tile | (id: string): KPITileModel | undefined | id: string | KPITileModel | undefined | One tile model by id, or undefined when no tile has that id. |
| value | (id: string): unknown | id: string | unknown | A tile's computed value. Null for an unknown id, and for a tile that measured nothing. |
| nodes | (): KPINodeModel[] | - | KPINodeModel[] | The top-level nodes of the hierarchy. Empty on a flat panel. |
| node | (key: string): KPINodeModel | undefined | key: string | KPINodeModel | undefined | One node by its key, at any depth. |
| visibleNodes | (): KPINodeModel[] | - | KPINodeModel[] | The nodes on screen: the roots, plus the children of every open branch. |
| expand | (key: string): KPI | key: string | KPI | Open a branch of a hierarchical panel by node key, re-render, and fire `node:toggle`. A key that is already open changes nothing. |
| collapse | (key: string): KPI | key: string | KPI | Close a branch by node key, re-render, and fire `node:toggle`. |
| toggle | (key: string): KPI | key: string | KPI | Flip a branch between open and closed, firing `node:toggle` on the change. |
| setRows | (rows: KPIRow[]): KPI | rows: KPIRow[] | KPI | Replace the source rows and recompute, and make that array the source again so a later `refresh()` re-reads it. Ignored with a warning on a grid-bound panel. |
| refresh | (): KPI | - | KPI | Recompute every tile and re-render. A bound panel re-reads the grid now rather than at the end of the turn; a configured panel re-reads its array; once rows have arrived through `rows.apply` nothing is re-read, so a routed feed is never thrown away. |
| getState | (): object | - | object | The restorable state: the rows the panel holds, and - on a hierarchical panel only - which branches are open. |
| setState | (snapshot: object): KPI | snapshot: object | KPI | Restore a snapshot from `getState`. A snapshot without `expanded` leaves the expansion alone rather than collapsing the rail. |
| on | (name: KPIEventName, fn: (event: KPIEventPayloads[KPIEventName]) => void): () => void | name: KPIEventNamefn: (event: KPIEventPayloads[KPIEventName]) => void | () => void | Register an event handler; returns a function that removes it. An unrecognised event name is warned about once. What each event carries is {@link KPIEventPayloads}; the handler is declared with the widest of them, so narrow on the name inside it. |
| off | (name: KPIEventName, fn: (event: KPIEventPayloads[KPIEventName]) => void): void | name: KPIEventNamefn: (event: KPIEventPayloads[KPIEventName]) => void | void | Remove a handler registered with `on`. |
| rebind | (grid: Grid): KPI | grid: Grid | KPI | Point the panel at a replacement grid after the one it was bound to was destroyed. The panel keeps its tiles and element, drops its subscriptions to the dead grid, subscribes to the new one and re-reads its rows. 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. On a panel not built over a grid the call is a no-op - its rows are its own. |
| destroy | (): void | - | void | Drop every listener, stop following the bound grid, stop any clock tile ticking, and empty the element (removing only the class the panel added). |
Events
| Event | When | Payload | Cancellable |
|---|---|---|---|
| tile:click | A tile was clicked, or Enter or Space was pressed on a focused one. | KPIEvent | no |
| tile:dblclick | A tile was double-clicked. | KPIEvent | no |
| tile:contextmenu | A context menu was requested on a tile. | KPIEvent | no |
| node:toggle | A branch of a hierarchical panel was expanded or collapsed, by the host or by a click on its twisty. | KPINodeToggleEvent | no |
| change | The panel rebuilt its model - new rows, a changed configuration, or a followed grid event. | KPIChangeEvent | no |
| tile:status | A tile's status moved - it crossed a threshold or a band, went silent, or came back. Fired once per tile per transition, including to and from `unknown`, and before the `change` that carries the rebuilt model; a tile whose status did not move is silent, which is what separates it from `change`. | KPITileStatusEvent | no |