Learn / Dashboards in plain JavaScript
Dashboards in plain JavaScript
A dashboard is declared, not assembled: you write what the board is — views, widgets, spans — and render() wires the pack grid, drag/resize with live push, fit/grow, pin and one-undo-per-gesture. This page is the whole contract: every option, every widget kind, the live handle, and the save/load round trip.
One call, a working board
import { render, dashboard } from '@grafloria/element';
const SPEC = dashboard({
columns: 12,
sizing: 'fit',
views: [{
id: 'overview', name: 'Overview',
widgets: [
{ id: 'rev', kind: 'kpi', span: 3, rows: 1,
data: { label: 'Total revenue', value: '$6.81M', delta: 12.4, spark: [42,45,51,61,76] } },
{ id: 'trend', kind: 'line', span: 8, rows: 2, title: 'Revenue vs target',
data: { series: [{ name: 'Revenue', values: [420,455,512,588,648] }], labels: ['Jan','Feb','Mar','Apr','May'] } },
{ id: 'mix', kind: 'donut', span: 4, rows: 2, pinned: true, title: 'By region',
data: { slices: [{ label: 'EMEA', value: 1920 }, { label: 'APAC', value: 1340 }] } },
],
}],
});
render(SPEC, document.getElementById('canvas'));
const H = SPEC.handle; // the live handle — every edit goes through it
dashboard() returns a render spec (nodes,
edges, renderCustomNode, finalize) plus the
handle; render() runs the finalize for you —
the same pattern as every kit.
The options, in depth
| Option | Default | What it does |
|---|---|---|
columns | 12 | Column count for every view (a view can override) |
gap | 8 | Gap between widgets and the board padding, px |
sizing | 'fit' |
'fit' keeps the board at its design height and squeezes rows —
nothing falls below the fold; 'grow' keeps the row height and
extends the board downward |
rowHeight | 130 | Row height in 'grow' mode, px |
width, height | 1180 × 660 | Board size, px |
float | false |
Off = gravity packs widgets upward, no holes; on = widgets sit wherever you drop them, gaps are legal |
rtl | false |
Column x=0 renders at the right edge. Cells are untouched
— the same widgets array describes the same layout in both
directions, only the pixels mirror |
responsive | — | Derive the live column count from the board's width:
{ columnWidth: 100 } or
{ breakpoints: [{ w: 480, c: 1 }, { w: 900, c: 6 }] }. Runs through
the engine's per-column layout cache, so narrowing and widening back
restores the wide layout exactly |
views / widgets | — | Mutually exclusive. views is the tab pattern (only one on
camera); widgets is shorthand for a single unnamed view |
renderWidget | built-ins | Your chart painter — called once per widget when it mounts; the host is reused across re-renders, so this is not a per-frame hook |
onLayoutChange | — | "Fires after any committed gesture, with the view whose layout changed" — the persistence hook |
binder | — | Extra options merged into the grid binder underneath — the escape hatch to the layer below (drag-out-to-remove zones, palette drop-in, gesture callbacks) |
The widget spec
{ id: 'trend', // required
kind: 'line', // free-form string handed to renderWidget (built-ins below)
span: 8, rows: 2, // cell spans — defaults 3 and 1
x: 0, y: 1, // explicit cell — OMIT and widgets flow in declaration
// order, wrapping at the column count
pinned: true, // never pushed, refuses the mover, survives every reflow
title: 'Revenue', // used by the built-in renderers' header
data: { ... } } // your payload — passed straight back to renderWidget
Mixing works: one widget with an explicit cell, the rest flowing around it — the flagship demo's Pipeline view does exactly that.
The six built-in kinds
Write no renderWidget and the kit draws the declared
kind from your own data in hand-rolled inline SVG — no charting
dependency, no sample dataset, and a renderer "must never throw: it paints into a
live board, mid-gesture, on every reflow" — bad or missing data degrades to an
empty-state note. The contracts, with shapes straight from the flagship demo:
kpi: { label: 'Total revenue', value: '$6.81M', // value is YOUR formatting
delta: +12.4, deltaLabel: 'vs last qtr', // signed % — up/green, down/red
spark: [42, 45, 47, 51, 55, 61, 76] } // oldest → newest
line: { series: [{ name: 'Revenue', values: [420, 455, 512] },
{ name: 'Target', values: [400, 430, 465] }],
labels: ['Jan', 'Feb', 'Mar'] } // a bare number[] also works
bar: { bars: [{ label: 'North America', value: 2860 }, { label: 'EMEA', value: 1920 }] }
donut: { slices: [{ label: 'EMEA', value: 1920, color: '#14b8a6' }],
centerLabel: '$6.73M', centerCaption: 'total' }
funnel: { stages: [{ label: 'Lead', value: 1200 }, { label: 'Qualified', value: 820 }] }
table: { columns: ['Rep', 'Deals', 'Revenue'],
rows: [['A. Farouk', 38, '$1.24M'], ['M. Haddad', 31, '$0.98M']] }
Any other kind falls back to a titled placeholder frame, so a layout
is testable before any chart exists.
Your own charts: renderWidget
import { dashboard, defaultWidgetRenderer } from '@grafloria/element';
dashboard({
views,
renderWidget: (widget, host) => {
defaultWidgetRenderer(widget, host); // let the kit draw the card + chart…
host.firstElementChild.classList.add('my-chrome'); // …then decorate it
host.onclick = () => select(widget.id);
},
});
The seam is deliberate — the kit "hands you the widget and a raw HTML host (the
renderer's custom-node path, which unlike metadata.html is not
sanitised, so real <svg>/<canvas> is fine)".
Composing with defaultWidgetRenderer first is exactly how the flagship
demo adds its focus ring and pin marker. And because the board renders in light DOM,
the cards are styled by your page stylesheet like any
custom node.
The live handle
Every runtime edit is one call on SPEC.handle — no commands to
sequence, no models to build:
H.views // view ids, in declaration order
H.activeView // the one on camera
H.showView('sales'); // the tab switch — others park off-camera, camera re-frames
const w = H.addWidget({ kind: 'bar', span: 6, rows: 2, data: {...} });
// CREATES the node, wires its metadata, commits node + membership as ONE undoable
// step, auto-positions into the first free hole when the spec names no cell
H.setSizing('grow'); H.getSizing(); // the two toolbar toggles, live
H.setFloat(true); H.getFloat();
H.setColumns(6); H.getColumns(); // per-column layout CACHE: shrink then grow
// back restores the wide layout; an explicit
// call PINS the count against `responsive`
H.setRtl(true); H.getRtl();
H.refresh(); // re-read the boards after undo/redo or out-of-band edits
H.fit(); // re-frame the camera on the active view
H.metrics(); // live geometry: columns, gap, rows, rowHeight, frame
H.exportIds(); // the ids ONE view occupies — for scoped export
H.binderOf(); // the documented escape hatch to the grid binder
H.dispose();
Per-widget, H.widget(id) returns a WidgetHandle:
const w = H.widget('trend');
w.cell; w.rect; w.spec; w.node; // where it is, what it is
await w.resize(6, 3); // in CELLS — resolves true when accepted
await w.moveTo(0, 2); // …the board may refuse
w.pin(); w.pinned; // locked: never pushed, drags refused
w.bringToFront(); w.sendToBack(); // one undoable step each
w.update({ data: nextData }); // replace data (and title/kind) and repaint
w.repaint(); // re-run renderWidget after your data changed
w.remove(); // ONE undoable step incl. the survivors' re-pack
api.getEngine().undo(). After an undo/redo call H.refresh()
so the grid and the model agree again. See
Commands & undo.export() frames the whole model — "a two-view board writes a
~21,000px document that is almost entirely empty — with no warning, because nothing
is technically wrong." Pass { includeIds: H.exportIds() }; the set
includes the view's group as well as its widgets, which a hand-rolled id list from
toJSON() would drop.Saving: the round trip
H.toJSON() returns the whole board as plain data — and that data
is valid dashboard() input:
// save — everything except the function seams
const snapshot = H.toJSON(); // views + cells + columns, gap, rowHeight,
// sizing, float, rtl — read from the LIVE board,
// so a mode the user changed is what you get back
// …later: a true round trip. renderWidget cannot be written to a file,
// so it is supplied again on the way back in:
const SPEC2 = dashboard({ ...snapshot, renderWidget });
render(SPEC2, host);
Two properties worth knowing. First, cells serialise from the engine's
largest cached column count — "a board currently squeezed to 1 column
still writes out the 12-column layout its user authored": saving on a phone
saves the desktop layout. Second, JSON.stringify(H) calls the same
toJSON(), so there is no partial-answer footgun.
The persistence hook completes the pattern:
dashboard({
views,
renderWidget,
onLayoutChange: (viewId, widgets) => {
// after every committed gesture — save the WHOLE board, not just the view
localStorage.setItem('board', JSON.stringify(SPEC.handle.toJSON()));
},
});
There is also a second, document-level round trip: serialise the entire diagram
with DiagramSerializer and load it with
fromDocument(json, { renderWidget }) — the loaded spec carries the
same DashboardHandle, rebuilt by the same builder, so
showView/addWidget/toJSON all work on the
reload. Two carried limits, both because the datum is not in the document:
responsive is a runtime seam and is never serialised, and
renderWidget/onLayoutChange are functions — pass
renderWidget to fromDocument again or the reload silently
drops your chrome.
What a board is underneath
No magic: each view is a GroupModel whose chrome is suppressed with
frameChrome: 'none' — a frameless group, the pure layout
container from Groups. Each widget is a custom HTML node
(custom: true, unconnectable, ports stripped — a widget is not a wiring
endpoint), and the board geometry that turns cells into pixels is persisted as group
metadata so a saved document can rebind its grid. Which is why everything else on
this site — export, collaboration, commands — works on dashboards unchanged.
Live: the dashboard builder — tabs, palette, versions →
Live: grid options — nesting, responsive columns, RTL, pinning →
Containers: a widget that holds widgets
Give a widget a widgets array and it becomes a container —
a locked slab in its board with a nested pack grid inside. The inner grid
takes its own columns (default: the container's span) and
maxRows (default: the row extent of the declared children;
resizing a child past it grows the container in the parent board — the
escalation ratchet). Cross-boundary drag adopts in both directions with
one-undo gestures, handle.addWidget(spec, containerId)
targets a container directly, and toJSON() serialises the
nesting from live membership — a dragged-in tile lands under its new
parent in the snapshot, and the snapshot rebuilds through
dashboard() unchanged. Nesting is exercised to two levels.
const spec = dashboard({
columns: 12,
widgets: [
{ id: 'trend', kind: 'line', span: 8, rows: 2, data: { /* … */ } },
{ id: 'kpis', title: 'KPI section', span: 12, rows: 1, columns: 4,
widgets: [
{ id: 'k-rev', kind: 'kpi', span: 1, data: { label: 'Revenue', value: '$6.8M' } },
{ id: 'k-cust', kind: 'kpi', span: 1, data: { label: 'Customers', value: '1,284' } },
{ id: 'k-win', kind: 'kpi', span: 1, data: { label: 'Win rate', value: '27.4%' } },
] },
],
});
render(spec, host);
// later: spec.handle.toJSON() — the tree, from live membership
kind/data
on it are carried for your bookkeeping, not painted. A FULL bounded
section refuses adoption (the tile snaps home) — leave a free slot if you
want drag-in room. Container removal through the widget handle is not
supported yet.Live: dashboard containers — drag across the boundary, escalate, undo →