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.

Shipped · demo runs live on this page

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();

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.

Records as bodies live on this page

    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.

    TypeScript
    // 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".