demo D272
A panel of stat tiles
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
The configuration
'kpi-viewer': () => ({
rows: [],
config: {},
foot: [
'one feed drives a grid and a row of stat tiles at once, through createDataRouter',
'each tile is an aggregate over the same rows: a running total, an average against a baseline, a live count, and the biggest deal',
'the open-deals tile carries good, warn and critical bands, so a number crossing a line changes its state on the spot',
'a tile keeps a running accumulator, so every arriving change nudges only the figures it touches rather than rereading the set',
],
mount: (el: HTMLElement, LG: any) => {
let a = 20263507 >>> 0;
const rnd = () => { a = (a + 0x6d2b79f5) >>> 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const pick = <T,>(list: T[]) => list[Math.floor(rnd() * list.length)];
const REGIONS = ['EMEA', 'AMER', 'APAC'];
const STAGES = ['prospect', 'qualified', 'proposal', 'won'];
let nextId = 1;
const deal = (): Deal => ({
id: 'D-' + String(1000 + nextId++),
kind: 'deal',
stage: pick(STAGES),
region: pick(REGIONS),
value: 500 + Math.round(rnd() * 24500),
});
const money = { style: 'currency', currency: 'USD', decimals: 0 } as const;
const panelEl = document.createElement('div');
panelEl.style.cssText = 'border:1px solid var(--rule);border-radius:9px;background:var(--paper);padding:12px;min-width:0';
const panelCol = document.createElement('div');
panelCol.style.minWidth = '0';
panelCol.append(gridTitle('Live figures'), panelEl);
const gridEl = document.createElement('div');
gridEl.style.cssText = 'height:320px;border:1px solid var(--rule);border-radius:9px;background:var(--paper);min-width:0';
const gridCol = document.createElement('div');
gridCol.style.minWidth = '0';
gridCol.style.marginTop = '12px';
gridCol.append(gridTitle('Deals'), gridEl);
el.append(panelCol, gridCol);
const grid = LG.createGrid(gridEl, {
rowKey: 'id',
theme: 'light',
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: [],
});
const live: string[] = [];
let kpi: any;
let router: any;
let timer: any;
let disposed = false;
Promise.all([loadKPI(), loadDataRouter()])
.then(([K, DR]: any[]) => {
if (disposed) return;
kpi = K.createKPI(panelEl, {
rowKey: 'id',
columns: 5,
tiles: [
{ id: 'revenue', label: 'Pipeline value', aggregation: 'sum', field: 'value', format: 'currency' },
{ id: 'avg', label: 'Average deal', 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: Deal) => r.stage !== 'won', thresholds: { warn: 14, critical: 22, direction: 'lowerIsBetter' } },
{ id: 'biggest', label: 'Biggest deal', aggregation: 'max', field: 'value', format: 'currency', sparkline: 'value' },
],
});
router = DR.createDataRouter({ key: 'kind', rowKey: 'id', overlap: true });
router.attach(grid, 'deal');
router.attach(kpi, 'deal');
const seed: Deal[] = [];
for (let i = 0; i < 18; i++) { const d = deal(); seed.push(d); live.push(d.id); }
router.load(seed);
timer = setInterval(() => {
if (document.hidden) return;
const batch: any[] = [];
const n = 1 + Math.floor(rnd() * 2);
for (let i = 0; i < n; i++) { const d = deal(); batch.push({ op: 'upsert', row: d }); live.push(d.id); }
if (rnd() < 0.5 && live.length > 12) {
const at = Math.floor(rnd() * live.length);
const id = live.splice(at, 1)[0];
batch.push({ op: 'delete', row: { id, kind: 'deal' } });
}
router.apply(batch);
}, 1300);
})
.catch((err) => console.error('[kpi-viewer]', err));
return () => {
disposed = true;
clearInterval(timer);
kpi?.destroy?.();
router?.destroy?.();
grid?.destroy?.();
};
},
})
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.
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.