Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ezWcLmBCtTVKFPxfdK2MK
783 lines
26 KiB
TypeScript
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%)`}
|
|
/>
|
|
)
|
|
})}
|
|
</>
|
|
)
|
|
}
|