Add a colour-wheel widget to the dashboard

A custom hue ring — a conic gradient, not a canvas — with saturation and
brightness sliders beside or under it depending on the tile's shape, sized
for a wall panel and reachable from a keyboard. It publishes [h, s, v] by
default, which is what the reference installation's DMX encoders read, and
`format` switches that to [r, g, b] or "#rrggbb".

`usePublish` moves to its own module so a widget in a file of its own can
reach it without importing `widgets.tsx` back.
This commit is contained in:
2026-08-22 12:50:59 +02:00
parent 3cc2d353cd
commit 9927577cec
13 changed files with 804 additions and 101 deletions
+15 -2
View File
@@ -3138,7 +3138,7 @@ export const WidgetDefSchema = {
},
type: {
type: 'string',
enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'button', 'switch', 'slider', 'input', 'dropdown'],
enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'button', 'switch', 'slider', 'input', 'dropdown', 'color'],
title: 'Type'
},
title: {
@@ -3173,7 +3173,20 @@ message. One with \`\`source: "query"\`\` asks instead, and its config is
\`\`{source, request, request_dtype: "record", message, dtype: "series",
refresh_s, range_s}\`\`: it publishes \`\`{range_s, interval_s}\`\` to
\`\`request\`\` exactly as a slider publishes a value, and draws the \`\`series\`\`
a flow answers with on \`\`message\`\`.`
a flow answers with on \`\`message\`\`.
A colour widget picks what it publishes with \`\`format\`\`, because fixtures
differ and a change node per tile is not the answer:
- \`\`hsv\`\` (the default) — \`\`[h, s, v]\`\`, hue 0-360 degrees, saturation and
value 0-100 percent. What the Node-RED installation this ports feeds its
3CH/4CH DMX encoders, which divide by 360 and by 100.
- \`\`rgb\`\`\`\`[r, g, b]\`\`, each 0-255. The conventional range; the
reference's own encoders produce it after converting.
- \`\`hex\`\`\`\`"#rrggbb"\`\`, lowercase. Conventional likewise.
The first two are a \`\`list\`\` message, the third a \`\`str\`\`, which is what
\`\`COLOR_DTYPES\`\` records and the check below holds a binding to.`
} as const;
export const WorkerInfoSchema = {
+15 -2
View File
@@ -1041,10 +1041,23 @@ export type ValidationResult = {
* refresh_s, range_s}``: it publishes ``{range_s, interval_s}`` to
* ``request`` exactly as a slider publishes a value, and draws the ``series``
* a flow answers with on ``message``.
*
* A colour widget picks what it publishes with ``format``, because fixtures
* differ and a change node per tile is not the answer:
*
* - ``hsv`` (the default) — ``[h, s, v]``, hue 0-360 degrees, saturation and
* value 0-100 percent. What the Node-RED installation this ports feeds its
* 3CH/4CH DMX encoders, which divide by 360 and by 100.
* - ``rgb`` — ``[r, g, b]``, each 0-255. The conventional range; the
* reference's own encoders produce it after converting.
* - ``hex`` — ``"#rrggbb"``, lowercase. Conventional likewise.
*
* The first two are a ``list`` message, the third a ``str``, which is what
* ``COLOR_DTYPES`` records and the check below holds a binding to.
*/
export type WidgetDef = {
id: string;
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
title?: string;
layout?: {
[key: string]: Placement;
@@ -1054,7 +1067,7 @@ export type WidgetDef = {
};
};
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color';
export type WorkerInfo = {
name: string;
@@ -28,7 +28,14 @@ const PREVIEWS = 8
/** Widgets that draw a shape, and widgets that are controls. The rest read out. */
const GRAPHIC = new Set(["chart", "forecast", "bar", "gauge"])
const INPUT = new Set(["button", "switch", "slider", "input", "dropdown"])
const INPUT = new Set([
"button",
"switch",
"slider",
"input",
"dropdown",
"color",
])
const shade = (type: string) =>
INPUT.has(type)
@@ -0,0 +1,319 @@
import { useState } from "react"
import type { WidgetDef } from "@/client"
// The wheel's own sizing rule lives beside the other widget CSS.
import "./dashboard.css"
import { usePublish } from "./publish"
import type { WidgetProps } from "./widgets"
/** Three numbers: a colour in whichever of the two triples is meant. */
export type Triple = [number, number, number]
export type ColorFormat = "hsv" | "rgb" | "hex"
/**
* What each format puts on the wire, by payload type.
*
* Mirrored on the server (`COLOR_DTYPES` in `app/flow/dashboards.py`), which
* refuses a binding the format cannot carry.
*/
export const COLOR_DTYPES: Record<ColorFormat, string> = {
hsv: "list",
rgb: "list",
hex: "str",
}
/** The formats, as the editor offers them. */
export const COLOR_FORMATS = [
["hsv", "HSV"],
["rgb", "RGB"],
["hex", "Hex"],
] as const
/** Which format this widget sends. Anything unrecorded is the default. */
export function colorFormatOf(widget: WidgetDef): ColorFormat {
const format = widget.config?.format
return format === "rgb" || format === "hex" ? format : "hsv"
}
/** How far one arrow key moves the hue. A degree at a time is 360 presses. */
const HUE_STEP = 5
/** Nothing published yet: white at full brightness, which is a lamp that is on. */
const UNSET: Triple = [0, 0, 100]
/** Middle of the ring, in percent of the wheel, where the handle rides. */
const RING_RADIUS = 39
/**
* The hue ring, as one CSS gradient rather than a canvas repainted per frame.
*
* `from 0deg` starts at twelve o'clock and runs clockwise, which is the frame
* the pointer and keyboard maths below share. These are the only colours in
* this file that are not tokens, deliberately: a hue wheel paints the value it
* publishes rather than the palette (root DESIGN-GUIDELINES.md → Colour).
*/
const HUE_RING = `conic-gradient(from 0deg, ${[0, 60, 120, 180, 240, 300, 360]
.map((hue) => `hsl(${hue} 100% 50%)`)
.join(", ")})`
const clamp = (value: number, high: number) =>
Math.min(high, Math.max(0, Math.round(value)))
/** A hue is an angle: 370 degrees is 10, and -10 is 350. */
const wrap = (hue: number) => ((Math.round(hue) % 360) + 360) % 360
/**
* HSV to RGB — the same conversion the reference's DMX encoders do, so a
* fixture wired to `rgb` gets what one wired to `hsv` works out for itself.
*
* Hue 0-360 degrees, saturation and value 0-100 percent in; three 0-255
* channels out.
*/
export function hsvToRgb([hue, saturation, value]: Triple): Triple {
const level = clamp(value, 100) / 100
const chroma = level * (clamp(saturation, 100) / 100)
const sector = (((hue % 360) + 360) % 360) / 60
const second = chroma * (1 - Math.abs((sector % 2) - 1))
const base = level - chroma
const [red, green, blue] =
sector < 1
? [chroma, second, 0]
: sector < 2
? [second, chroma, 0]
: sector < 3
? [0, chroma, second]
: sector < 4
? [0, second, chroma]
: sector < 5
? [second, 0, chroma]
: [chroma, 0, second]
const channel = (part: number) => Math.round((part + base) * 255)
return [channel(red), channel(green), channel(blue)]
}
/** The way back, for a colour some flow set rather than this wheel. */
export function rgbToHsv(rgb: Triple): Triple {
const [red, green, blue] = rgb.map((channel) => clamp(channel, 255) / 255)
const high = Math.max(red, green, blue)
const spread = high - Math.min(red, green, blue)
let hue = 0
if (spread) {
hue =
high === red
? ((green - blue) / spread) % 6
: high === green
? (blue - red) / spread + 2
: (red - green) / spread + 4
hue = (hue * 60 + 360) % 360
}
return [
Math.round(hue),
Math.round(high ? (spread / high) * 100 : 0),
Math.round(high * 100),
]
}
const toHex = (rgb: Triple) =>
`#${rgb.map((channel) => clamp(channel, 255).toString(16).padStart(2, "0")).join("")}`
const fromHex = (text: string): Triple | null => {
const digits = /^#?([0-9a-f]{6})$/i.exec(text)?.[1]
if (!digits) return null
const at = (index: number) =>
Number.parseInt(digits.slice(index * 2, index * 2 + 2), 16)
return [at(0), at(1), at(2)]
}
/** What this control publishes, in the format its config picked. */
export function encodeColor(hsv: Triple, format: ColorFormat): unknown {
if (format === "hsv") return hsv
const rgb = hsvToRgb(hsv)
return format === "rgb" ? rgb : toHex(rgb)
}
/**
* What came back over the socket, as the wheel's own three numbers.
*
* Null for anything that is not a colour in this format — nothing published
* yet, or a flow that answered with something else.
*/
export function decodeColor(
value: unknown,
format: ColorFormat,
): Triple | null {
if (format === "hex") {
const rgb = typeof value === "string" ? fromHex(value) : null
return rgb && rgbToHsv(rgb)
}
if (!Array.isArray(value) || value.length < 3) return null
const [first, second, third] = value.slice(0, 3).map(Number)
if (![first, second, third].every(Number.isFinite)) return null
if (format === "rgb") return rgbToHsv([first, second, third])
return [wrap(first), clamp(second, 100), clamp(third, 100)]
}
/** The colour a set of three makes, for the swatch and the handle. */
const cssOf = (hsv: Triple) => `rgb(${hsvToRgb(hsv).join(" ")})`
/** Where the handle sits: hue as an angle, clockwise from the top. */
const handleAt = (hue: number) => ({
left: `${50 + RING_RADIUS * Math.sin((hue * Math.PI) / 180)}%`,
top: `${50 - RING_RADIUS * Math.cos((hue * Math.PI) / 180)}%`,
})
/** One of the two components under the wheel, named and with its reading. */
function Level({
label,
value,
onChange,
onCommit,
}: {
label: string
value: number
onChange: (value: number) => void
onCommit: () => void
}) {
return (
<label className="grid gap-1">
<span className="flex items-baseline justify-between text-xs text-muted-foreground">
{label}
<span className="tabular-nums">{value}%</span>
</span>
<input
type="range"
min={0}
max={100}
value={value}
className="h-11 w-full accent-[var(--primary)] md:h-8"
onChange={(event) => onChange(Number(event.target.value))}
// Only the release publishes, as the slider widget does: a drag would
// otherwise send a value per pixel and flood whatever is listening.
onPointerUp={onCommit}
onKeyUp={onCommit}
/>
</label>
)
}
/**
* A colour, set on a wheel and published as one message.
*
* The ring is a conic gradient rather than a canvas, so moving the handle
* repaints nothing. Sized for a finger — the ring is roughly a fifth of the
* wheel wide and the sliders keep their 44px target — and reachable without
* one: the ring is a slider in its own right, with arrow keys on the hue and
* two labelled sliders under it.
*
* ponytail: the ring reads the angle only, never how far from the centre the
* finger is, so saturation stays a slider rather than the radius of a disc.
* The ceiling is a colour set in one gesture; a disc would put two values on a
* control that can announce one, and neither of them on a keyboard.
*/
export function ColorWidget({ widget, dashboard }: WidgetProps) {
const { target, value, send, pulse } = usePublish(widget, dashboard)
// While dragging, the wheel follows the finger rather than the engine.
const [draft, setDraft] = useState<Triple | null>(null)
if (!target)
return <p className="text-sm text-muted-foreground">Pick a message.</p>
const format = colorFormatOf(widget)
const current = draft ?? decodeColor(value, format) ?? UNSET
const [hue, saturation, brightness] = current
const name = widget.title || target
const commit = () => {
if (draft === null) return
send(encodeColor(draft, format))
setDraft(null)
}
/** The hue under the pointer: where it is relative to the wheel's centre. */
const aim = (event: React.PointerEvent<HTMLDivElement>) => {
const box = event.currentTarget.getBoundingClientRect()
const x = event.clientX - (box.left + box.width / 2)
const y = event.clientY - (box.top + box.height / 2)
const degrees = (Math.atan2(y, x) * 180) / Math.PI + 90
setDraft([wrap(degrees), saturation, brightness])
}
return (
// The pulse hangs off the frame, so it stays outside every box below:
// both the wheel's and the tile's own are query containers, and a
// container is a containing block for anything absolute inside it.
<>
{pulse}
<div className="widget-color min-h-0 flex-1">
{/* Wheel above the components, or beside them once the tile is wider
than it is tall — the shape a wall panel's rows usually are. */}
<div className="widget-color-body flex h-full min-h-0 flex-col gap-2">
<div className="widget-wheel-box min-h-0 flex-1">
{/* A ring is not a range input and a native one cannot be bent into a
circle, so it says what it is and answers the same keys. */}
<div
role="slider"
tabIndex={0}
aria-label={`${name} hue`}
aria-valuemin={0}
aria-valuemax={359}
aria-valuenow={hue}
aria-valuetext={`${hue} degrees`}
data-testid="color-wheel"
className="widget-wheel relative mx-auto touch-none rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
style={{ background: HUE_RING }}
onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId)
aim(event)
}}
onPointerMove={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId))
aim(event)
}}
onPointerUp={commit}
onKeyDown={(event) => {
const step =
event.key === "ArrowRight" || event.key === "ArrowUp"
? HUE_STEP
: event.key === "ArrowLeft" || event.key === "ArrowDown"
? -HUE_STEP
: 0
if (!step) return
event.preventDefault()
setDraft([wrap(hue + step), saturation, brightness])
}}
onKeyUp={commit}
>
{/* What the three components add up to, drawn where a wheel is
usually read: in the middle. */}
<span
aria-hidden
data-testid="color-swatch"
className="pointer-events-none absolute inset-[22%] rounded-full border-4 border-card"
style={{ background: cssOf(current) }}
/>
<span
aria-hidden
className="pointer-events-none absolute size-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card shadow-e1"
style={{ ...handleAt(hue), background: cssOf([hue, 100, 100]) }}
/>
</div>
</div>
<div className="widget-color-levels grid gap-2">
<Level
label="Saturation"
value={saturation}
onChange={(next) => setDraft([hue, next, brightness])}
onCommit={commit}
/>
<Level
label="Brightness"
value={brightness}
onChange={(next) => setDraft([hue, saturation, next])}
onCommit={commit}
/>
</div>
</div>
</div>
</>
)
}
@@ -0,0 +1,79 @@
/**
* The colour conversions, checked.
*
* ponytail: a script rather than a suite. The frontend's only runner is
* Playwright, and a wheel's arithmetic does not need a browser — so this is
* plain asserts, run by hand or from a review:
*
* cd frontend && bun run src/components/Dashboard/color.check.ts
*
* It is typechecked with the rest of `src` and imported by nothing, so it is
* not in the bundle. Move it into a real suite the day the frontend gets one.
*/
import assert from "node:assert/strict"
import {
decodeColor,
encodeColor,
hsvToRgb,
rgbToHsv,
type Triple,
} from "./ColorWidget"
/** The primaries, plus the two corners a conversion usually gets wrong. */
const KNOWN: [Triple, Triple, string][] = [
[[0, 100, 100], [255, 0, 0], "#ff0000"],
[[120, 100, 100], [0, 255, 0], "#00ff00"],
[[240, 100, 100], [0, 0, 255], "#0000ff"],
[[60, 100, 100], [255, 255, 0], "#ffff00"],
[[180, 100, 100], [0, 255, 255], "#00ffff"],
[[300, 100, 100], [255, 0, 255], "#ff00ff"],
// No saturation is white at full value and black at none, whatever the hue.
[[210, 0, 100], [255, 255, 255], "#ffffff"],
[[210, 100, 0], [0, 0, 0], "#000000"],
// Half-lit and unsaturated: the grey a value slider at 50% should give.
[[0, 0, 50], [128, 128, 128], "#808080"],
// Amber, the seed's own starting colour.
[[38, 72, 80], [204, 150, 57], "#cc9639"],
]
for (const [hsv, rgb, hex] of KNOWN) {
assert.deepEqual(hsvToRgb(hsv), rgb, `hsv ${hsv} -> rgb`)
assert.equal(encodeColor(hsv, "hex"), hex, `hsv ${hsv} -> hex`)
assert.deepEqual(encodeColor(hsv, "rgb"), rgb, `hsv ${hsv} -> rgb payload`)
assert.deepEqual(encodeColor(hsv, "hsv"), hsv, "hsv is published as it is")
}
// Round trip, on every hue the wheel can stop on. A colour that survives
// hsv -> rgb -> hsv is one a flow can set and the wheel still draw.
for (let hue = 0; hue < 360; hue += 5) {
for (const [saturation, value] of [
[100, 100],
[72, 80],
[40, 60],
]) {
const hsv: Triple = [hue, saturation, value]
const back = rgbToHsv(hsvToRgb(hsv))
assert.ok(
Math.abs(back[0] - hue) <= 1 &&
Math.abs(back[1] - saturation) <= 1 &&
Math.abs(back[2] - value) <= 1,
`round trip ${hsv} came back as ${back}`,
)
}
}
// What arrives from the engine, in each format.
assert.deepEqual(decodeColor([38, 72, 80], "hsv"), [38, 72, 80])
assert.deepEqual(decodeColor([204, 150, 57], "rgb"), [38, 72, 80])
assert.deepEqual(decodeColor("#cc9639", "hex"), [38, 72, 80])
// Hue wraps rather than clamps; the rest is held to its range.
assert.deepEqual(decodeColor([370, 120, -5], "hsv"), [10, 100, 0])
// Nothing published yet, or an answer that is not a colour at all.
for (const wrong of [undefined, null, "amber", [1, 2], ["a", "b", "c"]]) {
assert.equal(decodeColor(wrong, "hsv"), null, `${JSON.stringify(wrong)}`)
}
assert.equal(decodeColor("#nothex", "hex"), null)
console.log("colour conversions ok")
@@ -90,6 +90,51 @@
box-shadow: inset 0 0 0 2px var(--primary);
}
/*
* The colour wheel, sized to whatever tile it was put on.
*
* `min(100cqw, 100cqh)` is what keeps one square inside a box of any shape
* without measuring anything in JS — the box is the query container, so the
* wheel reads its height as well as its width. The ring itself is a conic
* gradient set on the element, so nothing here repaints per frame.
*/
.widget-wheel-box {
container-type: size;
}
.widget-wheel {
width: min(100cqw, 100cqh);
aspect-ratio: 1;
}
/* The tile is its own query container, so the widget can be laid out by the
shape it was given rather than by the viewport — a wall panel's rows are
often wider than they are tall, and a wheel stacked above two sliders in one
of those is a dot. A container cannot answer a query about itself, which is
what the inner `-body` is for. */
.widget-color {
container-type: size;
}
@container (min-aspect-ratio: 3 / 2) {
.widget-color-body {
flex-direction: row;
align-items: center;
}
/* Square by its height, taken from the tile: the wheel keeps whatever room
the row has and the components take the rest of the width. */
.widget-color-body > .widget-wheel-box {
flex: none;
width: 100cqh;
}
.widget-color-levels {
flex: 1;
min-width: 0;
}
}
/*
* Motion. A value settling is a neutral state change; a selection indicator
* moving is emphasized (Material). `<MotionConfig reducedMotion="user">` only
@@ -36,6 +36,7 @@ import {
import { cn } from "@/lib/utils"
import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget"
import { MAX_SERIES, refreshFor } from "./ChartWidget"
import { COLOR_DTYPES, COLOR_FORMATS, colorFormatOf } from "./ColorWidget"
import {
CANVAS_PRESETS,
COLUMN_CHOICES,
@@ -435,6 +436,15 @@ export function WidgetPanel({
value={str(cfg[isInput ? "target" : "message"])}
label={isInput ? "Publishes to" : "Shows"}
testId="widget-message"
// A colour widget may bind either shape, and the format is what
// decides which of the two — so it filters rather than the type.
filter={
widget.type === "color"
? (message) =>
message.dtype === COLOR_DTYPES[colorFormatOf(widget)] &&
message.writable !== false
: undefined
}
onPick={(message, dtype) =>
set({ [isInput ? "target" : "message"]: message, dtype })
}
@@ -867,6 +877,36 @@ export function WidgetPanel({
</div>
) : null}
{widget.type === "color" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Sends</Label>
<Segmented
value={colorFormatOf(widget)}
options={COLOR_FORMATS}
label="What this wheel publishes"
testId="widget-format"
onChange={(format) =>
set({
format,
// Both triples are a `list` and hex is a `str`, so a change
// between the two shapes takes the binding with it rather
// than leaving a message this control can no longer carry.
...(COLOR_DTYPES[format as keyof typeof COLOR_DTYPES] ===
str(cfg.dtype)
? {}
: { target: "", dtype: undefined }),
})
}
/>
<p className="text-xs text-muted-foreground">
HSV is{" "}
<span className="font-mono">[h 0-360, s 0-100, v 0-100]</span>,
RGB <span className="font-mono">[r, g, b]</span> 0-255, Hex{" "}
<span className="font-mono">"#rrggbb"</span>.
</p>
</div>
) : null}
{widget.type === "switch" || widget.type === "dropdown" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Style</Label>
@@ -0,0 +1,103 @@
import { useEffect, useState } from "react"
import type { ApiError, WidgetDef } from "@/client"
import { useLiveValue } from "@/components/Flow/liveStore"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
// The transmit overlay's rule lives beside the other widget CSS, and CSS is
// chunked per entry — so the sheet is pulled in wherever the pulse is drawn.
import "./dashboard.css"
import { usePublishMessage } from "./queries"
/**
* How long a control shows what it sent before falling back to the engine.
*
* ponytail: a flat 3 s rather than anything the engine tells us. A publish the
* server takes but nothing ever echoes — a message no flow consumes — would
* otherwise leave the tile holding a value that is not the truth, forever.
*/
const HOLD_MS = 3000
/** Whether what came back over the socket is what this control sent. */
function confirms(live: unknown, sent: unknown): boolean {
// Readings are scalars nearly always; a dropdown may carry a record and a
// colour wheel an array, and comparing those as text is cheaper than a deep
// walk for the same answer.
return live === sent || JSON.stringify(live) === JSON.stringify(sent)
}
/**
* Publishing, with the value shown as sent until the engine confirms it.
*
* A control publishes over HTTP and reads the result back over the socket, so
* between the two the live value is still the old one — a handle let go of
* would snap back to it. The hold ends when the echo matches, when the publish
* is refused, or on `HOLD_MS`; success is silent, because the echo is the
* confirmation.
*
* Its own module rather than `widgets.tsx`, which every widget file is
* imported *by*: a control drawn in a file of its own can only reach this
* without closing that circle if it does not sit there.
*/
export function usePublish(widget: WidgetDef, dashboard: string) {
const cfg = (widget.config ?? {}) as Record<string, unknown>
const target = cfg.target == null ? "" : String(cfg.target)
const publish = usePublishMessage()
const live = useLiveValue(target || undefined)
const { showErrorToast } = useCustomToast()
// Boxed: holding `false` or `null` is not the same as holding nothing.
const [held, setHeld] = useState<{ value: unknown } | null>(null)
useEffect(() => {
if (!held) return
const timer = setTimeout(() => setHeld(null), HOLD_MS)
return () => clearTimeout(timer)
}, [held])
useEffect(() => {
if (held && confirms(live?.value, held.value)) setHeld(null)
}, [held, live])
return {
target,
/** What the control draws: what it sent, until the engine answers. */
value: held ? held.value : live?.value,
send: (value: unknown) => {
if (!target) return
setHeld({ value })
publish.mutate(
{
name: target,
value,
dashboard,
widget: widget.id,
label: widget.title || widget.id,
kind: widget.type,
},
{
onError: (error) => {
// Back to the engine's own value, and say which message refused it
// — a panel showing several controls cannot tell them apart.
setHeld(null)
handleError.call(
(detail: string) => showErrorToast(`${target}: ${detail}`),
error as ApiError,
)
},
},
)
},
/**
* The in-flight pulse, drawn over the whole tile.
*
* Absolutely positioned and inert, so it neither resizes the widget nor
* moves anything around it. Every control renders it; the frame is what it
* hangs off, which is why it must not be put inside a child that positions
* itself.
*/
pulse: publish.isPending ? (
<span aria-hidden className="widget-transmit" />
) : null,
pending: publish.isPending,
}
}
+18 -93
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"
import { useState } from "react"
import type { ApiError, WidgetDef } from "@/client"
import type { WidgetDef } from "@/client"
import { useLiveValue } from "@/components/Flow/liveStore"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -18,18 +18,17 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import useCustomToast from "@/hooks/useCustomToast"
import { cn } from "@/lib/utils"
import { handleError } from "@/utils"
import { BarWidget, segmentsOf } from "./BarWidget"
import { ChartWidget } from "./ChartWidget"
import { ClockWidget } from "./ClockWidget"
// The transmit overlay's rule lives beside the other widget CSS; a widget is
import { COLOR_DTYPES, ColorWidget, colorFormatOf } from "./ColorWidget"
// The segmented thumb's rule lives beside the other widget CSS; a widget is
// drawn by the editor as well as by the view, so the sheet is pulled in here.
import "./dashboard.css"
import { ForecastWidget } from "./ForecastWidget"
import { IconWidget } from "./IconWidget"
import { usePublishMessage } from "./queries"
import { usePublish } from "./publish"
/** Widget types that put a value into the graph rather than read one. */
export const INPUT_WIDGETS = new Set([
@@ -38,6 +37,7 @@ export const INPUT_WIDGETS = new Set([
"slider",
"input",
"dropdown",
"color",
])
export type WidgetKind = WidgetDef["type"]
@@ -61,6 +61,9 @@ export const WIDGET_DTYPES: Partial<Record<WidgetKind, string[]>> = {
notification: ["record"],
bar: ["float", "int"],
forecast: ["list"],
// Either shape a colour can travel as; its `format` decides which of the two
// this widget means, which `widgetIssue` holds the binding to.
color: ["list", "str"],
// An icon maps weather strings, bool hints and numbers alike, and a clock
// binds nothing at all, so neither has a row to be held to.
}
@@ -87,6 +90,7 @@ export const WIDGET_LABELS: Record<WidgetKind, string> = {
slider: "Slider",
input: "Input",
dropdown: "Dropdown",
color: "Colour",
}
/** Default footprint per type, in grid units. */
@@ -106,6 +110,7 @@ export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
slider: { w: 4, h: 2 },
input: { w: 4, h: 2 },
dropdown: { w: 4, h: 2 },
color: { w: 4, h: 4 },
}
function config(widget: WidgetDef): Record<string, unknown> {
@@ -189,6 +194,12 @@ export function widgetIssue(widget: WidgetDef): string | null {
if (!acceptsDtype(widget.type, dtype)) {
return `${bound} is a ${dtype}; a ${WIDGET_LABELS[widget.type].toLowerCase()} cannot carry that.`
}
if (widget.type === "color") {
const want = COLOR_DTYPES[colorFormatOf(widget)]
if (dtype && dtype !== want) {
return `${bound} is a ${dtype}; this wheel sends ${colorFormatOf(widget)}, which is a ${want}.`
}
}
// Only a bar nests further readings, and an unrecorded type binds anything.
// Read through `segmentsOf` so a stacked bar is judged segment by segment
// rather than only in the one-reading shape it used to carry.
@@ -556,93 +567,6 @@ function NotificationWidget({ widget }: WidgetProps) {
// Input
// ---------------------------------------------------------------------------
/**
* How long a control shows what it sent before falling back to the engine.
*
* ponytail: a flat 3 s rather than anything the engine tells us. A publish the
* server takes but nothing ever echoes — a message no flow consumes — would
* otherwise leave the tile holding a value that is not the truth, forever.
*/
const HOLD_MS = 3000
/** Whether what came back over the socket is what this control sent. */
function confirms(live: unknown, sent: unknown): boolean {
// Readings are scalars nearly always; a dropdown may carry a record, and
// comparing those as text is cheaper than a deep walk for the same answer.
return live === sent || JSON.stringify(live) === JSON.stringify(sent)
}
/**
* Publishing, with the value shown as sent until the engine confirms it.
*
* A control publishes over HTTP and reads the result back over the socket, so
* between the two the live value is still the old one — a handle let go of
* would snap back to it. The hold ends when the echo matches, when the publish
* is refused, or on `HOLD_MS`; success is silent, because the echo is the
* confirmation.
*/
function usePublish(widget: WidgetDef, dashboard: string) {
const cfg = config(widget)
const target = text(cfg.target)
const publish = usePublishMessage()
const live = useLiveValue(target || undefined)
const { showErrorToast } = useCustomToast()
// Boxed: holding `false` or `null` is not the same as holding nothing.
const [held, setHeld] = useState<{ value: unknown } | null>(null)
useEffect(() => {
if (!held) return
const timer = setTimeout(() => setHeld(null), HOLD_MS)
return () => clearTimeout(timer)
}, [held])
useEffect(() => {
if (held && confirms(live?.value, held.value)) setHeld(null)
}, [held, live])
return {
target,
/** What the control draws: what it sent, until the engine answers. */
value: held ? held.value : live?.value,
send: (value: unknown) => {
if (!target) return
setHeld({ value })
publish.mutate(
{
name: target,
value,
dashboard,
widget: widget.id,
label: widget.title || widget.id,
kind: widget.type,
},
{
onError: (error) => {
// Back to the engine's own value, and say which message refused it
// — a panel showing several controls cannot tell them apart.
setHeld(null)
handleError.call(
(detail: string) => showErrorToast(`${target}: ${detail}`),
error as ApiError,
)
},
},
)
},
/**
* The in-flight pulse, drawn over the whole tile.
*
* Absolutely positioned and inert, so it neither resizes the widget nor
* moves anything around it. Every control renders it; the frame is what it
* hangs off.
*/
pulse: publish.isPending ? (
<span aria-hidden className="widget-transmit" />
) : null,
pending: publish.isPending,
}
}
function ButtonWidget({ widget, dashboard }: WidgetProps) {
const cfg = config(widget)
const { target, send, pending, pulse } = usePublish(widget, dashboard)
@@ -963,6 +887,7 @@ const RENDERERS: Partial<
slider: SliderWidget,
input: InputWidget,
dropdown: DropdownWidget,
color: ColorWidget,
}
export function WidgetBody({ widget, dashboard }: WidgetProps) {