Round dashboards: shape and ring, the ring drawn and turned in every look, Knob preset and face guide in the editor

This commit is contained in:
Melvin Strobl
2026-10-14 14:23:55 +02:00
parent 9bed078c41
commit be3446cb16
21 changed files with 1447 additions and 57 deletions
+35
View File
@@ -90,6 +90,12 @@ 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 —
@@ -475,6 +481,14 @@ 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.
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 = ""
@@ -534,6 +548,24 @@ class DashboardDef(BaseModel):
)
return self
@model_validator(mode="after")
def _check_shape(self) -> DashboardDef:
"""Square a round canvas, and hold the ring to a widget it can draw."""
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
def setting_messages(self) -> list[str]:
"""Every message a bound setting reads. Empty for a static dashboard."""
@@ -549,6 +581,9 @@ 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")
+21
View File
@@ -445,6 +445,27 @@ 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],
)
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_setting_refuses_a_message_it_cannot_carry():
"""The same rule a widget binding is held to, from the document alone."""
DashboardDef(
+46
View File
@@ -168,6 +168,52 @@ Background draws an image beneath the widgets. Lock disables publishing from
controls and shows **Read-only** when viewing the dashboard. It does not
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.
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.
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.
### 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.
| 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 |
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.
## Showing one
Open `/view/{name}` for a dashboard-only view with your normal session.
+38 -14
View File
@@ -91,12 +91,13 @@ for (const theme of ["light", "dark"]) {
await captureDashboards(page, dir)
await capturePanels(page, dir)
await captureMedia(page, dir)
await captureRound(page, dir)
await captureRuns(page, dir)
await captureWorkers(page, dir)
await context.close()
console.log(
` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-panel,app-panels,app-runs,app-run,app-run-context,app-workers}.png`,
` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-panel,app-panels,app-round,app-runs,app-run,app-run-context,app-workers}.png`,
)
}
}
@@ -123,20 +124,24 @@ async function captureRuns(page, dir) {
await page.waitForTimeout(1500)
await page.screenshot({ path: `${dir}/app-runs-compare.png`, fullPage: true })
// The API is its own host here; the SPA's origin does not proxy /api.
// 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 (base) => {
const answer = await fetch(`${base}/api/v1/dashboards/`, {
headers: {
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
},
})
if (!answer.ok) return null
const body = await answer.json()
return (body.data || [])
.map((one) => one.name)
.find((n) => n.endsWith("_results"))
}, 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
@@ -254,6 +259,25 @@ async function captureMedia(page, dir) {
await page.screenshot({ path: `${dir}/app-media.png` })
}
/**
* A round dashboard, on the panel that carries it: the Demo's thermostat on
* `/panel/knob`, ring and all. Skipped where the Demo is not seeded.
*/
async function captureRound(page, dir) {
const answer = await page.goto(`${APP_URL}/panel/knob`, {
waitUntil: "networkidle",
})
if (!answer?.ok()) return
try {
await page.getByTestId("ring").waitFor({ timeout: 10000 })
} catch {
console.warn(" no round dashboard drew — is the Demo seeded?")
return
}
await page.waitForTimeout(1000)
await page.screenshot({ path: `${dir}/app-round.png` })
}
/**
* The flow editor, empty-handed if the instance has no flows yet: seeds one
* with a node so the canvas and the node panel are both worth looking at.
+22
View File
@@ -676,6 +676,17 @@ export const DashboardDef_InputSchema = {
title: 'Canvas Height',
default: 1080
},
shape: {
type: 'string',
enum: ['rect', 'round'],
title: 'Shape',
default: 'rect'
},
ring: {
type: 'string',
title: 'Ring',
default: ''
},
icon: {
type: 'string',
title: 'Icon',
@@ -744,6 +755,17 @@ export const DashboardDef_OutputSchema = {
title: 'Canvas Height',
default: 1080
},
shape: {
type: 'string',
enum: ['rect', 'round'],
title: 'Shape',
default: 'rect'
},
ring: {
type: 'string',
title: 'Ring',
default: ''
},
icon: {
type: 'string',
title: 'Icon',
+6
View File
@@ -168,6 +168,8 @@ export type DashboardDef_Input = {
columns?: number;
canvas_width?: number;
canvas_height?: number;
shape?: 'rect' | 'round';
ring?: string;
icon?: string;
widgets?: Array<WidgetDef>;
settings?: {
@@ -177,6 +179,8 @@ export type DashboardDef_Input = {
has_draft?: boolean;
};
export type shape = 'rect' | 'round';
/**
* A dashboard as stored, and as the API hands it over.
*/
@@ -186,6 +190,8 @@ export type DashboardDef_Output = {
columns?: number;
canvas_width?: number;
canvas_height?: number;
shape?: 'rect' | 'round';
ring?: string;
icon?: string;
widgets?: Array<WidgetDef>;
settings?: {
@@ -6,6 +6,7 @@ import { type CSSProperties, useEffect, useRef, useState } from "react"
import {
GridLayout,
type Layout,
noCompactor,
type Position,
setTransform,
} from "react-grid-layout"
@@ -48,7 +49,10 @@ import {
DashboardView,
GRID_GAP,
isPlaced,
isRound,
offFace,
placement,
ringOf,
rowHeightOf,
rowsOf,
widgetsOf,
@@ -63,6 +67,7 @@ import {
usePublishDashboard,
useSaveDashboard,
} from "./queries"
import { RoundFace } from "./RingWidget"
import { PaletteProvider } from "./settings"
import { useUi } from "./ui"
import { showTitle } from "./ui/core/config"
@@ -234,8 +239,10 @@ export function DashboardEditor({
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.
const stacked = useIsMobile()
// 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.
const round = isRound(draft)
const stacked = useIsMobile() && !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.
@@ -310,6 +317,11 @@ 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 })
@@ -380,6 +392,7 @@ export function DashboardEditor({
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) => ({
@@ -393,13 +406,15 @@ export function DashboardEditor({
// the rail's column when the panel carries one.
const area = areaOf(draft, hasRail)
const layout = layoutOf(widgets, columns)
const layout = layoutOf(placedWidgets, 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)
/** Store what the grid ended up doing, unless it did nothing. */
const applyLayout = (next: Layout) => {
@@ -428,7 +443,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 = [...widgets].sort((a, b) => {
const ordered = [...placedWidgets].sort((a, b) => {
const left = at.get(a.id)
const right = at.get(b.id)
return (
@@ -504,7 +519,22 @@ export function DashboardEditor({
) : null}
</div>
) : (
<CanvasSurface dashboard={draft} dots={edit} rail={rail}>
<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)
}}
/>
}
>
{(scale) =>
!edit ? (
<DashboardView dashboard={draft} rail={hasRail} />
@@ -514,6 +544,10 @@ export function DashboardEditor({
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),
@@ -535,7 +569,7 @@ export function DashboardEditor({
}}
resizeConfig={{ handles: ["e", "s", "se"] }}
>
{widgets.map((widget) => (
{placedWidgets.map((widget) => (
<div key={widget.id}>{pickable(widget, true)}</div>
))}
</GridLayout>
@@ -626,6 +660,26 @@ 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>
) : null}
<Tooltip>
@@ -729,6 +783,7 @@ export function DashboardEditor({
<>
<DashboardPalette
open={paletteOpen}
round={round}
onOpenChange={setPaletteOpen}
onAdd={addWidget}
onSettings={openSettings}
@@ -737,10 +792,22 @@ 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
updateWidgets(widgets.filter((other) => other.id !== active.id))
// 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,
})
setSelected(null)
}}
onClose={() => setSelected(null)}
@@ -19,6 +19,20 @@ 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))],
@@ -34,6 +48,7 @@ export function DashboardPalette({
onAdd,
onSettings,
onPublish,
round,
}: {
open: boolean
onOpenChange: (open: boolean) => void
@@ -41,6 +56,8 @@ 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. */
round?: boolean
}) {
const navigate = useNavigate()
const { data } = useQuery({ ...dashboardsQueryOptions(), enabled: open })
@@ -79,6 +96,9 @@ export function DashboardPalette({
<span>{WIDGET_LABELS[kind]}</span>
<span className="text-xs text-muted-foreground">
{WIDGET_DESCRIPTIONS[kind]}
{round && POOR_ON_ROUND.has(kind)
? " · Poor fit for round"
: ""}
</span>
</span>
</CommandItem>
@@ -13,6 +13,7 @@ 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
@@ -21,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 = [6, 8, 12, 16, 24]
export const COLUMN_CHOICES = [4, 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 }
@@ -40,6 +41,9 @@ export const CANVAS_PRESETS = [
{ label: "Full HD", width: 1920, height: 1080 },
{ label: '10" tablet, portrait', width: 800, height: 1280 },
{ label: "Full HD, portrait", width: 1080, height: 1920 },
// A rotary knob's round screen. The only round one: a circle needs nothing
// but its diameter, so any other is a custom width.
{ label: "Knob", width: 360, height: 360, shape: "round" as const },
]
/** The gap between widgets, in pixels. Matches the `gap-3` view mode uses. */
@@ -75,10 +79,32 @@ export const widgetsOf = (dashboard: DashboardDef_Output) =>
export const columnsOf = (dashboard: Dashboard) =>
dashboard.columns || DEFAULT_COLUMNS
export const canvasOf = (dashboard: Dashboard) => ({
width: dashboard.canvas_width || DEFAULT_CANVAS.width,
height: dashboard.canvas_height || DEFAULT_CANVAS.height,
})
/** Drawn in the circle its canvas inscribes, 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) => {
const width = dashboard.canvas_width || DEFAULT_CANVAS.width
return {
width,
height: isRound(dashboard)
? width
: dashboard.canvas_height || DEFAULT_CANVAS.height,
}
}
/** One grid row, in pixels, on the canvas the grid was first drawn for. */
const DEFAULT_ROW_HEIGHT = 80
@@ -107,15 +133,23 @@ const CELL_ASPECT = DEFAULT_COLUMN_WIDTH / DEFAULT_ROW_HEIGHT
* one: a rail is part of the screen rather than something beside it, so the
* 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`).
*/
export const areaOf = (dashboard: Dashboard, rail = false) => {
const { width, height } = canvasOf(dashboard)
const left = GRID_INSET + (rail ? RAIL_INSET : 0)
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
return {
left,
top: GRID_INSET,
width: Math.max(1, width - left - GRID_INSET),
height: Math.max(1, height - 2 * GRID_INSET),
top,
width: Math.max(1, width - left - (round ? inset : GRID_INSET)),
height: Math.max(1, height - 2 * top),
}
}
@@ -156,14 +190,18 @@ 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. */
/** The dashboard-switching rail, drawn on the panel rather than beside it.
* On a round dashboard, the page dots in the ring's gap. */
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
@@ -187,6 +225,7 @@ export function CanvasSurface({
const { width, height } = canvasOf(dashboard)
const scale = Math.min(box.width / width, box.height / height)
const root = useCanvasRoot(dashboard, touch)
const round = isRound(dashboard)
const area = areaOf(dashboard, Boolean(rail))
// The arrangement, held across the renders that are not about it. This
// component reads the dashboard's own settings, so a bound one wakes it on
@@ -208,6 +247,8 @@ export function CanvasSurface({
data-look={root["data-look"]}
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,
@@ -218,6 +259,10 @@ 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,
left: (box.width - width * scale) / 2,
top: (box.height - height * scale) / 2,
transform: `scale(${scale})`,
@@ -249,6 +294,10 @@ export function CanvasSurface({
) : null}
{content}
</div>
{round ? face : null}
{round && dots ? (
<FaceGuide size={width} ring={Boolean(ringOf(dashboard))} />
) : null}
</div>
</LookProvider>
) : null}
@@ -256,6 +305,83 @@ 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.
*
@@ -346,15 +472,18 @@ 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
? [...all].sort((a, b) => {
? [...placed].sort((a, b) => {
const left = placement(a)
const right = placement(b)
return (left.y ?? 0) - (right.y ?? 0) || (left.x ?? 0) - (right.x ?? 0)
})
: all
: placed
const columns = columnsOf(dashboard)
const grid = (
@@ -1,10 +1,10 @@
import { useQueries } from "@tanstack/react-query"
import type { LinkProps } from "@tanstack/react-router"
import { Link, type LinkProps } from "@tanstack/react-router"
import { useReducedMotion } from "motion/react"
import { useEffect, useRef } from "react"
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
import { useUi } from "./ui"
import { TESTID, useUi } from "./ui"
/**
* Switching between the dashboards one panel was assigned.
@@ -21,17 +21,24 @@ import { 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)
@@ -69,7 +76,30 @@ export function PanelRail({
// places the rail itself.
return (
<div ref={box} className="contents">
<Rail entries={entries} horizontal={horizontal} />
{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} />
)}
</div>
)
}
@@ -4,7 +4,9 @@ import type { Dashboard } from "@/components/Dashboard/DashboardView"
import {
CanvasSurface,
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"
@@ -29,7 +31,8 @@ export function PanelSurface({
}: {
dashboard: Dashboard
/** A landscape arrangement scaled onto a phone comes out at about a fifth of
* its size, which reads as nothing at all. Stack it instead. */
* its size, which reads as nothing at all. Stack it instead. Never a round
* one: a knob's face is about a phone's width already. */
stacked?: boolean
/** The way between this panel's dashboards, drawn on the canvas itself, or
* along the bottom of the screen when stacked. */
@@ -38,6 +41,7 @@ export function PanelSurface({
touch?: boolean
}) {
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(
@@ -46,7 +50,7 @@ export function PanelSurface({
)
return (
<div className="relative size-full">
{stacked ? (
{stack ? (
// Nothing scales a stack, so there is no canvas element here to state
// the look on: this branch mounts what `CanvasSurface` mounts for the
// other one, touch included.
@@ -67,13 +71,18 @@ export function PanelSurface({
) : (
// The panel's own surface, scaled to fit. No dots: nothing is being
// arranged here.
<CanvasSurface dashboard={dashboard} rail={rail} touch={touch}>
<CanvasSurface
dashboard={dashboard}
rail={rail}
face={<RoundFace dashboard={dashboard} />}
touch={touch}
>
{canvas}
</CanvasSurface>
)}
{/* Outside the canvas, so it needs the look stated for it. */}
<LookProvider dashboard={dashboard} touch={touch}>
{stacked && rail ? (
{stack && rail ? (
// Lifted clear of the rail floating along the bottom.
<div className="pointer-events-none absolute inset-x-0 top-0 bottom-20">
<LockNotice dashboard={dashboard} />
@@ -0,0 +1,524 @@
import { motion, useTransform } from "motion/react"
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,
decodeColor,
encodeColor,
type Triple,
wrap,
} from "./ui/core/color"
import {
config,
format,
fractionOf,
num,
showTitle,
text,
} from "./ui/core/config"
import {
fractionAt,
RING_HANDLE,
RING_INSET,
RING_MARK,
RING_STROKE,
RING_TITLE_RISE,
RING_TITLE_SIZE,
ringArc,
ringPoint,
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
/** How much wheel a notch is: what one detent of a mouse wheel reports. */
const WHEEL_NOTCH = 100
/** A key that turns the ring, as a notch would. */
const ARROWS: Record<string, number> = {
ArrowUp: 1,
ArrowRight: 1,
ArrowDown: -1,
ArrowLeft: -1,
}
/** How many strokes the colour ring's hue is drawn in: 2° each. */
const HUE_SEGMENTS = 150
/**
* What one type of ring does with its value.
*
* The value is whatever the type turns — a number, an option's index, a colour
* — and the rest says where it sits along the sweep and how a notch or a
* pointer moves it. No `notch` means a ring that only shows.
*/
type Dial = {
value: unknown
/** Where it sits along the sweep, 0..1; null draws no handle. */
at: (value: unknown) => number | null
notch?: (value: unknown, by: number) => unknown
pick?: (fraction: number, value: unknown) => unknown
/** What it publishes, in the shape the same widget sends from the grid. */
wire: (value: unknown) => unknown
/** Written out; empty for a colour, whose handle is the reading. */
reading: (value: unknown) => string
unit?: string
/** A value arc from the start to the reading: a slider and a gauge. */
fill?: boolean
handle?: boolean
marks?: number[]
hue?: boolean
}
function dialOf(widget: WidgetDef, live: unknown): Dial {
const cfg = config(widget)
const unit = cfg.unit ? text(cfg.unit) : undefined
if (widget.type === "dropdown") {
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
const last = options.length - 1
const at = (index: unknown) =>
(index as number) < 0 ? null : last > 0 ? (index as number) / last : 0.5
return {
value: options.findIndex((option) => text(option.value) === text(live)),
at,
// No wrap: the first and last options are the ring's two ends.
notch:
last < 0
? undefined
: (index, by) => Math.min(last, Math.max(0, (index as number) + by)),
pick: (fraction) => Math.round(fraction * last),
wire: (index) =>
options[index as number]?.value ?? options[index as number]?.label,
reading: (index) => {
const option = options[index as number]
return option ? (option.label ?? text(option.value)) : "—"
},
handle: true,
marks: options.map((_, index) => at(index) ?? 0.5),
}
}
if (widget.type === "color") {
const form = colorFormatOf(widget)
const step = num(cfg.hue_step, 5)
// Turning sets the hue alone; saturation and brightness stay what they
// were, or full when nothing has been sent yet.
const hsv: Triple = decodeColor(live, form) ?? [0, 100, 100]
return {
value: hsv,
at: (value) => (value as Triple)[0] / 360,
notch: (value, by) => {
const [h, s, v] = value as Triple
return [wrap(h + by * step), s, v]
},
pick: (fraction, value) => {
const [, s, v] = value as Triple
return [wrap(fraction * 360), s, v]
},
wire: (value) => encodeColor(value as Triple, form),
reading: () => "",
handle: true,
hue: true,
}
}
const min = num(cfg.min, 0)
const max = num(cfg.max, 100)
if (widget.type === "gauge") {
const precision = cfg.precision === undefined ? 1 : num(cfg.precision, 1)
return {
value: typeof live === "number" ? live : null,
at: (value) => fractionOf(value as number | null, min, max),
wire: (value) => value,
reading: (value) => format(value, precision),
unit,
fill: true,
}
}
// A slider. Its value is written to the step's own decimals, as the
// slider in the grid labels its scale.
const step = num(cfg.step, 1) || 1
const digits = (String(step).split(".")[1] ?? "").length
const fit = (value: number) =>
Number(Math.min(max, Math.max(min, value)).toFixed(digits))
return {
value: typeof live === "number" ? live : min,
at: (value) => fractionOf(value as number, min, max),
notch: (value, by) => fit((value as number) + by * step),
pick: (fraction) =>
fit(min + Math.round((fraction * (max - min)) / step) * step),
wire: (value) => value,
reading: (value) => format(value, digits),
unit,
fill: true,
handle: true,
}
}
/**
* A round dashboard's edge: its ring, and what the ring says.
*
* 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.
*/
export function RoundFace({
dashboard,
editing,
selected,
onPick,
}: {
dashboard: Dashboard
editing?: boolean
selected?: boolean
onPick?: (id: string) => void
}) {
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 editing ? (
face
) : (
<LockedProvider dashboard={dashboard}>{face}</LockedProvider>
)
}
/**
* One widget, drawn along the edge of a round face.
*
* A slider, a selector, a colour or a gauge — each is one value along a range,
* 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`).
*/
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)
// 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 dial = dialOf(widget, widget.type === "gauge" ? bound?.value : value)
const { current, latest, move, commit } = useRingDraft(dial.value, (next) =>
send(dial.wire(next)),
)
const svg = useRef<SVGSVGElement>(null)
const face = useRef<HTMLDivElement>(null)
const spin = useRef(0)
const turns = Boolean(dial.notch) && !locked
const at = dial.at(current)
const animated = useAnimatedFraction(at ?? 0)
const shown = useTransform(animated, (fraction) => (fraction > 0 ? 1 : 0))
const turn = (by: number) => {
if (turns && dial.notch) move(dial.notch(latest(), by), true)
}
// On the whole face rather than the ring: a knob turns wherever it is held.
// Up — away from you — is clockwise, which is up the scale.
const onWheel = useRef<(event: WheelEvent) => void>(() => {})
onWheel.current = (event) => {
spin.current += event.deltaMode === 1 ? event.deltaY * 33 : event.deltaY
if (Math.abs(spin.current) < WHEEL_NOTCH / 2) return
const notches = Math.max(
1,
Math.round(Math.abs(spin.current) / WHEEL_NOTCH),
)
turn(spin.current < 0 ? notches : -notches)
spin.current = 0
}
useEffect(() => {
const canvas = face.current?.parentElement
const listener = (event: WheelEvent) => onWheel.current(event)
canvas?.addEventListener("wheel", listener, { passive: true })
return () => canvas?.removeEventListener("wheel", listener)
}, [])
const aim = (event: React.PointerEvent) => {
const box = svg.current?.getBoundingClientRect()
if (!box || !dial.pick) return
move(
dial.pick(
fractionAt(
event.clientX - (box.left + box.width / 2),
event.clientY - (box.top + box.height / 2),
),
latest(),
),
false,
)
}
const title = showTitle(widget) ? widget.title || "" : ""
const reading = dial.reading(current)
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)
return (
<div
ref={face}
className="pointer-events-none absolute inset-0"
data-testid="ring"
>
{/* biome-ignore lint/a11y/noSvgWithoutTitle: named by `aria-label` in the role spread below, which the rule cannot see. */}
<svg
ref={svg}
width={size}
height={size}
className="dui-ring absolute inset-0"
// A control that turns, or a reading that only shows.
{...(dial.notch
? {
role: "slider",
"aria-label": name,
"aria-valuetext": `${reading}${dial.unit ?? ""}` || undefined,
"aria-valuenow":
typeof current === "number" ? current : undefined,
"aria-disabled": locked || undefined,
}
: {
role: "img",
"aria-label": `${name}: ${reading}${dial.unit ?? ""}`,
})}
tabIndex={turns ? 0 : undefined}
onKeyDown={(event) => {
const by = ARROWS[event.key]
if (!by || !turns) return
event.preventDefault()
turn(by)
}}
>
<path
d={whole}
fill="none"
strokeWidth={RING_STROKE + 8}
strokeLinecap="round"
className="dui-ring-focus"
/>
<g className={cn("dui-ring-arc", locked && "opacity-45")}>
{dial.hue ? (
<HueTrack size={size} />
) : (
<path
d={whole}
fill="none"
strokeWidth={RING_STROKE}
strokeLinecap="round"
className="dui-ring-track"
/>
)}
{/* A slider's reading follows the hand, so it is drawn where it
is. A gauge eases to its reading, revealed rather than re-pathed
as the gauge tile's arc is, and transparent at the minimum where
its round caps would still paint a dot. */}
{dial.fill && dial.notch && at ? (
<path
d={ringArc(size, 0, at)}
fill="none"
strokeWidth={RING_STROKE}
strokeLinecap="round"
className="dui-ring-fill"
/>
) : dial.fill && !dial.notch ? (
<motion.path
d={whole}
fill="none"
strokeWidth={RING_STROKE}
strokeLinecap="round"
className="dui-ring-fill"
style={{ pathLength: animated, opacity: shown }}
/>
) : null}
{dial.marks?.map((mark, index) => {
const point = ringPoint(size, mark)
return (
<circle
// Two options may share a label; position is the identity.
key={`mark-${index}`}
cx={point.x}
cy={point.y}
r={RING_MARK / 2}
className="dui-ring-mark"
/>
)
})}
{handle ? (
<circle
cx={handle.x}
cy={handle.y}
r={RING_HANDLE / 2}
className={cn("dui-ring-handle", dial.hue && "dui-ring-swatch")}
style={dial.hue ? { fill: cssOf(current as Triple) } : undefined}
/>
) : null}
</g>
{selected ? (
<path
d={whole}
fill="none"
strokeWidth={RING_INSET}
className="stroke-primary opacity-15"
/>
) : null}
{turns || editing ? (
<path
d={whole}
fill="none"
stroke="transparent"
strokeWidth={RING_INSET}
className="dui-ring-hit"
data-testid="ring-hit"
onPointerDown={(event) => {
if (editing) {
onPick?.()
return
}
event.currentTarget.setPointerCapture(event.pointerId)
aim(event)
}}
onPointerMove={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
aim(event)
}
}}
// Every way a drag can end publishes, as the grid's slider does:
// a browser taking the gesture over ends it in a cancel.
onPointerUp={commit}
onPointerCancel={commit}
onLostPointerCapture={commit}
/>
) : 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,
}}
>
{reading}
{dial.unit ? (
<span className="text-[0.4em] text-muted-foreground">
{dial.unit}
</span>
) : null}
</span>
)}
</div>
) : null}
</div>
)
}
/**
* The colour ring's track: every hue in order along the sweep, red at both
* ends. Drawn as short strokes — an SVG stroke takes no conic gradient — each
* a hair longer than its share so no seam shows between two.
*/
function HueTrack({ size }: { size: number }) {
return (
<>
{Array.from({ length: HUE_SEGMENTS }, (_, index) => {
const from = index / HUE_SEGMENTS
const to = Math.min(1, (index + 1.5) / HUE_SEGMENTS)
const end = index === 0 || index === HUE_SEGMENTS - 1
return (
<path
// A fixed run of strokes; the index is the identity.
key={`hue-${index}`}
d={ringArc(size, from, to)}
fill="none"
strokeWidth={RING_STROKE}
strokeLinecap={end ? "round" : "butt"}
stroke={`hsl(${((index + 0.5) / HUE_SEGMENTS) * 360} 100% 50%)`}
/>
)
})}
</>
)
}
+87 -10
View File
@@ -55,6 +55,8 @@ import {
canvasOf,
columnsOf,
type Dashboard,
isRound,
RING_WIDGETS,
} from "./DashboardView"
import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons"
import { messageCatalogQueryOptions } from "./queries"
@@ -489,11 +491,19 @@ 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. */
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
@@ -600,6 +610,20 @@ 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">
@@ -1416,6 +1440,22 @@ export function WidgetPanel({
})
}
/>
{onRing ? (
<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
</FieldLabel>
<Input
type="number"
min={1}
max={180}
value={str(cfg.hue_step ?? 5)}
onChange={(event) =>
set({ hue_step: Number(event.target.value) || 5 })
}
/>
</div>
) : null}
</div>
) : null}
@@ -1513,6 +1553,12 @@ 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>
@@ -1520,9 +1566,10 @@ export function WidgetPanel({
)
}
/** Presets are matched on their size, so a custom one simply matches none. */
const sizeKey = (size: { width: number; height: number }) =>
`${size.width}x${size.height}`
/** Presets are matched on their size and shape, so a custom one simply
* matches none. */
const sizeKey = (size: { width: number; height: number; shape?: string }) =>
`${size.width}x${size.height}${size.shape === "round" ? "o" : ""}`
/**
* The optional half of a setting: which message, if any, drives it.
@@ -1706,6 +1753,7 @@ export function DashboardPanel({
}) {
const [confirmOpen, setConfirmOpen] = useState(false)
const canvas = canvasOf(dashboard)
const round = isRound(dashboard)
const theme = settingOf(dashboard, "theme")
const locked = settingOf(dashboard, "locked")
const look = settingOf(dashboard, "look")
@@ -1783,14 +1831,16 @@ export function DashboardPanel({
<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."
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."
>
<Select
value={
CANVAS_PRESETS.some(
(preset) => sizeKey(preset) === sizeKey(canvas),
(preset) =>
sizeKey(preset) ===
sizeKey({ ...canvas, shape: dashboard.shape }),
)
? sizeKey(canvas)
? sizeKey({ ...canvas, shape: dashboard.shape })
: ""
}
onValueChange={(value) => {
@@ -1801,6 +1851,10 @@ 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.
shape: preset.shape ?? "rect",
...(preset.shape ? {} : { ring: "" }),
})
}}
>
@@ -1815,21 +1869,44 @@ export function DashboardPanel({
))}
</SelectContent>
</Select>
<Segmented
value={round ? "round" : "rect"}
options={[
["rect", "Rectangle"],
["round", "Round"],
]}
label="Canvas shape"
testId="dashboard-shape"
onChange={(shape) =>
onChange(
shape === "round"
? { shape: "round", canvas_height: canvas.width }
: { shape: "rect", ring: "" },
)
}
/>
<div className="flex gap-2">
<Input
type="number"
aria-label="Canvas width"
aria-label={round ? "Canvas diameter" : "Canvas width"}
data-testid="canvas-width"
value={canvas.width}
onChange={(event) =>
onChange({ canvas_width: Number(event.target.value) || 0 })
}
onChange={(event) => {
const width = Number(event.target.value) || 0
onChange(
round
? { canvas_width: width, canvas_height: width }
: { canvas_width: width },
)
}}
/>
<Input
type="number"
aria-label="Canvas height"
data-testid="canvas-height"
value={canvas.height}
// A circle's height is its width.
disabled={round}
onChange={(event) =>
onChange({ canvas_height: Number(event.target.value) || 0 })
}
@@ -3,23 +3,34 @@
*
* A 240° arc starting at the lower left, which is the shape a gauge is
* expected to have. Both looks draw the same sweep; only the stroke differs.
*
* Angles are degrees clockwise from three o'clock. A round dashboard's ring
* (`ring.ts`) draws with the same function, on a circle of its own.
*/
export const GAUGE_SWEEP = 240
export const GAUGE_START = 150
const RADIUS = 42
const point = (angle: number) => {
const point = (angle: number, radius: number, centre: number) => {
const radians = (angle * Math.PI) / 180
return [50 + RADIUS * Math.cos(radians), 50 + RADIUS * Math.sin(radians)]
return [
centre + radius * Math.cos(radians),
centre + radius * Math.sin(radians),
]
}
/** One arc of the dial, as an SVG path in a `0 0 100 78` box. */
export function arcPath(from: number, to: number): string {
const [x1, y1] = point(from)
const [x2, y2] = point(to)
/** One arc of the dial, as an SVG path — by default in a `0 0 100 78` box. */
export function arcPath(
from: number,
to: number,
radius = RADIUS,
centre = 50,
): string {
const [x1, y1] = point(from, radius, centre)
const [x2, y2] = point(to, radius, centre)
const large = Math.abs(to - from) > 180 ? 1 : 0
return `M ${x1} ${y1} A ${RADIUS} ${RADIUS} 0 ${large} 1 ${x2} ${y2}`
return `M ${x1} ${y1} A ${radius} ${radius} 0 ${large} 1 ${x2} ${y2}`
}
/** The whole track, which the reading is drawn over a fraction of. */
@@ -542,3 +542,88 @@
text-align: left;
}
}
/*
* A round dashboard (`data-shape="round"` on the canvas).
*
* 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.
*/
[data-shape="round"] .dui-frame {
border-color: transparent;
background: none;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
padding: 0.5rem;
}
/* Glass's light along the pane's top edge, which has no pane to fall on. */
[data-shape="round"] .dui-frame::before {
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.
*/
.dui-ring {
overflow: visible;
outline: none;
}
.dui-ring-hit {
pointer-events: stroke;
cursor: pointer;
/* The drag is the control, for the reason the slider's input says. */
touch-action: none;
}
.dui-ring[aria-disabled] .dui-ring-hit {
cursor: default;
}
.dui-ring-focus {
stroke: color-mix(in srgb, var(--ring) 50%, transparent);
opacity: 0;
}
.dui-ring:focus-visible .dui-ring-focus {
opacity: 1;
}
.dui-ring-mark {
fill: var(--muted-foreground);
}
/* Page dots in the ring's gap, where a rect dashboard has its rail. */
.dui-dot-mark {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--muted-foreground);
opacity: 0.5;
}
.dui-dot[aria-current="page"] .dui-dot-mark {
background: var(--foreground);
opacity: 1;
}
@@ -0,0 +1,113 @@
/**
* A round dashboard's ring: where it runs, and what turning it 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
* degrees clockwise from three o'clock — what `arc.ts` and LVGL both use — so
* a device drawing the same dashboard natively ports these numbers as they
* stand. `app/docs/interface/dashboards.md` lists them for that purpose.
*/
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. */
export const RING_INSET = 40
/** Where the sweep begins: the lower left. */
export const RING_START = 120
/** Leaves a 60° gap centred at the bottom, which holds the title. */
export const RING_SWEEP = 300
/** The track's and the reading's stroke. */
export const RING_STROKE = 16
/** The handle's diameter. */
export const RING_HANDLE = 28
/** A dropdown's option marks, as a diameter. */
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 ring's centre line, on a face of diameter `size`. */
export const ringRadius = (size: number) => size / 2 - RING_INSET / 2
/** An arc of the ring from `from` to `to`, both fractions of the sweep. */
export const ringArc = (size: number, from: number, to: number) =>
arcPath(
RING_START + from * RING_SWEEP,
RING_START + to * RING_SWEEP,
ringRadius(size),
size / 2,
)
/** A point on the ring's centre line, `at` a fraction along the sweep. */
export function ringPoint(size: number, at: number) {
const radians = ((RING_START + at * RING_SWEEP) * Math.PI) / 180
const radius = ringRadius(size)
return {
x: size / 2 + radius * Math.cos(radians),
y: size / 2 + radius * Math.sin(radians),
}
}
/**
* How far along the sweep a pointer is, from its offset to the face's centre.
*
* In the gap it snaps to the nearer end, so a drag carried past either end
* holds there rather than jumping across to the other.
*/
export function fractionAt(dx: number, dy: number): number {
const angle = (Math.atan2(dy, dx) * 180) / Math.PI
const along = (((angle - RING_START) % 360) + 360) % 360
if (along <= RING_SWEEP) return along / RING_SWEEP
return along < (360 + RING_SWEEP) / 2 ? 1 : 0
}
/**
* Turning a ring: the value moves at once, and one publish follows.
*
* A notch — a wheel, an arrow key, an encoder's detent — moves the draft and
* restarts a `RING_SETTLE_MS` wait; a drag moves it until the pointer lets go.
* Either way exactly one value is sent, the one the ring came to rest on, so a
* fast turn is not a burst of publishes for a lamp to stutter through. Without
* a draft the ring follows the engine, through the caller's hold-until-echo.
*/
export function useRingDraft<T>(value: T, send: (value: T) => void) {
const [draft, setDraft] = useState<{ value: T } | null>(null)
// Read between renders: notches arrive faster than React draws, and each
// one steps from the last rather than from what was last drawn.
const pending = useRef<{ value: T } | null>(null)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const sender = useRef(send)
sender.current = send
const commit = () => {
if (timer.current) clearTimeout(timer.current)
timer.current = null
const next = pending.current
pending.current = null
setDraft(null)
if (next) sender.current(next.value)
}
// A turn still settling when the ring goes away is sent rather than lost.
// biome-ignore lint/correctness/useExhaustiveDependencies: on unmount only.
useEffect(() => commit, [])
return {
current: draft ? draft.value : value,
/** The value a further notch steps from. */
latest: () => (pending.current ? pending.current.value : value),
/** Move the draft; `settle` publishes once nothing has moved it for a while. */
move: (next: T, settle: boolean) => {
pending.current = { value: next }
setDraft({ value: next })
if (timer.current) clearTimeout(timer.current)
timer.current = settle ? setTimeout(commit, RING_SETTLE_MS) : null
},
/** Publish the draft now: the pointer let go. */
commit,
}
}
@@ -315,3 +315,26 @@
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
}
/* --- a round dashboard's ring -------------------------------------------- */
[data-look="fluksio"] .dui-ring-track {
stroke: var(--muted);
}
[data-look="fluksio"] .dui-ring-fill {
stroke: var(--primary);
}
/* The slider's handle: a ring of primary around the page colour. */
[data-look="fluksio"] .dui-ring-handle {
fill: var(--background);
stroke: var(--primary);
stroke-width: 2px;
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.2));
}
[data-look="fluksio"] .dui-ring-swatch {
stroke: var(--card);
stroke-width: 3px;
}
@@ -415,3 +415,27 @@
outline: 2px solid var(--ring);
outline-offset: 2px;
}
/* --- a round dashboard's ring -------------------------------------------- */
[data-look="glass"] .dui-ring-track {
stroke: var(--gl-track);
}
/* Lit, as the gauge's arc is. */
[data-look="glass"] .dui-ring-fill {
stroke: var(--primary);
filter: drop-shadow(0 0 4px var(--primary));
}
[data-look="glass"] .dui-ring-handle {
fill: #fff;
stroke: var(--gl-border);
stroke-width: 1px;
filter: drop-shadow(0 2px 8px rgb(0 0 0 / 0.35));
}
[data-look="glass"] .dui-ring-swatch {
stroke: rgb(255 255 255 / 0.85);
stroke-width: 2px;
}
@@ -319,3 +319,22 @@
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* --- a round dashboard's ring -------------------------------------------- */
[data-look="material"] .dui-ring-track {
stroke: var(--m3-sc);
}
[data-look="material"] .dui-ring-fill {
stroke: var(--primary);
}
[data-look="material"] .dui-ring-handle {
fill: var(--primary);
}
[data-look="material"] .dui-ring-swatch {
stroke: var(--card);
stroke-width: 3px;
}
+1
View File
@@ -113,6 +113,7 @@ function PanelRoute() {
dashboards={dashboards}
current={current}
horizontal={stacked}
dots={dashboard?.shape === "round"}
linkFor={(name) => ({
to: "/panel/$id",
params: { id },
+104
View File
@@ -0,0 +1,104 @@
import { expect, test } from "@playwright/test"
import { api, apiPage, deleteAll } from "./utils/api"
/**
* A round dashboard's ring publishes once a turn settles, not per notch.
*
* 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.
*/
const flowName = `test_ring_${Date.now().toString(36)}`
const dashboard = `${flowName}_d`
const target = `${flowName}.level`
test.use({ storageState: "playwright/.auth/user.json" })
test.describe.configure({ mode: "serial" })
test.beforeAll(async ({ browser }) => {
const page = await apiPage(browser)
// One node that only declares: a publish lands without waking a run.
await api(page, `/flows/${flowName}`, {
method: "PUT",
data: {
name: flowName,
title: "Ring",
version: 1,
nodes: [
{
id: "emit",
type: "python",
provides: [{ name: "level", dtype: "float" }],
},
],
},
})
const flow = await (await api(page, `/flows/${flowName}?draft=true`)).json()
await api(page, `/flows/${flowName}/publish`, {
method: "POST",
data: { version: flow.definition.version },
})
await api(page, `/dashboards/${dashboard}`, { method: "DELETE" })
const made = await api(page, `/dashboards/${dashboard}`, {
method: "PUT",
data: {
name: dashboard,
title: "Ring",
version: 0,
columns: 4,
canvas_width: 360,
canvas_height: 360,
shape: "round",
ring: "level",
widgets: [
{
id: "level",
type: "slider",
title: "Level",
config: { target, dtype: "float", min: 0, max: 10, step: 0.5 },
},
],
},
})
if (!made.ok())
throw new Error(`dashboard PUT ${made.status()}: ${await made.text()}`)
await api(page, `/dashboards/${dashboard}/publish`, {
method: "POST",
data: { version: (await made.json()).version },
})
await page.close()
})
test.afterAll(async ({ browser }) => {
await deleteAll(browser, [`/dashboards/${dashboard}`, `/flows/${flowName}`])
})
test("two wheel notches publish once, two steps on", async ({ page }) => {
const sent: unknown[] = []
page.on("request", (request) => {
if (
request.method() === "POST" &&
request.url().endsWith(`/messages/${target}`)
)
sent.push(request.postDataJSON()?.value)
})
await page.goto(`/view/${dashboard}`)
const ring = page.getByRole("slider", { name: "Level" })
await ring.waitFor({ timeout: 20000 })
const box = (await ring.boundingBox())!
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
// Away from you is clockwise, up the scale.
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.poll(() => sent, { timeout: 5000 }).toEqual([1])
// Settled: nothing follows the one publish.
await page.waitForTimeout(1000)
expect(sent).toEqual([1])
})