tutorial
Build a real-time operations console: one feed, many grids
Last updated 5 September 2026
An 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 one decoupled and only ever seeing 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 finished console is one click away if you want to see the destination first.
Open the finished console in the sandbox
The problem: one feed, many views
The obvious ways to build this screen both hurt. Open a socket per table 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 grids 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
Load three things from the CDN with ordinary script tags: the grid, the
charts module and the data-router module. No build step and no import: the
grid is on the global LatticeGrid, the charts module adds
createChart to it, and the data-router module is on
LatticeGridDataRouter. Everything runs on your own origin. On
localhost the grid is free to use; a deployed site is licensed per domain,
which the sandbox already carries for you.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.31.0/lattice-grid.min.css">
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.31.0/lattice-grid.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.31.0/modules/charts.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@toclocoinc/lattice-grid@1.31.0/modules/data-router.min.js"></script>
Lay out three panels for the grids and a strip for the chart. The grids mount into these elements by id.
<style>
body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; background: #f6f7f9; color: #1b2430; }
.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 { 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; } }
</style>
<div class="ops">
<div class="panel"><h2>Orders</h2><div id="orders" class="grid"></div></div>
<div class="panel"><h2>Shipments</h2><div id="shipments" class="grid"></div></div>
<div class="panel"><h2>Incidents</h2><div id="incidents" class="grid"></div></div>
</div>
<div id="chart"></div>
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.
// 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 };
}
}
Route the feed
Create the three grids, one per entity type, and a headless grid to hold the rollup the chart will read. A headless grid is a real grid with no table of its own: it stores and computes, and here it feeds the chart.
var money = { style: 'currency', currency: 'USD', decimals: 0 };
var orders = LatticeGrid.createGrid(document.getElementById('orders'), {
rowKey: 'id', theme: 'light', selection: 'multiple',
columns: [
{ 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' },
],
});
var shipments = LatticeGrid.createGrid(document.getElementById('shipments'), {
rowKey: 'id', theme: 'light',
columns: [
{ 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' },
],
});
var incidents = LatticeGrid.createGrid(document.getElementById('incidents'), {
rowKey: 'id', theme: 'light',
columns: [
{ 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' },
],
});
// A headless grid holds the rollup with no table of its own; the chart reads it.
var metrics = LatticeGrid.createHeadlessGrid({
rowKey: 't',
columns: [{ field: 't', type: 'number' }, { field: 'events', type: 'number' }],
});
Now the router. Give it the field to partition on, then attach each grid to
the value it wants. From here on, a record of type order reaches
only the orders grid, an incident only the incidents grid, and
nothing is dropped: the router can tell you how many records matched no route.
// 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.
var router = LatticeGridDataRouter.createDataRouter({ key: 'type', rowKey: 'id' });
router.attach(orders, 'order');
router.attach(shipments, 'shipment');
router.attach(incidents, 'incident');
router.attach(metrics, 'metric', { rowKey: 't' });
Apply live updates
One handler drives everything. Parse each message and hand it to the router:
a snapshot with router.load, a delta with router.apply.
The router computes a keyed difference per grid, so an update lands on exactly
the row it changes. Scroll position and any selection are preserved, and a
grid that has no change in a given tick is not touched at all.
// One socket, one handler. The snapshot hydrates every grid at once; each delta
// is applied in place through the same keyed path, so scroll and selection hold.
var socket = new MockWebSocket({ feed: opsFeed({ seed: 7 }), rate: 900, jitter: 300 });
socket.onmessage = function (event) {
var message = JSON.parse(event.data);
if (message.kind === 'snapshot') router.load(message.rows);
else router.apply(message.changes);
};
Chart the same feed
The chart reads from the headless grid, so it is driven by the same feed as everything else with no extra wiring. As the rollup route receives its rows, the line advances.
// The chart follows the headless grid, so it moves as the feed does.
var chart = LatticeGrid.createChart({ grid: metrics, container: '#chart', type: 'line', x: 't', y: 'events', title: 'Throughput per tick' });
Link two grids
An operations screen is more useful when the views relate. Cross-grid selection ties two grids together on a shared field: select one or more orders and the incidents grid narrows to the same regions on its own. Clear the selection and it restores. Neither grid holds a reference to the other; the router recomputes what the linked grid shows and pushes it through the same keyed path, so the linked grid stays as simple as before.
// Cross-grid selection: pick orders and the incidents grid narrows to their
// regions. Deselect to restore the full set. Neither grid knows about the other.
router.link(orders, incidents, { from: 'region', to: 'region' });
That is the whole console: one feed, three grids, a chart, and a link between two of the grids. Run it in the sandbox and edit any part of it live.
Go to production
When there is a real server to talk to, the change is one line. 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:
var socket = new WebSocket('wss://ops.example.com/stream');
// everything below it stays exactly as written.
Two notes for the live version. A deployed page needs a licence for its domain, which is a single call the sandbox already makes for you. 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 independently testable and 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, or browse the demo catalogue for the grid features each panel here is using.