@devicechain/widgets
React dashboard widgets for DeviceChain, plus the renderer that lays a board out.
Thirty-two widgets — charts, gauges, tables, a fleet table, alarm views,
command controls, an entity selector and a map — all bound to live data through
@devicechain/dashboards.
Themed entirely with CSS custom properties. No Tailwind, no global styles, no opinions about your app's shell — these are meant to be embedded.
npm install @devicechain/widgets @devicechain/dashboards @devicechain/client graphql react react-dom
Render a board
import { DashboardRenderer } from '@devicechain/widgets';
import { DashboardHub, parseDashboardDefinition } from '@devicechain/dashboards';
const hub = new DashboardHub({ resolver, authorities: user.scopes });
const definition = parseDashboardDefinition(json);
<DashboardRenderer definition={definition} hub={hub} actions={hub} />;
Omit actions for a strictly read-only mount and the acting widgets — alarm
acknowledge, command send — render without their controls. That is the whole
opt-in: a viewer that never passes actions cannot write, regardless of what the
board contains.
A templated board takes its slot bindings on the renderer, not the hub:
<DashboardRenderer bindings={bindings} … />. Keep one hub for the board
(useDashboardHub(resolver, authorities) builds and disposes it); a new
bindings map re-subscribes only the widgets whose slot moved.
Several built-ins load their component code on first mount, so a board without
them never downloads it. Until that code arrives the widget's box is empty. To
draw the whole board on its first frame, await preloadWidgets with the board's
widget types before you render it:
import { createBuiltinWidgetRegistry, preloadWidgets } from '@devicechain/widgets';
const registry = createBuiltinWidgetRegistry();
await preloadWidgets(registry, definition.widgets.map((w) => w.type));
It never rejects: a chunk that fails to load still fails in the widget itself, where its error boundary shows it.
Or drive a single widget yourself with ConnectedWidget; it resolves the widget's
component, channel and binding policy through a registry (the built-ins by default,
or the one you pass as registry; see createBuiltinWidgetRegistry and
builtinWidgetDefinitions, and @devicechain/widget-sdk to add your own). A widget
bound to a slot needs its datasource resolved first (resolveWidgets or
resolveWidgetDatasource).
The widgets
| channel | widgets |
|---|---|
| measurement | timeseries-chart, latest-card, gauge, linear-gauge, level-gauge, battery, status-light, signal-bars, compass, progress-ring, table, bar-chart, pie-chart, fleet-table, fleet-count, device-status, online-doughnut, history-table, status-grid, state-timeline, scada-symbol |
| aggregate | stat-card |
| none (static) | label, image |
| alarm | alarm-table, alarm-count, alarm-severity |
| control | command-button, command-slider, command-toggle |
| selection | entity-selector |
| location | map |
fleet-table, fleet-count, and bar-chart in its one-bar-per-device mode, read a device
group member by member. The other measurement widgets, and stat-card, show one
device and refuse a group. stat-card shows one figure (average, minimum, maximum, sum, count or
latest) over the board's time range.
device-status and online-doughnut read the devices' connectivity (online or offline, and when
each was last active) from device-state, not their readings. They ride the measurement channel's
member feed (data.members[].presence), so the channel set is unchanged. device-status shows one
device; online-doughnut takes a group.
history-table lists one device's stored history as rows, one per time bucket over the board's
span, each cell the bucket's average (the table says "avg per 5m"). It reads the store's buckets
(binding.history), not the live feed, and refuses a group.
A frame offers a data download from its title bar when its widget opts in: timeseries-chart,
fleet-table and history-table hand it a table (CSV), and the charts drawn on a canvas
(timeseries-chart, gauge, compass) a PNG. The file holds what the widget shows, for the
span and bucket on screen, and its name says so. A widget of your own opts in with the
optional exportRows prop of WidgetFrame (see the widget SDK).
The time-series chart, the dial gauge and the compass are drawn with Apache ECharts; the other widgets are plain DOM and SVG. The map is MapLibre GL, loaded lazily so a board with no map on it downloads none of it.
WIDGET_OPTIONS is the typed schema for every widget's options bag —
validateWidgetOptions reports exactly where a stored bag disagrees with what the
renderer expects, which is what lets an authoring UI show a problem instead of
rendering something wrong.
Rendering a map: one required piece of host wiring
MapLibre parses tiles in a web worker, and it derives that worker's URL at
runtime from its own module URL — a computed string no bundler can trace. So
no bundler emits the file, the browser 404s it, new Worker() does not throw, and
the map renders as an empty box with nothing in the console. It is a famously
quiet failure, and it is not something this package can paper over: only your
bundler can emit that file.
So the worker URL comes from you, through a provider. On Vite that is one import:
import { MapRuntimeProvider } from '@devicechain/widgets';
import { viteMapRuntime } from '@devicechain/widgets/vite';
<MapRuntimeProvider runtime={viteMapRuntime}>
<DashboardRenderer definition={definition} hub={hub} />
</MapRuntimeProvider>;
On any other bundler you supply the URL yourself. There is one requirement, and
it is the whole of the difficulty: MapLibre loads that URL as a module worker,
so whatever you serve there must be a module with no unresolved imports left in
it. maplibre-gl/dist/maplibre-gl-worker.mjs is not one on its own — its first
line imports a sibling, maplibre-gl-shared.mjs.
So do not point at a lone copy of the worker file, and in particular do not
reach for new URL('maplibre-gl/dist/maplibre-gl-worker.mjs', import.meta.url).
It looks right and it is not: webpack copies the file as an asset, the sibling is
never emitted, and the worker dies on its own first line — with a 200 on the
worker URL, a canvas on screen, markers placed, the build exited 0 and nothing in
the console. The map just has no map in it.
Two recipes that work. On webpack, give the worker its own entry, so webpack bundles its imports into it:
entry: {
main: './src/index.tsx',
'maplibre-worker': 'maplibre-gl/dist/maplibre-gl-worker.mjs',
},
output: {
filename: (data) =>
data.chunk.name === 'maplibre-worker' ? 'maplibre-worker.js' : '[name].[contenthash].js',
},
…and point at the filename you chose:
const runtime = { workerUrl: '/maplibre-worker.js' };
Or, on any bundler at all, copy the two files yourself:
// BOTH, under their own names, into one served directory:
// maplibre-gl/dist/maplibre-gl-worker.mjs -> public/vendor/
// maplibre-gl/dist/maplibre-gl-shared.mjs -> public/vendor/
const runtime = {
workerUrl: '/vendor/maplibre-gl-worker.mjs',
loadStyles: () => import('maplibre-gl/dist/maplibre-gl.css'),
};
loadStyles is optional — import MapLibre's stylesheet yourself if you would
rather have it eagerly. Supplying it here keeps the 83 KB (10.7 KB gzipped) on the
map's lazy chunk, so a viewer who opens no map downloads none of it.
A map widget with no provider above it renders a visible notice, not a blank canvas. That is deliberate: an unwired host should be able to see what is missing.
maplibre-gl is a peer dependency, so your app and this package share exactly one
copy. A worker built from one version driving a main thread on another is the same
blank map by a different route.
Basemap tiles
TenantBasemapProvider supplies the tile source. It is genuinely optional — with
no provider the map falls back to a plain view — and the resolution cascade lives
in @devicechain/client so every DeviceChain surface resolves an override over
the tenant default identically.
Theming
Every colour, radius and font comes from a CSS custom property, so a host restyles
widgets by setting variables on any ancestor. The DeviceChain values ship as
@devicechain/brand; nothing
here requires them.
Upgrading from 0.21
severityColor(severity)is nowseverityColor(colors, severity); the colours come fromuseSeverityColors()in@devicechain/widget-sdk.- The
timeseries-chartoptionwindow(a sample count) is replaced bywindowSeconds(a time span), and the chart's options version moves from 1 to 2. A stored board converts when it is parsed: the sample count is dropped and the chart follows the board's time range. - Thirteen widgets are new, so
WidgetChanneland the widget id unions gained members; an exhaustiveswitchover them needs new cases.
See If you build against the npm packages in the release's upgrade guide.
Compatibility
React 19, ESM only, built for bundler resolution (Vite, webpack, Rollup, esbuild).
The emitted declarations use extensionless specifiers, which resolve under
TypeScript's bundler module resolution and not under node16/nodenext.
License
Apache-2.0. See LICENSE.