Draw a Custom Dashboard Widget With Canvas2D

When no built-in widget fits, a paint(ctx, w, h) script can draw anything: the Canvas2D drawing model, theme palette, and live dataset access explained.

A paint function's code drawing a live dial gauge on a canvas panel

Dashboards are built from a vocabulary of standard widgets: plots, gauges, bars, compasses, LED panels. Serial Studio ships more than twenty of them, and most instruments you will ever need are in that set. But every so often the right visualization for your data simply does not exist as a prefab: a seven-segment readout, a polar plot, an attitude indicator drawn your way. The escape hatch is the Painter widget, a blank canvas that runs a drawing script you write, and if you have ever drawn on a <canvas> element in a browser, you already know how to use it.

Immediate mode: redraw everything, every time

The Painter follows the Canvas2D model, which is immediate mode: nothing you draw persists. The widget calls your paint function at the refresh rate (60 Hz by default, configurable from 1 to 240), hands it a drawing context and the current width and height, and expects the function to redraw the entire widget from scratch:

function paint(ctx, w, h) {
  ctx.fillStyle = theme.widget_base;
  ctx.fillRect(0, 0, w, h);

  if (datasets.length === 0) return;

  const ds = datasets[0];
  const frac = (ds.value - ds.min) / (ds.max - ds.min || 1);

  ctx.fillStyle = theme.accent;
  ctx.fillRect(16, h - 24, (w - 32) * frac, 8);

  ctx.font = "16px monospace";
  ctx.fillStyle = theme.widget_text;
  ctx.fillText(ds.value.toFixed(2) + " " + ds.units, 16, 24);
}

That is a complete widget: a horizontal bar with a numeric readout. The two guards are earning their keep, not padding the example: a freshly added widget has no datasets bound yet, so datasets[0] would be undefined on the first paint, and a dataset whose min equals its max would otherwise divide by zero. The ctx object exposes the familiar Canvas2D surface, fillRect, beginPath, moveTo, lineTo, arc, stroke, fillText, fill and stroke styles, fonts, and text alignment, mapped onto the application's native renderer. Redrawing everything at 60 Hz sounds wasteful and is not; simple vector drawing is far below what the renderer can do, and immediate mode is why the code stays this short. There is no scene graph to update, no invalidation logic, no state to keep consistent. The current data comes in, the picture goes out.

Data in, state on the side

Inside the script, datasets is an array of the datasets bound to the widget, each carrying value, min, max, title, and units, so one script scales to however many channels you attach. Drawing wants the current value, but some visuals also need memory: a peak-hold marker, a decaying trail, a scrolling history. For that there is a second, optional hook, onFrame(), which runs when a new frame of data arrives (with timing available via frame.timestampMs). The clean division of labor is the one the bundled scripts use: onFrame updates state in top-level variables, paint only reads them and draws.

Match the theme instead of fighting it

Hardcoded colors look right in one theme and wrong in the other. The script therefore gets a theme object with the active palette: widget_base, alternate_base, widget_border, widget_text, widget_highlight, accent, alarm, placeholder_text. Route every color through it, as the example above does, and your widget follows light mode, dark mode, and any custom theme without a line of per-theme code.

Sharp edges worth knowing

Two practical notes save the most debugging time. First, ctx.arc maps to the native renderer's arc-path primitive, which means a partial arc should be preceded by a moveTo to the arc's start point; otherwise the path connects from wherever it was, and you get a chord you did not ask for. The bundled dial gauge script documents this trick in place. Second, the widget guards itself against runaway scripts: a paint call that has not returned after 250 milliseconds is terminated, and consistently slow paints (over 30 ms) earn a warning first. An infinite loop in your drawing code costs you one widget, not the dashboard, and the incoming data stream never stops behind it.

Start from a template, not from zero

The fastest route is to open one of the eighteen bundled painter templates and bend it toward what you need. The set is a tour of what the API can do: an oscilloscope, a radar sweep with afterglow, an artificial horizon, a heatmap, a segmented VU meter with peak hold, a seven-segment display, a polar plot, an XY scope with a Lissajous mode, sparkline grids, status grids, progress rings. Each is a single readable file built on the same two hooks described here.

The Painter widget is part of Serial Studio Pro. The Painter reference documents the full context API and the template gallery; if your custom widget needs calibrated values rather than raw ones, dataset transforms happen upstream of the widget, so the values arriving in datasets are already the real units.

Comments

Copied to clipboard!