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:
@@ -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