Skip to content
04Surfaces

Dashboards

Live operational dashboards: the grid, every widget, data binding, metrics, the actions graph, spatial and attribute filters, breakpoints, refresh and the ops theme.

The model

A dashboard is a grid of widgets over one project. Widgets bind to datasets and metrics, emit events, and accept actions. Nothing is a screenshot: values come from the server on a refresh interval, and every widget carries an as-of stamp.

dashboard props
{
  variant: "dark",     // light · dark · ops
  refreshSec: 60,      // 0 turns polling off
  cols: 24,
  rowHeight: 36,
  gap: 12,
  showAsOf: true
}

Authoring

Studio in dashboard mode: the Library of widgets, the grid canvas and the inspector.
  1. Widgets: drag any of them onto the grid.
  2. Grid: 24 columns by default, 36 px rows, 12 px gap. Drag to move, drag an edge to resize; blocks snap.
  3. Map widget: as many as you like, each bound to its own beat.
  4. KPI: selected here, so the inspector shows its binding.
  5. Inspector: Data (dataset, metric, field) and Visualisation (encodings, sort, top-N, ramp) for the selected widget.
  6. Save map view: a dashboard's map widget saves a beat like any other map.

Every widget

The gallery below is a real published dashboard holding one of each, bound to a live sample dataset.

A dashboard containing one of every Spatly widget type.
  1. Map: the spatial widget; every other widget can filter it and be filtered by it.
  2. KPI · gauge · sparkline: the single-number family.
  3. Bar and pie: categorical breakdowns, clickable.
  4. Time series · histogram · scatter: the distribution family.
WidgetBinds toNotes
mapWidgeta beat, a layer2D or 3D; carries the spatial-selection toolbar when draw is on
kpiWidgeta metricbig number, delta against the previous window, optional sparkline, threshold colours
gaugeWidgeta metricvalue between min and max
sparklineWidgeta metric with a time seriestrend only, no axes
timeseriesWidgeta metric with a time seriesline or area, bucketed
barWidgeta metric with groupByhorizontal or vertical, stack by a second field, top-N with the tail rolled into Other, sort, value labels, click to filter
pieWidgeta metric with groupBypie or donut with a legend and a centre total
histogramWidgeta dataset fieldbin count; brush a range to filter
scatterWidgettwo numeric fieldsoptional size and colour encodings; drag to brush a selection
tableWidgeta datasetsortable, per-column filter row, sticky header, windowed above 1,000 rows, CSV export of exactly what is on screen, row click flies and flashes
listWidgeta datasetfeature cards; follows selection and viewport
filterWidgeta dataset fieldmulti-select chips with live counts, an all action, inner search past ten options
rangeWidgeta numeric fielddual-thumb slider with the value histogram drawn behind the track
searchWidgeta text fielddebounced substring search; matches highlight on the map
toggleWidgeta boolean-ish fieldtri-state: any → on → off, with counts
dateRangeWidgeta time field24h / 7d / 30d / All presets, explicit from and to values, and a brush over a mini histogram
timeControla time fieldplayback across the time range
spatialFilterWidgeta datasetrectangle · circle · polygon · lasso, driving a map widget
legendWidgetlayersclasses as buttons; click to filter everywhere
scaleWidgetthe mapatlas scale bar and the current zoom
bookmarkStripbeatsone button per saved beat
textWidget · imageWidget · svgWidgetnothingnarrative in the grid; svg takes raw markup (scripts and on* attributes are stripped)
storyEmbedWidgeta published storya chapter of another surface, framed
insightWidgeta published insightan Insight Container inside the grid

Data binding

A widget binds to a metric or straight to a dataset field.

a metric
{
  "name": "Events in view",
  "datasetId": "<datasetId>",
  "aggregation": "count",
  "field": null,
  "groupBy": null,
  "filter": [">=", ["get", "mag"], 5],
  "spatialScope": "viewport",
  "timeseries": { "timeField": "time_iso", "bucket": "month" },
  "format": { "decimals": 0, "compact": true, "prefix": "", "unit": "" }
}
KeyValues
aggregationcount sum avg min max median distinct latest
filtera MapLibre expression, applied before aggregating
spatialScopeall · viewport (recomputes as the camera moves) · selection · aoi (the drawn area)
timeseriesa time field and a bucket: hour day week month year
formatdecimals, compact notation, prefix, unit

Values come from /api/live/metrics/:id with an ETag; the viewer polls every refreshSec and a 304 costs nothing. Metrics with spatialScope: "viewport" recompute server-side against the current bounding box.

The inspector's Data section builds all of that with pickers. They are typed, so a numeric-only field never appears where a number is required, and there is a filter expression escape hatch for the cases the pickers do not cover.

The actions graph

"When A happens, do B." Every widget emits events; every widget accepts effects.

The Actions panel listing five wired actions on a dashboard.
  1. Add action: pick a source widget, an event, a target and an effect.
  2. Extent → recompute: the map's extentChange re-scopes every viewport metric.
  3. Bar → filter all: clicking a bar writes a category filter that every widget honours.
  4. Map select → filter: clicking a feature filters the rest of the dashboard.
  5. Table row → fly + flash: selecting a row flies the map and flashes the feature.
  6. Enabled: every rule can be switched off without deleting it.
EventEmitted by
selectmap click, bar/pie click, table row, list item, scatter brush
filterChangecategory filter, range slider, search, toggle, date range, spatial filter
extentChangea map widget, when the camera settles
timeChangethe time control and the date range
hover · clickany widget
EffectDoes
filterwrites into the shared filter store; every bound widget narrows
filterViewportre-scopes viewport metrics to the current bounding box
highlight · flashrings or pulses matching features on the map
flyTo · setBeatmoves a map widget
setTimemoves every time-aware widget
clearFilterresets one target or all of them
openopens a URL

The target is a widget id, map for the first map, or * for every widget.

A new dashboard is wired for free. Without a single explicit action you already get: map extent → recompute viewport metrics; bar / category filter / range / search / toggle → filter everything; map select → filter; scatter brush → select. Add rules only when you want something other than the obvious.

Spatial filters: draw an area

The map widget carries a glass toolbar: pointer · rectangle · circle · polygon · lasso · clear.

A circle drawn on the dashboard map, with the AOI chip and every widget showing a filtered badge.
  1. Draw toolbar: pick a shape, then draw on the map.
  2. Filter chip: Circle · r 3330 km · N features, with an × to clear it.
  3. Clear all: removes every active filter at once.
  4. Feature count: N of 995 features under the map title.
  5. Spatial filter widget: the same area, with a zoom to action and a live count.
  6. Filtered badge: every widget the area is narrowing says so in its header.
  • Circle: click and drag from the centre; a live r 872 km readout follows the cursor, and two handles (centre to move, rim to resize) stay on the shape.
  • Polygon: click vertices, double-click or Enter to finish, Esc to cancel.
  • Lasso: freehand; the path is simplified before it becomes a ring.
  • Everything outside the selection is dimmed by a mask layer, so the shape reads as a selection rather than decoration.

The result is stored as a GeoJSON polygon (a circle becomes a 64-gon) with the drawing intent kept in properties (shape, center, radiusM), so the toolbar can reopen it. Client-side widgets intersect it directly; server-side metrics receive it as the aoi parameter, validated (Polygon or MultiPolygon, closed rings, ≤ 20,000 vertices) before it reaches PostGIS.

noteon a dataset served as vector tiles the client only holds what the viewport loaded, so the map dims correctly and server-side metrics are exact, but a client-side feature count describes the loaded tiles.

Attribute filters

Filters compose with AND. Every widget that is being narrowed shows a filtered badge, the header carries one removable chip per active filter plus Clear all, and the whole state is mirrored into ?f=<compact JSON> and restored on load, so a filtered view is a link.

WidgetWhat it writes
filterWidgeta set of category values
rangeWidgeta numeric range; the histogram behind the track is computed from what survives every other filter
searchWidgeta like pattern on a label field
toggleWidgettrue / false / any on a boolean-ish field
dateRangeWidgeta time window
legendWidgetwith click to filter on, hides that class everywhere
histogramWidget · scatterWidgeta brushed range or a feature selection

Refresh, as-of and stale

refreshSec on the dashboard sets the poll interval (60 s by default; 0 stops polling). Each widget shows as of HH:MM:SS. After three consecutive failures on the underlying connection the widget goes hatched and reports last success N minutes ago. Nothing is quietly wrong. See Data & live sources.

The published dashboard

A published dashboard: map with the draw toolbar, a category bar chart, filter chips, a KPI and a feature table.
  1. Map: pan, zoom, click a feature; the draw toolbar is top-left.
  2. Bar: clicking a bar filters everything else.
  3. Category filter: chips with live counts.
  4. Draw an area: the spatial filter widget, when you would rather not draw on the map itself.
  5. KPI: recomputed for whatever is in view.
  6. Table: sortable, filterable, CSV-exportable; a row click flies the map.
  7. As-of: when this data was last fetched, and a manual refresh.

Breakpoints

Each widget carries a grid rectangle per breakpoint: wall, desktop, tablet, phone. Placing a widget sets desktop and wall together; tablet and phone fall back to full width unless you place them. Switch breakpoint with the Preview width control and drag. You are editing that breakpoint's layout, not the others.

The ops theme

variant: "ops" is pure black, high contrast, 17 px base type and tighter radii, built for a wall display read from across the room. night is a softer dark for a room with people in it. See Theming.

Alerts

Any metric on the dashboard can carry an alert rule: a comparison, a threshold, an optional area or time scope, recipients and a cooldown. Rules are evaluated after every refresh of the underlying source.

Performance notes

  • Under about 5,000 features filtering happens client-side against the cached features; above that, metrics keep going to the server with bbox, where and aoi, memoised per key.
  • Search debounces and only touches the filter store, so no keystroke causes a refetch.
  • The table windows above 1,000 rows; the scatter caps at 6,000 points.
  • Drawing an area re-serialises only the mask and the ring, never the dataset.

Verified on 2026-09-02 against the local development build: the widget gallery above is a dashboard this documentation's screenshot script builds, publishes and photographs; the AOI figure is a circle drawn in a real browser on the published sample dashboard, with the resulting counts read back from the page.

Was this page helpful?

Something wrong or missing? Write to hello@spatly.io.