Files
app/frontend/src/components/Dashboard/RoundFace.tsx
T
Melvin Strobl d90c18587b
Docs / docs (push) Successful in 20s
Playwright Tests / test-playwright (1, 2) (push) Failing after 4m22s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m12s
pre-commit / pre-commit (push) Failing after 2m33s
Test Backend / test-backend (3.12) (push) Successful in 5m1s
Test Backend / test-backend (3.14) (push) Successful in 4m52s
Compose Smoke Test / test-compose (push) Successful in 33s
Playwright Tests / merge-reports (push) Successful in 1m32s
Merge remote-tracking branch 'origin/main'
2026-10-15 21:17:33 +02:00

1026 lines
31 KiB
TypeScript

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<WidgetKind>([
"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<WidgetKind>([
...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<WidgetKind>(["icon", "notification", "markdown"])
/** The face each round dashboard showed last, for as long as the page lives. */
const lastFace = new Map<string, number>()
/** 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<HTMLElement | null>,
axes: { x: Axis; y?: Axis },
swiped: React.RefObject<boolean>,
/** 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<Dashboard | null>(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 (
<motion.div className="absolute inset-0" style={{ y }}>
{pages.map(([page, at]) => {
if (!page) return null
const view = (
<RoundView
dashboard={page}
size={size}
peek={at !== 0}
onDashboard={
at === 0 && onDashboard
? (by) => 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.
<TrackPage key={page.name} dashboard={page} at={at} size={size}>
<PaletteProvider dashboard={page}>
{editing ? (
view
) : (
<LockedProvider dashboard={page}>{view}</LockedProvider>
)}
</PaletteProvider>
</TrackPage>
)
})}
</motion.div>
)
}
/**
* 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 (
<LookProvider dashboard={dashboard} touch={touch}>
<div
data-look={root["data-look"]}
data-touch={root["data-touch"]}
data-palette={root["data-palette"]}
className={cn("absolute inset-0 text-foreground", root.className)}
style={{ ...root.style, translate: `0 ${at * size}px` }}
inert={at !== 0}
>
{children}
</div>
</LookProvider>
)
}
/** 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 (
<output
data-testid="dashboard-announce"
className="flex h-14 max-w-[280px] items-center gap-2.5 rounded-full border border-border bg-card px-5 font-medium text-card-foreground shadow-e2"
style={{ fontSize: 24 }}
>
{Glyph ? <Glyph className="size-6 shrink-0" aria-hidden /> : null}
<span className="truncate">{dashboard.title || dashboard.name}</span>
</output>
)
}
/**
* 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<HTMLDivElement>(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 (
<div
// The same element wherever it sits on the track, so a face that
// slides in is not drawn afresh once it has.
key={one.id}
className="absolute inset-0"
style={{ translate: `${offset}px 0` }}
>
<ErrorBoundary
fallback={<Middle size={size}>This face could not be drawn.</Middle>}
resetKeys={[one.id]}
>
<Face
widget={one}
dashboard={dashboard.name}
size={size}
editing={editing}
onPick={onPick && (() => onPick(one.id))}
/>
</ErrorBoundary>
{/* 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) ? (
<div
aria-live="polite"
data-testid={TESTID.locked}
className="pointer-events-none absolute inset-x-0 flex items-center justify-center gap-1.5 text-muted-foreground"
style={{
top: size - GAP_RISE - GAP_SIZE,
height: GAP_SIZE * 2,
fontSize: GAP_SIZE,
}}
>
<Lock style={{ width: GAP_SIZE, height: GAP_SIZE }} aria-hidden />
Read-only
</div>
) : null}
</div>
)
}
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.
<div
ref={box}
data-testid="round-face"
data-face={index}
className="absolute inset-0 select-none"
// The swipe is the gesture here, so the browser must not take it for a
// scroll and cancel it.
style={{ touchAction: "none" }}
onPointerDown={track}
// A swipe that ended on a control was not a press of it.
onClickCapture={(event) => {
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)
}
}}
>
<motion.div className="absolute inset-0" style={{ x }}>
{drawn(strip.at, 0)}
{strip.beside === null
? null
: drawn(strip.beside, Math.sign(strip.beside - strip.at) * size)}
</motion.div>
{widgets.length > 1 ? (
<nav
aria-label="Faces"
className="absolute inset-x-0 flex justify-center"
style={{ top: size - DOTS_RISE - DOT_PITCH / 2, height: DOT_PITCH }}
>
{widgets.map((one, at) => (
<button
key={one.id}
type="button"
aria-label={one.title || WIDGET_LABELS[one.type]}
aria-current={at === index ? "page" : undefined}
data-testid={`face-dot-${at}`}
className="dui-dot flex items-center justify-center"
style={{ width: pitch, height: DOT_PITCH }}
onClick={() => page(at)}
>
<span
className="dui-dot-mark"
style={{ width: DOT, height: DOT }}
/>
</button>
))}
</nav>
) : null}
<AnimatePresence>
{announce ? (
<motion.div
key="announce"
variants={fadeIn}
initial="hidden"
animate="visible"
exit="exit"
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center"
>
<NamePill dashboard={dashboard} />
</motion.div>
) : null}
</AnimatePresence>
</div>
)
}
/**
* 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 <BarFace widget={widget} size={size} />
}
if (RING_TYPES.has(widget.type) && !issue) {
return (
<Ring
widget={widget}
dashboard={dashboard}
size={size}
editing={editing}
onPick={onPick}
/>
)
}
const title = showTitle(widget) ? widget.title : ""
return (
<>
{title ? <FaceTitle size={size}>{title}</FaceTitle> : null}
{issue ? (
<Middle size={size}>
<p className="text-muted-foreground">{issue}</p>
</Middle>
) : widget.type === "stat" ? (
<StatFace widget={widget} />
) : widget.type === "switch" ? (
<Middle size={size}>
<SwitchFace widget={widget} dashboard={dashboard} />
</Middle>
) : widget.type === "button" ? (
<Middle size={size}>
<ButtonFace widget={widget} dashboard={dashboard} />
</Middle>
) : widget.type === "clock" ? (
<Middle size={size}>
<ClockFace />
</Middle>
) : CENTRED.has(widget.type) ? (
<Middle size={size}>
<WidgetBody widget={widget} dashboard={dashboard} />
</Middle>
) : (
<Inscribed widget={widget} dashboard={dashboard} size={size} />
)}
</>
)
}
/** The face's middle: `BODY_WIDTH` by `BODY_HEIGHT`, centred on it. */
function Middle({
size,
children,
}: {
size: number
children: React.ReactNode
}) {
return (
<div
className="dui-face-body absolute flex items-center justify-center text-center"
style={{
left: (size - BODY_WIDTH) / 2,
top: (size - BODY_HEIGHT) / 2,
width: BODY_WIDTH,
height: BODY_HEIGHT,
}}
>
{children}
</div>
)
}
/**
* 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 (
<div
className="absolute"
style={{
left: size / 2 - half,
width: 2 * half,
top: (size - BODY_HEIGHT) / 2,
height: half + BODY_HEIGHT / 2,
}}
>
<Frame scroll={scrolls(widget)}>
<WidgetBody widget={widget} dashboard={dashboard} />
</Frame>
</div>
)
}
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 (
<CentreReading
written={format(live?.value, precision)}
unit={cfg.unit ? text(cfg.unit) : undefined}
>
{numeric ? <motion.span>{label}</motion.span> : undefined}
</CentreReading>
)
}
/**
* 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 (
<>
<svg
width={size}
height={size}
className="pointer-events-none absolute inset-0"
aria-hidden="true"
>
{readings.map((row, index) => (
<BarArc
// Two rows may read the same message; position is the identity.
key={`row-${index}`}
fraction={row.fraction}
color={row.color}
radius={radii[index]}
stroke={stroke}
size={size}
/>
))}
</svg>
{title ? (
<FaceTitle
size={size}
// Clear of the inner arcs, which come in this far with many rows.
width={Math.min(
TITLE_WIDTH,
chordWidth(inner, TITLE_RISE + TITLE_SIZE / 2, 8),
)}
>
{title}
</FaceTitle>
) : null}
<div
className="pointer-events-none absolute grid items-center gap-x-2 tabular-nums"
style={{
left: size / 2,
translate: "-50%",
width: "max-content",
maxWidth: width,
top: (size - height) / 2,
gridTemplateColumns: "auto minmax(0, 1fr) auto",
gridAutoRows: legend.line,
fontSize: legend.size,
}}
>
{readings.map((row, index) => (
<div
key={`row-${index}`}
role="img"
aria-label={`${row.label} ${format(row.value, row.precision)}${row.unit ?? ""}`}
data-testid="bar-face-row"
className="contents"
>
<span
className="size-2 rounded-full"
style={{ background: row.color }}
/>
<span className="truncate text-muted-foreground">{row.label}</span>
<span className="whitespace-nowrap text-right">
{format(row.value, row.precision)}
{row.unit ?? ""}
</span>
</div>
))}
</div>
</>
)
}
/** 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 (
<>
<path
d={d}
fill="none"
strokeWidth={stroke}
strokeLinecap="round"
className="dui-ring-track"
/>
<motion.path
d={d}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
style={{ pathLength: along, opacity: shown }}
/>
</>
)
}
/** The time as large as a reading, shrunk to fit as one is, over the date. */
function ClockFace() {
const { time, date } = useClock()
return (
<div className="grid gap-1">
<p
data-testid="clock-time"
className="leading-none tabular-nums"
style={{ fontSize: fitSize(time, CENTRE_VALUE) }}
>
{time}
</p>
<p className="text-muted-foreground">{date}</p>
</div>
)
}
/** 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 (
<div className="dui-face-press" style={{ width: PRESS, height: PRESS }}>
<Button
variant={on ? "filled" : "tonal"}
pressed={on}
label={widget.title || target}
disabled={locked}
onClick={() => send(!on)}
>
<span className="flex flex-col items-center gap-1">
<Power className="size-10" aria-hidden />
{on ? "On" : "Off"}
</span>
</Button>
</div>
)
}
function ButtonFace({ widget, dashboard }: WidgetProps) {
const { Button } = useUi()
const { send, pending, locked } = usePublish(widget, dashboard)
return (
<div className="dui-face-press" style={{ width: PRESS, height: PRESS }}>
<Button
variant="tonal"
disabled={pending || locked}
onClick={() => send(config(widget).value ?? true)}
>
<span className="dui-face-label">
{text(config(widget).label, widget.title || "Send")}
</span>
</Button>
</div>
)
}