app/rcc/ai/docs/painter_js.md
The Painter widget (Pro) provides a Canvas-style 2D drawing surface. Each
group with widgetType: 8 (GroupWidget::Painter) gets its own painter
script. Bind by calling project.painter.setCode {groupId, code}.
A painter group can be empty (zero datasets). It will then draw using
peer datasets read via datasetGetFinal(uniqueId) and shared registers
read via tableGet(table, register). Don't duplicate datasets just to
feed a painter; see "Reading peer datasets" below.
The script defines one REQUIRED function plus several OPTIONAL ones. Names must match exactly.
paint(ctx, w, h): REQUIRED. Called every UI tick (60 Hz default,
configurable 1–240 via dashboard.setFps) to redraw the canvas, whether
or not new data arrived. This means a painter can animate freely
(clocks, spinners, eased camera moves) without waiting for a frame. ctx
is a Canvas-2D-like context. w and h are the current widget size in
pixels. Treat the canvas as ephemeral; clear what you need at the top of
each call. Because paint runs even when idle, keep it to layout + draw
against cached state; do not put per-frame data logic here.
onFrame(): OPTIONAL. Called once per dashboard update tick
(immediately before the next paint), only when new data arrived, with
no arguments. This is the place to sample fresh dataset values. Read live
values via datasets[i].value (etc.) or via datasetGetFinal(uniqueId)
and cache them in top-level vars. Unlike paint, onFrame does NOT
fire on idle ticks, so per-frame work belongs here, not in paint.
bootstrap() does not exist. Top-level statements at the script's
outer scope run once when the script compiles; that is your "bootstrap".
The function name is paint, not draw, not render. The engine
looks up globalThis.paint by name and reports
missing paint(ctx, w, h) function if it's absent.
The painter receives mouse and wheel input. Define any of these globals to
handle them. Coordinates are in widget pixels (same space as paint's
w/h). Each handler that fires triggers an immediate repaint, so you can
mutate top-level state (camera angle, selection, pan offset) and see it on
screen the same tick. They run under the same watchdog as paint.
onPress(x, y, button) // mouse button down. button: 1=left, 2=right, 4=middle
onDrag(x, y, dx, dy) // mouse moved with a button held; dx/dy = delta since last move
onRelease(x, y) // mouse button up
onMove(x, y) // hover (no button held)
onWheel(x, y, delta) // wheel; delta in notch steps (+up / -down), ~1.0 per notch
onDoubleClick(x, y) // double-click, e.g. to reset a view
Typical pattern: keep view state in top-level vars, mutate it in
onDrag / onWheel, and read it in paint.
var camYaw = 0, camPitch = 0, zoom = 1.0;
function onDrag(x, y, dx, dy) {
camYaw += dx * 0.01;
camPitch += dy * 0.01;
}
function onWheel(x, y, delta) {
zoom *= (1.0 + delta * 0.1);
if (zoom < 0.2) zoom = 0.2;
if (zoom > 5.0) zoom = 5.0;
}
function onDoubleClick(x, y) { camYaw = 0; camPitch = 0; zoom = 1.0; }
function paint(ctx, w, h) {
// ...draw using camYaw / camPitch / zoom...
}
Input handlers fire both on the live dashboard and in the project-editor preview dialog, so you can test interaction while authoring. A painter with no input handlers behaves exactly as before (events pass through).
These are available as soon as your script starts; you don't import them.
// datasets[i] -- read-only proxy. May be empty (length === 0) for a
// painter group with no own datasets. Numeric indexing + .length.
datasets[0].value // numeric current value (post-transform)
datasets[0].rawValue // numeric value before transforms
datasets[0].text // string form of value
datasets[0].rawText // string form of rawValue
datasets[0].title // user-set title
datasets[0].units // user-set units
datasets[0].uniqueId // runtime id
datasets[0].widget // widget hint string ("bar", "gauge", ...)
datasets[0].min // dataset min (any of the *Min hints)
datasets[0].max // dataset max
datasets[0].plotMin / .plotMax // plot Y-axis bounds
datasets[0].widgetMin / .widgetMax // gauge / bar bounds
datasets[0].fftMin / .fftMax // FFT bounds
datasets[0].alarmLow / .alarmHigh // legacy alarm thresholds (derived; see below)
datasets[0].alarmBands // [{min,max,severity,color,label,blink}, ...] (see below)
datasets[0].ledHigh // LED activation threshold
datasets[0].hasPlot / .hasFft / .hasLed
datasets.length
// group -- the host group's project metadata
group.id
group.title
group.columns
group.sourceId
// frame -- the most recent parsed frame's metadata
frame.number // monotonic frame counter
frame.timestampMs
// theme -- ThemeManager palette, always in sync with the active theme.
// PRIMARY keys for painters (these match what the QML dashboard widgets
// already use, so a painter-rendered widget visually fits the rest of the
// dashboard):
theme.widget_base // canvas / card background
theme.widget_border // grid lines, frames, outlines
theme.widget_text // primary value labels and titles
theme.widget_highlight // primary signal / value colour
theme.alarm // red, used for alarm states / second hand / errors
theme.placeholder_text // muted secondary labels
theme.pane_section_label // small caption text
theme.widget_colors // ARRAY of per-channel distinct colors, indexable
// as theme.widget_colors[i % theme.widget_colors.length]
// Other keys also exist (text, window, base, mid, highlight, accent,
// link, error, groupbox_background, ...) but prefer the widget_* ones
// above for painter content -- they're tuned for in-widget rendering.
// console -- log/warn/error to the editor status pane
console.log("...")
console.warn("...")
console.error("...")
// Data-table API (shared with transforms; see "Tables and registers")
tableGet(tableName, registerName) // -> number | string
tableSet(tableName, registerName, value) // user-table writes only
datasetGetRaw(uniqueId) // raw value of any dataset
datasetGetFinal(uniqueId) // final value of any dataset
// Closed-loop control APIs (shared with parsers and transforms)
deviceWrite(data, sourceId?) // -> { ok, error? }
actionFire(actionId) // -> { ok, error? }
// Dashboard helpers (shared with parsers and transforms)
clearPlots() // wipe plot/multiplot/FFT/GPS/3D/waterfall
setPlotPoints(n) // horizontal sample window (n >= 1)
setTerminalVisible(visible) // bool
setNotificationLogVisible(visible) // bool
setClockVisible(visible) // bool
setStopwatchVisible(visible) // bool
setActiveWorkspace(idOrName) // workspaceId (>= 1000) OR title
deviceWrite's default sourceId follows the painter's group sourceId
(updated automatically when the host group changes). actionFire fires a
project Action by its stable actionId. The dashboard helpers all return
{ ok, error? } and never log.
Call these from onFrame(), NOT paint(). paint runs on every
dashboard tick (up to 60 Hz); a write or clearPlots per paint will
saturate the link / yank the plot. onFrame() also runs at UI cadence,
so guard with a state transition or a frame.number change, not a
per-call write.
Bar / Gauge / Meter datasets carry an alarmBands array describing
colored value ranges (aviation-tachometer style: white / green / yellow
/ red). Each entry:
{
min: Number, // lower bound (inclusive)
max: Number, // upper bound (exclusive at top of range)
severity: Number, // 0=Info, 1=OK, 2=Warning, 3=Critical
color: String, // "#rrggbb" override; empty -> severity's theme color
label: String, // optional name (used in notifications)
blink: Boolean // optional; LED panels flash while the band is active
}
Bands may have gaps and may overlap. Severity ≥ Warning posts a notification when the value enters the band (3-second per-dataset cooldown suppresses oscillation spam), even when no widget showing the dataset is visible.
// Find the band a value sits in (linear scan; band counts are tiny)
function bandFor(ds, value) {
if (!ds.alarmBands) return null;
for (var i = 0; i < ds.alarmBands.length; i++) {
var b = ds.alarmBands[i];
if (value >= b.min && value <= b.max) return b;
}
return null;
}
function colorFor(ds, value) {
var b = bandFor(ds, value);
if (!b) return theme.widget_highlight;
if (b.color && b.color.length) return b.color;
// Severity defaults match the QML widgets' theme palette
if (b.severity >= 3) return theme.alarm;
return theme.widget_highlight;
}
Legacy compat. datasets[i].alarmLow and .alarmHigh are still
readable; they're derived from the first / last band with severity ≥
Warning (NaN when no such band exists). Old painter scripts that
predate the band schema keep working unchanged. Prefer alarmBands for
new code; it carries the full color/severity/label intent.
Top-level var is preserved across paint calls; that's how you cache
geometry, gradients, and counters.
Object.defineProperty / Object.freeze your top-level state.
The engine recompiles the script when you save edits and re-evaluation
must be able to reassign the locals.var (not const / let) for anything you intend to mutate
across calls.Painter groups address peer data by uniqueId, not by dataset index inside the painter group. The right pattern:
project.dataset.list (or project.group.list's datasetSummary)
and read the uniqueId of each dataset you want to render. The id is
stable for a given (sourceId, groupId, datasetId) triple.datasetGetFinal(uniqueId) (post-transform) or
datasetGetRaw(uniqueId) (pre-transform) inside paint/onFrame.// Peer-dataset uniqueIds discovered ahead of time via dataset.list.
var SPEED_UID = 10001;
var RPM_UID = 10002;
function paint(ctx, w, h) {
var speed = datasetGetFinal(SPEED_UID) || 0;
var rpm = datasetGetFinal(RPM_UID) || 0;
// ...draw using speed and rpm without owning any datasets in this group
}
Why not duplicate datasets into the painter group? Because every dataset in a project costs a frame-table register, an exporter column, a dashboard row, and a project-file entry. Painters that just READ values should not own them.
Data tables let multiple scripts (transforms, painters) share state. Two register types:
tableGet thereafter returns the same
value. Use for: calibration coefficients, lookup tables, configuration
flags.tableSet from parsers, transforms,
painters, and output widgets. Holds the last value written indefinitely, with no
per-frame reset. The defaultValue is the starting value at
project load. Use for: filter/integrator state, cross-frame counters,
latched flags, and intermediate results another transform reads later
in the same frame.A system-managed table named __datasets__ is always present. It
holds two registers per dataset: raw:<uniqueId> and final:<uniqueId>,
populated by the FrameBuilder. You almost never read it through
tableGet. Use datasetGetRaw(uniqueId) / datasetGetFinal(uniqueId)
instead, which are faster and more legible.
For your own tables:
project.dataTable.add {name: "Calibration"}
project.dataTable.addRegister {table: "Calibration", name: "offset",
computed: false, defaultValue: 0}
project.dataTable.addRegister {table: "Calibration", name: "scale",
computed: false, defaultValue: 1}
then in any script:
var offset = tableGet("Calibration", "offset");
var scale = tableGet("Calibration", "scale");
return raw * scale + offset; // a transform body
Per-frame ordering: transforms run in group-array then dataset-array order, in a single pass. Inside a transform, reads of earlier datasets (raw or final) return this frame's values; reads of later datasets silently return the previous frame's. Painters run AFTER all transforms, so painters see final values for everything.
The Painter renderer requires moveTo BEFORE arc whenever you want the
arc to start a new subpath. Without moveTo, the arc is implicitly
connected to the previous path endpoint and may render a stray chord:
ctx.beginPath();
ctx.moveTo(cx + r, cy); // explicit start point
ctx.arc(cx, cy, r, 0, Math.PI); // arc opens cleanly
ctx.stroke();
For full circles, the order is the same: moveTo to the perimeter, then
arc to 2*Math.PI.
Use the widget_* family for in-painter content so the canvas matches
the rest of the dashboard:
ctx.fillStyle = theme.widget_base; // background
ctx.strokeStyle = theme.widget_border; // grid / frame
ctx.fillStyle = theme.widget_text; // value labels
ctx.fillStyle = theme.widget_highlight; // primary value / signal
ctx.fillStyle = theme.alarm; // alarm / red accent
For multi-channel painters (one color per dataset), index into the
widget_colors array. The dashboard's plot/multiplot widgets use the
same palette:
const palette = theme.widget_colors;
for (let i = 0; i < datasets.length; i++) {
ctx.strokeStyle = palette[i % palette.length];
// ...trace dataset i
}
The theme global stays in sync when the user switches between light
and dark themes. Hard-coded hex breaks that.
Lays out one circular ring per dataset. Demonstrates a square-ish grid, arc + moveTo discipline, and a value/title centre.
function paint(ctx, w, h) {
ctx.fillStyle = theme.widget_base;
ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = theme.widget_border;
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, w - 2, h - 2);
if (datasets.length === 0) return;
var n = datasets.length;
var cols = Math.min(n, Math.max(1, Math.round(Math.sqrt(n * w / h))));
var rows = Math.ceil(n / cols);
var pad = 12;
var cw = (w - pad * 2) / cols;
var ch = (h - pad * 2) / rows;
for (var i = 0; i < n; i++) {
var col = i % cols;
var row = Math.floor(i / cols);
var cx = pad + cw * col + cw * 0.5;
var cy = pad + ch * row + ch * 0.5 - 6;
var rr = Math.max(10, Math.min(cw, ch) * 0.38 - 4);
var ds = datasets[i];
var v = Number.isFinite(ds.value) ? ds.value : 0;
var span = (ds.max - ds.min) || 1;
var norm = Math.max(0, Math.min(1, (v - ds.min) / span));
// Track ring (full circle, the same color the dashboard uses for grid lines).
ctx.strokeStyle = theme.widget_border;
ctx.lineWidth = 12;
ctx.lineCap = "butt";
ctx.beginPath();
ctx.moveTo(cx + rr, cy);
ctx.arc(cx, cy, rr, 0, Math.PI * 2);
ctx.stroke();
// Value arc -- pick a per-channel color from theme.widget_colors so
// multi-channel painters use the same palette as multiplots.
if (norm > 0) {
var startA = -Math.PI * 0.5;
var endA = startA + Math.PI * 2 * norm;
var palette = theme.widget_colors || [theme.widget_highlight];
ctx.strokeStyle = palette[i % palette.length];
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(cx + Math.cos(startA) * rr, cy + Math.sin(startA) * rr);
ctx.arc(cx, cy, rr, startA, endA);
ctx.stroke();
ctx.lineCap = "butt";
}
// Centre value.
ctx.fillStyle = theme.widget_text;
ctx.font = "bold 18px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText((norm * 100).toFixed(0) + "%", cx, cy);
// Title under the ring.
ctx.fillStyle = theme.placeholder_text || theme.widget_text;
ctx.font = "10px sans-serif";
ctx.textBaseline = "alphabetic";
ctx.fillText((ds.title || "").substring(0, 16), cx, cy + rr + 18);
}
ctx.textAlign = "start";
ctx.textBaseline = "alphabetic";
}
Demonstrates a painter group with datasets.length === 0. The script
reads peer datasets by uniqueId (discovered ahead of time via
project.dataset.list) and renders a coloured status tile per channel.
// Peer dataset uniqueIds, discovered via project.dataset.list. Each
// constant is set once at script load; edit the script if your project
// layout changes.
var CHANNELS = [
{ uid: 10001, label: "Voltage", warn: 11.0, alarm: 10.5 },
{ uid: 10002, label: "Current", warn: 8.0, alarm: 9.5 },
{ uid: 10003, label: "Temp", warn: 70, alarm: 85 },
{ uid: 10004, label: "Pressure", warn: 110, alarm: 130 }
];
function tileColor(value, c) {
if (!Number.isFinite(value)) return theme.widget_border;
if (value >= c.alarm) return theme.alarm;
if (value >= c.warn) return theme.accent;
return theme.widget_highlight;
}
function paint(ctx, w, h) {
ctx.fillStyle = theme.widget_base;
ctx.fillRect(0, 0, w, h);
var pad = 8;
var cols = 2;
var rows = Math.ceil(CHANNELS.length / cols);
var cw = (w - pad * (cols + 1)) / cols;
var ch = (h - pad * (rows + 1)) / rows;
for (var i = 0; i < CHANNELS.length; i++) {
var c = CHANNELS[i];
var x = pad + (i % cols) * (cw + pad);
var y = pad + Math.floor(i / cols) * (ch + pad);
var v = datasetGetFinal(c.uid);
ctx.fillStyle = tileColor(v, c);
ctx.fillRect(x, y, cw, ch);
ctx.fillStyle = theme.highlighted_text || theme.widget_text;
ctx.font = "bold 14px sans-serif";
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.fillText(c.label, x + 8, y + 8);
ctx.font = "bold 22px sans-serif";
ctx.textBaseline = "bottom";
var label = Number.isFinite(v) ? v.toFixed(1) : "—";
ctx.fillText(label, x + 8, y + ch - 8);
}
ctx.textBaseline = "alphabetic";
ctx.textAlign = "start";
}
Demonstrates a painter that uses NO dataset at all and just renders
something useful from frame.timestampMs.
function paint(ctx, w, h) {
ctx.fillStyle = theme.widget_base;
ctx.fillRect(0, 0, w, h);
var cx = w * 0.5;
var cy = h * 0.5;
var r = Math.min(w, h) * 0.42;
// Dial face.
ctx.fillStyle = theme.alternate_base || theme.widget_base;
ctx.beginPath();
ctx.moveTo(cx + r, cy);
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = theme.widget_border;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(cx + r, cy);
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
// Hour numerals.
ctx.fillStyle = theme.widget_text;
ctx.font = "bold 14px serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (var i = 1; i <= 12; i++) {
var ang = (i / 12) * Math.PI * 2 - Math.PI / 2;
ctx.fillText("" + i,
cx + Math.cos(ang) * r * 0.82,
cy + Math.sin(ang) * r * 0.82);
}
// Hands from wall-clock time.
var now = new Date(frame.timestampMs || Date.now());
var sec = now.getSeconds();
var min = now.getMinutes() + sec / 60;
var hour = (now.getHours() % 12) + min / 60;
function hand(angle, length, width, color) {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.cos(angle) * length, cy + Math.sin(angle) * length);
ctx.stroke();
}
hand((hour / 12) * Math.PI * 2 - Math.PI / 2, r * 0.50, 6, theme.widget_text);
hand((min / 60) * Math.PI * 2 - Math.PI / 2, r * 0.72, 4, theme.widget_text);
hand((sec / 60) * Math.PI * 2 - Math.PI / 2, r * 0.85, 1, theme.alarm);
ctx.lineCap = "butt";
ctx.textAlign = "start";
ctx.textBaseline = "alphabetic";
}
The Pro build ships ~18 reference painter scripts. Read them with:
scripts.list {kind: "painter"} -> [{id, name, datasetCount}, ...]
scripts.get {kind: "painter", id: "<id>"} -> {body: "<full source>"}
Adapt the closest match instead of writing from scratch.
The ctx argument behaves like a browser CanvasRenderingContext2D. The
list below is exhaustive: anything not listed is not implemented and
calling it from your script will throw or no-op.
Style state
fillStyle, strokeStyle: accept a CSS color string OR a gradient
object OR a pattern object (see "Gradients & patterns" below).lineWidth, lineCap ("butt" / "round" / "square"),
lineJoin ("miter" / "round" / "bevel"), miterLimit.setLineDash([on, off, ...]), getLineDash(), lineDashOffset.globalAlpha (clamped to [0, 1]).globalCompositeOperation: source-over (default), source-in, source-out,
source-atop, destination-over, destination-in, destination-out,
destination-atop, lighter, copy, xor, multiply, screen, overlay, darken,
lighten, color-dodge, color-burn, hard-light, soft-light, difference,
exclusion.shadowColor, shadowBlur, shadowOffsetX, shadowOffsetY.
Applied to fill, stroke, fillRect, strokeRect, fillText,
strokeText. A box-blur approximation is used for shadowBlur > 0;
large blur radii are expensive, so keep shadows out of per-frame
hotpaths if you can.imageSmoothingEnabled, imageSmoothingQuality ("low" / "medium" /
"high"; Qt has only one filter so quality is recorded for parity but not
switched).Paths
beginPath, closePath, moveTo, lineTo, rect,
roundRect(x, y, w, h, radii) (radii: number, [r], [tl, tr], [tl, tr, br],
or [tl, tr, br, bl]), arc(x, y, r, start, end, ccw?),
arcTo(x1, y1, x2, y2, r),
ellipse(x, y, rx, ry, rotation, start, end, ccw?),
quadraticCurveTo, bezierCurveTo.fill, stroke, clip, isPointInPath(x, y),
isPointInStroke(x, y).Rectangles
fillRect, strokeRect, clearRect.Transforms
save, restore.translate, rotate(rad), scale(sx, sy).transform(a, b, c, d, e, f) (multiply current matrix),
setTransform(a, b, c, d, e, f) (replace),
resetTransform().getTransform() returns {a, b, c, d, e, f}.Text
font (CSS-like shorthand: "12px sans-serif", "bold 14px Arial",
"italic 700 16px monospace"). Generic families (sans-serif, serif,
monospace) map to the dashboard's CommonFonts.textAlign ("start" / "end" / "left" / "right" / "center"),
textBaseline ("top" / "hanging" / "middle" / "alphabetic" / "bottom").fillText(text, x, y), strokeText(text, x, y).measureText(s) returns { width, actualBoundingBoxAscent, actualBoundingBoxDescent, fontBoundingBoxAscent, fontBoundingBoxDescent }.measureTextWidth(s) is a fast shortcut returning just the width.Images
drawImage(src, x, y) draws unscaled; a 5-arg drawImage call silently
ignores w/h. The scaled form is the separate function
drawImageScaled(src, x, y, w, h). src must be
a qrc:/ resource OR a path inside the project file's directory tree.
URLs (http://, https://, etc.) are rejected for sandbox reasons.createPattern(src, repetition) where repetition is "repeat" (default),
"repeat-x", "repeat-y", or "no-repeat". Bind the returned pattern via
ctx.fillStyle = pattern.Gradients
createLinearGradient(x0, y0, x1, y1) -> gradient.createRadialGradient(x0, y0, r0, x1, y1, r1) -> gradient.createConicGradient(startRadians, cx, cy) -> gradient.gradient.addColorStop(offset, color) (offset clamped to [0, 1]).ctx.fillStyle = gradient or ctx.strokeStyle = gradient.var.Geometry
ctx.width(), ctx.height() (methods) mirror the w / h arguments
to paint.Not supported (will throw or no-op)
drawImage from network URLs.putImageData, getImageData, createImageData.drawFocusIfNeeded, scrollPathIntoView.filter (CSS filter strings).Uint8ClampedArray, which QJSEngine cannot bridge cheaply.Gradient/pattern example:
var g = null;
function paint(ctx, w, h) {
if (!g) {
g = ctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, theme.widget_highlight);
g.addColorStop(0.5, theme.accent);
g.addColorStop(1, theme.alarm);
}
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
ctx.shadowColor = "rgba(0,0,0,0.45)";
ctx.shadowBlur = 8;
ctx.shadowOffsetY = 2;
ctx.fillStyle = theme.widget_text;
ctx.font = "bold 24px sans-serif";
ctx.fillText("Live", 12, 32);
ctx.shadowBlur = 0;
ctx.shadowColor = "rgba(0,0,0,0)";
}
paint runs at up to 60 Hz by default (configurable 1–240 via
dashboard.setFps), and only while dashboard data is updating. Cheap:
lineTo / arc / fillText calls,
arithmetic. Avoid: building intermediate strings every frame
(toFixed(1) is fine; JSON.stringify is not), creating new gradients
per call (cache in a top-level var).
Throwing inside paint logs a watchdog warning and the canvas keeps the
last successful frame on screen. The error surfaces in the editor's
status pane.