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