tutorial
Build a real-time operations console in Angular
Last updated 8 September 2026
An Angular operations screen shows several related things at once: orders coming in, shipments moving, incidents opening and closing, a throughput line ticking along. They are all the same live source. This tutorial takes one feed and drives the whole screen from it: three grids and a chart, each a standalone component that only ever sees its own slice, with no backend to run.
You will build it with a stand-in socket so it runs anywhere, then change a single line to point it at a real server. The complete source is one click away if you want to see the destination first. If you would rather follow this in plain JavaScript, the vanilla version builds the same screen without a framework, and the React version builds it with the React adapter.
Open the full source in the sandbox
The problem: one feed, many views
The obvious ways to build this screen both hurt. Open a socket per grid and you are running several connections that carry the same data, reconnecting several times, and reconciling them against each other. Put everything in one giant grid and you lose the thing that made the screen readable: orders, shipments and incidents are different shapes with different columns, and a reader wants them side by side, not interleaved.
The better shape is a router in front and plain components behind. One connection arrives, a router splits each record to the view it belongs in, and every grid stays simple: it is handed rows and knows nothing about the feed or its siblings. That is what the Data Router does, and it is the whole idea this tutorial builds on. You can see the end result on the Data Router demo and read the API it uses in the rows and sources reference.
Set up the page
The Angular adapter takes no dependency on Angular and carries no second copy of
the grid: you hand it your Angular and the grid's createGrid, and it
hands back a standalone component. Install the package, import the charts module
and the data-router module beside it, lay out three panels and a strip for the
chart in the component's template, and bootstrap one standalone component. On
localhost the grid is free to use; a deployed site is licensed per domain.
import * as ng from '@angular/core';
import { Component, ViewChild, AfterViewInit, OnDestroy } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { createGrid, createHeadlessGrid } from '@toclocoinc/lattice-grid';
import '@toclocoinc/lattice-grid/css';
import { createChart } from '@toclocoinc/lattice-grid/modules/charts';
import { createDataRouter } from '@toclocoinc/lattice-grid/modules/data-router';
import createLatticeGrid from '@toclocoinc/lattice-grid/modules/angular';
// Hand the adapter Angular and the grid once. It returns a standalone component
// whose config input is the grid configuration and whose grid getter is the
// live instance the router drives.
const { LatticeGridComponent } = createLatticeGrid({ ng, createGrid });
Build the feed
A live feed sends an opening snapshot, then a stream of changes. The helper below
does exactly that from a generator, on a timer, and it presents the same surface
as the browser's WebSocket: an onmessage handler, a
readyState, send and close. Because it
matches the real thing, the code you write against it is the code you ship. This
part is the same whether you build the screen in Angular or in plain JavaScript.
// A serverless stand-in for a live feed. It opens, sends a snapshot, then
// streams deltas on a timer, all from a generator you hand it. To go live,
// swap the one line that constructs it for: new WebSocket(url)
function rng(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) >>> 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
class MockWebSocket {
constructor({ feed, rate = 1000, jitter = 0, seed = 1, snapshotDelay = 60 }) {
this.readyState = 0;
this.url = 'mock://feed';
this.onopen = this.onmessage = this.onclose = this.onerror = null;
this._feed = feed; this._rate = rate; this._jitter = jitter; this._rand = rng(seed);
this._timer = null; this._paused = false;
setTimeout(() => this._open(), snapshotDelay);
}
send() {} // a real socket sends upstream; here, ignored
pause() { this._paused = true; if (this._timer) { clearTimeout(this._timer); this._timer = null; } }
resume() { if (!this._paused) return; this._paused = false; this._schedule(); }
close() { if (this.readyState === 3) return; this.readyState = 3; if (this._timer) clearTimeout(this._timer); this._timer = null; this.onclose && this.onclose({ type: 'close' }); }
_open() { this.readyState = 1; this.onopen && this.onopen({ type: 'open' }); this._pump(); this._schedule(); }
_schedule() {
if (this._timer || this._paused || this.readyState !== 1) return;
var gap = Math.max(0, this._rate + (this._rand() * 2 - 1) * this._jitter);
this._timer = setTimeout(() => { this._timer = null; this._pump(); this._schedule(); }, gap);
}
_pump() {
if (this.readyState !== 1) return;
var next = this._feed.next();
if (next.done) return this.close();
this.onmessage && this.onmessage({ type: 'message', data: JSON.stringify(next.value) });
}
}
The generator is where your domain lives. This one emits a mixed operations
stream: orders, shipments and incidents, plus a small throughput rollup for the
chart. Every record carries a type, which is what the router will
split on, and an id, which is its key. The snapshot fills the screen;
each delta upserts a few records and occasionally resolves an incident.
// One mixed stream: orders, shipments, incidents and a throughput rollup.
// Every record carries a 'type', which is the field the router partitions on,
// and an 'id', its row key. The snapshot fills the screen; each delta upserts a
// few records and now and then resolves an incident.
function* opsFeed({ seed = 7, orders = 40, shipments = 24, incidents = 12, batch = 4 } = {}) {
var rand = rng(seed);
var REGIONS = ['EMEA', 'AMER', 'APAC'];
var CUSTOMERS = ['Northwind', 'Contoso', 'Fabrikam', 'Initech', 'Umbra', 'Globex'];
var CARRIERS = ['DHL', 'FedEx', 'Maersk', 'UPS'];
var SERVICES = ['Checkout', 'Search', 'Payments', 'Inventory', 'Auth'];
var SEVERITIES = ['low', 'medium', 'high', 'critical'];
var pick = function (list) { return list[Math.floor(rand() * list.length)]; };
var seq = 0, tick = 0;
var openInc = new Set();
var order = () => ({ id: 'ORD-' + (1000 + seq++), type: 'order', region: pick(REGIONS), customer: pick(CUSTOMERS), value: 200 + Math.round(rand() * 9800), status: 'open' });
var shipment = () => ({ id: 'SHP-' + (1000 + seq++), type: 'shipment', region: pick(REGIONS), carrier: pick(CARRIERS), units: 1 + Math.round(rand() * 400), status: 'in transit' });
var incident = () => { var id = 'INC-' + (1000 + seq++); openInc.add(id); return { id, type: 'incident', region: pick(REGIONS), service: pick(SERVICES), severity: pick(SEVERITIES), status: 'open' }; };
var metric = (events) => ({ id: 'M-' + tick, type: 'metric', t: tick, events });
var rows = [];
for (var i = 0; i < orders; i++) rows.push(order());
for (var i = 0; i < shipments; i++) rows.push(shipment());
for (var i = 0; i < incidents; i++) rows.push(incident());
for (tick = 0; tick < 12; tick++) rows.push(metric(1 + Math.floor(rand() * batch * 2)));
yield { kind: 'snapshot', rows };
for (;;) {
var changes = [];
var n = 1 + Math.floor(rand() * batch);
for (var i = 0; i < n; i++) {
var roll = rand();
changes.push({ op: 'upsert', row: roll < 0.45 ? order() : roll < 0.75 ? shipment() : incident() });
}
if (rand() < 0.5 && openInc.size) {
var id = [...openInc][Math.floor(rand() * openInc.size)];
openInc.delete(id);
changes.push({ op: 'upsert', row: { id, type: 'incident', status: 'resolved', region: pick(REGIONS), service: pick(SERVICES), severity: pick(SEVERITIES) } });
}
changes.push({ op: 'upsert', row: metric(changes.length) });
tick++;
yield { kind: 'delta', changes };
}
}
Define the columns
Declare a column set per grid at module scope, above the component. Keeping them out of the component body gives each one a stable reference, so the adapter pushes them into the grid once and never rebuilds it on a later change.
const money = { style: 'currency', currency: 'USD', decimals: 0 };
const orderColumns = [
{ field: 'id', title: 'Order', layout: { width: 110 } },
{ field: 'region', title: 'Region', filter: { type: 'set' } },
{ field: 'customer', title: 'Customer' },
{ field: 'value', title: 'Value', type: 'number', format: money, total: 'sum' },
];
const shipmentColumns = [
{ field: 'id', title: 'Shipment', layout: { width: 110 } },
{ field: 'region', title: 'Region', filter: { type: 'set' } },
{ field: 'carrier', title: 'Carrier' },
{ field: 'units', title: 'Units', type: 'number', total: 'sum' },
{ field: 'status', title: 'Status' },
];
const incidentColumns = [
{ field: 'id', title: 'Incident', layout: { width: 110 } },
{ field: 'region', title: 'Region', filter: { type: 'set' } },
{ field: 'service', title: 'Service' },
{ field: 'severity', title: 'Severity' },
{ field: 'status', title: 'Status' },
];
Write the component
Each grid carries a template reference whose .grid is the live
instance. None of the grid configs carries a rows field: the router
owns the rows, so the adapter and the router never contend for them. A headless
grid, a real grid with no table of its own, holds the rollup the chart reads.
One method wires it all together. By the time Angular calls
ngAfterViewInit the three grids exist and their references are set. It
creates the router keyed on type, attaches each grid to the value it
wants, links orders to incidents so selecting orders narrows the incidents grid to
the same regions, points the chart at the headless grid, and opens the socket. The
snapshot arrives as router.load and every delta as
router.apply, which computes a keyed difference per grid so an update
lands on exactly the row it changes, with scroll and selection preserved. The
ngOnDestroy hook closes the feed and tears the router down when the
component is destroyed.
@Component({
selector: 'app-root',
standalone: true,
imports: [LatticeGridComponent],
template:
'<div class="ops">' +
' <div class="panel"><h2>Orders</h2>' +
' <lattice-grid #orders [config]="ordersGrid" class="grid"></lattice-grid></div>' +
' <div class="panel"><h2>Shipments</h2>' +
' <lattice-grid #shipments [config]="shipmentsGrid" class="grid"></lattice-grid></div>' +
' <div class="panel"><h2>Incidents</h2>' +
' <lattice-grid #incidents [config]="incidentsGrid" class="grid"></lattice-grid></div>' +
'</div>' +
'<div #chart id="chart"></div>',
styles: [`
.ops { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
.panel { background: #fff; border: 1px solid #e3e6ea; border-radius: 10px; overflow: hidden; min-width: 0; }
.panel h2 { font-size: 13px; margin: 0; padding: 8px 12px; border-bottom: 1px solid #eef0f3; color: #3a4250; font-weight: 600; }
.grid { display: block; height: 300px; }
#chart { height: 200px; margin-top: 12px; background: #fff; border: 1px solid #e3e6ea; border-radius: 10px; }
@media (max-width: 820px) { .ops { grid-template-columns: 1fr; } }
`],
})
export class AppComponent implements AfterViewInit, OnDestroy {
// Each grid carries a template reference whose .grid is the live instance the
// router drives; the chart host is a plain element.
@ViewChild('orders') ordersRef!: LatticeGridComponent;
@ViewChild('shipments') shipmentsRef!: LatticeGridComponent;
@ViewChild('incidents') incidentsRef!: LatticeGridComponent;
@ViewChild('chart') chartEl!: ng.ElementRef<HTMLElement>;
// None of the grid configs carries rows: the router owns the rows, so the
// adapter and the router never contend for them.
ordersGrid = { rowKey: 'id', theme: 'light', selection: 'multiple', columns: orderColumns };
shipmentsGrid = { rowKey: 'id', theme: 'light', columns: shipmentColumns };
incidentsGrid = { rowKey: 'id', theme: 'light', columns: incidentColumns };
private socket: any;
private router: any;
private chart: any;
private metrics: any;
// The view is ready here, so every grid instance exists and its ref is set.
ngAfterViewInit() {
const orders = this.ordersRef.grid;
const shipments = this.shipmentsRef.grid;
const incidents = this.incidentsRef.grid;
// A headless grid holds the rollup with no table of its own; the chart reads it.
this.metrics = createHeadlessGrid({
rowKey: 't',
columns: [{ field: 't', type: 'number' }, { field: 'events', type: 'number' }],
});
// One router, keyed on 'type'. Each grid is attached to the value it wants and
// only ever sees its own slice; the router fans the one feed out to all of them.
this.router = createDataRouter({ key: 'type', rowKey: 'id' });
this.router.attach(orders, 'order');
this.router.attach(shipments, 'shipment');
this.router.attach(incidents, 'incident');
this.router.attach(this.metrics, 'metric', { rowKey: 't' });
// Cross-grid selection: pick orders and the incidents grid narrows to their
// regions. Neither grid holds a reference to the other.
this.router.link(orders, incidents, { from: 'region', to: 'region' });
// The chart follows the headless grid, so it moves as the feed does.
this.chart = createChart({
grid: this.metrics, container: this.chartEl.nativeElement,
type: 'line', x: 't', y: 'events', title: 'Throughput per tick',
});
// One socket, one handler. The snapshot hydrates every grid at once; each
// delta lands in place through the same keyed path, so scroll and selection hold.
this.socket = new MockWebSocket({ feed: opsFeed({ seed: 7 }), rate: 900, jitter: 300 });
this.socket.onmessage = (event: MessageEvent) => {
const message = JSON.parse(event.data);
if (message.kind === 'snapshot') this.router.load(message.rows);
else this.router.apply(message.changes);
};
}
// Tearing the component down closes the feed and disposes the router and chart.
ngOnDestroy() {
this.socket && this.socket.close();
this.chart && this.chart.destroy();
this.router && this.router.destroy();
this.metrics && this.metrics.destroy();
}
}
Bootstrap it
Bootstrap the standalone component. One round trip hydrates every grid and the chart at once, and the stream keeps them all moving from there.
bootstrapApplication(AppComponent);
That is the whole console: one feed, three grids, a chart, and a link between two of the grids. Open the full source in the sandbox to copy into your project.
Go to production
When there is a real server to talk to, the change is one line inside
ngAfterViewInit. Everything that parses messages and routes them stays
exactly as written, because the stand-in was built to the real socket's surface
from the start.
// The whole change from mock to live is this one line, inside ngAfterViewInit:
this.socket = new WebSocket('wss://ops.example.com/stream');
// everything that parses messages and routes them stays exactly as written.
Two notes for the live version. A deployed page needs a licence for its domain, which is a single call. And where a connection can drop, a reconnect that asks the server for a fresh snapshot restores the screen from the same handler you already have: the snapshot path rebuilds every grid, and the deltas carry on.
What you built
One round trip hydrated a whole screen, and one stream keeps it moving. Each grid is an independent standalone component that knows nothing of the others, the chart rides the same feed, and the connection count stayed at one. Swapping the mock for a real socket is the only change between this page and production.
Next, see the Data Router demo for the same idea as a compact example, read the Angular adapter guide for the inputs and the grid getter in detail, or browse the demo catalogue for the grid features each panel here is using.