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

559 lines
16 KiB
TypeScript

import { Power } from "lucide-react"
import {
AnimatePresence,
motion,
useIsPresent,
type Variants,
} from "motion/react"
import { useEffect, useRef, useState } from "react"
import { ErrorBoundary } from "react-error-boundary"
import type { WidgetDef } from "@/client"
import { fadeIn } from "@/lib/motion"
import { useShortcuts } from "@/lib/shortcuts"
import { useClock } from "./ClockWidget"
import type { Dashboard } from "./DashboardView"
import { useBoundValue } from "./dataContext"
import { resolveIcon } from "./icons"
import { usePublish } from "./publish"
import { CentreReading, FaceTitle, Ring } from "./RingWidget"
import { useUi } from "./ui"
import { config, format, num, scrolls, showTitle, text } from "./ui/core/config"
import { useLook } from "./ui/core/look"
import { LOOK } from "./ui/core/motion"
import {
ANNOUNCE_MS,
BODY_HEIGHT,
BODY_WIDTH,
CENTRE_VALUE,
DOT,
DOT_PITCH,
DOTS_RISE,
DOTS_WIDTH,
fitSize,
PRESS,
SWIPE,
} from "./ui/core/ring"
import { 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,
"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
/** How far a face or a dashboard slides in as it is paged to, in canvas
* pixels, fading in as it comes. */
const PAGE_SLIDE = 48
/** A face arrives from the side it was paged towards. */
const TURN: Variants = {
enter: (way: number) => ({ opacity: 0, x: way * PAGE_SLIDE }),
shown: { opacity: 1, x: 0 },
leave: (way: number) => ({ opacity: 0, x: way * -PAGE_SLIDE }),
}
/** And a dashboard from below or above, as the faces do from the side. */
const RISE: Variants = {
enter: (way: number) => ({ opacity: 0, y: way * PAGE_SLIDE }),
shown: { opacity: 1, y: 0 },
leave: (way: number) => ({ opacity: 0, y: way * -PAGE_SLIDE }),
}
/**
* A panel's round dashboards, one at a time, paged up and down.
*
* The next one rises in from below as the last leaves upwards — the way a
* swipe up moved them — and the previous one comes down from above. One that
* arrives without a turn, by its address, only fades in.
*/
export function RoundPanel({
dashboard,
size,
onDashboard,
}: {
dashboard: Dashboard
size: number
onDashboard?: (by: number) => void
}) {
const { look } = useLook()
// Read between renders: the turn asked for, and the side the dashboard it
// brings arrives from once it has loaded.
const turned = useRef(0)
const shown = useRef(dashboard.name)
const way = useRef(0)
if (shown.current !== dashboard.name) {
shown.current = dashboard.name
way.current = turned.current
turned.current = 0
}
return (
<AnimatePresence initial={false} custom={way.current}>
<motion.div
key={dashboard.name}
custom={way.current}
variants={RISE}
initial="enter"
animate="shown"
exit="leave"
transition={LOOK[look].spring}
className="absolute inset-0"
>
<RoundView
dashboard={dashboard}
size={size}
onDashboard={
onDashboard &&
((by) => {
turned.current = by
onDashboard(by)
})
}
/>
</motion.div>
</AnimatePresence>
)
}
/**
* A round dashboard: one widget at a time, each drawn as the whole face.
*
* Its widgets are the faces, in their order. Left and right page through
* them — a swipe, a page dot, PageUp/PageDown — stopping at either end; up and
* down are the panel's other dashboards (`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.
*
* 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,
}: {
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
}) {
const widgets = dashboard.widgets ?? []
const { look } = useLook()
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)
// Read between renders: the face a page turn left, for the side the next
// one arrives from.
const was = useRef(index)
const way = useRef(0)
if (was.current !== index) {
way.current = index > was.current ? 1 : -1
was.current = index
}
const swiped = useRef(false)
// A dashboard on its way out keeps its keys to itself.
const present = useIsPresent()
const [announce, setAnnounce] = useState(
() =>
Boolean(onDashboard) &&
lastShown !== null &&
lastShown !== dashboard.name,
)
useEffect(() => {
if (onDashboard) lastShown = dashboard.name
}, [onDashboard, dashboard.name])
useEffect(() => {
if (!announce) return
const timer = setTimeout(() => setAnnounce(false), ANNOUNCE_MS)
return () => clearTimeout(timer)
}, [announce])
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 && present
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),
})
/** A drag that ends far enough away is a swipe, measured in canvas pixels
* whatever the canvas is scaled to. A drag along the ring turns it. */
const track = (event: React.PointerEvent) => {
swiped.current = false
if ((event.target as Element).closest(".dui-ring-hit")) return
const from = { x: event.clientX, y: event.clientY }
window.addEventListener(
"pointerup",
(end) => {
const scale =
(box.current?.getBoundingClientRect().width || size) / size
const dx = (end.clientX - from.x) / scale
const dy = (end.clientY - from.y) / scale
if (Math.max(Math.abs(dx), Math.abs(dy)) < SWIPE) return
swiped.current = true
// Towards the left is onwards, as a page is turned.
if (Math.abs(dx) >= Math.abs(dy)) page(index + (dx < 0 ? 1 : -1))
else onDashboard?.(dy < 0 ? 1 : -1)
},
{ once: true },
)
}
if (!widget) return null
const Glyph = resolveIcon(dashboard.icon ?? "")
// Closer together where there are more than fit the gap.
const pitch = Math.min(DOT_PITCH, DOTS_WIDTH / widgets.length)
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)
}
}}
>
<AnimatePresence initial={false} custom={way.current}>
<motion.div
key={widget.id}
custom={way.current}
variants={TURN}
initial="enter"
animate="shown"
exit="leave"
transition={LOOK[look].spring}
className="absolute inset-0"
>
<ErrorBoundary
fallback={
<Middle size={size}>This face could not be drawn.</Middle>
}
resetKeys={[widget.id]}
>
<Face
widget={widget}
dashboard={dashboard.name}
size={size}
editing={editing}
onPick={onPick && (() => onPick(widget.id))}
/>
</ErrorBoundary>
</motion.div>
</AnimatePresence>
{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"
>
<output
data-testid="dashboard-announce"
className="flex h-9 max-w-[220px] items-center gap-2 rounded-full border border-border bg-card/80 px-4 font-medium shadow-e2 backdrop-blur-md"
style={{ fontSize: 15 }}
>
{Glyph ? <Glyph className="size-4 shrink-0" aria-hidden /> : null}
<span className="truncate">
{dashboard.title || dashboard.name}
</span>
</output>
</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 (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>
)
}
/** 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>
)
}