Cookbook
Data-driven fields
The point of a field over a chart is that the data carries the physics.
bindData is the bridge: records become bodies, the numbers you already have become
the field's state, and the relationships you already know become real edges — so what a reader
sees is a measurement of the data, not a decoration applied to it.
The shape of a binding
Three things divide cleanly. The mapper owns each record: its tokens, its
metric values, its edges. The Pattern frames the field: which metrics are
tracked and bound to --field-*. Your CSS owns the appearance, and
never learns any physics — it reads a number.
import { bindData } from '@fundamental-engine/dom';
// Records become bodies. The MAPPER owns the per-record physics and meaning;
// the PATTERN frames which metrics are tracked and written back as --field-*.
const binding = bindData(list, tickets, (t) => ({
id: t.id, // the identity updates diff against
label: t.title,
body: { tokens: ['attract'], strength: 0.3 + t.priority, range: 180, feedback: true },
metrics: { priority: t.priority }, // → --field-priority on this record
relationships: t.blocks // → a real graph edge, not a decoration
? [{ to: t.blocks, type: 'blocks', strength: t.priority }]
: [],
}), { pattern: 'priority-well', tag: 'li', className: 'ticket' });
binding.update(nextTickets); // diffed by id: survivors keep identity, removals decay out
binding.inspect(); // { records, bodies, relationships }
binding.destroy(); /* The data's own numbers come back as custom properties.
Nothing here knows about physics — it reads a measurement. */
.ticket {
border-left: 3px solid
color-mix(in srgb, var(--accent) calc(var(--field-priority, 0) * 100%), transparent);
opacity: calc(0.55 + var(--field-priority, 0) * 0.45);
} Live, on this page
Four tickets, bound. The left rule and the opacity are driven by
--field-priority — the value the binding wrote, not a class this page assigned.
Apply the update and the set changes by id: two records survive and keep their
identity, one arrives, two leave.
starting…
The status line is binding.inspect() — the binding's own count of records, bodies and relationships.
At scale
The first render is the easy half. What decides whether a data-driven field survives contact
with production is the update path: update() diffs by id, so a
poll that returns mostly-unchanged rows costs almost nothing, survivors are re-measured rather
than rebuilt, and removed records decay out over decayMs instead of
popping.
// At scale the shape that matters is the UPDATE, not the first render.
// bindData diffs by id, so a poll that returns mostly-unchanged rows is cheap:
async function refresh() {
const rows = await fetch('/api/tickets').then((r) => r.json());
binding.update(rows); // added → enter · kept → re-measured · removed → decay out
}
setInterval(refresh, 30_000);
// Own the lifecycle: a binding holds a scoped platform and a rAF loop.
onUnmount(() => binding.destroy()); - Give every record a stable id. The diff is only as good as the identity you supply.
- Keep the mapper pure. It runs per record on every update.
- Map meaning, not appearance.
priority,confidence,staleness— then let CSS decide what those look like. - Own the teardown. A binding holds a scoped platform and a loop.
The production-scale version
Twelve example pages take a real dataset each — a market feed, an inbox, a dependency graph, a release calendar — and run exactly this pattern at full size, with committed snapshots that upgrade to live data in the browser. They are the honest answer to "does this hold up on real data".