559 lines
16 KiB
TypeScript
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>
|
|
)
|
|
}
|