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
+39 -1
View File
@@ -60,9 +60,17 @@ WidgetType = Literal[
"slider",
"input",
"dropdown",
"color",
]
INPUT_WIDGETS = {"button", "switch", "slider", "input", "dropdown"}
INPUT_WIDGETS = {"button", "switch", "slider", "input", "dropdown", "color"}
#: What a colour widget puts on the wire, by the format it was configured for.
#: The default is what the Node-RED installation this ports already sends its
#: DMX encoders — ``[h, s, v]``, hue in degrees and the other two in percent —
#: and the two alternatives exist because fixtures differ. Mirrored in the
#: client (``frontend/src/components/Dashboard/ColorWidget.tsx``).
COLOR_DTYPES = {"hsv": "list", "rgb": "list", "hex": "str"}
#: What a widget may be pointed at, by payload type. A switch that reads a
#: float has nothing to show and nothing safe to send, so the pairing belongs
@@ -80,6 +88,9 @@ WIDGET_DTYPES: dict[str, set[str]] = {
"notification": {"record"},
"bar": {"float", "int"},
"forecast": {"list"},
# Either shape a colour can travel as; which of the two this widget means
# is its ``format``, checked against ``COLOR_DTYPES`` below.
"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.
}
@@ -107,6 +118,19 @@ class WidgetDef(BaseModel):
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.
"""
id: str
@@ -217,6 +241,19 @@ class WidgetDef(BaseModel):
if isinstance(inner, list) and len(inner) > BAR_SEGMENTS:
raise ValueError(f"a bar nests at most {BAR_SEGMENTS} readings")
if self.type == "color":
# The row above allows both shapes a colour travels as; the format
# is what decides which of them this widget actually sends. An
# unknown one is read as the default, exactly as the client does.
fmt = str(self.config.get("format") or "hsv")
want = COLOR_DTYPES.get(fmt, "list")
bound = str(self.config.get("dtype") or "")
if bound and bound != want:
raise ValueError(
f"a colour widget sending {fmt} needs a '{want}' message, "
f"not a '{bound}'"
)
allowed = WIDGET_DTYPES.get(self.type)
if not allowed:
return self
@@ -558,6 +595,7 @@ def default_dashboard(name: str) -> DashboardDef:
__all__ = [
"BAR_SEGMENTS",
"COLOR_DTYPES",
"DASHBOARD_DIR",
"HISTORY_CAP",
"INPUT_WIDGETS",
+37
View File
@@ -303,6 +303,43 @@ def test_a_querying_chart_asks_with_a_record_and_draws_a_series():
WidgetDef(id="c", type="chart", config=query_chart(request_dtype="json"))
def test_a_colour_widget_publishes_the_shape_its_format_names():
"""The format picks the payload, so the binding is held to that one."""
wheel = WidgetDef(
id="lamp",
type="color",
config={"target": "a.color", "dtype": "list", "format": "hsv"},
)
assert wheel.target == "a.color"
# It publishes rather than reads: nothing is drawn from a message.
assert wheel.messages == []
# Hex is the same widget speaking a string, and rgb is still a list.
WidgetDef(
id="l", type="color", config={"target": "a.c", "dtype": "str", "format": "hex"}
)
WidgetDef(
id="l", type="color", config={"target": "a.c", "dtype": "list", "format": "rgb"}
)
# Nothing recorded binds anything, as everywhere else.
WidgetDef(id="l", type="color", config={"target": "a.c"})
with pytest.raises(ValueError):
WidgetDef(
id="l",
type="color",
config={"target": "a.c", "dtype": "str", "format": "hsv"},
)
with pytest.raises(ValueError):
WidgetDef(
id="l",
type="color",
config={"target": "a.c", "dtype": "list", "format": "hex"},
)
with pytest.raises(ValueError):
WidgetDef(id="l", type="color", config={"target": "a.c", "dtype": "float"})
def test_a_querying_chart_keeps_no_ring():
"""The answer carries its own past; a ring would store it twice."""
widget = WidgetDef(
+7
View File
@@ -45,6 +45,13 @@ and dragging is off. Picking a widget and editing its settings still works.
| **Slider** | `float`, `int` | min, max, step |
| **Input** | text or a number | free entry |
| **Dropdown** | one of a list | a mode, a scene, a preset |
| **Colour** | `[h, s, v]`, `[r, g, b]` or `"#rrggbb"` | a hue wheel with saturation and brightness, for an RGB fixture |
The colour wheel sends `[h, s, v]` by default — hue 0-360 degrees, saturation
and value 0-100 percent, which is what a DMX encoder expects — and its
**Sends** setting switches that to `[r, g, b]` (0-255 each) or to a
`"#rrggbb"` string, because fixtures differ. The first two bind a `list`
message, the third a `str`.
A control publishes the message it is bound to, exactly as a node would. On the
flow canvas it is drawn as a labelled endpoint feeding the nodes that read it,
+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) {
+79 -2
View File
@@ -15,7 +15,7 @@ What it builds::
The panel is a small-house energy and climate display — the thing a person
would actually hang in a hallway — and it is also the widget gallery: all
fifteen types are on it, including the variants no editor field can write
sixteen types are on it, including the variants no editor field can write
(a switch drawn as a button, a segmented dropdown, a bar with a nested
reading, an icon ladder with labels, fixed chart axes, decimal places).
@@ -236,6 +236,41 @@ def process(day_start, climate=None):
return {"forecast": forecast, "agenda": agenda}
'''
LAMP_SOURCE = '''"""What the hallway lamp was set to, said in words.
The wheel on the panel publishes ``[h, s, v]`` — hue in degrees, saturation and
value in percent, which is what a DMX encoder divides down. A real installation
puts an MQTT or Art-Net node here; this only names the colour, so the panel can
show that the control reached something.
"""
#: Where each name starts, in degrees. Read from the bottom up, so the last row
#: a hue has passed wins — and red is at both ends, because the wheel wraps.
NAMES = [
(0, "Red"),
(15, "Amber"),
(45, "Yellow"),
(70, "Lime"),
(100, "Green"),
(160, "Cyan"),
(200, "Blue"),
(260, "Violet"),
(290, "Magenta"),
(320, "Pink"),
(350, "Red"),
]
def process(light_color):
hue, saturation, value = (list(light_color) + [0, 0, 0])[:3]
if value < 2:
return {"light_state": "Off"}
if saturation < 10:
return {"light_state": "White at {:.0f}%".format(value)}
name = next(label for at, label in reversed(NAMES) if hue >= at)
return {"light_state": "{} at {:.0f}%".format(name, value)}
'''
HOUSE_INPUTS = [
# What the controls on the panel write to. The initial values are what the
# dashboard reads back before anyone touches anything, which is why the
@@ -244,6 +279,12 @@ HOUSE_INPUTS = [
{"spec": {"name": "away", "dtype": "bool"}, "initial": False},
{"spec": {"name": "setpoint", "dtype": "float"}, "initial": 21.0},
{"spec": {"name": "tariff", "dtype": "float"}, "initial": 32.0},
# Three numbers rather than a record: [hue, saturation, value], which is
# what the colour wheel sends and what a DMX encoder reads.
{
"spec": {"name": "light_color", "dtype": "list", "item": "float"},
"initial": [38, 72, 80],
},
# False rather than nothing: a declared message with no starting value is
# a node that can never run, and the engine says so on the canvas.
{"spec": {"name": "boost", "dtype": "bool"}, "initial": False},
@@ -368,6 +409,13 @@ HOUSE_NODES = [
"requires": [{"name": "boost", "dtype": "bool"}],
"provides": [{"name": "hot_water", "dtype": "bool"}],
},
{
"id": "lamp",
"type": "python",
"title": "Name the lamp's colour",
"requires": [{"name": "light_color", "dtype": "list", "item": "float"}],
"provides": [{"name": "light_state", "dtype": "str"}],
},
{
"id": "water_label",
"type": "change",
@@ -961,6 +1009,30 @@ ENERGY_WIDGETS = [
"label": "Boost 15 min",
},
},
{
"id": "mood",
"type": "color",
"title": "Hallway lamp",
# Wider than it is tall, which is what the wheel lays itself out for:
# the ring takes the row's height and the two components sit beside it.
"layout": at(0, 6, 11, 2),
"config": {
"target": msg(HOUSE, "light_color"),
"dtype": "list",
# The default, said out loud: [h, s, v] as a DMX encoder reads it.
# "rgb" sends three 0-255 channels, "hex" a "#rrggbb" string.
"format": "hsv",
},
},
{
"id": "lamp",
"type": "stat",
"title": "Lamp",
"layout": at(11, 6, 5, 2),
# What the flow made of the colour — the proof that the wheel reached
# something, the way the hot-water button has its own state beside it.
"config": {"message": msg(HOUSE, "light_state"), "dtype": "str"},
},
]
MODEL_WIDGETS = [
@@ -1249,7 +1321,12 @@ def main() -> int:
HOUSE,
"House",
HOUSE_NODES,
{"house": HOUSE_SOURCE, "meter": METER_SOURCE, "plan": PLAN_SOURCE},
{
"house": HOUSE_SOURCE,
"meter": METER_SOURCE,
"plan": PLAN_SOURCE,
"lamp": LAMP_SOURCE,
},
inputs=HOUSE_INPUTS,
)
seed_flow(