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
1026 lines
31 KiB
TypeScript
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>
|
|
)
|
|
}
|