flow: structured dtypes, and the widgets that read them

A series, record or list message declares its shape instead of riding
DType.JSON, so a widget binds a shape rather than some JSON and a wrong
binding is refused before anything runs. A list declares its item type,
which is what keeps list[float] expressible for a pipeline.

On top of that: an agenda over a list, a notification over a record, and
a dashboard alert channel that publishes engine faults as one — so a
panel can show what went wrong without a flow wiring it by hand.

Also: only None means a node published nothing, a falsy value of the
wrong shape is now the named error it always should have been; and the
gauge's readout says its size is viewBox geometry rather than type scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 15:06:45 +02:00
co-authored by Claude Opus 5
parent 18837e8880
commit 413501c6ce
16 changed files with 751 additions and 43 deletions
+20 -7
View File
@@ -58,6 +58,8 @@ export function UplotChart({
labels,
plots,
empty = "Nothing has come through yet.",
unit,
yRange,
onCursor,
onSelect,
}: {
@@ -66,6 +68,10 @@ export function UplotChart({
/** The points of each series, in the same order as `labels`. */
plots: HistoryPoint[][]
empty?: string
/** Written after every reading, on the axis and in the legend. */
unit?: string
/** A y axis fixed to these bounds; unset lets it follow the data. */
yRange?: [number, number]
/** The x value under the pointer, and null once it leaves the plot. */
onCursor?: (ts: number | null) => void
/** The x value clicked, or null for a click that landed on no point. */
@@ -85,8 +91,9 @@ export function UplotChart({
const points = plots.reduce((total, plot) => total + plot.length, 0)
// The identity of the series set: the chart is rebuilt when it changes,
// while a new reading only sets its data.
const key = labels.join(" ")
// while a new reading only sets its data. The unit and the fixed range are
// part of it — both are baked into the axes when the chart is built.
const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}`
// uPlot leaves its axes half-initialised while the scales have no range, and
// a resize in that window (a card still settling, say) draws them anyway and
// throws. Waiting for the first reading avoids the state altogether.
@@ -139,16 +146,21 @@ export function UplotChart({
},
],
},
scales: { x: { time: true } },
scales: {
x: { time: true },
...(yRange ? { y: { range: yRange } } : {}),
},
axes: [
{ ...axis, size: 28 },
{
...axis,
size: 46,
// The unit is written after every tick, so the gutter widens to
// hold it rather than clipping the number in front of it.
size: unit ? 62 : 46,
// The gutter has a fixed width, so a grouped "15,000" would be
// clipped to something that reads as a different number entirely.
values: (_self: uPlot, ticks: number[]) =>
ticks.map((value) => si(value)),
ticks.map((value) => (unit ? `${si(value)} ${unit}` : si(value))),
},
],
series: [
@@ -160,8 +172,9 @@ export function UplotChart({
// rebuilt chart.
stroke: () => seriesColor(index),
// The cursor readout is what decides how wide the legend gets, so
// it is shortened here and the unit named in the card's title.
value: (_self: uPlot, raw: number) => si(raw),
// it is shortened here; a named unit is short enough to keep.
value: (_self: uPlot, raw: number) =>
unit ? `${si(raw)} ${unit}` : si(raw),
// Series arrive on their own clocks; a joined table is mostly
// holes, and a line with a hole per point is not a line.
spanGaps: true,