demo D272
A panel of stat tiles in ESM
The same feed as a row of live figures beside a grid: a total, an average against a baseline, a count with good, warn and critical bands, and a sparkline, moving as data flows
createKPI
This puts a row of live stat tiles beside the grid reading the very same rows: a total, an average against a baseline, a count with good, warn and critical bands, and a sparkline. It keeps the figures and the data in step because they are one source, so the tiles never drift from the table.
This is the ES module version. The grid is imported straight from a CDN as a module, the same configuration as the vanilla tab without a global script tag, so it drops into any bundler or a bare module script.
The configuration
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.71.0/lattice-grid.min.css">
<div id="tiles" style="border: 1px solid #e6e6e6; border-radius: 9px; padding: 12px"></div>
<div style="display: flex; gap: 12px; margin-top: 12px">
<div id="fresh" style="flex: 1; border: 1px solid #e6e6e6; border-radius: 9px; padding: 12px"></div>
<div id="static" style="flex: 1; border: 1px solid #e6e6e6; border-radius: 9px; padding: 12px"></div>
</div>
<button id="stop" type="button" style="margin-top: 10px">Stop the feed</button>
<div id="grid" style="height: 320px; margin-top: 12px"></div>
<script type="module">
import { createKPI } from '@toclocoinc/lattice-grid/modules/kpi';
import { createDataRouter } from '@toclocoinc/lattice-grid/modules/data-router';
const money = { style: 'currency', currency: 'USD', decimals: 0 };
// The left pane is a real grid over the deal rows.
const grid = createGrid(document.getElementById('grid'), {
rowKey: 'id',
columns: [
{ field: 'id', title: 'Ref', layout: { width: 96, pin: 'start' } },
{ field: 'region', title: 'Region', filter: { type: 'set' } },
{ field: 'stage', title: 'Stage', filter: { type: 'set' } },
{ field: 'value', title: 'Value', type: 'number', format: money, total: 'sum' },
],
rows: [],
});
// A row of stat tiles: each is an aggregate over the same rows. baseline draws
// a delta; thresholds give a good/warn/critical band; sparkline plots a series.
const kpi = createKPI(document.getElementById('tiles'), {
rowKey: 'id',
columns: 5,
tiles: [
{ id: 'revenue', label: 'Pipeline value', aggregation: 'sum', field: 'value', format: 'currency' },
{ id: 'avg', label: 'Average deal vs $12k', aggregation: 'avg', field: 'value', format: { type: 'currency', decimals: 0 }, baseline: 12000 },
{ id: 'count', label: 'Live deals', aggregation: 'count' },
{ id: 'open', label: 'Still open', aggregation: 'count', filter: (r) => r.stage !== 'won', thresholds: { warn: 14, critical: 22, direction: 'lowerIsBetter' } },
{ id: 'biggest', label: 'Biggest deal', aggregation: 'max', field: 'value', format: 'currency', sparkline: 'value' },
],
});
// Two small panels beside it read the same total over the same feed.
// maxAge and ageBy are a setting on the panel, not on one tile: only 'fresh'
// declares them, so only it can tell a stopped feed from a healthy one.
// Past 30 seconds with no row whose observedAt is recent, every tile on it
// reports unknown - a dashed edge and "No recent data" - while the panel
// beside it keeps showing its last total, because nothing told it what
// recent means.
const fresh = createKPI(document.getElementById('fresh'), {
rowKey: 'id',
maxAge: 30_000,
ageBy: 'observedAt',
tiles: [{ id: 'freshness', label: 'Pipeline value (30s freshness)', aggregation: 'sum', field: 'value', format: 'currency' }],
});
const staticTotal = createKPI(document.getElementById('static'), {
rowKey: 'id',
tiles: [{ id: 'no-freshness', label: 'Same total, no freshness check', aggregation: 'sum', field: 'value', format: 'currency' }],
});
// Four routes off one partition key reading the same "deal" stream, so a
// change lands in the grid and every tile panel at once. overlap lets a
// record reach every matching route rather than only the first.
const router = createDataRouter({ key: 'kind', rowKey: 'id', overlap: true });
router.attach(grid, 'deal');
router.attach(kpi, 'deal');
router.attach(fresh, 'deal');
router.attach(staticTotal, 'deal');
router.load(seed); // deals tagged kind: 'deal': id, stage, region, value, observedAt
// A live delta each tick: { op: 'upsert' | 'delete', row }. An add contributes,
// a delete reverses, so the totals move both ways as deals arrive and close.
// Every upsert carries a fresh observedAt, which is what the freshness panel
// grades. Stop the interval (the button here, or losing the tab) and that
// stops arriving too: the freshness panel ages out after 30 seconds, the
// plain total beside it does not.
const timer = setInterval(() => router.apply(nextBatch()), 1300); // nextBatch(): upserts and the odd delete, each with observedAt: Date.now()
document.getElementById('stop').addEventListener('click', () => clearInterval(timer));
</script>
The headline numbers, off the same feed as the grid
A dashboard usually keeps its figures apart from its data: one query fills the table, another fills the tiles, and the two drift the moment anything moves. Here they are the same rows. A row of stat tiles sits beside the grid and reads the very feed the grid reads, so the total, the average and the count on screen are the ones in the table underneath, not a second version of them computed somewhere else.
Each tile is one figure over the rows: a running total, an average measured against a baseline so you see the gap at a glance, a live count, the biggest value in the set, a distinct count, or a figure you work out yourself. Give a tile good, warn and critical bands and it changes state the instant a number crosses a line, so a threshold you agreed reads as a colour rather than a note someone has to check. Add a sparkline and the tile carries its own recent shape beside the number. A tile is a labelled figure a keyboard can reach and a screen reader can read aloud, and the sparkline holds still for anyone who has asked for less motion.
Because every viewer reads a dataset the same way, one live feed can drive a grid, a board, a schedule and this panel of tiles at once. Route the feed once and the tiles move for the same reason the rows do: a new record lands and the count ticks up, a deal closes and the total drops, all without a page asking the server the same question twice. And it stays quick under a fast feed, because each tile keeps a running figure and every arriving change nudges only the numbers it touches rather than adding the whole set back up.
A silent feed is not the same as a genuine zero
The two small panels below the main strip compute the very same total over the very same feed, and only one of them is told what “recent” means. A feed that stopped six hours ago still has rows, all old, and a plain aggregate over them still returns a number - correct as an aggregate, and indistinguishable from a healthy reading. maxAge and ageBy are a setting on the panel, not on one tile: declare them (here, a 30-second window read off each deal’s own observedAt) and the panel can tell the two apart. Past the window with nothing recent, every tile on it reports unknown - a null value, a dashed edge, and a “No recent data” caption - rather than a number at all; a panel with no maxAge keeps grading whatever it holds, however old. Stop the feed above and wait: the freshness panel goes quiet on schedule, and the one beside it does not, because nothing has told it a silence looks any different from a total that simply stopped changing.
How do I show my data as a panel of stat tiles?
Load the KPI module and call createKPI(element, config), or drop the script in and reach for LatticeGridKPI on the page. Give it rows (or a live grid to read) and a tiles list. Each tile names an aggregation: sum, avg, min, max, count, countDistinct, or a reducer of your own; the field it reads; and an optional filter for the rows it counts. Set format for currency, percent or compact numbers, baseline for a delta, thresholds for the good, warn and critical bands, and sparkline to plot a series in the tile. To drive it from one feed beside your other views, hand the same records to a Data Router and attach the panel as a route: the router feeds the tiles through the same change path it feeds a grid, so they update live for free.