Round dashboards: one face at a time, a face per widget type, paged by swipe, dots and keys; slider reading; face row in the editor

This commit is contained in:
Melvin Strobl
2026-10-14 15:24:25 +02:00
parent be3446cb16
commit 2008150692
20 changed files with 1238 additions and 604 deletions
+21 -31
View File
@@ -90,12 +90,6 @@ INPUT_WIDGETS = {
"color",
}
#: What a round dashboard may draw on its ring: the widgets that are one value
#: along a range, which is what turning a knob's ring can set or a circle's
#: edge can show. Mirrored in the client
#: (``frontend/src/components/Dashboard/DashboardView.tsx``).
RING_WIDGETS = {"slider", "dropdown", "color", "gauge"}
#: What a colour widget puts on the wire, by the format it was configured for.
#: The default is what the Node-RED instance this ports already sends its
#: DMX encoders — ``[h, s, v]``, hue in degrees and the other two in percent —
@@ -231,6 +225,10 @@ class WidgetDef(BaseModel):
The first two are a ``list`` message, the third a ``str``, which is what
``COLOR_DTYPES`` records and the check below holds a binding to.
A slider may also show what it is setting: ``reading`` binds
``{message, dtype, label}`` — a room's temperature beside its setpoint —
held to the same numbers its target is.
"""
id: str
@@ -297,6 +295,12 @@ class WidgetDef(BaseModel):
)
return [*outer, *self.inner_bindings]
@property
def reading(self) -> dict[str, Any]:
"""What a slider reads beside the value it sets; empty for anything else."""
reading = self.config.get("reading") if self.type == "slider" else None
return reading if isinstance(reading, dict) else {}
@property
def messages(self) -> list[str]:
"""Every message name this widget reads."""
@@ -318,8 +322,8 @@ class WidgetDef(BaseModel):
return [
str(row.get("message")) for row in self.bar_rows if row.get("message")
]
name = self.config.get("message")
return [str(name)] if name else []
names = [self.config.get("message"), self.reading.get("message")]
return [str(name) for name in names if name]
@property
def target(self) -> str:
@@ -358,7 +362,10 @@ class WidgetDef(BaseModel):
]
if self.type == "bar":
return [str(row.get("dtype") or "") for row in self.bar_rows]
return [str(self.config.get("dtype") or "")]
return [
str(self.config.get("dtype") or ""),
str(self.reading.get("dtype") or ""),
]
@model_validator(mode="after")
def _check_binding(self) -> WidgetDef:
@@ -481,14 +488,11 @@ class DashboardDef(BaseModel):
#: "unset" and the client falls back to its default.
canvas_width: int = Field(default=1920, ge=0, le=7680)
canvas_height: int = Field(default=1080, ge=0, le=4320)
#: ``round`` draws the dashboard in the circle inscribed in its canvas, for
#: a round screen such as a rotary knob. A circle has one size, so a round
#: canvas is square: its height is its width.
#: ``round`` draws the dashboard on the circle inscribed in its canvas, for
#: a round screen such as a rotary knob: one widget at a time, each a whole
#: face, in the order of ``widgets`` (``layout`` is not read). A circle has
#: one size, so a round canvas is square: its height is its width.
shape: Literal["rect", "round"] = "rect"
#: The widget a round dashboard draws along its edge instead of in the grid
#: — one of ``RING_WIDGETS``, by id — which a knob's ring turns. Empty
#: draws no ring.
ring: str = ""
#: A lucide icon name, drawn on the panel rail; empty falls back to two
#: letters of the title.
icon: str = ""
@@ -550,20 +554,9 @@ class DashboardDef(BaseModel):
@model_validator(mode="after")
def _check_shape(self) -> DashboardDef:
"""Square a round canvas, and hold the ring to a widget it can draw."""
"""Square a round canvas."""
if self.shape == "round":
self.canvas_height = self.canvas_width
if not self.ring:
return self
if self.shape != "round":
raise ValueError("only a round dashboard has a ring")
widget = next((w for w in self.widgets if w.id == self.ring), None)
if widget is None:
raise ValueError(
f"the ring names '{self.ring}', which is not a widget here"
)
if widget.type not in RING_WIDGETS:
raise ValueError(f"a '{widget.type}' widget cannot be drawn on the ring")
return self
@property
@@ -581,9 +574,6 @@ class DashboardDef(BaseModel):
"""
shapes = []
for widget in self.widgets:
# Drawn along the edge, so it takes no place in the grid.
if widget.id == self.ring:
continue
at = (
widget.layout.get("lg")
or widget.layout.get("md")
+11 -2
View File
@@ -252,7 +252,11 @@ PANEL_WIDGETS = [
{
"id": "w_slider",
"type": "slider",
"config": {"target": "demo.slider", "dtype": "float"},
"config": {
"target": "demo.slider",
"dtype": "float",
"reading": {"message": "demo.slider_reading", "dtype": "float"},
},
},
{"id": "w_input", "type": "input", "config": {"target": "demo.input"}},
{"id": "w_dropdown", "type": "dropdown", "config": {"target": "demo.dropdown"}},
@@ -333,7 +337,12 @@ def test_a_panel_speaks_only_its_own_widgets_messages(
), target
# And reads the history of everything its widgets bind to.
for name in ("demo.temperature", "demo.switch_state", "demo.query_series"):
for name in (
"demo.temperature",
"demo.switch_state",
"demo.slider_reading",
"demo.query_series",
):
assert (
client.get(f"{messages}/{name}/history", headers=panel_headers).status_code
== 200
+20 -17
View File
@@ -445,25 +445,28 @@ def test_a_dashboard_is_cut_into_a_sane_number_of_columns():
DashboardDef(name="house", columns=columns)
def test_a_round_dashboard_is_square_and_rings_one_of_its_own_widgets():
dial = WidgetDef(id="dial", type="slider", layout={"lg": Placement(x=1)})
stat = WidgetDef(id="stat", type="stat")
knob = DashboardDef(
name="knob",
shape="round",
canvas_width=360,
canvas_height=200,
ring="dial",
widgets=[dial, stat],
)
def test_a_round_dashboard_is_square():
knob = DashboardDef(name="knob", shape="round", canvas_width=360, canvas_height=200)
assert knob.canvas_height == 360
# Drawn along the edge, so the outline of the grid leaves it out.
assert [shape.type for shape in knob.footprint] == ["stat"]
for shape, ring in (("rect", "dial"), ("round", "gone"), ("round", "stat")):
with pytest.raises(ValueError):
DashboardDef(name="knob", shape=shape, ring=ring, widgets=[dial, stat])
def test_a_slider_reads_what_it_shows_beside_its_value():
"""The room's temperature beside its setpoint: read, and held to numbers."""
setpoint = {"target": "house.setpoint", "dtype": "float"}
slider = WidgetDef(
id="target",
type="slider",
config={**setpoint, "reading": {"message": "house.indoor", "dtype": "float"}},
)
assert slider.messages == ["house.indoor"]
with pytest.raises(ValueError):
WidgetDef(
id="target",
type="slider",
config={**setpoint, "reading": {"message": "house.mode", "dtype": "str"}},
)
def test_a_setting_refuses_a_message_it_cannot_carry():
+70 -29
View File
@@ -171,48 +171,89 @@ restrict API permissions and does not disable controls in edit mode.
## Round dashboards
For a round screen such as a rotary knob, set the canvas to **Round** in the
dashboard settings, or pick the **Knob** preset (360×360). A round dashboard is
drawn in the circle its canvas holds, so its canvas is square (`shape: "round"`
forces `canvas_height` to `canvas_width`). Tiles are drawn without a card, and
there is no rail; a panel with several dashboards shows page dots instead.
dashboard settings, or pick the **Knob** preset (360×360). A round canvas is
square (`shape: "round"` forces `canvas_height` to `canvas_width`), and nothing
outside its circle is drawn.
One Slider, Selector, Colour or Gauge can go **On the ring** (`ring: <widget
id>`): it is drawn along the edge instead of in the grid. Drag along the ring or
use the mouse wheel; one notch is one step, one option or one hue step
(`hue_step`, default 5°, wrapping). The value moves at once and is published
once, 300 ms after the last notch or when the pointer is released. A gauge on
the ring only shows. When the ring is the only widget, its reading is written
large in the middle.
A round dashboard has no grid. It shows one widget at a time, each drawn as the
whole face, in the order of `widgets`; `layout` is not read. In the editor, the
faces are a row above the dock: press one to show it and open its settings,
drag it along the row (or Alt+←/→) to move it, and add more with **Add widget**.
The editor shades what lies outside the circle and warns about tiles whose
corners leave it. Inputs, button grids, charts, forecasts, agendas, websites
and a segmented Selector are marked as a poor fit, but allowed.
A Slider, Selector, Colour or Gauge draws on the **ring** along the edge, which
a knob's own ring turns. In a browser, drag along the ring or use the mouse
wheel (or the arrow keys once it has focus): one notch is one step, one option
(no wrap) or one hue step (`hue_step`, default 5°, wrapping). The value moves
at once and is published once, 300 ms after the last notch or when the pointer
is released. A Gauge only shows. On any other face, turning a knob only wakes
the screen.
A Slider can also show what it sets against, such as the room's temperature
beside a setpoint: **Beside it** binds `reading: {message, dtype, label}` to a
`float` or `int`. That reading is written large in the middle, in the slider's
unit and to its step's decimals, under its label; the slider's own title and
value move to the line in the ring's gap.
Value, Switch, Button, Clock, Icon, Notification and Text have faces of their
own. Every other type is drawn as its rectangular body in the middle of the
face, and the editor marks it **Poor fit for round**.
### Moving between faces
| Gesture | Effect |
|---|---|
| Swipe left / right | next / previous face of this dashboard; stops at either end |
| Swipe up / down | next / previous dashboard of the panel (`/panel/{id}` only); stops at either end |
| Page dot | that face |
| PageDown / PageUp | next / previous face |
| 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.
### Geometry
Devices that draw dashboards natively use these numbers. They are canvas
pixels, scaled with the canvas; S is the canvas width. Angles are degrees
clockwise from three o'clock.
pixels, scaled with the canvas; S is the canvas width and c = S/2. Angles are
degrees clockwise from three o'clock. Text sizes are font sizes; muted is the
look's muted text colour.
| Part | Value |
|---|---|
| Face | circle of diameter S at (S/2, S/2); nothing is drawn outside it |
| Ring band | 40 along the edge, only when a ring is set |
| Grid area | the square inset by 12, plus 40 with a ring; columns, rows and cell proportions as on a rectangular canvas. Knob, 4 columns, ring: 256×256, columns 55, rows 30 at a 42 pitch, 6 rows |
| Safe circle | radius S/2 − 40 with a ring, S/2 without; a tile corner outside it is warned about |
| Ring arc | start 120°, sweep 300° (ends at 60°), centre-line radius S/2 − 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 |
| Selector | a 6 mark per option at i/(n−1) of the sweep (one option: the middle), the selected one under the handle; no wrap |
| Colour | hue h at h/360 of the sweep, red at both ends; the handle is filled with the colour |
| Gauge | track and value arc, no handle |
| Gap line | centred at y = S − 28, 13 high, at most 160 wide: the title (muted), then the reading unless it is in the middle; a colour has none |
| Page dots | 6 dots 16 apart, centred at y = S − 12; the current one in the text colour, the others muted at half opacity |
| Centre reading | value 72 with its unit at 0.4 em, muted; a Selector's label 36; a colour swatch 96 across with a hairline border |
| Tiles | no surface, border or shadow; padding 8; title 12, muted and centred; a single reading centred |
| Face | circle of diameter S at (c, c) |
| Title | one line centred at y = c − 80, 15, muted, at most 200 wide; the widget's title, or the label of a slider's reading |
| Middle | a 240 × 128 box centred at (c, c); what a face draws in its middle is centred in it and clipped to it |
| Centre reading | centred at (c, c), line height 1, its unit after it at 0.4 em, muted. Size: 72, or 240 ÷ (0.6 × the characters written) rounded down where that is smaller, never below 24 |
| 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; 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 |
| Selector | a 6 mark 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 |
| Gap line | centred at y = S − 28, 13, at most 160 wide |
| Value | the reading as the centre reading, to its precision; a word shrinks like any other |
| Switch | a disc 128 across centred at (c, c): the look's button, filled when on and tonal when off, with a power glyph 40 over the word On or Off at 15; a press toggles |
| Button | a disc 128 across centred at (c, c), tonal, its label at 15 over at most three lines; a press sends its value |
| Clock | the time (hours and minutes, the device's locale) sized as a centre reading, over the date (weekday, day, month) at 15, muted, 4 apart; the pair centred at (c, c) |
| Icon | the glyph 96 across over its label at 15, muted, 4 apart; the pair centred at (c, c). Nothing matched: a dash at 72, muted |
| Notification | the title at 15, medium (an error in the destructive colour, led by ⚠), over the body at 15, muted; centred lines in the middle |
| Text | centred lines at 15: `#` at 1.35 em and `##` medium, `###` muted, list items led by a bullet; in the middle |
| Not wired | the problem, at 15, muted, in the middle; a ring type draws no ring |
| Any other type | its rectangular body, without a card, from y = c − 64 to c + S/(2√2), x = c ± S/(2√2) (Knob: 53–307 × 116–307) |
| Page dots | one per face, from two faces: dots 6 across on targets 16 apart (closer when more than 128 wide would be needed), centred at y = S − 12; the current one in the text colour, the others muted at half opacity |
| Dashboard name | a pill centred at (c, c), 36 high: the icon 16 and the title at 15, medium, at most 220 wide, on the frosted surface; for 1000 ms |
| Paging | a new face slides in 48 from the side it was paged towards and fades in; the old one leaves the other way |
| Locked | controls do nothing and read as disabled; the ring at 0.45 opacity |
Paint follows the look: Fluksio draws a muted track, a primary value and a
handle of the ground colour ringed in primary; Material a tonal track and a
primary handle; Glass a translucent track, a glowing value and a white handle.
A Switch or Button face wears the look's own button.
## Showing one
+18 -15
View File
@@ -127,21 +127,24 @@ async function captureRuns(page, dir) {
// The API is its own host here, unless the stack serves it under the SPA's
// own origin (a LAN stack), whose CSP then refuses the other host.
const apiUrl = APP_URL.replace("//app.", "//api.")
const results = await page.evaluate(async (bases) => {
for (const base of bases) {
const answer = await fetch(`${base}/api/v1/dashboards/`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
},
}).catch(() => null)
if (!answer?.ok) continue
const body = await answer.json()
return (body.data || [])
.map((one) => one.name)
.find((n) => n.endsWith("_results"))
}
return null
}, [apiUrl, ""])
const results = await page.evaluate(
async (bases) => {
for (const base of bases) {
const answer = await fetch(`${base}/api/v1/dashboards/`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
},
}).catch(() => null)
if (!answer?.ok) continue
const body = await answer.json()
return (body.data || [])
.map((one) => one.name)
.find((n) => n.endsWith("_results"))
}
return null
},
[apiUrl, ""],
)
// A dashboard read against two runs: the same page a live run is watched
// on, showing finished ones. This is the seam the feature exists for — and
+5 -11
View File
@@ -682,11 +682,6 @@ export const DashboardDef_InputSchema = {
title: 'Shape',
default: 'rect'
},
ring: {
type: 'string',
title: 'Ring',
default: ''
},
icon: {
type: 'string',
title: 'Icon',
@@ -761,11 +756,6 @@ export const DashboardDef_OutputSchema = {
title: 'Shape',
default: 'rect'
},
ring: {
type: 'string',
title: 'Ring',
default: ''
},
icon: {
type: 'string',
title: 'Icon',
@@ -4504,7 +4494,11 @@ differ and a change node per tile is not the answer:
- \`\`hex\`\` — \`\`"#rrggbb"\`\`, lowercase. Conventional likewise.
The first two are a \`\`list\`\` message, the third a \`\`str\`\`, which is what
\`\`COLOR_DTYPES\`\` records and the check below holds a binding to.`
\`\`COLOR_DTYPES\`\` records and the check below holds a binding to.
A slider may also show what it is setting: \`\`reading\`\` binds
\`\`{message, dtype, label}\`\` — a room's temperature beside its setpoint —
held to the same numbers its target is.`
} as const;
export const WidgetFootprintSchema = {
+4 -2
View File
@@ -169,7 +169,6 @@ export type DashboardDef_Input = {
canvas_width?: number;
canvas_height?: number;
shape?: 'rect' | 'round';
ring?: string;
icon?: string;
widgets?: Array<WidgetDef>;
settings?: {
@@ -191,7 +190,6 @@ export type DashboardDef_Output = {
canvas_width?: number;
canvas_height?: number;
shape?: 'rect' | 'round';
ring?: string;
icon?: string;
widgets?: Array<WidgetDef>;
settings?: {
@@ -1462,6 +1460,10 @@ export type WebPushKey = {
*
* The first two are a ``list`` message, the third a ``str``, which is what
* ``COLOR_DTYPES`` records and the check below holds a binding to.
*
* A slider may also show what it is setting: ``reading`` binds
* ``{message, dtype, label}`` — a room's temperature beside its setpoint —
* held to the same numbers its target is.
*/
export type WidgetDef = {
id: string;
@@ -8,7 +8,7 @@ import type { WidgetProps } from "./widgets"
* The tick is a plain one-second interval and the formatting is the browser's
* own locale, so there is no 12/24-hour setting to carry.
*/
export function ClockWidget(_props: WidgetProps) {
export function useClock() {
const [now, setNow] = useState(() => new Date())
useEffect(() => {
@@ -16,21 +16,27 @@ export function ClockWidget(_props: WidgetProps) {
return () => clearInterval(timer)
}, [])
return {
time: now.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
}),
date: now.toLocaleDateString(undefined, {
weekday: "long",
day: "numeric",
month: "long",
}),
}
}
export function ClockWidget(_props: WidgetProps) {
const { time, date } = useClock()
return (
<div className="min-w-0">
<p className="dui-clock truncate" data-testid="clock-time">
{now.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}
</p>
<p className="truncate text-muted-foreground">
{now.toLocaleDateString(undefined, {
weekday: "long",
day: "numeric",
month: "long",
})}
{time}
</p>
<p className="truncate text-muted-foreground">{date}</p>
</div>
)
}
@@ -1,12 +1,11 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { Check, ExternalLink, Pencil, Plus, Settings2 } from "lucide-react"
import { motion } from "motion/react"
import { motion, Reorder } from "motion/react"
import { type CSSProperties, useEffect, useRef, useState } from "react"
import {
GridLayout,
type Layout,
noCompactor,
type Position,
setTransform,
} from "react-grid-layout"
@@ -44,15 +43,14 @@ import { DashboardPalette } from "./DashboardPalette"
import {
areaOf,
CanvasSurface,
canvasOf,
columnsOf,
type Dashboard,
DashboardView,
GRID_GAP,
isPlaced,
isRound,
offFace,
placement,
ringOf,
rowHeightOf,
rowsOf,
widgetsOf,
@@ -67,7 +65,7 @@ import {
usePublishDashboard,
useSaveDashboard,
} from "./queries"
import { RoundFace } from "./RingWidget"
import { ROUND_TYPES, RoundView } from "./RoundFace"
import { PaletteProvider } from "./settings"
import { useUi } from "./ui"
import { showTitle } from "./ui/core/config"
@@ -116,6 +114,91 @@ function Pickable({
)
}
/**
* A round dashboard's faces, in the order a panel pages through them.
*
* Its arrangement, since a round dashboard has no grid: press a face to show
* it and open its settings, and drag it along the row — or Alt+←/→ on it — to
* move it. A type drawn as a rectangle in the middle of the face says it is a
* poor fit. Floating chrome over the canvas, as the dock is.
*/
function FaceRow({
widgets,
shown,
reorder,
onPick,
onOrder,
}: {
widgets: WidgetDef[]
shown?: string
/** Dragging is a desktop affordance (DESIGN-GUIDELINES.md → Responsive). */
reorder: boolean
onPick: (id: string) => void
onOrder: (ids: string[]) => void
}) {
const ids = widgets.map((widget) => widget.id)
const move = (from: number, to: number) => {
if (to < 0 || to >= ids.length) return
const next = [...ids]
next.splice(to, 0, ...next.splice(from, 1))
onOrder(next)
}
return (
<Reorder.Group
as="div"
axis="x"
values={ids}
onReorder={onOrder}
layoutScroll
aria-label="Faces"
data-testid="face-row"
className="absolute bottom-full left-1/2 mb-3 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 gap-1 overflow-x-auto rounded-full border border-border bg-card/80 p-1 shadow-e2 backdrop-blur-md [scrollbar-width:none]"
>
{widgets.map((widget, at) => (
<Reorder.Item
key={widget.id}
value={widget.id}
as="div"
dragListener={reorder}
className="shrink-0"
>
<button
type="button"
data-testid={`face-${widget.id}`}
aria-current={widget.id === shown ? "true" : undefined}
className={cn(
"flex h-11 items-center gap-1.5 rounded-full px-3 text-sm md:h-8",
widget.id === shown
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
onClick={() => onPick(widget.id)}
onKeyDown={(event) => {
const by =
event.key === "ArrowRight"
? 1
: event.key === "ArrowLeft"
? -1
: 0
if (!event.altKey || !by) return
event.preventDefault()
move(at, at + by)
}}
>
<span className="tabular-nums opacity-60">{at + 1}</span>
<span className="max-w-32 truncate">
{widget.title || WIDGET_LABELS[widget.type]}
</span>
{ROUND_TYPES.has(widget.type) ? null : (
<span className="text-xs text-muted-foreground">Poor fit</span>
)}
</button>
</Reorder.Item>
))}
</Reorder.Group>
)
}
/** How long to sit on edits before saving, so typing is not a save per key. */
const AUTOSAVE_MS = 800
@@ -140,7 +223,8 @@ const INTERACTIVE =
* still working on it. The grid item covers both; the frame is what a stacked
* dashboard has instead.
*/
const ON_WIDGET = "[data-testid=widget-frame], .react-grid-item"
const ON_WIDGET =
"[data-testid=widget-frame], .react-grid-item, [data-testid=round-face]"
function nextId(dashboard: DashboardDef_Output, type: string): string {
const taken = new Set(widgetsOf(dashboard).map((widget) => widget.id))
@@ -236,13 +320,17 @@ export function DashboardEditor({
}) {
const [draft, setDraft] = useState<Dashboard>(dashboard)
const [selected, setSelected] = useState<string | null>(null)
// The face a round dashboard shows: the widget picked, or the one last
// picked or paged to once its settings are put away.
const [face, setFace] = useState<string | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
const [paletteOpen, setPaletteOpen] = useState(false)
// A phone reads the dashboard rather than arranges it, so the grid library
// never mounts there. See DESIGN-GUIDELINES.md → Responsive. A round one is
// the exception: a knob's face is about a phone's width as it is.
// never mounts there. See DESIGN-GUIDELINES.md → Responsive. A round one has
// no grid to arrange, and a knob's face is about a phone's width as it is.
const round = isRound(draft)
const stacked = useIsMobile() && !round
const mobile = useIsMobile()
const stacked = mobile && !round
// The dashboard's own appearance, on the surface only: the shell around the
// canvas stays whatever the person editing chose for the app. Non-stacked,
// `CanvasSurface` states it on the canvas box itself.
@@ -266,6 +354,10 @@ export function DashboardEditor({
version.current = dashboard.version
}, [dashboard.version])
useEffect(() => {
if (selected) setFace(selected)
}, [selected])
const store = (next: Dashboard) => {
const request = save
.mutateAsync({ ...next, version: version.current })
@@ -317,11 +409,6 @@ export function DashboardEditor({
const hasDraft = Boolean(dashboard.has_draft)
const columns = columnsOf(draft)
const widgets = widgetsOf(draft)
// The ring is drawn along a round face's edge and takes no cell.
const ring = ringOf(draft)
const placedWidgets = ring
? widgets.filter((widget) => widget !== ring)
: widgets
const updateWidgets = (next: WidgetDef[]) => {
commit({ ...draft, widgets: next })
@@ -386,35 +473,39 @@ export function DashboardEditor({
(panel.dashboards ?? []).length > 1 &&
(panel.dashboards ?? []).includes(draft.name),
)?.dashboards ?? null
const hasRail = Boolean(railDashboards) && !stacked
const rail = railDashboards ? (
<PanelRail
dashboards={railDashboards}
current={draft.name}
horizontal={stacked}
dots={round}
// Editing one dashboard of a panel and editing its neighbour are the
// same job, so the rail keeps whichever mode this one is in.
linkFor={(name) => ({
to: "/dashboards/$name",
params: { name },
search: edit ? { edit: true } : {},
})}
/>
) : null
// A round face has no room for one: a panel swipes between those instead.
const hasRail = Boolean(railDashboards) && !stacked && !round
const rail =
railDashboards && !round ? (
<PanelRail
dashboards={railDashboards}
current={draft.name}
horizontal={stacked}
// Editing one dashboard of a panel and editing its neighbour are the
// same job, so the rail keeps whichever mode this one is in.
linkFor={(name) => ({
to: "/dashboards/$name",
params: { name },
search: edit ? { edit: true } : {},
})}
/>
) : null
// What the arrangement is laid out in: the canvas less its margin, and less
// the rail's column when the panel carries one.
const area = areaOf(draft, hasRail)
const layout = layoutOf(placedWidgets, columns)
const layout = layoutOf(widgets, columns)
const rows = rowsOf(draft, hasRail)
// maxRows below only constrains a *drag*. A stored placement is corrected
// against `cols` and nothing else, so a canvas that shrank leaves whatever
// now falls past its bottom edge drawn under the clip.
const clipped = layout.filter((item) => item.y + item.h > rows)
// A round face cuts the corners off the grid it is laid out in.
const cornered = offFace(draft, layout)
// now falls past its bottom edge drawn under the clip. A round face has no
// grid to fall off.
const clipped = round ? [] : layout.filter((item) => item.y + item.h > rows)
const shown = Math.max(
0,
widgets.findIndex((widget) => widget.id === (selected ?? face)),
)
/** Store what the grid ended up doing, unless it did nothing. */
const applyLayout = (next: Layout) => {
@@ -443,7 +534,7 @@ export function DashboardEditor({
* room above it — it has to move sideways. */
const reflow = () => {
const at = new Map(layout.map((item) => [item.i, item]))
const ordered = [...placedWidgets].sort((a, b) => {
const ordered = [...widgets].sort((a, b) => {
const left = at.get(a.id)
const right = at.get(b.id)
return (
@@ -519,35 +610,32 @@ export function DashboardEditor({
) : null}
</div>
) : (
<CanvasSurface
dashboard={draft}
dots={edit}
rail={rail}
face={
<RoundFace
dashboard={draft}
editing={edit}
selected={Boolean(ring) && ring?.id === selected}
onPick={(id) => {
setSettingsOpen(false)
setSelected(id)
}}
/>
}
>
<CanvasSurface dashboard={draft} dots={edit} rail={rail}>
{(scale) =>
!edit ? (
<DashboardView dashboard={draft} rail={hasRail} />
) : round ? (
// One face at a time, the one picked in the row of faces below.
<RoundView
dashboard={draft}
size={canvasOf(draft).width}
face={shown}
editing
onFace={(index) => {
setFace(widgets[index].id)
if (selected) setSelected(widgets[index].id)
}}
onPick={(id) => {
setSettingsOpen(false)
setSelected(id)
}}
/>
) : (
<GridLayout
width={area.width}
layout={layout}
onLayoutChange={applyLayout}
positionStrategy={scaledStrategy(scale)}
// A circle is narrowest at the top, which is where packing
// upwards would put everything: a round face keeps each tile
// where it was placed.
compactor={round ? noCompactor : undefined}
gridConfig={{
cols: columns,
rowHeight: rowHeightOf(draft, hasRail),
@@ -569,7 +657,7 @@ export function DashboardEditor({
}}
resizeConfig={{ handles: ["e", "s", "se"] }}
>
{placedWidgets.map((widget) => (
{widgets.map((widget) => (
<div key={widget.id}>{pickable(widget, true)}</div>
))}
</GridLayout>
@@ -591,6 +679,8 @@ export function DashboardEditor({
className={cn(
"absolute inset-0 overflow-hidden px-4 pb-24 pt-20 transition-[padding] duration-200",
panelOpen && "md:pr-[27rem]",
// Clear of the row of faces above the dock.
edit && round && "pb-40",
)}
data-testid="dashboard-canvas"
onClick={(event) => {
@@ -660,26 +750,23 @@ export function DashboardEditor({
Reflow
</Button>
</motion.div>
) : cornered.length > 0 && !stacked ? (
// Not a fault and nothing to re-pack: the tile still shows,
// less what the circle cuts off it.
<motion.div
variants={slideUp}
initial="hidden"
animate="visible"
exit="exit"
transition={transitions.emphasized}
role="status"
aria-live="polite"
data-testid="canvas-cornered"
className="absolute bottom-full left-1/2 mb-3 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center rounded-full border border-border bg-card/80 px-4 py-1.5 shadow-e2 backdrop-blur-md"
>
<span className="truncate text-sm">
{cornered.length === 1
? "1 widget reaches past the round face — its corners will be cut off."
: `${cornered.length} widgets reach past the round face — their corners will be cut off.`}
</span>
</motion.div>
) : round && widgets.length > 0 ? (
<FaceRow
widgets={widgets}
shown={widgets[shown]?.id}
reorder={!mobile}
onPick={(id) => {
setSettingsOpen(false)
setSelected(id)
}}
onOrder={(ids) =>
updateWidgets(
ids.flatMap((id) =>
widgets.filter((widget) => widget.id === id),
),
)
}
/>
) : null}
<Tooltip>
@@ -793,21 +880,10 @@ export function DashboardEditor({
<WidgetPanel
widget={active}
round={round}
onRing={Boolean(active) && draft.ring === active?.id}
// One ring per face, so putting a widget on it takes it off any
// other.
onRingChange={(on) =>
active && commit({ ...draft, ring: on ? active.id : "" })
}
onChange={(changes) => active && patch(active.id, changes)}
onDelete={() => {
if (!active) return
// A ring naming a widget that is gone is refused on save.
commit({
...draft,
widgets: widgets.filter((other) => other.id !== active.id),
ring: draft.ring === active.id ? "" : draft.ring,
})
updateWidgets(widgets.filter((other) => other.id !== active.id))
setSelected(null)
}}
onClose={() => setSelected(null)}
@@ -10,6 +10,7 @@ import {
CommandList,
} from "@/components/ui/command"
import { dashboardsQueryOptions } from "./queries"
import { ROUND_TYPES } from "./RoundFace"
import {
INPUT_WIDGETS,
WIDGET_DESCRIPTIONS,
@@ -19,20 +20,6 @@ import {
const KINDS = Object.keys(WIDGET_LABELS) as WidgetKind[]
/**
* What a round face draws badly: a row of controls or text, a plot or a strip
* that wants the width a circle only has across its middle, and a page in a
* tile. Offered all the same — a soft mark, not a refusal.
*/
const POOR_ON_ROUND = new Set<WidgetKind>([
"input",
"buttons",
"chart",
"forecast",
"agenda",
"embed",
])
const GROUPS = [
["Show a value", KINDS.filter((kind) => !INPUT_WIDGETS.has(kind))],
["Control something", KINDS.filter((kind) => INPUT_WIDGETS.has(kind))],
@@ -56,7 +43,7 @@ export function DashboardPalette({
onSettings: () => void
/** Absent while there is nothing to publish. */
onPublish?: () => void
/** The dashboard is round: the types that draw badly on it say so. */
/** The dashboard is round: the types with no face of their own say so. */
round?: boolean
}) {
const navigate = useNavigate()
@@ -96,7 +83,7 @@ export function DashboardPalette({
<span>{WIDGET_LABELS[kind]}</span>
<span className="text-xs text-muted-foreground">
{WIDGET_DESCRIPTIONS[kind]}
{round && POOR_ON_ROUND.has(kind)
{round && !ROUND_TYPES.has(kind)
? " · Poor fit for round"
: ""}
</span>
@@ -4,6 +4,7 @@ import type { DashboardDef_Output, Placement, WidgetDef } from "@/client"
import { cn } from "@/lib/utils"
import "./dashboard.css"
import { RoundView } from "./RoundFace"
import {
LockedProvider,
PaletteProvider,
@@ -13,7 +14,6 @@ import { useUi } from "./ui"
import { scrolls, showTitle } from "./ui/core/config"
import { LookProvider, useCanvasRoot, useLook } from "./ui/core/look"
import { gridStagger, LOOK } from "./ui/core/motion"
import { RING_INSET } from "./ui/core/ring"
import { WidgetBody, widgetIssue } from "./widgets"
export type Dashboard = DashboardDef_Output
@@ -22,7 +22,7 @@ export type Dashboard = DashboardDef_Output
export const DEFAULT_COLUMNS = 12
/** What a grid size setting may be set to; a panel is matched to one of these. */
export const COLUMN_CHOICES = [4, 6, 8, 12, 16, 24]
export const COLUMN_CHOICES = [6, 8, 12, 16, 24]
/** The panel a dashboard is drawn for, if the document does not say. */
export const DEFAULT_CANVAS = { width: 1920, height: 1080 }
@@ -79,21 +79,10 @@ export const widgetsOf = (dashboard: DashboardDef_Output) =>
export const columnsOf = (dashboard: Dashboard) =>
dashboard.columns || DEFAULT_COLUMNS
/** Drawn in the circle its canvas inscribes, for a round screen. */
/** Drawn on the circle its canvas inscribes, one widget at a time, for a
* round screen. */
export const isRound = (dashboard: Dashboard) => dashboard.shape === "round"
/** Widget types a round dashboard may draw on its ring. Mirrors the server's
* `RING_WIDGETS` (`flow/dashboards.py`). */
export const RING_WIDGETS = new Set(["slider", "dropdown", "color", "gauge"])
/** The widget a round dashboard draws along its edge, if it has one. */
export const ringOf = (dashboard: Dashboard): WidgetDef | null =>
(isRound(dashboard) &&
widgetsOf(dashboard).find(
(widget) => widget.id === dashboard.ring && RING_WIDGETS.has(widget.type),
)) ||
null
/** A round canvas is square whatever its stored height says: a circle has one
* size. The server squares it on save; this covers a draft not saved yet. */
export const canvasOf = (dashboard: Dashboard) => {
@@ -134,22 +123,18 @@ const CELL_ASPECT = DEFAULT_COLUMN_WIDTH / DEFAULT_ROW_HEIGHT
* room it takes comes out of the canvas the same way the margin does. Which
* cell each widget sits in never changes — only how big a cell is.
*
* A round dashboard has no rail — a panel shows page dots in the ring's gap
* instead — and is inset evenly, by the ring's band as well when it has one.
* The cells are the same maths in that square; what a circle cuts off of it
* is the editor's to warn about (`offFace`).
* A round dashboard has no grid and no rail: it is one face at a time, drawn
* on the whole canvas (`RoundView`).
*/
export const areaOf = (dashboard: Dashboard, rail = false) => {
const { width, height } = canvasOf(dashboard)
const round = isRound(dashboard)
const inset = GRID_INSET + (ringOf(dashboard) ? RING_INSET : 0)
const left = round ? inset : GRID_INSET + (rail ? RAIL_INSET : 0)
const top = round ? inset : GRID_INSET
if (isRound(dashboard)) return { left: 0, top: 0, width, height }
const left = GRID_INSET + (rail ? RAIL_INSET : 0)
return {
left,
top,
width: Math.max(1, width - left - (round ? inset : GRID_INSET)),
height: Math.max(1, height - 2 * top),
top: GRID_INSET,
width: Math.max(1, width - left - GRID_INSET),
height: Math.max(1, height - 2 * GRID_INSET),
}
}
@@ -190,18 +175,14 @@ export function CanvasSurface({
dashboard,
dots,
rail,
face,
touch,
children,
}: {
dashboard: Dashboard
/** Draw the placement grid. A wall panel is not being arranged, so: no. */
dots?: boolean
/** The dashboard-switching rail, drawn on the panel rather than beside it.
* On a round dashboard, the page dots in the ring's gap. */
/** The dashboard-switching rail, drawn on the panel rather than beside it. */
rail?: React.ReactNode
/** What a round dashboard draws over the whole face: its ring. */
face?: React.ReactNode
/** Whether the panel this canvas hangs on is touched rather than pointed at. */
touch?: boolean
children: (scale: number) => React.ReactNode
@@ -248,7 +229,6 @@ export function CanvasSurface({
data-touch={root["data-touch"]}
data-palette={root["data-palette"]}
data-shape={round ? "round" : undefined}
data-arranging={dots ? "" : undefined}
className={cn(
"absolute overflow-hidden bg-background text-foreground",
root.className,
@@ -259,10 +239,8 @@ export function CanvasSurface({
...root.style,
width,
height,
// Round, nothing outside the circle is drawn — except while it
// is being arranged, where a tile reaching past the edge has to
// stay in reach to be moved back (`FaceGuide` shades it).
clipPath: round && !dots ? "circle(50%)" : undefined,
// Round, nothing outside the circle is drawn.
clipPath: round ? "circle(50%)" : undefined,
left: (box.width - width * scale) / 2,
top: (box.height - height * scale) / 2,
transform: `scale(${scale})`,
@@ -289,15 +267,11 @@ export function CanvasSurface({
>
{/* Over the ground rather than under it: the dots are what is
being arranged against, and a wallpaper must not hide them. */}
{dots ? (
{dots && !round ? (
<div className="dot-canvas pointer-events-none absolute inset-0" />
) : null}
{content}
</div>
{round ? face : null}
{round && dots ? (
<FaceGuide size={width} ring={Boolean(ringOf(dashboard))} />
) : null}
</div>
</LookProvider>
) : null}
@@ -305,83 +279,6 @@ export function CanvasSurface({
)
}
/**
* The circle, while a round dashboard is being arranged.
*
* The canvas is not clipped then, so what lies outside the face is shaded
* rather than hidden, and the edge the grid's tiles must keep inside — the
* ring's inner edge, when there is one — is drawn dashed.
*/
function FaceGuide({ size, ring }: { size: number; ring: boolean }) {
const half = size / 2
const safe = half - (ring ? RING_INSET : 0)
return (
<svg
role="presentation"
width={size}
height={size}
className="pointer-events-none absolute inset-0 z-[5]"
data-testid="face-guide"
>
<path
fillRule="evenodd"
className="fill-background opacity-70"
d={`M0 0H${size}V${size}H0Z M${half} 0a${half} ${half} 0 1 0 0.01 0Z`}
/>
<circle
cx={half}
cy={half}
r={half - 0.5}
fill="none"
className="stroke-border"
/>
{ring ? (
<circle
cx={half}
cy={half}
r={safe}
fill="none"
strokeDasharray="4 4"
className="stroke-muted-foreground opacity-50"
/>
) : null}
</svg>
)
}
/**
* Which tiles reach past a round dashboard's face: any corner outside the
* circle the grid has to keep inside, which is the ring's inner edge when
* there is a ring. A device clips them, so the editor says so.
*/
export function offFace(
dashboard: Dashboard,
items: readonly { i: string; x: number; y: number; w: number; h: number }[],
): string[] {
if (!isRound(dashboard)) return []
const { width } = canvasOf(dashboard)
const area = areaOf(dashboard)
const column = (area.width + GRID_GAP) / columnsOf(dashboard)
const row = rowPitchOf(dashboard)
const reach = width / 2 - (ringOf(dashboard) ? RING_INSET : 0)
const out = (x: number, y: number) =>
Math.hypot(x - width / 2, y - width / 2) > reach
return items
.filter((item) => {
const left = area.left + item.x * column
const top = area.top + item.y * row
const right = left + item.w * column - GRID_GAP
const bottom = top + item.h * row - GRID_GAP
return (
out(left, top) ||
out(right, top) ||
out(left, bottom) ||
out(right, bottom)
)
})
.map((item) => item.i)
}
/**
* What the widgets sit on.
*
@@ -449,6 +346,7 @@ export function DashboardView({
stacked,
rail,
editing,
onDashboard,
}: {
dashboard: Dashboard
renderWidget?: (widget: WidgetDef) => React.ReactNode
@@ -459,12 +357,19 @@ export function DashboardView({
/** Being arranged rather than used, so the lock is not mounted — the
* 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). */
onDashboard?: (by: number) => void
}) {
const all = widgetsOf(dashboard)
const round = isRound(dashboard)
if (all.length === 0) {
return (
<p
className="text-sm text-muted-foreground"
className={cn(
"text-sm text-muted-foreground",
round && "grid h-full place-items-center px-16 text-center",
)}
data-testid="dashboard-empty"
>
Nothing on this dashboard yet. Edit it to add a widget.
@@ -472,30 +377,38 @@ export function DashboardView({
)
}
// The ring is drawn along the face's edge (`RoundFace`), not in a cell.
const ring = ringOf(dashboard)
const placed = ring ? all.filter((widget) => widget !== ring) : all
// Stacked, the arrangement becomes a reading order, so it follows the rows
// the panel shows rather than the order widgets happened to be added in.
const widgets = stacked
? [...placed].sort((a, b) => {
? [...all].sort((a, b) => {
const left = placement(a)
const right = placement(b)
return (left.y ?? 0) - (right.y ?? 0) || (left.x ?? 0) - (right.x ?? 0)
})
: placed
: all
const columns = columnsOf(dashboard)
const grid = (
<PaletteProvider dashboard={dashboard}>
<WidgetGrid
dashboard={dashboard}
widgets={widgets}
columns={columns}
stacked={stacked}
rail={rail}
renderWidget={renderWidget}
/>
{round ? (
// Its own face memory per dashboard, so a panel switching to another
// starts that one where it was left.
<RoundView
key={dashboard.name}
dashboard={dashboard}
size={canvasOf(dashboard).width}
onDashboard={onDashboard}
/>
) : (
<WidgetGrid
dashboard={dashboard}
widgets={widgets}
columns={columns}
stacked={stacked}
rail={rail}
renderWidget={renderWidget}
/>
)}
</PaletteProvider>
)
@@ -1,10 +1,10 @@
import { useQueries } from "@tanstack/react-query"
import { Link, type LinkProps } from "@tanstack/react-router"
import type { LinkProps } from "@tanstack/react-router"
import { useReducedMotion } from "motion/react"
import { useEffect, useRef } from "react"
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
import { TESTID, useUi } from "./ui"
import { useUi } from "./ui"
/**
* Switching between the dashboards one panel was assigned.
@@ -21,24 +21,17 @@ import { TESTID, useUi } from "./ui"
*
* Reading the dashboards it links to is also what fills the labels, and it
* warms the cache for the neighbours so a switch draws immediately.
*
* A round dashboard has no column to give: its way to the neighbours is a row
* of page dots in the gap at the bottom of the face, the one place the ring
* leaves free. Same links, in canvas pixels; a round device swipes instead.
*/
export function PanelRail({
dashboards,
current,
linkFor,
horizontal,
dots,
}: {
dashboards: string[]
current: string
linkFor: (name: string) => LinkProps
horizontal?: boolean
/** Page dots for a round dashboard, instead of the rail. */
dots?: boolean
}) {
const { Rail } = useUi()
const box = useRef<HTMLDivElement>(null)
@@ -76,30 +69,7 @@ export function PanelRail({
// places the rail itself.
return (
<div ref={box} className="contents">
{dots ? (
// 16px targets centred 12px above the face's bottom edge, each with
// a 6px dot.
<nav
aria-label="Dashboards on this panel"
data-testid={TESTID.rail}
className="pointer-events-auto absolute inset-x-0 bottom-1 z-10 flex justify-center"
>
{entries.map((entry) => (
<Link
key={entry.name}
{...entry.link}
aria-label={entry.label}
aria-current={entry.active ? "page" : undefined}
data-testid={`panel-rail-${entry.name}`}
className="dui-dot flex size-4 items-center justify-center"
>
<span className="dui-dot-mark" />
</Link>
))}
</nav>
) : (
<Rail entries={entries} horizontal={horizontal} />
)}
<Rail entries={entries} horizontal={horizontal} />
</div>
)
}
@@ -6,7 +6,6 @@ import {
DashboardView,
isRound,
} from "@/components/Dashboard/DashboardView"
import { RoundFace } from "@/components/Dashboard/RingWidget"
import { useDashboardLocked } from "@/components/Dashboard/settings"
import { useUi } from "@/components/Dashboard/ui"
import { LookProvider } from "@/components/Dashboard/ui/core/look"
@@ -28,6 +27,7 @@ export function PanelSurface({
stacked,
rail,
touch,
onDashboard,
}: {
dashboard: Dashboard
/** A landscape arrangement scaled onto a phone comes out at about a fifth of
@@ -39,14 +39,23 @@ export function PanelSurface({
rail?: React.ReactNode
/** 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. */
onDashboard?: (by: number) => void
}) {
const hasRail = Boolean(rail)
const stack = stacked && !isRound(dashboard)
// Stable, so the canvas can keep the tiles it already built when one of the
// dashboard's own settings re-renders the route above this.
const canvas = useCallback(
() => <DashboardView dashboard={dashboard} rail={hasRail} />,
[dashboard, hasRail],
() => (
<DashboardView
dashboard={dashboard}
rail={hasRail}
onDashboard={onDashboard}
/>
),
[dashboard, hasRail, onDashboard],
)
return (
<div className="relative size-full">
@@ -71,12 +80,7 @@ export function PanelSurface({
) : (
// The panel's own surface, scaled to fit. No dots: nothing is being
// arranged here.
<CanvasSurface
dashboard={dashboard}
rail={rail}
face={<RoundFace dashboard={dashboard} />}
touch={touch}
>
<CanvasSurface dashboard={dashboard} rail={rail} touch={touch}>
{canvas}
</CanvasSurface>
)}
+132 -113
View File
@@ -3,10 +3,8 @@ import { useEffect, useRef } from "react"
import type { WidgetDef } from "@/client"
import { cn } from "@/lib/utils"
import { canvasOf, type Dashboard, ringOf, widgetsOf } from "./DashboardView"
import { useBoundValue } from "./dataContext"
import { usePublish } from "./publish"
import { LockedProvider } from "./settings"
import {
colorFormatOf,
cssOf,
@@ -24,26 +22,27 @@ import {
text,
} from "./ui/core/config"
import {
CENTRE_LABEL,
CENTRE_SWATCH,
CENTRE_VALUE,
fitSize,
fractionAt,
GAP_RISE,
GAP_SIZE,
GAP_WIDTH,
RING_HANDLE,
RING_INSET,
RING_MARK,
RING_STROKE,
RING_TITLE_RISE,
RING_TITLE_SIZE,
ringArc,
ringPoint,
TITLE_RISE,
TITLE_SIZE,
TITLE_WIDTH,
useRingDraft,
} from "./ui/core/ring"
import { useAnimatedFraction } from "./ui/core/values"
/** How far apart the gap's line may run, centred: inside the face at any size. */
const GAP_WIDTH = 160
/** The centre reading, drawn when the ring is all the dashboard has. */
const CENTRE_VALUE = 72
const CENTRE_LABEL = 36
const CENTRE_SWATCH = 96
import type { Series } from "./widgets"
/** How much wheel a notch is: what one detent of a mouse wheel reports. */
const WHEEL_NOTCH = 100
@@ -173,41 +172,64 @@ function dialOf(widget: WidgetDef, live: unknown): Dial {
}
/**
* A round dashboard's edge: its ring, and what the ring says.
* A face's title: one slot, above the middle, whichever widget it is.
*
* Drawn over the whole face rather than in a cell, at canvas pixels — the
* geometry is in `ui/core/ring.ts`. Nothing for a dashboard without a ring.
* Arranging, a press on the ring picks it as a press on a tile does, and it is
* never locked, for the reason the grid is not.
* What is written there names what the middle shows — which is why a slider
* reading something beside its value hands the slot to that reading.
*/
export function RoundFace({
dashboard,
editing,
selected,
onPick,
export function FaceTitle({
size,
children,
}: {
dashboard: Dashboard
editing?: boolean
selected?: boolean
onPick?: (id: string) => void
size: number
children: React.ReactNode
}) {
const ring = ringOf(dashboard)
if (!ring) return null
const face = (
<Ring
widget={ring}
dashboard={dashboard.name}
size={canvasOf(dashboard).width}
alone={widgetsOf(dashboard).length === 1}
editing={editing}
selected={selected}
onPick={onPick && (() => onPick(ring.id))}
/>
return (
<p
className="pointer-events-none absolute truncate text-center text-muted-foreground"
style={{
left: size / 2 - TITLE_WIDTH / 2,
width: TITLE_WIDTH,
top: size / 2 - TITLE_RISE - TITLE_SIZE,
lineHeight: `${TITLE_SIZE * 2}px`,
fontSize: TITLE_SIZE,
}}
>
{children}
</p>
)
return editing ? (
face
) : (
<LockedProvider dashboard={dashboard}>{face}</LockedProvider>
}
/**
* A reading written large in the middle of a face, its unit small beside it.
*
* Sized from what is written (`fitSize`), so a word shrinks to fit where a
* number would not have to; `children` may tween towards it meanwhile.
*/
export function CentreReading({
written,
unit,
max = CENTRE_VALUE,
children,
}: {
written: string
unit?: string
max?: number
children?: React.ReactNode
}) {
return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span
data-testid="face-centre"
className="flex items-baseline gap-1 whitespace-nowrap leading-none tabular-nums"
style={{ fontSize: fitSize(written, max) }}
>
{children ?? written}
{unit ? (
<span className="text-[0.4em] text-muted-foreground">{unit}</span>
) : null}
</span>
</div>
)
}
@@ -218,30 +240,35 @@ export function RoundFace({
* which is the one thing a knob's ring can set and a circle's edge can show.
* Turned by a drag along it or by the mouse wheel, a notch a step, an option
* or a hue step; the value moves at once and one publish follows once the
* turning stops (`useRingDraft`).
* turning stops (`useRingDraft`). Its reading is written large in the middle,
* unless a slider reads something beside it: then that is in the middle, and
* the slider's own title and value move to the gap's line.
*
* Arranging, a press on the ring picks it as a press on a tile does.
*/
function Ring({
export function Ring({
widget,
dashboard,
size,
alone,
editing,
selected,
onPick,
}: {
widget: WidgetDef
dashboard: string
size: number
/** Nothing else on the face: the reading is written large in the middle. */
alone: boolean
editing?: boolean
selected?: boolean
onPick?: () => void
}) {
const { value, send, locked } = usePublish(widget, dashboard)
const cfg = config(widget)
// A gauge reads its message rather than publishing to a target.
const message = text(config(widget).message)
const bound = useBoundValue(widget.type === "gauge" ? message : undefined)
const bound = useBoundValue(
widget.type === "gauge" ? text(cfg.message) : undefined,
)
// What a slider sets against — a room's temperature beside its setpoint.
const beside: Series =
widget.type === "slider" ? ((cfg.reading ?? {}) as Series) : {}
const read = useBoundValue(beside.message || undefined)
const dial = dialOf(widget, widget.type === "gauge" ? bound?.value : value)
const { current, latest, move, commit } = useRingDraft(dial.value, (next) =>
send(dial.wire(next)),
@@ -295,9 +322,12 @@ function Ring({
const title = showTitle(widget) ? widget.title || "" : ""
const reading = dial.reading(current)
// Written to the slider's own decimals and in its unit: the two are read
// against each other.
const other = beside.message ? dial.reading(read?.value ?? null) : null
const handle = dial.handle && at !== null ? ringPoint(size, at) : null
const whole = ringArc(size, 0, 1)
const name = widget.title || text(config(widget).target, widget.id)
const name = widget.title || text(cfg.target, widget.id)
return (
<div
@@ -397,14 +427,6 @@ function Ring({
/>
) : null}
</g>
{selected ? (
<path
d={whole}
fill="none"
strokeWidth={RING_INSET}
className="stroke-primary opacity-15"
/>
) : null}
{turns || editing ? (
<path
d={whole}
@@ -435,62 +457,59 @@ function Ring({
) : null}
</svg>
{/* The gap at the bottom: the title, and the reading unless it is
already written large in the middle. */}
<div
className="absolute flex items-center justify-center gap-1.5 whitespace-nowrap"
style={{
left: size / 2 - GAP_WIDTH / 2,
width: GAP_WIDTH,
top: size - RING_TITLE_RISE - RING_TITLE_SIZE,
height: RING_TITLE_SIZE * 2,
fontSize: RING_TITLE_SIZE,
}}
>
{title ? (
<span className="min-w-0 truncate text-muted-foreground">
{title}
</span>
) : null}
{!alone && reading ? (
<span className="shrink-0 tabular-nums">
{reading}
{dial.unit ?? ""}
</span>
) : null}
</div>
{alone ? (
<div className="absolute inset-0 flex items-center justify-center">
{dial.hue ? (
<span
data-testid="ring-centre"
className="rounded-full border border-border"
style={{
width: CENTRE_SWATCH,
height: CENTRE_SWATCH,
background: cssOf(current as Triple),
}}
/>
) : (
<span
data-testid="ring-centre"
className="flex items-baseline gap-1 leading-none tabular-nums"
style={{
fontSize:
widget.type === "dropdown" ? CENTRE_LABEL : CENTRE_VALUE,
}}
>
{other === null ? (
title ? (
<FaceTitle size={size}>{title}</FaceTitle>
) : null
) : (
<>
{beside.label ? (
<FaceTitle size={size}>{beside.label}</FaceTitle>
) : null}
{/* The gap at the bottom: what the ring sets, by name. */}
<div
data-testid="ring-gap"
className="absolute flex items-center justify-center gap-1.5 whitespace-nowrap"
style={{
left: size / 2 - GAP_WIDTH / 2,
width: GAP_WIDTH,
top: size - GAP_RISE - GAP_SIZE,
height: GAP_SIZE * 2,
fontSize: GAP_SIZE,
}}
>
{title ? (
<span className="min-w-0 truncate text-muted-foreground">
{title}
</span>
) : null}
<span className="shrink-0 tabular-nums">
{reading}
{dial.unit ? (
<span className="text-[0.4em] text-muted-foreground">
{dial.unit}
</span>
) : null}
{dial.unit ?? ""}
</span>
)}
</div>
</>
)}
{dial.hue ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span
data-testid="face-centre"
className="rounded-full border border-border"
style={{
width: CENTRE_SWATCH,
height: CENTRE_SWATCH,
background: cssOf(current as Triple),
}}
/>
</div>
) : null}
) : (
<CentreReading
written={other ?? reading}
unit={dial.unit}
max={widget.type === "dropdown" ? CENTRE_LABEL : CENTRE_VALUE}
/>
)}
</div>
)
}
@@ -0,0 +1,482 @@
import { Power } from "lucide-react"
import { AnimatePresence, motion, type Variants } from "motion/react"
import { useEffect, useRef, useState } from "react"
import { ErrorBoundary } from "react-error-boundary"
import type { WidgetDef } from "@/client"
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
/** A face arrives from the side it was paged towards. */
const TURN: Variants = {
enter: (way: number) => ({ opacity: 0, x: way * 48 }),
shown: { opacity: 1, x: 0 },
leave: (way: number) => ({ opacity: 0, x: way * -48 }),
}
/**
* 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)
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)
}
useShortcuts({
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 },
)
}
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"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
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>
)
}
+80 -54
View File
@@ -56,7 +56,6 @@ import {
columnsOf,
type Dashboard,
isRound,
RING_WIDGETS,
} from "./DashboardView"
import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons"
import { messageCatalogQueryOptions } from "./queries"
@@ -492,18 +491,13 @@ function coerce(raw: string): unknown {
export function WidgetPanel({
widget,
round,
onRing,
onRingChange,
onChange,
onDelete,
onClose,
}: {
widget: WidgetDef | null
/** The dashboard is round, so a widget of the right type may go on its ring. */
/** The dashboard is round: what only a round face draws is set here too. */
round?: boolean
/** This widget is the one drawn on the ring. */
onRing?: boolean
onRingChange?: (on: boolean) => void
onChange: (changes: Partial<WidgetDef>) => void
onDelete: () => void
onClose: () => void
@@ -517,6 +511,8 @@ export function WidgetPanel({
onChange({ config: { ...cfg, ...changes } })
const series = seriesOf(widget)
// What a slider on a round face reads beside its own value.
const reading = (cfg.reading ?? {}) as Series
const runsPick = (cfg.runs ?? {}) as RunsPick
const setSeries = (next: Series[]) => set({ series: next })
// A bar's readings. An empty row stands in for none, so a fresh bar offers
@@ -610,20 +606,6 @@ export function WidgetPanel({
/>
</div>
{round && RING_WIDGETS.has(widget.type) ? (
<div className="flex items-center justify-between gap-2 text-sm">
<FieldLabel help="Drawn along the round face's edge instead of in the grid, and turned by dragging along it or with the wheel. A knob turns it with its ring. One widget per dashboard: this takes it from any other.">
On the ring
</FieldLabel>
<Switch
checked={Boolean(onRing)}
aria-label="On the ring"
data-testid="widget-on-ring"
onCheckedChange={(on) => onRingChange?.(on)}
/>
</div>
) : null}
{widget.type === "embed" ? (
<>
<div className="grid gap-1.5">
@@ -1130,7 +1112,10 @@ export function WidgetPanel({
</div>
) : null}
{widget.type === "slider" ? (
{/* A round face draws a slider on its ring, and a switch or a
selector one way only, so how they are drawn is not a choice
there. */}
{widget.type === "slider" && !round ? (
<div className="grid gap-1.5">
<FieldLabel help="A fader takes the tile's height, so give it a tall one. Either way the scale is dropped, since a column has no room for the labels.">
Drawn as
@@ -1148,6 +1133,52 @@ export function WidgetPanel({
</div>
) : null}
{widget.type === "slider" && round ? (
<div className="grid gap-1.5">
<FieldLabel help="A reading drawn large in the middle of the face — the room's temperature beside the setpoint this sets. The slider's own value then moves to the line at the bottom. Written in the slider's unit and to its step's decimals.">
Beside it
</FieldLabel>
<div className="flex items-end gap-1.5">
<div className="min-w-0 flex-1">
<MessagePicker
kind="slider"
value={str(reading.message)}
label="Reads"
hideLabel
testId="widget-reading"
placeholder="Nothing"
// Read rather than set, so a message no control may write
// is as good as any.
filter={(message) => acceptsDtype("slider", message.dtype)}
onPick={(message, dtype) =>
set({ reading: { ...reading, message, dtype } })
}
/>
</div>
<Input
className="w-28"
value={reading.label ?? ""}
placeholder="Label"
aria-label="Reading label"
onChange={(event) =>
set({ reading: { ...reading, label: event.target.value } })
}
/>
{reading.message ? (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove reading"
onClick={() => set({ reading: undefined })}
>
<X />
</Button>
) : null}
</div>
</div>
) : null}
{widget.type === "button" ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Sends</Label>
@@ -1440,7 +1471,7 @@ export function WidgetPanel({
})
}
/>
{onRing ? (
{round ? (
<div className="grid gap-1.5">
<FieldLabel help="How far one notch of the ring turns the hue, in degrees. It wraps round from violet to red.">
Hue step
@@ -1527,7 +1558,7 @@ export function WidgetPanel({
</div>
) : null}
{widget.type === "switch" || widget.type === "dropdown" ? (
{(widget.type === "switch" || widget.type === "dropdown") && !round ? (
<div className="grid gap-1.5">
<Label className="text-sm font-normal">Style</Label>
{widget.type === "switch" ? (
@@ -1553,12 +1584,6 @@ export function WidgetPanel({
onChange={(style) => set({ style })}
/>
)}
{round && !onRing && cfg.style === "segmented" ? (
<p className="text-xs text-muted-foreground">
Poor fit for round: a row of choices needs the width a circle
only has across its middle. The ring holds them better.
</p>
) : null}
</div>
) : null}
</div>
@@ -1808,30 +1833,33 @@ export function DashboardPanel({
/>
</PanelSection>
<PanelSection
title="Grid"
help="How many columns wide the layout is. Match it to the panel this will hang on."
>
<Select
value={String(columnsOf(dashboard))}
onValueChange={(value) => onChange({ columns: Number(value) })}
{/* A round face shows one widget at a time, so it has no grid. */}
{round ? null : (
<PanelSection
title="Grid"
help="How many columns wide the layout is. Match it to the panel this will hang on."
>
<SelectTrigger data-testid="dashboard-columns">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLUMN_CHOICES.map((count) => (
<SelectItem key={count} value={String(count)}>
{count} columns
</SelectItem>
))}
</SelectContent>
</Select>
</PanelSection>
<Select
value={String(columnsOf(dashboard))}
onValueChange={(value) => onChange({ columns: Number(value) })}
>
<SelectTrigger data-testid="dashboard-columns">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLUMN_CHOICES.map((count) => (
<SelectItem key={count} value={String(count)}>
{count} columns
</SelectItem>
))}
</SelectContent>
</Select>
</PanelSection>
)}
<PanelSection
title="Canvas"
help="The size of the panel this is drawn for, in pixels. Editing and viewing both scale it to fit, so the arrangement looks the same everywhere. Round draws it in the circle the canvas holds, for a round screen such as a knob, which makes the canvas square."
help="The size of the panel this is drawn for, in pixels. Editing and viewing both scale it to fit, so the arrangement looks the same everywhere. Round is for a round screen such as a knob: one widget at a time, each the whole face, in the circle a square canvas holds."
>
<Select
value={
@@ -1851,10 +1879,8 @@ export function DashboardPanel({
onChange({
canvas_width: preset.width,
canvas_height: preset.height,
// A preset is a screen, and a screen has a shape. Only a
// round one carries a ring.
// A preset is a screen, and a screen has a shape.
shape: preset.shape ?? "rect",
...(preset.shape ? {} : { ring: "" }),
})
}}
>
@@ -1881,7 +1907,7 @@ export function DashboardPanel({
onChange(
shape === "round"
? { shape: "round", canvas_height: canvas.width }
: { shape: "rect", ring: "" },
: { shape: "rect" },
)
}
/>
@@ -544,13 +544,62 @@
}
/*
* A round dashboard (`data-shape="round"` on the canvas).
* 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`.
*
* A circle has no corners for a card to sit in, so a tile drops the surface
* its look paints and keeps only what it shows: no fill, no border, no shadow,
* a smaller muted title and half the padding. A face is read about its
* vertical axis, so a title and a single reading are centred on it. Arranging,
* a dashed hairline still says where each tile is.
* The middle of a face is written at 15px 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;
line-height: 1.35;
}
.dui-face-body .dui-clock {
font-size: 72px;
line-height: 1;
}
.dui-face-body .dui-glyph {
width: 96px;
}
/* A notification's title is a row in its tile; centred lines here, its
warning sign leading the first. */
.dui-face-body p {
display: block;
}
.dui-face-body p > span[aria-hidden] {
margin-inline-end: 0.375em;
}
/* A switch's or a button's one round press, in the look's own paint. */
.dui-face-press > button {
width: 100%;
height: 100%;
border-radius: 50%;
padding: 0 12px;
font-size: 15px;
}
.dui-face-press > button > :last-child {
overflow: visible;
white-space: normal;
}
.dui-face-label {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
/*
* A type with no face of its own keeps its body, but a circle has no corners
* for a card to sit in: the frame drops the surface its look paints.
*/
[data-shape="round"] .dui-frame {
border-color: transparent;
@@ -558,7 +607,7 @@
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
padding: 0.5rem;
padding: 0;
}
/* Glass's light along the pane's top edge, which has no pane to fall on. */
@@ -566,24 +615,10 @@
content: none;
}
[data-shape="round"] .dui-frame-head,
[data-shape="round"] .dui-frame-body > [data-testid="readout"] {
justify-content: center;
}
[data-shape="round"] .dui-frame-title {
font-size: 0.75rem;
}
[data-shape="round"][data-arranging] .dui-frame {
border: 1px dashed
color-mix(in srgb, var(--muted-foreground) 40%, transparent);
}
/*
* The ring along a round face's edge (`RingWidget.tsx`). Its geometry is in
* `ring.ts`; each look paints the track, the reading and the handle.
* Only the band itself takes a pointer, so the tiles inside stay pressable.
* Only the band itself takes a pointer, so the face inside stays swipeable.
*/
.dui-ring {
overflow: visible;
@@ -614,10 +649,8 @@
fill: var(--muted-foreground);
}
/* Page dots in the ring's gap, where a rect dashboard has its rail. */
/* Page dots, one per face, in the ring's gap. */
.dui-dot-mark {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--muted-foreground);
opacity: 0.5;
@@ -1,5 +1,6 @@
/**
* A round dashboard's ring: where it runs, and what turning it does.
* A round dashboard's face: where its ring runs, where each face puts its
* title and its reading, and what turning the ring does.
*
* Everything is in canvas pixels on a face whose diameter `size` is the
* canvas width, and scales with the canvas as the grid's gap does. Angles are
@@ -11,7 +12,7 @@ import { useEffect, useRef, useState } from "react"
import { arcPath } from "./arc"
/** The band along the face's edge the ring takes; the grid is inset by it. */
/** The band along the face's edge the ring takes. */
export const RING_INSET = 40
/** Where the sweep begins: the lower left. */
export const RING_START = 120
@@ -25,10 +26,60 @@ export const RING_HANDLE = 28
export const RING_MARK = 6
/** How long the ring waits after the last notch before it publishes. */
export const RING_SETTLE_MS = 300
/** The gap's line — title, then the reading — centred this far above the
* bottom of the face, at this size. */
export const RING_TITLE_RISE = 28
export const RING_TITLE_SIZE = 13
/** 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. */
export const GAP_RISE = 28
export const GAP_SIZE = 13
export const GAP_WIDTH = 160
/** 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_WIDTH = 200
/** What a face draws in its middle — a reading, a press, a few lines — fits
* this box, centred on the middle of the face. */
export const BODY_WIDTH = 240
export const BODY_HEIGHT = 128
/** A reading written large in the middle, a selector's label, a colour's
* swatch; a long reading shrinks to fit the body's width, down to the floor. */
export const CENTRE_VALUE = 72
export const CENTRE_LABEL = 36
export const CENTRE_SWATCH = 96
export const CENTRE_FLOOR = 24
/** A switch's or a button's disc, as a diameter. */
export const PRESS = 128
/** The page dots, one per face: centred this far above the bottom of the
* face, a dot of this size per target of this pitch — closer where more
* than fit this width, which is about the face's own there. */
export const DOTS_RISE = 12
export const DOT = 6
export const DOT_PITCH = 16
export const DOTS_WIDTH = 128
/** A drag this long, mostly along one axis, is a swipe rather than a press. */
export const SWIPE = 40
/** How long a change of dashboard shows which one it landed on. */
export const ANNOUNCE_MS = 1000
/**
* The size a centre reading is written at: `max`, or smaller where it would
* not fit the body's width at about 0.6 em a character, but never below the
* floor. Counted rather than measured, so a device without a browser's text
* layout comes out at the same size.
*/
export const fitSize = (written: string, max: number) =>
Math.max(
CENTRE_FLOOR,
Math.min(max, Math.floor(BODY_WIDTH / (0.6 * Math.max(1, written.length)))),
)
/** The ring's centre line, on a face of diameter `size`. */
export const ringRadius = (size: number) => size / 2 - RING_INSET / 2
+20 -4
View File
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query"
import { keepPreviousData, useQuery } from "@tanstack/react-query"
import { createFileRoute, redirect } from "@tanstack/react-router"
import { useCallback } from "react"
import type { Dashboard } from "@/components/Dashboard/DashboardView"
import { PanelRail } from "@/components/Dashboard/PanelRail"
@@ -26,7 +27,8 @@ import { cn } from "@/lib/utils"
* anything about the other.
*
* Which one is open lives in the URL, so a panel that reboots comes back where
* it was rather than at the first one.
* it was rather than at the first one. A round dashboard has no room for a
* rail: its panel is swiped up and down between them instead.
*/
export const Route = createFileRoute("/panel/$id")({
component: PanelRoute,
@@ -45,6 +47,7 @@ export const Route = createFileRoute("/panel/$id")({
function PanelRoute() {
const { id } = Route.useParams()
const { d } = Route.useSearch()
const navigate = Route.useNavigate()
useFlowSocket()
const { data: panel } = useQuery(panelQueryOptions(id))
@@ -56,9 +59,22 @@ function PanelRoute() {
const { data: dashboard } = useQuery({
...dashboardQueryOptions(current ?? ""),
enabled: Boolean(current),
// The one showing stays until the next has loaded, rather than the
// screen going blank between them.
placeholderData: keepPreviousData,
})
const stacked = useIsMobile()
const rail = dashboards.length > 1
const round = dashboard?.shape === "round"
// Stops at either end, as the faces of one do.
const step = useCallback(
(by: number) => {
const all = panel?.dashboards ?? []
const next = all[all.indexOf(current ?? "") + by]
if (next) navigate({ search: { d: next } })
},
[panel, current, navigate],
)
// Said by the panel rather than by the dashboard: this is the screen, and
// whether it is touched or pointed at is a fact about the screen. A document
// hanging on a hallway tablet and on a desk browser is one document either
@@ -105,15 +121,15 @@ function PanelRoute() {
dashboard={dashboard as Dashboard}
stacked={stacked}
touch={touch}
onDashboard={rail && round ? step : undefined}
// Drawn on the panel rather than beside it: a screen showing four
// dashboards shows the way between them too.
rail={
rail && current ? (
rail && current && !round ? (
<PanelRail
dashboards={dashboards}
current={current}
horizontal={stacked}
dots={dashboard?.shape === "round"}
linkFor={(name) => ({
to: "/panel/$id",
params: { id },
+13 -4
View File
@@ -8,7 +8,8 @@ import { api, apiPage, deleteAll } from "./utils/api"
* A knob is turned in bursts, and a lamp or a thermostat fed a value per
* detent stutters through every one of them. So the ring moves at once and
* sends the value it came to rest on, once — which is what two wheel notches
* in quick succession must come to.
* in quick succession must come to. And a round dashboard is one face at a
* time, paged through without touching the ring.
*/
const flowName = `test_ring_${Date.now().toString(36)}`
const dashboard = `${flowName}_d`
@@ -48,11 +49,9 @@ test.beforeAll(async ({ browser }) => {
name: dashboard,
title: "Ring",
version: 0,
columns: 4,
canvas_width: 360,
canvas_height: 360,
shape: "round",
ring: "level",
widgets: [
{
id: "level",
@@ -60,6 +59,12 @@ test.beforeAll(async ({ browser }) => {
title: "Level",
config: { target, dtype: "float", min: 0, max: 10, step: 0.5 },
},
{
id: "note",
type: "markdown",
title: "Note",
config: { content: "The second face" },
},
],
},
})
@@ -96,9 +101,13 @@ test("two wheel notches publish once, two steps on", async ({ page }) => {
await page.mouse.wheel(0, -100)
await page.mouse.wheel(0, -100)
// The value moves at once; the publish waits for the turn to settle.
await expect(page.getByTestId("ring-centre")).toHaveText("1.0")
await expect(page.getByTestId("face-centre")).toHaveText("1.0")
await expect.poll(() => sent, { timeout: 5000 }).toEqual([1])
// Settled: nothing follows the one publish.
await page.waitForTimeout(1000)
expect(sent).toEqual([1])
// Paging is not the ring's arrows: the next face, the ring untouched.
await page.keyboard.press("PageDown")
await expect(page.getByText("The second face")).toBeVisible()
})