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:
@@ -60,9 +60,17 @@ WidgetType = Literal[
|
|||||||
"slider",
|
"slider",
|
||||||
"input",
|
"input",
|
||||||
"dropdown",
|
"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
|
#: 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
|
#: 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"},
|
"notification": {"record"},
|
||||||
"bar": {"float", "int"},
|
"bar": {"float", "int"},
|
||||||
"forecast": {"list"},
|
"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
|
# 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.
|
# 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
|
refresh_s, range_s}``: it publishes ``{range_s, interval_s}`` to
|
||||||
``request`` exactly as a slider publishes a value, and draws the ``series``
|
``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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
@@ -217,6 +241,19 @@ class WidgetDef(BaseModel):
|
|||||||
if isinstance(inner, list) and len(inner) > BAR_SEGMENTS:
|
if isinstance(inner, list) and len(inner) > BAR_SEGMENTS:
|
||||||
raise ValueError(f"a bar nests at most {BAR_SEGMENTS} readings")
|
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)
|
allowed = WIDGET_DTYPES.get(self.type)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
return self
|
return self
|
||||||
@@ -558,6 +595,7 @@ def default_dashboard(name: str) -> DashboardDef:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BAR_SEGMENTS",
|
"BAR_SEGMENTS",
|
||||||
|
"COLOR_DTYPES",
|
||||||
"DASHBOARD_DIR",
|
"DASHBOARD_DIR",
|
||||||
"HISTORY_CAP",
|
"HISTORY_CAP",
|
||||||
"INPUT_WIDGETS",
|
"INPUT_WIDGETS",
|
||||||
|
|||||||
@@ -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"))
|
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():
|
def test_a_querying_chart_keeps_no_ring():
|
||||||
"""The answer carries its own past; a ring would store it twice."""
|
"""The answer carries its own past; a ring would store it twice."""
|
||||||
widget = WidgetDef(
|
widget = WidgetDef(
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ and dragging is off. Picking a widget and editing its settings still works.
|
|||||||
| **Slider** | `float`, `int` | min, max, step |
|
| **Slider** | `float`, `int` | min, max, step |
|
||||||
| **Input** | text or a number | free entry |
|
| **Input** | text or a number | free entry |
|
||||||
| **Dropdown** | one of a list | a mode, a scene, a preset |
|
| **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
|
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,
|
flow canvas it is drawn as a labelled endpoint feeding the nodes that read it,
|
||||||
|
|||||||
@@ -3138,7 +3138,7 @@ export const WidgetDefSchema = {
|
|||||||
},
|
},
|
||||||
type: {
|
type: {
|
||||||
type: 'string',
|
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: 'Type'
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
@@ -3173,7 +3173,20 @@ message. One with \`\`source: "query"\`\` asks instead, and its config is
|
|||||||
\`\`{source, request, request_dtype: "record", message, dtype: "series",
|
\`\`{source, request, request_dtype: "record", message, dtype: "series",
|
||||||
refresh_s, range_s}\`\`: it publishes \`\`{range_s, interval_s}\`\` to
|
refresh_s, range_s}\`\`: it publishes \`\`{range_s, interval_s}\`\` to
|
||||||
\`\`request\`\` exactly as a slider publishes a value, and draws the \`\`series\`\`
|
\`\`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;
|
} as const;
|
||||||
|
|
||||||
export const WorkerInfoSchema = {
|
export const WorkerInfoSchema = {
|
||||||
|
|||||||
@@ -1041,10 +1041,23 @@ export type ValidationResult = {
|
|||||||
* refresh_s, range_s}``: it publishes ``{range_s, interval_s}`` to
|
* refresh_s, range_s}``: it publishes ``{range_s, interval_s}`` to
|
||||||
* ``request`` exactly as a slider publishes a value, and draws the ``series``
|
* ``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.
|
||||||
*/
|
*/
|
||||||
export type WidgetDef = {
|
export type WidgetDef = {
|
||||||
id: string;
|
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;
|
title?: string;
|
||||||
layout?: {
|
layout?: {
|
||||||
[key: string]: Placement;
|
[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 = {
|
export type WorkerInfo = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -28,7 +28,14 @@ const PREVIEWS = 8
|
|||||||
|
|
||||||
/** Widgets that draw a shape, and widgets that are controls. The rest read out. */
|
/** Widgets that draw a shape, and widgets that are controls. The rest read out. */
|
||||||
const GRAPHIC = new Set(["chart", "forecast", "bar", "gauge"])
|
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) =>
|
const shade = (type: string) =>
|
||||||
INPUT.has(type)
|
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);
|
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
|
* Motion. A value settling is a neutral state change; a selection indicator
|
||||||
* moving is emphasized (Material). `<MotionConfig reducedMotion="user">` only
|
* moving is emphasized (Material). `<MotionConfig reducedMotion="user">` only
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget"
|
import { MAX_SEGMENTS, type Segment, segmentsOf } from "./BarWidget"
|
||||||
import { MAX_SERIES, refreshFor } from "./ChartWidget"
|
import { MAX_SERIES, refreshFor } from "./ChartWidget"
|
||||||
|
import { COLOR_DTYPES, COLOR_FORMATS, colorFormatOf } from "./ColorWidget"
|
||||||
import {
|
import {
|
||||||
CANVAS_PRESETS,
|
CANVAS_PRESETS,
|
||||||
COLUMN_CHOICES,
|
COLUMN_CHOICES,
|
||||||
@@ -435,6 +436,15 @@ export function WidgetPanel({
|
|||||||
value={str(cfg[isInput ? "target" : "message"])}
|
value={str(cfg[isInput ? "target" : "message"])}
|
||||||
label={isInput ? "Publishes to" : "Shows"}
|
label={isInput ? "Publishes to" : "Shows"}
|
||||||
testId="widget-message"
|
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) =>
|
onPick={(message, dtype) =>
|
||||||
set({ [isInput ? "target" : "message"]: message, dtype })
|
set({ [isInput ? "target" : "message"]: message, dtype })
|
||||||
}
|
}
|
||||||
@@ -867,6 +877,36 @@ export function WidgetPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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" ? (
|
{widget.type === "switch" || widget.type === "dropdown" ? (
|
||||||
<div className="grid gap-1.5">
|
<div className="grid gap-1.5">
|
||||||
<Label className="text-sm font-normal">Style</Label>
|
<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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { useLiveValue } from "@/components/Flow/liveStore"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -18,18 +18,17 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import useCustomToast from "@/hooks/useCustomToast"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { handleError } from "@/utils"
|
|
||||||
import { BarWidget, segmentsOf } from "./BarWidget"
|
import { BarWidget, segmentsOf } from "./BarWidget"
|
||||||
import { ChartWidget } from "./ChartWidget"
|
import { ChartWidget } from "./ChartWidget"
|
||||||
import { ClockWidget } from "./ClockWidget"
|
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.
|
// drawn by the editor as well as by the view, so the sheet is pulled in here.
|
||||||
import "./dashboard.css"
|
import "./dashboard.css"
|
||||||
import { ForecastWidget } from "./ForecastWidget"
|
import { ForecastWidget } from "./ForecastWidget"
|
||||||
import { IconWidget } from "./IconWidget"
|
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. */
|
/** Widget types that put a value into the graph rather than read one. */
|
||||||
export const INPUT_WIDGETS = new Set([
|
export const INPUT_WIDGETS = new Set([
|
||||||
@@ -38,6 +37,7 @@ export const INPUT_WIDGETS = new Set([
|
|||||||
"slider",
|
"slider",
|
||||||
"input",
|
"input",
|
||||||
"dropdown",
|
"dropdown",
|
||||||
|
"color",
|
||||||
])
|
])
|
||||||
|
|
||||||
export type WidgetKind = WidgetDef["type"]
|
export type WidgetKind = WidgetDef["type"]
|
||||||
@@ -61,6 +61,9 @@ export const WIDGET_DTYPES: Partial<Record<WidgetKind, string[]>> = {
|
|||||||
notification: ["record"],
|
notification: ["record"],
|
||||||
bar: ["float", "int"],
|
bar: ["float", "int"],
|
||||||
forecast: ["list"],
|
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
|
// 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.
|
// 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",
|
slider: "Slider",
|
||||||
input: "Input",
|
input: "Input",
|
||||||
dropdown: "Dropdown",
|
dropdown: "Dropdown",
|
||||||
|
color: "Colour",
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default footprint per type, in grid units. */
|
/** 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 },
|
slider: { w: 4, h: 2 },
|
||||||
input: { w: 4, h: 2 },
|
input: { w: 4, h: 2 },
|
||||||
dropdown: { w: 4, h: 2 },
|
dropdown: { w: 4, h: 2 },
|
||||||
|
color: { w: 4, h: 4 },
|
||||||
}
|
}
|
||||||
|
|
||||||
function config(widget: WidgetDef): Record<string, unknown> {
|
function config(widget: WidgetDef): Record<string, unknown> {
|
||||||
@@ -189,6 +194,12 @@ export function widgetIssue(widget: WidgetDef): string | null {
|
|||||||
if (!acceptsDtype(widget.type, dtype)) {
|
if (!acceptsDtype(widget.type, dtype)) {
|
||||||
return `${bound} is a ${dtype}; a ${WIDGET_LABELS[widget.type].toLowerCase()} cannot carry that.`
|
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.
|
// Only a bar nests further readings, and an unrecorded type binds anything.
|
||||||
// Read through `segmentsOf` so a stacked bar is judged segment by segment
|
// Read through `segmentsOf` so a stacked bar is judged segment by segment
|
||||||
// rather than only in the one-reading shape it used to carry.
|
// rather than only in the one-reading shape it used to carry.
|
||||||
@@ -556,93 +567,6 @@ function NotificationWidget({ widget }: WidgetProps) {
|
|||||||
// Input
|
// 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) {
|
function ButtonWidget({ widget, dashboard }: WidgetProps) {
|
||||||
const cfg = config(widget)
|
const cfg = config(widget)
|
||||||
const { target, send, pending, pulse } = usePublish(widget, dashboard)
|
const { target, send, pending, pulse } = usePublish(widget, dashboard)
|
||||||
@@ -963,6 +887,7 @@ const RENDERERS: Partial<
|
|||||||
slider: SliderWidget,
|
slider: SliderWidget,
|
||||||
input: InputWidget,
|
input: InputWidget,
|
||||||
dropdown: DropdownWidget,
|
dropdown: DropdownWidget,
|
||||||
|
color: ColorWidget,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WidgetBody({ widget, dashboard }: WidgetProps) {
|
export function WidgetBody({ widget, dashboard }: WidgetProps) {
|
||||||
|
|||||||
Reference in New Issue
Block a user