From 9927577cec910c6768e81cd89e75adb0c4211b20 Mon Sep 17 00:00:00 2001 From: stroblme Date: Sat, 22 Aug 2026 12:50:59 +0200 Subject: [PATCH] Add a colour-wheel widget to the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/fluksio/flow/dashboards.py | 40 ++- backend/tests/flow/test_dashboards.py | 37 ++ docs/interface/dashboards.md | 7 + frontend/src/client/schemas.gen.ts | 17 +- frontend/src/client/types.gen.ts | 17 +- .../src/components/Common/DashboardMosaic.tsx | 9 +- .../src/components/Dashboard/ColorWidget.tsx | 319 ++++++++++++++++++ .../src/components/Dashboard/color.check.ts | 79 +++++ .../src/components/Dashboard/dashboard.css | 45 +++ frontend/src/components/Dashboard/panels.tsx | 40 +++ frontend/src/components/Dashboard/publish.tsx | 103 ++++++ frontend/src/components/Dashboard/widgets.tsx | 111 +----- scripts/seed_demo.py | 81 ++++- 13 files changed, 804 insertions(+), 101 deletions(-) create mode 100644 frontend/src/components/Dashboard/ColorWidget.tsx create mode 100644 frontend/src/components/Dashboard/color.check.ts create mode 100644 frontend/src/components/Dashboard/publish.tsx diff --git a/backend/fluksio/flow/dashboards.py b/backend/fluksio/flow/dashboards.py index 21d8bf0..ad9f065 100644 --- a/backend/fluksio/flow/dashboards.py +++ b/backend/fluksio/flow/dashboards.py @@ -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", diff --git a/backend/tests/flow/test_dashboards.py b/backend/tests/flow/test_dashboards.py index 60b8b6d..21f7513 100644 --- a/backend/tests/flow/test_dashboards.py +++ b/backend/tests/flow/test_dashboards.py @@ -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( diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 9ade29a..4462d44 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -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, diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index b180c20..361c7d9 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -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 = { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 3e077b8..9479809 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -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; diff --git a/frontend/src/components/Common/DashboardMosaic.tsx b/frontend/src/components/Common/DashboardMosaic.tsx index 70a1c63..b1ce53f 100644 --- a/frontend/src/components/Common/DashboardMosaic.tsx +++ b/frontend/src/components/Common/DashboardMosaic.tsx @@ -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) diff --git a/frontend/src/components/Dashboard/ColorWidget.tsx b/frontend/src/components/Dashboard/ColorWidget.tsx new file mode 100644 index 0000000..b61c0cf --- /dev/null +++ b/frontend/src/components/Dashboard/ColorWidget.tsx @@ -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 = { + 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 ( + + ) +} + +/** + * 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(null) + if (!target) + return

Pick a message.

+ + 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) => { + 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} +
+ {/* 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. */} +
+
+ {/* 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. */} +
{ + 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. */} + + +
+
+
+ setDraft([hue, next, brightness])} + onCommit={commit} + /> + setDraft([hue, saturation, next])} + onCommit={commit} + /> +
+
+
+ + ) +} diff --git a/frontend/src/components/Dashboard/color.check.ts b/frontend/src/components/Dashboard/color.check.ts new file mode 100644 index 0000000..202cab9 --- /dev/null +++ b/frontend/src/components/Dashboard/color.check.ts @@ -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") diff --git a/frontend/src/components/Dashboard/dashboard.css b/frontend/src/components/Dashboard/dashboard.css index 376973a..c8c4082 100644 --- a/frontend/src/components/Dashboard/dashboard.css +++ b/frontend/src/components/Dashboard/dashboard.css @@ -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). `` only diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index bd0bdaa..6085f6d 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -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({ ) : null} + {widget.type === "color" ? ( +
+ + + 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 }), + }) + } + /> +

+ HSV is{" "} + [h 0-360, s 0-100, v 0-100], + RGB [r, g, b] 0-255, Hex{" "} + "#rrggbb". +

+
+ ) : null} + {widget.type === "switch" || widget.type === "dropdown" ? (
diff --git a/frontend/src/components/Dashboard/publish.tsx b/frontend/src/components/Dashboard/publish.tsx new file mode 100644 index 0000000..0717531 --- /dev/null +++ b/frontend/src/components/Dashboard/publish.tsx @@ -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 + 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 ? ( + + ) : null, + pending: publish.isPending, + } +} diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx index a193200..f141b4a 100644 --- a/frontend/src/components/Dashboard/widgets.tsx +++ b/frontend/src/components/Dashboard/widgets.tsx @@ -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> = { 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 = { slider: "Slider", input: "Input", dropdown: "Dropdown", + color: "Colour", } /** Default footprint per type, in grid units. */ @@ -106,6 +110,7 @@ export const WIDGET_SIZES: Record = { 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 { @@ -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 ? ( - - ) : 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) { diff --git a/scripts/seed_demo.py b/scripts/seed_demo.py index f3f4140..07d643e 100644 --- a/scripts/seed_demo.py +++ b/scripts/seed_demo.py @@ -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(