import { useQuery } from "@tanstack/react-query" import { Lock, Power } from "lucide-react" import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, } from "motion/react" import { useEffect, useLayoutEffect, useRef, useState } from "react" import { flushSync } from "react-dom" import { ErrorBoundary } from "react-error-boundary" import type { WidgetDef } from "@/client" import { slotsFor } from "@/components/Common/UplotChart" import { duration, fadeIn } from "@/lib/motion" import { useShortcuts } from "@/lib/shortcuts" import { cn } from "@/lib/utils" import { useClock } from "./ClockWidget" import type { Dashboard } from "./DashboardView" import { useBoundValue, useBoundValues } from "./dataContext" import { resolveIcon } from "./icons" import { usePublish } from "./publish" import { dashboardQueryOptions } from "./queries" import { CentreReading, FaceTitle, hasGapLine, Ring } from "./RingWidget" import { LockedProvider, PaletteProvider, useLocked, usePalette, } from "./settings" import { useUi } from "./ui" import { arcPath } from "./ui/core/arc" import { config, format, num, rowsOf, scrolls, showTitle, text, } from "./ui/core/config" import { TESTID } from "./ui/core/contract" import { LookProvider, useCanvasRoot, useLook } from "./ui/core/look" import { LOOK } from "./ui/core/motion" import { ANNOUNCE_MS, BODY_HEIGHT, BODY_WIDTH, barArcs, barLegend, CENTRE_VALUE, chordWidth, DOT, DOT_PITCH, DOTS_RISE, DOTS_WIDTH, fitSize, GAP_RISE, GAP_SIZE, PAGE_SLOP, PRESS, pageOffset, RING_START, RING_SWEEP, TITLE_RISE, TITLE_SIZE, TITLE_WIDTH, turnsPage, } from "./ui/core/ring" import { barReadings, useAnimatedFraction, useAnimatedNumber, } from "./ui/core/values" import { WIDGET_LABELS, WidgetBody, type WidgetKind, widgetIssue, } from "./widgets" /** What a round face draws along its ring, which a knob's ring turns: one * value along a range each. */ export const RING_TYPES = new Set([ "slider", "dropdown", "color", "gauge", ]) /** * What has a face drawn for the circle. Every other type is drawn as its * rectangular body in the middle of the face — allowed, and marked in the * editor as a poor fit for round. */ export const ROUND_TYPES = new Set([ ...RING_TYPES, "bar", "stat", "switch", "button", "clock", "icon", "notification", "markdown", ]) /** Drawn by their own body, centred: a glyph, a few lines. */ const CENTRED = new Set(["icon", "notification", "markdown"]) /** The face each round dashboard showed last, for as long as the page lives. */ const lastFace = new Map() /** The dashboard a panel showed last, so landing on another can say which. */ let lastShown: string | null = null /** * One way a round face is swiped: sideways through its faces, or up and down * through the panel's dashboards. Offsets and speeds are canvas pixels. */ type Axis = { /** Whether there is a page onwards (1) or back (-1). */ open: (by: 1 | -1) => boolean /** The page dragged to `offset`, the one `by` that way beside it. */ drag: (offset: number, by: 1 | -1) => void /** Let go: turn to the page that way, or go back, at `speed` per second. */ release: (by: 1 | -1, turn: boolean, speed: number) => void } /** * Swiping, with the page under the finger. * * Nothing moves until the drag has gone `PAGE_SLOP` along one axis, which it * then keeps; from there the page follows the finger exactly and the next one * comes in beside it, or, past the last, the page gives a little and no more. * On release it turns if it went far enough or was flung (`turnsPage`), and * goes back otherwise. A drag that starts on the ring turns the ring instead. */ function usePageDrag( size: number, box: React.RefObject, axes: { x: Axis; y?: Axis }, swiped: React.RefObject, /** Settles whatever is still moving before a new drag takes hold. */ settle: () => void, ) { const latest = useRef(axes) latest.current = axes return (event: React.PointerEvent) => { swiped.current = false if (!event.isPrimary) return if ((event.target as Element).closest(".dui-ring-hit")) return settle() const scale = (box.current?.getBoundingClientRect().width || size) / size const from = { x: event.clientX, y: event.clientY } let axis: "x" | "y" | null = null let along = 0 // The last moments of the drag, for the speed it was let go at. const trail: [number, number][] = [] const target = () => (axis === "y" ? latest.current.y : latest.current.x) const move = (moved: PointerEvent) => { if (moved.pointerId !== event.pointerId) return const dx = (moved.clientX - from.x) / scale const dy = (moved.clientY - from.y) / scale if (!axis) { if (Math.max(Math.abs(dx), Math.abs(dy)) < PAGE_SLOP) return axis = Math.abs(dx) >= Math.abs(dy) ? "x" : "y" swiped.current = true } along = axis === "x" ? dx : dy trail.push([moved.timeStamp, along]) while (trail.length > 2 && moved.timeStamp - trail[0][0] > 80) { trail.shift() } // Towards the left, or up, is onwards, as a page is turned. const by = along < 0 ? 1 : -1 const page = target() page?.drag(pageOffset(along, page.open(by)), by) } const end = (ended: PointerEvent) => { if (ended.pointerId !== event.pointerId) return window.removeEventListener("pointermove", move) window.removeEventListener("pointerup", end) window.removeEventListener("pointercancel", end) const page = target() if (!axis || !page) return // Over the last 80 ms before letting go: a drag held still and then // released was not flung. const recent = trail.filter(([at]) => ended.timeStamp - at <= 80) const [then, was] = recent[0] ?? [ended.timeStamp, along] const speed = ended.timeStamp > then ? ((along - was) / (ended.timeStamp - then)) * 1000 : 0 const by = along < 0 ? 1 : -1 page.release( by, ended.type === "pointerup" && page.open(by) && turnsPage(along, speed, size), speed, ) } window.addEventListener("pointermove", move) window.addEventListener("pointerup", end) window.addEventListener("pointercancel", end) } } /** * A panel's round dashboards, one at a time, paged up and down. * * The dashboards either side are drawn a screen above and below, ahead of * time and live, each on the face it would open on, so a swipe up or down * drags the next one in exactly as a sideways swipe drags the next face. Let * go far enough and the turn completes; Shift+PageUp/PageDown turn the same * way. One opened by its address replaces the last at once. */ export function RoundPanel({ dashboard, size, onDashboard, around, editing, }: { dashboard: Dashboard size: number onDashboard?: (by: number) => void /** The panel's dashboards before and after this one. */ around?: [string | undefined, string | undefined] /** Being arranged, so nothing is locked. */ editing?: boolean }) { const { look } = useLook() const still = useReducedMotion() const spring = LOOK[look].spring const y = useMotionValue(0) // The dashboard held at the origin while a turn is on its way to the next, // where that one is and what it is called, and whether the turn has landed. const [held, setHeld] = useState(null) const [turning, setTurning] = useState<{ by: 1 | -1; name: string } | null>( null, ) const [landed, setLanded] = useState(false) const name = (by: number) => around?.[by > 0 ? 1 : 0] const read = (of: string | undefined) => ({ ...dashboardQueryOptions(of ?? ""), enabled: Boolean(of), }) const before = useQuery(read(around?.[0])).data const after = useQuery(read(around?.[1])).data // By the name it had when the turn began: the panel's neighbours are // counted from the new dashboard as soon as it is asked for. const target = useQuery(read(turning?.name)).data const turn = (by: 1 | -1, speed = 0) => { const next = name(by) if (!next || !onDashboard || held) return setHeld(dashboard) setTurning({ by, name: next }) setLanded(false) onDashboard(by) if (still) { y.jump(-by * size) setLanded(true) return } // Landed once it is within a pixel, rather than when the spring has // quite come to rest: the dashboard can take over from there. animate(y, -by * size, { ...spring, velocity: speed, onUpdate: (at) => { if (Math.abs(at + by * size) < 1) setLanded(true) }, }).then(() => setLanded(true)) } // Landed, and the panel has moved on to it: it becomes the one at the // origin, which it is already drawn as, so nothing moves. useLayoutEffect(() => { if (!held || !landed || dashboard.name === held.name) return setHeld(null) setTurning(null) setLanded(false) y.jump(0) }, [held, landed, dashboard.name, y]) const rise: Axis = { open: (by) => Boolean(name(by) && onDashboard) && !held, drag: (offset) => { if (!held) y.set(offset) }, release: (by, turnIt, speed) => { if (held) return if (turnIt) turn(by, speed) else if (still) y.jump(0) else animate(y, 0, { ...spring, velocity: speed }) }, } // On the track by place: the one shown and its neighbours a screen either // side; during a turn, the one it left and the one it is going to. const pages: [Dashboard | undefined, number][] = held && turning ? [ [held, 0], [target, turning.by], ] : [ [before, -1], [dashboard, 0], [after, 1], ] return ( {pages.map(([page, at]) => { if (!page) return null const view = ( turn(by > 0 ? 1 : -1) : undefined } rise={at === 0 ? rise : undefined} /> ) return ( // Keyed by name, so a neighbour becomes the one shown, and back, // without being drawn afresh: its own face memory and all. {editing ? ( view ) : ( {view} )} ) })} ) } /** * One dashboard on a round panel's track, in its own look. * * The canvas states the look, theme and palette of the dashboard shown, so a * neighbour beside it, or the one a turn is leaving, would wear those too. * Each page restates its own instead. The ground stays the canvas's, which * changes over as soon as the turn begins. */ function TrackPage({ dashboard, at, size, children, }: { dashboard: Dashboard /** Its place on the track, in screens from the one shown. */ at: number size: number children: React.ReactNode }) { // Touch belongs to the screen, so it is the canvas's whichever page this is. const { touch } = useLook() const root = useCanvasRoot(dashboard, touch) return (
{children}
) } /** A dashboard's icon and title: what a panel shows for a moment of a * dashboard it has just landed on. Opaque, so the face under it does not * read through the name. */ function NamePill({ dashboard }: { dashboard: Dashboard }) { const Glyph = resolveIcon(dashboard.icon ?? "") return ( {Glyph ? : null} {dashboard.title || dashboard.name} ) } /** * A round dashboard: one widget at a time, each drawn as the whole face. * * Its widgets are the faces, in their order, side by side: a swipe drags the * one shown and brings the next in beside it, and a page dot or * PageUp/PageDown slides it across; either stops at the ends. Up and down are * the panel's other dashboards (`rise`, `onDashboard`: a swipe or * Shift+PageUp/PageDown), and landing on one names it for a moment. Each * dashboard comes back to the face it was left on. The arrow keys stay the * ring's. A `peek` is a neighbour drawn beside the one shown: it answers to * nothing until it becomes the one shown. * * Arranging, the editor says which face is shown (`face`, `onFace`) and a * press on a face that is not one of its controls picks it (`onPick`). */ export function RoundView({ dashboard, size, face, onFace, editing, onPick, onDashboard, rise, peek, }: { dashboard: Dashboard /** The face's diameter: the canvas width. */ size: number face?: number onFace?: (index: number) => void editing?: boolean onPick?: (id: string) => void /** To the next (1) or the previous (-1) dashboard on this panel. */ onDashboard?: (by: number) => void /** Swiping up and down, to the panel's other dashboards. */ rise?: Axis peek?: boolean }) { const widgets = dashboard.widgets ?? [] const { look } = useLook() const still = useReducedMotion() const [own, setOwn] = useState(() => lastFace.get(dashboard.name) ?? 0) const index = Math.max(0, Math.min(widgets.length - 1, face ?? own)) const widget = widgets[index] const box = useRef(null) const swiped = useRef(false) const locked = useLocked() // The faces on the track: the one at its origin and, while one is peeked // at or on its way in, the one beside it, on the side it lies. const [strip, setStrip] = useState<{ at: number; beside: number | null }>( () => ({ at: index, beside: null }), ) const x = useMotionValue(0) // The speed a swipe was let go at, which the turn it starts carries on, and // whether a drag has the track. const fling = useRef(0) const dragging = useRef(false) const [announce, setAnnounce] = useState(false) // Named for a moment when a panel lands on it from another — once it is // the one shown, not while it waits beside it. useEffect(() => { if (!onDashboard || peek) return if (lastShown !== null && lastShown !== dashboard.name) setAnnounce(true) lastShown = dashboard.name }, [onDashboard, peek, dashboard.name]) useEffect(() => { if (!announce) return // Held for its whole time once faded in, as the docs and openpanel count it. const timer = setTimeout( () => setAnnounce(false), duration.base * 1000 + ANNOUNCE_MS, ) return () => clearTimeout(timer) }, [announce]) // A new face slides in from beside the one shown: brought there by a // swipe already, or put there now for a dot, a key or the editor. useLayoutEffect(() => { if (index === strip.at) { // Turned back to before a turn had landed: it slides back. if (strip.beside === null || dragging.current || x.isAnimating()) return let live = true const back = animate(x, 0, LOOK[look].spring) back.then(() => live && setStrip({ at: index, beside: null })) return () => { live = false back.stop() } } if (strip.beside !== index) { // A turn still on its way finishes at once first. x.jump(0) setStrip({ at: strip.beside ?? strip.at, beside: index }) return } const side = Math.sign(index - strip.at) const land = () => setStrip({ at: index, beside: null }) if (still) { land() return } let live = true const turn = animate(x, -side * size, { ...LOOK[look].spring, velocity: fling.current, }) fling.current = 0 turn.then(() => live && land()) return () => { live = false turn.stop() } }, [index, strip, still, look, size, x]) // One face on the track again: it is moved to the origin and the track // with it, in the same frame, so nothing is seen to move. useLayoutEffect(() => { if (strip.beside === null) x.jump(0) }, [strip.beside, x]) const page = (next: number) => { if (next < 0 || next >= widgets.length || next === index) return lastFace.set(dashboard.name, next) setOwn(next) onFace?.(next) } const keys = !editing && !peek useShortcuts({ pagedown: () => (keys ? page(index + 1) : false), pageup: () => (keys ? page(index - 1) : false), "shift+pagedown": () => (onDashboard && keys ? onDashboard(1) : false), "shift+pageup": () => (onDashboard && keys ? onDashboard(-1) : false), }) const sideways: Axis = { open: (by) => index + by >= 0 && index + by < widgets.length, drag: (offset, by) => { dragging.current = true const beside = sideways.open(by) ? index + by : null if (strip.at !== index || strip.beside !== beside) { setStrip({ at: index, beside }) } x.set(offset) }, release: (by, turn, speed) => { dragging.current = false if (turn) { fling.current = speed page(index + by) return } const back = () => setStrip({ at: index, beside: null }) if (still) { back() return } animate(x, 0, { ...LOOK[look].spring, velocity: speed }).then(back) }, } const track = usePageDrag(size, box, { x: sideways, y: rise }, swiped, () => { // A turn still running lands at once, so the new drag starts from a // page at rest. if (!x.isAnimating() && strip.beside === null) return x.stop() flushSync(() => setStrip({ at: index, beside: null })) x.jump(0) }) if (!widget) return null // Closer together where there are more than fit the gap. const pitch = Math.min(DOT_PITCH, DOTS_WIDTH / widgets.length) const drawn = (at: number, offset: number) => { const one = widgets[at] if (!one) return null return (
This face could not be drawn.} resetKeys={[one.id]} > onPick(one.id))} /> {/* In the ring's gap, where a knob's bezel does not reach; a slider's gap line holds it already, led by the lock. */} {locked && !editing && !hasGapLine(one) ? (
Read-only
) : null}
) } return ( // A face is pressed, swiped and paged rather than focused: its controls // and the dots below are what the keyboard reaches. // biome-ignore lint/a11y/useKeyWithClickEvents: PageUp/PageDown page it, window-wide. // biome-ignore lint/a11y/noStaticElementInteractions: see above.
{ if (!swiped.current) return swiped.current = false event.stopPropagation() event.preventDefault() }} onClick={(event) => { if (!onPick) return const target = event.target as Element if (!target.closest("button, a, input, [role='slider']")) { onPick(widget.id) } }} > {drawn(strip.at, 0)} {strip.beside === null ? null : drawn(strip.beside, Math.sign(strip.beside - strip.at) * size)} {widgets.length > 1 ? ( ) : null} {announce ? ( ) : null}
) } /** * One widget, as a whole face: its title in the face's one title slot, and * what it shows or does in the middle. A ring type draws along the edge too. */ function Face({ widget, dashboard, size, editing, onPick, }: { widget: WidgetDef dashboard: string size: number editing?: boolean onPick?: () => void }) { const issue = widgetIssue(widget) if (widget.type === "bar" && !issue) { return } if (RING_TYPES.has(widget.type) && !issue) { return ( ) } const title = showTitle(widget) ? widget.title : "" return ( <> {title ? {title} : null} {issue ? (

{issue}

) : widget.type === "stat" ? ( ) : widget.type === "switch" ? ( ) : widget.type === "button" ? ( ) : widget.type === "clock" ? ( ) : CENTRED.has(widget.type) ? ( ) : ( )} ) } /** The face's middle: `BODY_WIDTH` by `BODY_HEIGHT`, centred on it. */ function Middle({ size, children, }: { size: number children: React.ReactNode }) { return (
{children}
) } /** * A type with no face of its own: its rectangular body, in the square the * circle inscribes, from just under the title slot down. Frameless, but in a * frame all the same, which is what its controls and type are measured off. */ function Inscribed({ widget, dashboard, size, }: WidgetProps & { size: number }) { const { Frame } = useUi() const half = size / Math.SQRT2 / 2 return (
) } type WidgetProps = { widget: WidgetDef; dashboard: string } function StatFace({ widget }: { widget: WidgetDef }) { const cfg = config(widget) const live = useBoundValue(text(cfg.message) || undefined) const precision = cfg.precision === undefined ? null : num(cfg.precision, 1) const { label, numeric } = useAnimatedNumber(live?.value, precision) return ( {numeric ? {label} : undefined} ) } /** * A bar as rings: an arc per row, the first outermost, each on its own scale * and in the dashboard's data colour for its row, as the tile's bars are. The * middle is their legend — a dot of the row's colour, its label and its value * — which is what says which arc is which. */ function BarFace({ widget, size }: { widget: WidgetDef; size: number }) { const rows = rowsOf(widget).filter((row) => row.message) const live = useBoundValues(rows.map((row) => row.message as string)) const readings = barReadings(widget, live, slotsFor(usePalette())) const { stroke, radii, inner } = barArcs(size, readings.length) const legend = barLegend(readings.length) const height = readings.length * legend.line // As wide as its widest line, and no wider than the inner disc is at the // legend's top and bottom lines. const width = Math.min(BODY_WIDTH, chordWidth(inner, height / 2, 16)) const title = showTitle(widget) ? widget.title : "" return ( <> {title ? ( {title} ) : null}
{readings.map((row, index) => (
{row.label} {format(row.value, row.precision)} {row.unit ?? ""}
))}
) } /** One row's arc: its track, and its reading eased along it. */ function BarArc({ fraction, color, radius, stroke, size, }: { fraction: number color: string radius: number stroke: number size: number }) { const along = useAnimatedFraction(fraction) // None at the minimum, where the round caps would still paint a dot. const shown = useTransform(along, (at) => (at > 0 ? 1 : 0)) const d = arcPath(RING_START, RING_START + RING_SWEEP, radius, size / 2) return ( <> ) } /** The time as large as a reading, shrunk to fit as one is, over the date. */ function ClockFace() { const { time, date } = useClock() return (

{time}

{date}

) } /** A latch, as one large round press that says its state in a word. */ function SwitchFace({ widget, dashboard }: WidgetProps) { const { Button } = useUi() const { target, value, send, locked } = usePublish(widget, dashboard) const on = value === true return (
) } function ButtonFace({ widget, dashboard }: WidgetProps) { const { Button } = useUi() const { send, pending, locked } = usePublish(widget, dashboard) return (
) }