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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Melvin Strobl
2026-10-14 17:08:51 +02:00
co-authored by Claude Opus 5.5
parent 541dbce584
commit 603635f947
7 changed files with 474 additions and 183 deletions
@@ -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">
+385 -151
View File
@@ -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