demo D272
A panel of stat tiles in Angular
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 Angular version. A standalone component takes the whole configuration through one config input, surfaces grid events as outputs, and exposes the live grid on a getter for anything the inputs do not cover.
The configuration
import { Component, inject } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { createGrid } from '@toclocoinc/lattice-grid';
import '@toclocoinc/lattice-grid/css';
import { createKPI } from '@toclocoinc/lattice-grid/modules/kpi';
import { createDataRouter } from '@toclocoinc/lattice-grid/modules/data-router';
import { LatticeGridComponent, LatticeKpiComponent, LatticeRouter, provideLattice, provideLatticeRouter } from '@toclocoinc/lattice-grid/angular';
const MONEY = { style: 'currency', currency: 'USD', decimals: 0 };
const OPEN = { warn: 14, critical: 22, direction: 'lowerIsBetter' };
@Component({
selector: 'app-root',
standalone: true,
imports: [LatticeGridComponent, LatticeKpiComponent],
// Four routes off one partition key reading the same "deal" stream, so a
// change lands in the grid and every tile panel at once.
providers: [provideLatticeRouter({ key: 'kind', rowKey: 'id', overlap: true })],
template:
'<lattice-kpi [config]="kpiConfig" (ready)="onReady(\'kpi\', $event)" style="display:block;margin-bottom:12px"></lattice-kpi>' +
'<div style="display:flex;gap:12px;margin-bottom:12px">' +
' <lattice-kpi [config]="freshConfig" (ready)="onReady(\'fresh\', $event)" style="flex:1;display:block"></lattice-kpi>' +
' <lattice-kpi [config]="staticConfig" (ready)="onReady(\'static\', $event)" style="flex:1;display:block"></lattice-kpi>' +
'</div>' +
'<lattice-grid route="deal" [config]="gridConfig" style="display:block;height:320px"></lattice-grid>',
})
export class AppComponent {
private routerService = inject(LatticeRouter);
private timer: ReturnType<typeof setInterval> | undefined;
gridConfig = {
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' },
],
};
// A running total, an average against a baseline, a live count, a count
// with a good/warn/critical band, and a sparkline over the biggest deal.
kpiConfig = {
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: any) => r.stage !== 'won', thresholds: OPEN },
{ id: 'biggest', label: 'Biggest deal', aggregation: 'max', field: 'value', format: 'currency', sparkline: 'value' },
],
};
// maxAge and ageBy are a setting on the panel, not on one tile: only this
// panel declares them, so only it can tell a stopped feed from a healthy
// one. Past 30 seconds with no row whose observedAt is recent, its tile
// reports unknown, while the plain total beside it keeps its last figure.
freshConfig = {
rowKey: 'id',
maxAge: 30_000,
ageBy: 'observedAt',
tiles: [{ id: 'freshness', label: 'Pipeline value (30s freshness)', aggregation: 'sum', field: 'value', format: 'currency' }],
};
staticConfig = {
rowKey: 'id',
tiles: [{ id: 'no-freshness', label: 'Same total, no freshness check', aggregation: 'sum', field: 'value', format: 'currency' }],
};
// Every panel attaches to the same 'deal' route as it becomes ready, so one
// feed drives the grid (via route="deal" above) and all three panels.
onReady(which: string, instance: any) {
this.routerService.attach(instance, 'deal');
if (which === 'static' && !this.timer) {
this.routerService.router.load([/* deals tagged kind: 'deal': id, stage, region, value, observedAt */]);
// A live delta each tick: 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.
this.timer = setInterval(() => this.routerService.router.apply([/* upserts and the odd delete, each with observedAt: Date.now() */]), 1300);
}
}
}
bootstrapApplication(AppComponent, {
providers: [provideLattice({ createGrid, createKPI, createDataRouter })],
});
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.