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

board.js
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

OptionDefaultWhat it does
columns12Column count for every view (a view can override)
gap8Gap 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
rowHeight130Row height in 'grow' mode, px
width, height1180 × 660Board size, px
floatfalse Off = gravity packs widgets upward, no holes; on = widgets sit wherever you drop them, gaps are legal
rtlfalse 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
renderWidgetbuilt-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
Undo is already wired. Drags, resizes, adds and removes each land as one undoable step on the engine's command stack — ⌘Z works, and buttons go through api.getEngine().undo(). After an undo/redo call H.refresh() so the grid and the model agree again. See Commands & undo.
Exporting a board? Scope it. Tabs park inactive views far off-camera, and 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
A container renders no card of its own — 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 →

Where next

  • Kits — the pattern dashboard() shares with erDiagram() and umlDiagram().
  • Groups — frameless containers, the primitive under every board.
  • Export — PNG/SVG/PDF, plus includeIds for one-board exports.