Round faces: a bar as concentric arcs with a legend in the middle; larger title, gap line and face text; Read-only in the ring's gap; a round dashboard unpadded on a phone; reduced-motion ring assertion
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f5412d2f7d
commit
541dbce584
@@ -118,6 +118,8 @@ export function LockNotice({ dashboard }: { dashboard: Dashboard }) {
|
||||
// Read straight from the document rather than through `useLocked`: that
|
||||
// provider sits inside the grid, and this notice is beside it.
|
||||
const locked = useDashboardLocked(dashboard)
|
||||
if (!locked) return null
|
||||
// A round face says it in its own gap (`RoundView`): a corner outside the
|
||||
// circle is under a knob's bezel.
|
||||
if (!locked || isRound(dashboard)) return null
|
||||
return <Notice>Read-only</Notice>
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Lock } from "lucide-react"
|
||||
import { type MotionValue, motion, useTransform } from "motion/react"
|
||||
import { useEffect, useId, useRef } from "react"
|
||||
|
||||
@@ -5,6 +6,7 @@ import type { WidgetDef } from "@/client"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useBoundValue } from "./dataContext"
|
||||
import { usePublish } from "./publish"
|
||||
import { useLocked } from "./settings"
|
||||
import {
|
||||
colorFormatOf,
|
||||
cssOf,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
showTitle,
|
||||
text,
|
||||
} from "./ui/core/config"
|
||||
import { TESTID } from "./ui/core/contract"
|
||||
import { tickIntervals } from "./ui/core/controls"
|
||||
import {
|
||||
CENTRE_LABEL,
|
||||
@@ -183,6 +186,12 @@ function dialOf(widget: WidgetDef, live: unknown): Dial {
|
||||
}
|
||||
}
|
||||
|
||||
/** A slider that reads something beside its value, which then takes the
|
||||
* middle and moves the slider's own title and value to the gap's line. */
|
||||
export const hasGapLine = (widget: WidgetDef) =>
|
||||
widget.type === "slider" &&
|
||||
Boolean(((config(widget).reading ?? {}) as Series).message)
|
||||
|
||||
/**
|
||||
* A face's title: one slot, above the middle, whichever widget it is.
|
||||
*
|
||||
@@ -191,17 +200,20 @@ function dialOf(widget: WidgetDef, live: unknown): Dial {
|
||||
*/
|
||||
export function FaceTitle({
|
||||
size,
|
||||
width = TITLE_WIDTH,
|
||||
children,
|
||||
}: {
|
||||
size: number
|
||||
/** Narrower where something is drawn inside the ring beside it. */
|
||||
width?: number
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<p
|
||||
className="pointer-events-none absolute truncate text-center text-muted-foreground"
|
||||
style={{
|
||||
left: size / 2 - TITLE_WIDTH / 2,
|
||||
width: TITLE_WIDTH,
|
||||
left: size / 2 - width / 2,
|
||||
width,
|
||||
top: size / 2 - TITLE_RISE - TITLE_SIZE,
|
||||
lineHeight: `${TITLE_SIZE * 2}px`,
|
||||
fontSize: TITLE_SIZE,
|
||||
@@ -273,6 +285,9 @@ export function Ring({
|
||||
onPick?: () => void
|
||||
}) {
|
||||
const { value, send, locked } = usePublish(widget, dashboard)
|
||||
// The dashboard's own lock, which the gap's line leads with when it holds
|
||||
// the gap (`RoundView` writes it there otherwise).
|
||||
const readOnly = useLocked()
|
||||
const cfg = config(widget)
|
||||
// A gauge reads its message rather than publishing to a target.
|
||||
const bound = useBoundValue(
|
||||
@@ -517,6 +532,15 @@ export function Ring({
|
||||
fontSize: GAP_SIZE,
|
||||
}}
|
||||
>
|
||||
{readOnly ? (
|
||||
<Lock
|
||||
role="img"
|
||||
aria-label="Read-only"
|
||||
data-testid={TESTID.locked}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
style={{ width: GAP_SIZE, height: GAP_SIZE }}
|
||||
/>
|
||||
) : null}
|
||||
{title ? (
|
||||
<span className="min-w-0 truncate text-muted-foreground">
|
||||
{title}
|
||||
|
||||
@@ -1,40 +1,66 @@
|
||||
import { Power } from "lucide-react"
|
||||
import { Lock, Power } from "lucide-react"
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
useIsPresent,
|
||||
useTransform,
|
||||
type Variants,
|
||||
} from "motion/react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { ErrorBoundary } from "react-error-boundary"
|
||||
|
||||
import type { WidgetDef } from "@/client"
|
||||
import { slotsFor } from "@/components/Common/UplotChart"
|
||||
import { fadeIn } from "@/lib/motion"
|
||||
import { useShortcuts } from "@/lib/shortcuts"
|
||||
import { useClock } from "./ClockWidget"
|
||||
import type { Dashboard } from "./DashboardView"
|
||||
import { useBoundValue } from "./dataContext"
|
||||
import { useBoundValue, useBoundValues } from "./dataContext"
|
||||
import { resolveIcon } from "./icons"
|
||||
import { usePublish } from "./publish"
|
||||
import { CentreReading, FaceTitle, Ring } from "./RingWidget"
|
||||
import { CentreReading, FaceTitle, hasGapLine, Ring } from "./RingWidget"
|
||||
import { useLocked, usePalette } from "./settings"
|
||||
import { useUi } from "./ui"
|
||||
import { config, format, num, scrolls, showTitle, text } from "./ui/core/config"
|
||||
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 { 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,
|
||||
PRESS,
|
||||
RING_START,
|
||||
RING_SWEEP,
|
||||
SWIPE,
|
||||
TITLE_RISE,
|
||||
TITLE_SIZE,
|
||||
TITLE_WIDTH,
|
||||
} from "./ui/core/ring"
|
||||
import { useAnimatedNumber } from "./ui/core/values"
|
||||
import {
|
||||
barReadings,
|
||||
useAnimatedFraction,
|
||||
useAnimatedNumber,
|
||||
} from "./ui/core/values"
|
||||
import {
|
||||
WIDGET_LABELS,
|
||||
WidgetBody,
|
||||
@@ -58,6 +84,7 @@ export const RING_TYPES = new Set<WidgetKind>([
|
||||
*/
|
||||
export const ROUND_TYPES = new Set<WidgetKind>([
|
||||
...RING_TYPES,
|
||||
"bar",
|
||||
"stat",
|
||||
"switch",
|
||||
"button",
|
||||
@@ -198,6 +225,7 @@ export function RoundView({
|
||||
const swiped = useRef(false)
|
||||
// A dashboard on its way out keeps its keys to itself.
|
||||
const present = useIsPresent()
|
||||
const locked = useLocked()
|
||||
const [announce, setAnnounce] = useState(
|
||||
() =>
|
||||
Boolean(onDashboard) &&
|
||||
@@ -312,6 +340,23 @@ export function RoundView({
|
||||
onPick={onPick && (() => onPick(widget.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(widget) ? (
|
||||
<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}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -386,6 +431,9 @@ function Face({
|
||||
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
|
||||
@@ -499,6 +547,131 @@ function StatFace({ widget }: { widget: WidgetDef }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
@@ -706,13 +706,13 @@
|
||||
* A round dashboard (`data-shape="round"` on the canvas): one widget at a
|
||||
* time, each the whole face (`RoundFace.tsx`). Its geometry is in `ring.ts`.
|
||||
*
|
||||
* The middle of a face is written at 15px and centred, and the bodies a face
|
||||
* The middle of a face is written at 17px and centred, and the bodies a face
|
||||
* borrows from its tile are sized for it here: a glyph — or the dash for none —
|
||||
* at the size a reading has, a note or a notification as centred lines.
|
||||
*/
|
||||
.dui-face-body {
|
||||
overflow: hidden;
|
||||
font-size: 15px;
|
||||
font-size: 17px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@@ -741,7 +741,7 @@
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
padding: 0 12px;
|
||||
font-size: 15px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.dui-face-press > button > :last-child {
|
||||
|
||||
@@ -32,16 +32,16 @@ export const RING_READING = 10
|
||||
export const RING_SETTLE_MS = 300
|
||||
/** The gap's line — a slider's title and value, when what it reads is in
|
||||
* the middle — centred this far above the bottom of the face, at this size,
|
||||
* and at most this wide: inside the face at any size. */
|
||||
* and at most this wide: clear of the ring's end caps at any size. */
|
||||
export const GAP_RISE = 28
|
||||
export const GAP_SIZE = 13
|
||||
export const GAP_WIDTH = 160
|
||||
export const GAP_SIZE = 15
|
||||
export const GAP_WIDTH = 140
|
||||
|
||||
/** Every face's title: centred this far above the middle, at this size, and
|
||||
* at most this wide. One place, so a face is read the same way whichever
|
||||
* widget it is. */
|
||||
export const TITLE_RISE = 80
|
||||
export const TITLE_SIZE = 15
|
||||
export const TITLE_SIZE = 18
|
||||
export const TITLE_WIDTH = 200
|
||||
|
||||
/** What a face draws in its middle — a reading, a press, a few lines — fits
|
||||
@@ -67,6 +67,44 @@ export const DOT = 6
|
||||
export const DOT_PITCH = 16
|
||||
export const DOTS_WIDTH = 128
|
||||
|
||||
/**
|
||||
* A bar's face: an arc per row, concentric, the first outermost.
|
||||
*
|
||||
* The arcs share a band this thick from the ring's own outer edge inward, a
|
||||
* constant gap apart, each as thick as the ring while that fits and thinner
|
||||
* with more rows. Its legend's lines are as large as the middle allows.
|
||||
*/
|
||||
export const BAR_BAND = 64
|
||||
export const BAR_GAP = 3
|
||||
|
||||
export function barArcs(size: number, rows: number) {
|
||||
const stroke = Math.min(RING_STROKE, (BAR_BAND - BAR_GAP * (rows - 1)) / rows)
|
||||
// The ring's track's outer edge, so up to three rows draw on the ring.
|
||||
const outer = size / 2 - RING_INSET / 2 + RING_STROKE / 2
|
||||
return {
|
||||
stroke,
|
||||
radii: Array.from(
|
||||
{ length: rows },
|
||||
(_, index) => outer - stroke / 2 - index * (stroke + BAR_GAP),
|
||||
),
|
||||
/** What is left inside the innermost arc. */
|
||||
inner: outer - rows * stroke - (rows - 1) * BAR_GAP,
|
||||
}
|
||||
}
|
||||
|
||||
/** A legend line's text size and height, by how many rows share the middle. */
|
||||
export const barLegend = (rows: number) =>
|
||||
rows <= 5
|
||||
? { size: 17, line: 22 }
|
||||
: rows <= 7
|
||||
? { size: 15, line: 18 }
|
||||
: { size: 13, line: 16 }
|
||||
|
||||
/** How wide a line `rise` above or below the middle can be inside a disc of
|
||||
* radius `inner`, less `margin`. */
|
||||
export const chordWidth = (inner: number, rise: number, margin: number) =>
|
||||
Math.floor(2 * Math.sqrt(Math.max(0, inner ** 2 - rise ** 2)) - margin)
|
||||
|
||||
/** A drag this long, mostly along one axis, is a swipe rather than a press. */
|
||||
export const SWIPE = 40
|
||||
|
||||
|
||||
@@ -108,7 +108,8 @@ function PanelRoute() {
|
||||
// edges by the grid inset, and a second margin outside it costs the
|
||||
// scale: a dashboard drawn for this screen would come out at 95% of
|
||||
// the screen it was drawn for.
|
||||
stacked ? "overflow-y-auto p-4" : "overflow-hidden",
|
||||
// A round one fills the screen as it does a knob's, stacked or not.
|
||||
stacked && !round ? "overflow-y-auto p-4" : "overflow-hidden",
|
||||
)}
|
||||
style={root.style}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"
|
||||
import { ContextBar } from "@/components/Dashboard/ContextBar"
|
||||
import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
||||
import { type Dashboard, isRound } from "@/components/Dashboard/DashboardView"
|
||||
import { useDataContext } from "@/components/Dashboard/dataContext"
|
||||
import { PanelSurface } from "@/components/Dashboard/PanelSurface"
|
||||
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
||||
@@ -66,8 +66,10 @@ function PanelView() {
|
||||
"h-svh w-full bg-background text-foreground",
|
||||
root.className,
|
||||
// See `panel.$id`: the canvas keeps its own margin, so a second one
|
||||
// here only shrinks it.
|
||||
stacked ? "overflow-y-auto p-4" : "overflow-hidden",
|
||||
// here only shrinks it — and a round one is never stacked.
|
||||
stacked && !isRound(dashboard as Dashboard)
|
||||
? "overflow-y-auto p-4"
|
||||
: "overflow-hidden",
|
||||
)}
|
||||
style={root.style}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user