Files
app/frontend/src/components/Dashboard/RingWidget.tsx
T

783 lines
26 KiB
TypeScript

import { Lock } from "lucide-react"
import { type MotionValue, motion, useTransform } from "motion/react"
import { Fragment, useEffect, useId, useRef, useState } from "react"
import type { WidgetDef } from "@/client"
import { cn } from "@/lib/utils"
import { useBoundValue } from "./dataContext"
import { usePublish } from "./publish"
import { useLocked } from "./settings"
import {
colorFormatOf,
cssOf,
decodeColor,
encodeColor,
type Triple,
wrap,
} from "./ui/core/color"
import {
config,
countersOf,
format,
fractionOf,
num,
showTitle,
text,
wrapsOf,
} from "./ui/core/config"
import { TESTID } from "./ui/core/contract"
import { tickIntervals } from "./ui/core/controls"
import {
type Counter,
cycleAt,
cycleOf,
rangeOf,
stepCounter,
writtenOf,
} from "./ui/core/counter"
import {
CENTRE_CAPTION,
CENTRE_LABEL,
CENTRE_SWATCH,
CENTRE_VALUE,
fitSize,
fractionAt,
GAP_RISE,
GAP_SIZE,
GAP_WIDTH,
RING_DRAG_STEP,
RING_HANDLE,
RING_INSET,
RING_MARK,
RING_READING,
RING_STROKE,
RING_SWEEP,
ringArc,
ringPoint,
TITLE_RISE,
TITLE_SIZE,
TITLE_WIDTH,
useRingDraft,
} from "./ui/core/ring"
import { useEased } from "./ui/core/values"
import type { Series } from "./widgets"
/** How much wheel a notch is: what one detent of a mouse wheel reports. */
const WHEEL_NOTCH = 100
/** A key that turns the ring, as a notch would. */
const ARROWS: Record<string, number> = {
ArrowUp: 1,
ArrowRight: 1,
ArrowDown: -1,
ArrowLeft: -1,
}
/** How many strokes the colour ring's hue is drawn in: 2° each. */
const HUE_SEGMENTS = 150
/**
* What one type of ring does with its value.
*
* The value is whatever the type turns — a number, an option's index, a colour
* — and the rest says where it sits along the sweep and how a notch or a
* pointer moves it. No `notch` means a ring that only shows.
*/
type Dial = {
value: unknown
/** Where it sits along the sweep, 0..1; null draws no handle. */
at: (value: unknown) => number | null
notch?: (value: unknown, by: number) => unknown
pick?: (fraction: number, value: unknown) => unknown
/** A drag that counts notches rather than picking a place: one per this
* many degrees along the ring, either way. */
drag?: number
/** What it publishes, in the shape the same widget sends from the grid. */
wire: (value: unknown) => unknown
/** Written out; empty for a colour, whose handle is the reading. */
reading: (value: unknown) => string
unit?: string
/** A value arc from the start to the reading: a slider, a selector and a
* gauge. */
fill?: boolean
handle?: boolean
/** Dots along the sweep, as fractions of it: a selector's options, a
* slider's ticks. */
stops?: number[]
hue?: boolean
/** Where it stands in a turn rather than along a range, so a move across
* the seam jumps, as a hue does. */
wraps?: boolean
}
/** `selected` is the counter a counter's ring turns. */
function dialOf(widget: WidgetDef, live: unknown, selected: number): Dial {
const cfg = config(widget)
const unit = cfg.unit ? text(cfg.unit) : undefined
if (widget.type === "dropdown") {
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
const last = options.length - 1
const at = (index: unknown) =>
(index as number) < 0 ? null : last > 0 ? (index as number) / last : 0.5
return {
value: options.findIndex((option) => text(option.value) === text(live)),
at,
// No wrap: the first and last options are the ring's two ends.
notch:
last < 0
? undefined
: (index, by) => Math.min(last, Math.max(0, (index as number) + by)),
pick: (fraction) => Math.round(fraction * last),
wire: (index) =>
options[index as number]?.value ?? options[index as number]?.label,
reading: (index) => {
const option = options[index as number]
return option ? (option.label ?? text(option.value)) : "—"
},
fill: true,
handle: true,
stops: options.map((_, index) => at(index) ?? 0.5),
}
}
if (widget.type === "color") {
const form = colorFormatOf(widget)
const step = num(cfg.hue_step, 5)
// Turning sets the hue alone; saturation and brightness stay what they
// were, or full when nothing has been sent yet.
const hsv: Triple = decodeColor(live, form) ?? [0, 100, 100]
return {
value: hsv,
at: (value) => (value as Triple)[0] / 360,
notch: (value, by) => {
const [h, s, v] = value as Triple
return [wrap(h + by * step), s, v]
},
pick: (fraction, value) => {
const [, s, v] = value as Triple
return [wrap(fraction * 360), s, v]
},
wire: (value) => encodeColor(value as Triple, form),
reading: () => "",
handle: true,
hue: true,
}
}
if (widget.type === "counter") {
// The selected counter, stepped with carry; its arc is where it stands
// in its turn, as a minute hand stands in the hour.
const counters = countersOf(widget)
const wrap = wrapsOf(widget)
const cycle = cycleOf(counters, selected)
const step = counters[selected].step ?? 1
return {
value: typeof live === "number" ? live : rangeOf(counters).lo,
at: (value) => cycleAt(counters, value as number, selected),
notch: (value, by) =>
stepCounter(counters, wrap, value as number, selected, by),
drag: cycle === null ? RING_DRAG_STEP : (RING_SWEEP * step) / cycle,
wire: (value) => value,
reading: (value) => writtenOf(counters, value as number).join(":"),
fill: true,
wraps: true,
}
}
const min = num(cfg.min, 0)
const max = num(cfg.max, 100)
if (widget.type === "gauge") {
const precision = cfg.precision === undefined ? 1 : num(cfg.precision, 1)
return {
value: typeof live === "number" ? live : null,
at: (value) => fractionOf(value as number | null, min, max),
wire: (value) => value,
reading: (value) => format(value, precision),
unit,
fill: true,
}
}
// A slider. Its value is written to the step's own decimals, as the
// slider in the grid labels its scale.
const step = num(cfg.step, 1) || 1
const digits = (String(step).split(".")[1] ?? "").length
const fit = (value: number) =>
Number(Math.min(max, Math.max(min, value)).toFixed(digits))
// The tile's scale, as dots: the same intervals its labels are cut into.
const intervals = tickIntervals((max - min) / step)
return {
value: typeof live === "number" ? live : min,
at: (value) => fractionOf(value as number, min, max),
notch: (value, by) => fit((value as number) + by * step),
pick: (fraction) =>
fit(min + Math.round((fraction * (max - min)) / step) * step),
wire: (value) => value,
reading: (value) => format(value, digits),
unit,
fill: true,
handle: true,
stops:
cfg.ticks !== false && max > min
? Array.from({ length: intervals + 1 }, (_, index) => index / intervals)
: [],
}
}
/** A slider that reads something beside its value, which then takes the
* middle and moves the slider's own title and value to the gap's line. */
export const hasGapLine = (widget: WidgetDef) =>
widget.type === "slider" &&
Boolean(((config(widget).reading ?? {}) as Series).message)
/**
* A face's title: one slot, above the middle, whichever widget it is.
*
* What is written there names what the middle shows — which is why a slider
* reading something beside its value hands the slot to that reading.
*/
export function FaceTitle({
size,
width = TITLE_WIDTH,
children,
}: {
size: number
/** Narrower where something is drawn inside the ring beside it. */
width?: number
children: React.ReactNode
}) {
return (
<p
className="pointer-events-none absolute truncate text-center text-muted-foreground"
style={{
left: size / 2 - width / 2,
width,
top: size / 2 - TITLE_RISE - TITLE_SIZE,
lineHeight: `${TITLE_SIZE * 2}px`,
fontSize: TITLE_SIZE,
}}
>
{children}
</p>
)
}
/**
* A reading written large in the middle of a face, its unit small beside it.
*
* Sized from what is written (`fitSize`), so a word shrinks to fit where a
* number would not have to; `children` may tween towards it meanwhile.
*/
export function CentreReading({
written,
unit,
max = CENTRE_VALUE,
children,
}: {
written: string
unit?: string
max?: number
children?: React.ReactNode
}) {
return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span
data-testid="face-centre"
className="flex items-baseline gap-1 whitespace-nowrap leading-none tabular-nums"
style={{ fontSize: fitSize(written, max) }}
>
{children ?? written}
{unit ? (
<span className="text-[0.4em] text-muted-foreground">{unit}</span>
) : null}
</span>
</div>
)
}
/**
* One widget, drawn along the edge of a round face.
*
* A slider, a selector, a colour, a gauge or a counter — each is one value,
* which is the one thing a knob's ring can set and a circle's edge can show.
* Turned by a drag along it or by the mouse wheel, a notch a step, an option
* or a hue step; the value moves at once and one publish follows once the
* turning stops (`useRingDraft`). What is drawn along the ring eases to a
* notch or a reading at the look's spring, and follows a drag exactly. Its reading is written large in the middle,
* unless a slider reads something beside it: then that is in the middle, and
* the slider's own title and value move to the gap's line.
*
* Arranging, a press on the ring picks it as a press on a tile does.
*/
export function Ring({
widget,
dashboard,
size,
editing,
onPick,
}: {
widget: WidgetDef
dashboard: string
size: number
editing?: boolean
onPick?: () => void
}) {
const { value, send, locked } = usePublish(widget, dashboard)
// The dashboard's own lock, which the gap's line leads with when it holds
// the gap (`RoundView` writes it there otherwise).
const readOnly = useLocked()
const cfg = config(widget)
// A gauge reads its message rather than publishing to a target.
const bound = useBoundValue(
widget.type === "gauge" ? text(cfg.message) : undefined,
)
// What a slider sets against — a room's temperature beside its setpoint.
const beside: Series =
widget.type === "slider" ? ((cfg.reading ?? {}) as Series) : {}
const read = useBoundValue(beside.message || undefined)
// The counter the ring turns: the finest until another is pressed, and
// again once the face is left.
const counters = widget.type === "counter" ? countersOf(widget) : []
const [chosen, choose] = useState<number | null>(null)
const selected = Math.min(chosen ?? Infinity, counters.length - 1)
const dial = dialOf(
widget,
widget.type === "gauge" ? bound?.value : value,
selected,
)
const { current, held, latest, move, commit } = useRingDraft(
dial.value,
(next) => send(dial.wire(next)),
)
const svg = useRef<SVGSVGElement>(null)
const face = useRef<HTMLDivElement>(null)
const spin = useRef(0)
// Where a counting drag last was, and how far it has gone since its last
// notch, in degrees.
const dragged = useRef({ angle: 0, by: 0 })
const turns = Boolean(dial.notch) && !locked
const at = dial.at(current)
const along = useEased(at ?? 0, held, Boolean(dial.hue || dial.wraps))
// At the minimum the value arc is a zero-length dash whose round caps would
// still paint a dot, so it is not drawn there.
const shown = useTransform(along, (fraction) => (fraction > 0 ? 1 : 0))
const handleX = useTransform(along, (fraction) => ringPoint(size, fraction).x)
const handleY = useTransform(along, (fraction) => ringPoint(size, fraction).y)
const turn = (by: number) => {
if (turns && dial.notch) move(dial.notch(latest(), by), true)
}
// On the whole face rather than the ring: a knob turns wherever it is held.
// Up — away from you — is clockwise, which is up the scale.
const onWheel = useRef<(event: WheelEvent) => void>(() => {})
onWheel.current = (event) => {
spin.current += event.deltaMode === 1 ? event.deltaY * 33 : event.deltaY
if (Math.abs(spin.current) < WHEEL_NOTCH / 2) return
const notches = Math.max(
1,
Math.round(Math.abs(spin.current) / WHEEL_NOTCH),
)
turn(spin.current < 0 ? notches : -notches)
spin.current = 0
}
useEffect(() => {
const canvas = face.current?.parentElement
const listener = (event: WheelEvent) => onWheel.current(event)
canvas?.addEventListener("wheel", listener, { passive: true })
return () => canvas?.removeEventListener("wheel", listener)
}, [])
const aim = (event: React.PointerEvent, start = false) => {
const box = svg.current?.getBoundingClientRect()
if (!box) return
const dx = event.clientX - (box.left + box.width / 2)
const dy = event.clientY - (box.top + box.height / 2)
if (dial.drag && dial.notch) {
// Counted as a knob's ring counts its detents, the shorter way round
// from the last move, so a drag carries on through the gap.
const angle = (Math.atan2(dy, dx) * 180) / Math.PI
const last = dragged.current
last.by = start ? 0 : last.by + ((angle - last.angle + 540) % 360) - 180
last.angle = angle
const steps = Math.trunc(last.by / dial.drag)
if (!steps) return
last.by -= steps * dial.drag
move(dial.notch(latest(), steps), false)
return
}
if (dial.pick) move(dial.pick(fractionAt(dx, dy), latest()), false)
}
const title = showTitle(widget) ? widget.title || "" : ""
const reading = dial.reading(current)
// Written to the slider's own decimals and in its unit: the two are read
// against each other.
const other = beside.message ? dial.reading(read?.value ?? null) : null
// The same reading, placed: a dot where it sits on the slider's scale,
// held to the sweep at either end.
const actual = typeof read?.value === "number" ? dial.at(read.value) : null
const readAt = useEased(actual ?? 0)
const readX = useTransform(readAt, (fraction) => ringPoint(size, fraction).x)
const readY = useTransform(readAt, (fraction) => ringPoint(size, fraction).y)
const readDot = actual === null ? null : { x: readX, y: readY }
// What the dots are drawn under: the value arc and the handle, which
// repaint whatever part of a dot they cover (`RingDots`).
const id = useId().replace(/[^\w-]/g, "")
const whole = ringArc(size, 0, 1)
const name = widget.title || text(cfg.target, widget.id)
return (
<div
ref={face}
className="pointer-events-none absolute inset-0"
data-testid="ring"
>
{/* biome-ignore lint/a11y/noSvgWithoutTitle: named by `aria-label` in the role spread below, which the rule cannot see. */}
<svg
ref={svg}
width={size}
height={size}
className="dui-ring absolute inset-0"
// A control that turns, or a reading that only shows.
{...(dial.notch
? {
role: "slider",
"aria-label": name,
"aria-valuetext": `${reading}${dial.unit ?? ""}` || undefined,
"aria-valuenow":
typeof current === "number" ? current : undefined,
"aria-disabled": locked || undefined,
}
: {
role: "img",
"aria-label": `${name}: ${reading}${dial.unit ?? ""}`,
})}
tabIndex={turns ? 0 : undefined}
onKeyDown={(event) => {
const by = ARROWS[event.key]
if (!by || !turns) return
event.preventDefault()
turn(by)
}}
>
<path
d={whole}
fill="none"
strokeWidth={RING_STROKE + 8}
strokeLinecap="round"
className="dui-ring-focus"
/>
<g className={cn("dui-ring-arc", locked && "opacity-45")}>
{dial.hue ? (
<HueTrack size={size} />
) : (
<path
d={whole}
fill="none"
strokeWidth={RING_STROKE}
strokeLinecap="round"
className="dui-ring-track"
/>
)}
<RingDots size={size} stops={dial.stops} reading={readDot} />
{/* Revealed rather than re-pathed, as the gauge tile's arc is: `d`
does not ease, and `pathLength` makes the reveal the fraction. */}
{dial.fill ? (
<>
<motion.path
d={whole}
fill="none"
strokeWidth={RING_STROKE}
strokeLinecap="round"
className="dui-ring-fill"
style={{ pathLength: along, opacity: shown }}
/>
<g className="dui-ring-reached" mask={`url(#${id}-fill)`}>
<RingDots size={size} stops={dial.stops} reading={readDot} />
</g>
</>
) : null}
{dial.handle && at !== null ? (
<motion.circle
cx={handleX}
cy={handleY}
r={RING_HANDLE / 2}
className={cn("dui-ring-handle", dial.hue && "dui-ring-swatch")}
style={dial.hue ? { fill: cssOf(current as Triple) } : undefined}
/>
) : null}
{readDot && dial.handle && at !== null ? (
<g className="dui-ring-on-handle" clipPath={`url(#${id}-handle)`}>
<RingDots size={size} reading={readDot} />
</g>
) : null}
</g>
<defs>
{/* The value arc again, as a stencil: a dot it has reached is
drawn in the colour that reads on it, to the pixel it covers. */}
<mask
id={`${id}-fill`}
maskUnits="userSpaceOnUse"
x={0}
y={0}
width={size}
height={size}
>
<motion.path
d={whole}
fill="none"
stroke="#fff"
strokeWidth={RING_STROKE}
strokeLinecap="round"
style={{ pathLength: along, opacity: shown }}
/>
</mask>
<clipPath id={`${id}-handle`}>
<motion.circle cx={handleX} cy={handleY} r={RING_HANDLE / 2} />
</clipPath>
</defs>
{turns || editing ? (
<path
d={whole}
fill="none"
stroke="transparent"
strokeWidth={RING_INSET}
className="dui-ring-hit"
data-testid="ring-hit"
onPointerDown={(event) => {
if (editing) {
onPick?.()
return
}
event.currentTarget.setPointerCapture(event.pointerId)
aim(event, true)
}}
onPointerMove={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
aim(event)
}
}}
// Every way a drag can end publishes, as the grid's slider does:
// a browser taking the gesture over ends it in a cancel.
onPointerUp={commit}
onPointerCancel={commit}
onLostPointerCapture={commit}
/>
) : null}
</svg>
{other === null ? (
title ? (
<FaceTitle size={size}>{title}</FaceTitle>
) : null
) : (
<>
{beside.label ? (
<FaceTitle size={size}>{beside.label}</FaceTitle>
) : null}
{/* The gap at the bottom: what the ring sets, by name. */}
<div
data-testid="ring-gap"
className="absolute flex items-center justify-center gap-1.5 whitespace-nowrap"
style={{
left: size / 2 - GAP_WIDTH / 2,
width: GAP_WIDTH,
top: size - GAP_RISE - GAP_SIZE,
height: GAP_SIZE * 2,
fontSize: GAP_SIZE,
}}
>
{readOnly ? (
<Lock
role="img"
aria-label="Read-only"
data-testid={TESTID.locked}
className="shrink-0 text-muted-foreground"
style={{ width: GAP_SIZE, height: GAP_SIZE }}
/>
) : null}
{title ? (
<span className="min-w-0 truncate text-muted-foreground">
{title}
</span>
) : null}
<span className="shrink-0 tabular-nums">
{reading}
{dial.unit ?? ""}
</span>
</div>
</>
)}
{widget.type === "counter" ? (
<CounterCentre
counters={counters}
value={current as number}
selected={selected}
// Nothing to choose between with one, or with a ring that is locked.
onSelect={counters.length > 1 && turns ? choose : undefined}
/>
) : dial.hue ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span
data-testid="face-centre"
className="rounded-full border border-border"
style={{
width: CENTRE_SWATCH,
height: CENTRE_SWATCH,
background: cssOf(current as Triple),
}}
/>
</div>
) : (
<CentreReading
written={other ?? reading}
unit={dial.unit}
max={widget.type === "dropdown" ? CENTRE_LABEL : CENTRE_VALUE}
/>
)}
</div>
)
}
/**
* A counter's digits, written as large as a reading, each over its label.
*
* The digits are sized as one reading (`fitSize` of all of them, colons and
* all), and the pair is centred on the face as the clock's time and date are.
* A press on a counter's digits picks it for the ring, and the one it turns
* is drawn in the primary colour.
*/
function CounterCentre({
counters,
value,
selected,
onSelect,
}: {
counters: Counter[]
value: number
selected: number
onSelect?: (index: number) => void
}) {
const written = writtenOf(counters, value)
const fontSize = fitSize(written.join(":"), CENTRE_VALUE)
return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div
data-testid="face-centre"
className="flex items-start leading-none tabular-nums"
>
{counters.map((counter, index) => {
const marked = onSelect && index === selected
return (
// Two counters may share a label; position is the identity.
<Fragment key={`counter-${index}`}>
{index > 0 ? (
<span aria-hidden style={{ fontSize }}>
:
</span>
) : null}
<button
type="button"
data-testid={`counter-${index}`}
aria-pressed={onSelect ? index === selected : undefined}
disabled={!onSelect}
className={cn(
"flex flex-col items-center gap-1",
onSelect && "pointer-events-auto",
marked && "text-primary",
)}
onClick={() => onSelect?.(index)}
>
<span style={{ fontSize }}>{written[index]}</span>
<span
className={cn(!marked && "text-muted-foreground")}
style={{ fontSize: CENTRE_CAPTION, lineHeight: 1.35 }}
>
{counter.label}
</span>
</button>
</Fragment>
)
})}
</div>
</div>
)
}
/**
* The ring's dots: its stops, and what a slider reads beside its value.
*
* Drawn on the ring's centre line, inside the band, and drawn up to three
* times: on the track, again through the value arc as a stencil, and the
* reading once more through the handle. Each copy is in the colour that reads
* on what it sits on, so a dot changes colour exactly where the arc or the
* handle passes over it — and the reading still shows on the handle.
*/
function RingDots({
size,
stops = [],
reading,
}: {
size: number
stops?: number[]
reading: { x: MotionValue<number>; y: MotionValue<number> } | null
}) {
return (
<>
{stops.map((stop, index) => {
const point = ringPoint(size, stop)
return (
<circle
// Two options may share a label; position is the identity.
key={`stop-${index}`}
cx={point.x}
cy={point.y}
r={RING_MARK / 2}
className="dui-ring-stop"
/>
)
})}
{reading ? (
<motion.circle
cx={reading.x}
cy={reading.y}
r={RING_READING / 2}
className="dui-ring-reading"
/>
) : null}
</>
)
}
/**
* The colour ring's track: every hue in order along the sweep, red at both
* ends. Drawn as short strokes — an SVG stroke takes no conic gradient — each
* a hair longer than its share so no seam shows between two.
*/
function HueTrack({ size }: { size: number }) {
return (
<>
{Array.from({ length: HUE_SEGMENTS }, (_, index) => {
const from = index / HUE_SEGMENTS
const to = Math.min(1, (index + 1.5) / HUE_SEGMENTS)
const end = index === 0 || index === HUE_SEGMENTS - 1
return (
<path
// A fixed run of strokes; the index is the identity.
key={`hue-${index}`}
d={ringArc(size, from, to)}
fill="none"
strokeWidth={RING_STROKE}
strokeLinecap={end ? "round" : "butt"}
stroke={`hsl(${((index + 0.5) / HUE_SEGMENTS) * 360} 100% 50%)`}
/>
)
})}
</>
)
}