Round paging follows the finger: faces and dashboards on a track, swiped one to one with the next beside it, turned or sent back on release, rubber-banded at the ends; keys and dots slide the same track; the reading dot a smaller companion of the handle
Docs / docs (push) Successful in 24s
Playwright Tests / test-playwright (1, 2) (push) Failing after 4m14s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m11s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 3m23s
Compose Smoke Test / test-compose (push) Successful in 34s
Playwright Tests / merge-reports (push) Successful in 1m33s
Docs / docs (push) Successful in 24s
Playwright Tests / test-playwright (1, 2) (push) Failing after 4m14s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m11s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 3m23s
Compose Smoke Test / test-compose (push) Successful in 34s
Playwright Tests / merge-reports (push) Successful in 1m33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
541dbce584
commit
603635f947
@@ -101,10 +101,12 @@ the track, the value's fill, or the handle.
|
||||
| Dot | Size, tile / ring | On the track | On the fill | On the handle |
|
||||
|---|---|---|---|---|
|
||||
| Stop: a tick, an option | half the track's thickness (4 on the 8 track) / 6 | `--muted-foreground` | `--primary-foreground` at 70 % | hidden under it |
|
||||
| Reading | three quarters of the track's thickness (6) / 10 | `--foreground` | `--primary-foreground` | Fluksio `--foreground`, Material `--primary-foreground`, Glass black at 75 % |
|
||||
| Reading | the track's thickness (8) / 12 | `--foreground` | `--primary-foreground` | Fluksio `--foreground`, Material `--primary-foreground`, Glass black at 75 % |
|
||||
|
||||
The reading is the larger dot in the stronger colour, and it is the one dot
|
||||
that shows on the handle, so a reading at the setpoint is still visible.
|
||||
The reading is a filled circle like the handle, smaller and in the text colour
|
||||
rather than the handle's paint, so it reads as what the room is beside what it
|
||||
is set to. It is larger than a stop and stronger in colour, and it is the one
|
||||
dot that shows on the handle, so a reading at the setpoint is still visible.
|
||||
|
||||
| Part | Value |
|
||||
|---|---|
|
||||
@@ -260,11 +262,11 @@ face, and the editor marks it **Poor fit for round**.
|
||||
| Shift+PageDown / Shift+PageUp | next / previous dashboard of the panel |
|
||||
| Arrow keys | turn the ring, not the page |
|
||||
|
||||
A swipe is a drag of at least 40 canvas pixels, decided by its longer axis; a
|
||||
drag that starts on the ring turns the ring instead. A dashboard reopens on the
|
||||
face it was last left on in this session, otherwise on its first. Landing on
|
||||
another dashboard shows its title and icon in the middle of the face for one
|
||||
second; a round panel has no rail.
|
||||
A swipe carries the page with the finger ([Swiping](#swiping)); a drag that
|
||||
starts on the ring turns the ring instead. A dashboard reopens on the face it
|
||||
was last left on in this session, otherwise on its first. Landing on another
|
||||
dashboard shows its title and icon in the middle of the face for one second; a
|
||||
round panel has no rail.
|
||||
|
||||
### Geometry
|
||||
|
||||
@@ -282,7 +284,7 @@ look's muted text colour.
|
||||
| Ring band | 40 along the edge |
|
||||
| Ring arc | start 120°, sweep 300° (ends at 60°), centre-line radius c − 20, stroke 16 with round caps |
|
||||
| Slider | track over the sweep, value arc from the start to the value (none at the minimum), handle 28 across, its ticks as [dots](#dots-on-the-track); the value as the centre reading |
|
||||
| Slider with a reading | the reading as the centre reading; the gap line holds the title (muted) and the slider's value. The reading as a dot 10 across on the centre line at its fraction of the sweep ([Dots on the track](#dots-on-the-track)) |
|
||||
| Slider with a reading | the reading as the centre reading; the gap line holds the title (muted) and the slider's value. The reading as a dot 12 across on the centre line at its fraction of the sweep ([Dots on the track](#dots-on-the-track)) |
|
||||
| Selector | a value arc from the start to the selected option (none at the first), a dot 6 across per option at i/(n−1) of the sweep (one option: the middle), the selected one under the handle; its label as the centre reading, at most 36 |
|
||||
| Colour | hue h at h/360 of the sweep in 150 strokes of 2°, red at both ends; the handle filled with the colour; a swatch 96 across in the middle, with a hairline border |
|
||||
| Gauge | track and value arc, no handle; the value as the centre reading |
|
||||
@@ -326,10 +328,10 @@ dashboard's look's.
|
||||
|
||||
| What | Moves | From → to | Timing | Input during it |
|
||||
|---|---|---|---|---|
|
||||
| Face paging (round) | the new face and the old | new: x +48 (next) or −48 (previous), opacity 0 → x 0, opacity 1; old: x 0, opacity 1 → x −48 (next) or +48, opacity 0. Both at once | spring | another page retargets: each face heads for its new end from where it is |
|
||||
| Dashboard paging (round panel) | the new dashboard's face and the old | the same on y: the next rises from +48, the previous comes down from −48. One opened by its address only fades | spring | as faces |
|
||||
| Face paging (round) | the faces, side by side on a track | the track slides one face width S, the new face coming in from the side it lies on (the next from the right). A dot that skips faces brings its face in beside the one shown, so a turn is always one width | spring, from where the track is (a swipe's release speed carried on) | a new turn while one runs: the running one lands at once and the next starts |
|
||||
| Dashboard paging (round panel) | the dashboard, and beside it the next one's name | slides S up (the next, from below) or down (the previous, from above), the name pill centred on an otherwise empty face; once it has landed and the dashboard has loaded, the dashboard replaces its name in place. One opened by its address replaces the last at once | spring | further paging waits for the turn to land |
|
||||
| Page dot | the current dot and the last | muted at 0.5 opacity ↔ text colour at 1 | 200 ms, cubic-bezier(0.4, 0, 0.2, 1) | retargets |
|
||||
| Dashboard name | the pill | opacity 0 → 1, held 1000 ms, 1 → 0 | in 200 ms, out 150 ms, cubic-bezier(0.4, 0, 0.2, 1) | — |
|
||||
| Dashboard name | the pill | opacity 0 → 1, held 1000 ms, 1 → 0; on a dashboard a turn brought, where the name came in already, it starts at 1 | in 200 ms, out 150 ms, cubic-bezier(0.4, 0, 0.2, 1) | — |
|
||||
| Ring value set by a notch, a key or the engine | the value arc's end, the handle (slider, selector, colour) | from where it is drawn to the new fraction of the sweep | spring | each notch retargets from where it is, at the speed it has |
|
||||
| Ring value set by a drag | the same | follows the pointer exactly | none | — |
|
||||
| Hue across red | the colour handle | a change of more than half the sweep jumps to the other end | none | — |
|
||||
@@ -341,17 +343,31 @@ dashboard's look's.
|
||||
| Dashboard change (rectangular panel) | every tile | the old dashboard goes at once; each new tile opacity 0 → 1, y +8 → 0 | spring | — |
|
||||
| Rail | the current entry's marker | slides to the new entry | spring | retargets |
|
||||
|
||||
With reduced motion asked for, nothing travels: pages and dashboards only
|
||||
cross-fade (opacity at the spring), and every value is drawn at its new place
|
||||
at once. The dot and the name pill, which only change colour or opacity, keep
|
||||
their timing.
|
||||
With reduced motion asked for, nothing travels on its own: a page under the
|
||||
finger still follows it, but every turn, return and value lands at once. The
|
||||
dot and the name pill, which only change colour or opacity, keep their timing.
|
||||
|
||||
A device may simplify where its hardware is slow, keeping the direction and
|
||||
the end state: a page may slide without the fade, fade without the slide, or
|
||||
cut; a retarget may restart the curve from where it is without carrying the
|
||||
speed; a counting number may switch; and the value arc may be redrawn each
|
||||
frame as an arc to the eased angle, which is what the browser's reveal
|
||||
amounts to.
|
||||
the end state: a turn a key or a dot asked for may cut; a retarget may restart
|
||||
the curve from where it is without carrying the speed; a counting number may
|
||||
switch; and the value arc may be redrawn each frame as an arc to the eased
|
||||
angle, which is what the browser's reveal amounts to.
|
||||
|
||||
### Swiping
|
||||
|
||||
Faces are swiped sideways and a panel's dashboards up and down, with the page
|
||||
under the finger, as a tile view moves.
|
||||
|
||||
| Part | Value |
|
||||
|---|---|
|
||||
| Start | a press anywhere on the face but the ring band, which turns the ring instead |
|
||||
| Axis | none until the drag has gone 8 from where it began along either axis; then the longer of the two, kept until release |
|
||||
| Follow | the page moves with the drag one to one, and the page that way is drawn beside it, edge to edge, S away: the next face, or the next dashboard's name pill on an otherwise empty face |
|
||||
| Past the last | nothing beside it; the page moves 0.3 of the drag, at most 48 |
|
||||
| Release | turns if the drag went at least S/4 (90 on the Knob), or went at least 8 and was moving at least 500 a second towards the next page over its last 80 ms; otherwise goes back |
|
||||
| Turn, go back | the look's spring from where the page is, carrying the release speed; a device may use the look's cubic-bezier over its duration from where it is |
|
||||
| Press during a turn | lands the turn at once; the new drag starts from a page at rest |
|
||||
| A swipe that began on a control | is not a press of it |
|
||||
|
||||
## Showing one
|
||||
|
||||
|
||||
@@ -347,6 +347,7 @@ export function DashboardView({
|
||||
rail,
|
||||
editing,
|
||||
onDashboard,
|
||||
around,
|
||||
}: {
|
||||
dashboard: Dashboard
|
||||
renderWidget?: (widget: WidgetDef) => React.ReactNode
|
||||
@@ -358,8 +359,9 @@ export function DashboardView({
|
||||
* stacked editor draws through here, where the canvas draws its own grid. */
|
||||
editing?: boolean
|
||||
/** A round one's way to the panel's other dashboards: next (1) or
|
||||
* previous (-1). */
|
||||
* previous (-1), and their names. */
|
||||
onDashboard?: (by: number) => void
|
||||
around?: [string | undefined, string | undefined]
|
||||
}) {
|
||||
const all = widgetsOf(dashboard)
|
||||
const round = isRound(dashboard)
|
||||
@@ -397,6 +399,7 @@ export function DashboardView({
|
||||
dashboard={dashboard}
|
||||
size={canvasOf(dashboard).width}
|
||||
onDashboard={onDashboard}
|
||||
around={around}
|
||||
/>
|
||||
) : (
|
||||
<WidgetGrid
|
||||
|
||||
@@ -28,6 +28,7 @@ export function PanelSurface({
|
||||
rail,
|
||||
touch,
|
||||
onDashboard,
|
||||
around,
|
||||
}: {
|
||||
dashboard: Dashboard
|
||||
/** A landscape arrangement scaled onto a phone comes out at about a fifth of
|
||||
@@ -40,8 +41,9 @@ export function PanelSurface({
|
||||
/** Whether the screen this hangs on is touched. Stated by the panel route. */
|
||||
touch?: boolean
|
||||
/** A round dashboard has no rail: it is swiped up and down to the panel's
|
||||
* others instead. */
|
||||
* others instead, whose names are `around` it. */
|
||||
onDashboard?: (by: number) => void
|
||||
around?: [string | undefined, string | undefined]
|
||||
}) {
|
||||
const hasRail = Boolean(rail)
|
||||
const stack = stacked && !isRound(dashboard)
|
||||
@@ -53,9 +55,10 @@ export function PanelSurface({
|
||||
dashboard={dashboard}
|
||||
rail={hasRail}
|
||||
onDashboard={onDashboard}
|
||||
around={around}
|
||||
/>
|
||||
),
|
||||
[dashboard, hasRail, onDashboard],
|
||||
[dashboard, hasRail, onDashboard, around],
|
||||
)
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Lock, Power } from "lucide-react"
|
||||
import {
|
||||
AnimatePresence,
|
||||
animate,
|
||||
motion,
|
||||
useIsPresent,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useTransform,
|
||||
type Variants,
|
||||
} from "motion/react"
|
||||
import { useEffect, useRef, useState } from "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"
|
||||
@@ -17,6 +20,7 @@ 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 { useLocked, usePalette } from "./settings"
|
||||
import { useUi } from "./ui"
|
||||
@@ -48,13 +52,15 @@ import {
|
||||
fitSize,
|
||||
GAP_RISE,
|
||||
GAP_SIZE,
|
||||
PAGE_SLOP,
|
||||
PRESS,
|
||||
pageOffset,
|
||||
RING_START,
|
||||
RING_SWEEP,
|
||||
SWIPE,
|
||||
TITLE_RISE,
|
||||
TITLE_SIZE,
|
||||
TITLE_WIDTH,
|
||||
turnsPage,
|
||||
} from "./ui/core/ring"
|
||||
import {
|
||||
barReadings,
|
||||
@@ -103,85 +109,253 @@ 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 }),
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/** 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 }),
|
||||
/**
|
||||
* 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 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.
|
||||
* Swiped, the dashboard follows the finger and the next one's name comes up
|
||||
* from below it (the previous one's down from above); let go far enough and
|
||||
* the turn completes, and the dashboard itself takes its name's place once it
|
||||
* has loaded. Shift+PageUp/PageDown turn the same way. One opened by its
|
||||
* address replaces the last at once.
|
||||
*/
|
||||
export function RoundPanel({
|
||||
dashboard,
|
||||
size,
|
||||
onDashboard,
|
||||
around,
|
||||
}: {
|
||||
dashboard: Dashboard
|
||||
size: number
|
||||
onDashboard?: (by: number) => void
|
||||
/** The panel's dashboards before and after this one. */
|
||||
around?: [string | undefined, string | undefined]
|
||||
}) {
|
||||
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>
|
||||
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,
|
||||
// the side the next one's name is on, whether the turn has landed, and the
|
||||
// last dashboard a turn brought, which has been named already.
|
||||
const [held, setHeld] = useState<Dashboard | null>(null)
|
||||
const [beside, setBeside] = useState<{ by: 1 | -1; name: string } | null>(
|
||||
null,
|
||||
)
|
||||
const [landed, setLanded] = useState(false)
|
||||
const [reached, setReached] = useState<string | null>(null)
|
||||
const shown = held ?? dashboard
|
||||
const name = (by: number) => around?.[by > 0 ? 1 : 0]
|
||||
|
||||
const turn = (by: 1 | -1, speed = 0) => {
|
||||
const next = name(by)
|
||||
if (!next || !onDashboard || held) return
|
||||
setHeld(dashboard)
|
||||
// Its name as it was when the turn began: the panel's neighbours are
|
||||
// counted from the new dashboard as soon as it is asked for.
|
||||
setBeside({ 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 dashboard it went to has loaded: that one takes its
|
||||
// name's place, which it was drawn as, so nothing moves.
|
||||
useLayoutEffect(() => {
|
||||
if (!held || !landed || dashboard.name === held.name) return
|
||||
setHeld(null)
|
||||
setBeside(null)
|
||||
setLanded(false)
|
||||
setReached(dashboard.name)
|
||||
y.jump(0)
|
||||
}, [held, landed, dashboard.name, y])
|
||||
|
||||
const rise: Axis = {
|
||||
open: (by) => Boolean(name(by) && onDashboard) && !held,
|
||||
drag: (offset, by) => {
|
||||
if (held) return
|
||||
const next = onDashboard ? name(by) : undefined
|
||||
if (next !== beside?.name) setBeside(next ? { by, name: next } : null)
|
||||
y.set(offset)
|
||||
},
|
||||
release: (by, turnIt, speed) => {
|
||||
if (held) return
|
||||
if (turnIt) {
|
||||
turn(by, speed)
|
||||
return
|
||||
}
|
||||
if (still) {
|
||||
y.jump(0)
|
||||
setBeside(null)
|
||||
return
|
||||
}
|
||||
animate(y, 0, { ...spring, velocity: speed }).then(() => setBeside(null))
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div className="absolute inset-0" style={{ y }}>
|
||||
<RoundView
|
||||
// Its own face memory per dashboard, so a panel switching to another
|
||||
// starts that one where it was left.
|
||||
key={shown.name}
|
||||
dashboard={shown}
|
||||
size={size}
|
||||
onDashboard={onDashboard && ((by) => turn(by > 0 ? 1 : -1))}
|
||||
rise={rise}
|
||||
named={reached === shown.name}
|
||||
/>
|
||||
{beside ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 flex items-center justify-center"
|
||||
style={{ top: beside.by * size, height: size }}
|
||||
>
|
||||
<DashboardName name={beside.name} />
|
||||
</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A dashboard's icon and title, on the frosted surface: what a panel shows
|
||||
* of a dashboard it is turning to, and of one it has just landed on. */
|
||||
function NamePill({ dashboard }: { dashboard: Dashboard | undefined }) {
|
||||
const Glyph = resolveIcon(dashboard?.icon ?? "")
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
/** The name of a dashboard not drawn yet, read for it — which is also what
|
||||
* has it loaded by the time a turn lands on it. */
|
||||
function DashboardName({ name }: { name: string }) {
|
||||
const { data } = useQuery({
|
||||
...dashboardQueryOptions(name),
|
||||
enabled: Boolean(name),
|
||||
})
|
||||
return <NamePill dashboard={data ?? ({ name } as Dashboard)} />
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
@@ -197,6 +371,8 @@ export function RoundView({
|
||||
editing,
|
||||
onPick,
|
||||
onDashboard,
|
||||
rise,
|
||||
named,
|
||||
}: {
|
||||
dashboard: Dashboard
|
||||
/** The face's diameter: the canvas width. */
|
||||
@@ -207,25 +383,31 @@ export function RoundView({
|
||||
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
|
||||
/** Turned to, its name already shown on the way: it stays without
|
||||
* fading in again. */
|
||||
named?: 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)
|
||||
// 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 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(
|
||||
() =>
|
||||
Boolean(onDashboard) &&
|
||||
@@ -243,6 +425,51 @@ export function RoundView({
|
||||
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)
|
||||
@@ -250,42 +477,96 @@ export function RoundView({
|
||||
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),
|
||||
pagedown: () => (editing ? false : page(index + 1)),
|
||||
pageup: () => (editing ? false : page(index - 1)),
|
||||
"shift+pagedown": () => (onDashboard && !editing ? onDashboard(1) : false),
|
||||
"shift+pageup": () => (onDashboard && !editing ? 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 },
|
||||
)
|
||||
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
|
||||
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)
|
||||
|
||||
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.
|
||||
@@ -315,50 +596,12 @@ export function RoundView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{/* 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>
|
||||
<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
|
||||
@@ -386,7 +629,7 @@ export function RoundView({
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<AnimatePresence>
|
||||
<AnimatePresence initial={!named}>
|
||||
{announce ? (
|
||||
<motion.div
|
||||
key="announce"
|
||||
@@ -396,16 +639,7 @@ export function RoundView({
|
||||
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>
|
||||
<NamePill dashboard={dashboard} />
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -525,8 +525,8 @@
|
||||
}
|
||||
|
||||
.dui-slider-reading {
|
||||
width: calc(var(--dui-track-size, 0.5rem) * 0.75);
|
||||
height: calc(var(--dui-track-size, 0.5rem) * 0.75);
|
||||
width: var(--dui-track-size, 0.5rem);
|
||||
height: var(--dui-track-size, 0.5rem);
|
||||
background: var(--dui-reading);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,9 +25,10 @@ export const RING_HANDLE = 28
|
||||
/** A stop on the ring — a selector's option, a slider's tick — as a dot this
|
||||
* wide on the ring's centre line. */
|
||||
export const RING_MARK = 6
|
||||
/** What a slider reads beside its value, as a larger dot on the centre line:
|
||||
* told from a stop by its size and its stronger colour. */
|
||||
export const RING_READING = 10
|
||||
/** What a slider reads beside its value, as a smaller companion of the
|
||||
* handle on the centre line: told from a stop by its size and its stronger
|
||||
* colour, and from the handle by its size and its fill. */
|
||||
export const RING_READING = 12
|
||||
/** How long the ring waits after the last notch before it publishes. */
|
||||
export const RING_SETTLE_MS = 300
|
||||
/** The gap's line — a slider's title and value, when what it reads is in
|
||||
@@ -105,8 +106,32 @@ export const barLegend = (rows: number) =>
|
||||
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
|
||||
/**
|
||||
* Swiping between pages, faces sideways and dashboards up and down. A drag
|
||||
* picks its axis once it has gone `PAGE_SLOP` along one; the page follows it
|
||||
* one to one, and past the last page gives `PAGE_RUBBER` of the drag, at most
|
||||
* `PAGE_OVERSCROLL`. Let go, it turns if it went `PAGE_COMMIT` of the face or
|
||||
* was flung at `PAGE_FLING` a second towards the next page.
|
||||
*/
|
||||
export const PAGE_SLOP = 8
|
||||
export const PAGE_RUBBER = 0.3
|
||||
export const PAGE_OVERSCROLL = 48
|
||||
export const PAGE_COMMIT = 0.25
|
||||
export const PAGE_FLING = 500
|
||||
|
||||
/** Where a page dragged `by` is drawn: under the finger while there is a
|
||||
* page that way (`open`), held back past the last. */
|
||||
export const pageOffset = (by: number, open: boolean) =>
|
||||
open
|
||||
? by
|
||||
: Math.sign(by) * Math.min(PAGE_OVERSCROLL, Math.abs(by) * PAGE_RUBBER)
|
||||
|
||||
/** Whether a drag let go `by` along, at `speed` a second, turns the page. */
|
||||
export const turnsPage = (by: number, speed: number, size: number) =>
|
||||
Math.abs(by) >= PAGE_COMMIT * size ||
|
||||
(Math.abs(by) >= PAGE_SLOP &&
|
||||
Math.sign(speed) === Math.sign(by) &&
|
||||
Math.abs(speed) >= PAGE_FLING)
|
||||
|
||||
/** How long a change of dashboard shows which one it landed on. */
|
||||
export const ANNOUNCE_MS = 1000
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
import { useCallback } from "react"
|
||||
import { useCallback, useMemo } from "react"
|
||||
|
||||
import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
||||
import { PanelRail } from "@/components/Dashboard/PanelRail"
|
||||
@@ -66,6 +66,15 @@ function PanelRoute() {
|
||||
const stacked = useIsMobile()
|
||||
const rail = dashboards.length > 1
|
||||
const round = dashboard?.shape === "round"
|
||||
// The ones either side, which a round one is swiped to and shows the
|
||||
// names of on the way. Stable, so the canvas is not rebuilt for it.
|
||||
const at = dashboards.indexOf(current ?? "")
|
||||
const before = dashboards[at - 1]
|
||||
const after = dashboards[at + 1]
|
||||
const around = useMemo(
|
||||
(): [string | undefined, string | undefined] => [before, after],
|
||||
[before, after],
|
||||
)
|
||||
// Stops at either end, as the faces of one do.
|
||||
const step = useCallback(
|
||||
(by: number) => {
|
||||
@@ -123,6 +132,7 @@ function PanelRoute() {
|
||||
stacked={stacked}
|
||||
touch={touch}
|
||||
onDashboard={rail && round ? step : undefined}
|
||||
around={rail && round ? around : undefined}
|
||||
// Drawn on the panel rather than beside it: a screen showing four
|
||||
// dashboards shows the way between them too.
|
||||
rail={
|
||||
|
||||
Reference in New Issue
Block a user