From 8b8cb5cda92abffb4674b2bef847b5de69321875 Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Wed, 14 Oct 2026 14:23:55 +0200 Subject: [PATCH] Round dashboards: shape and ring, the ring drawn and turned in every look, Knob preset and face guide in the editor Co-Authored-By: Claude Opus 5.5 --- backend/fluksio/flow/dashboards.py | 35 ++ backend/tests/flow/test_dashboards.py | 21 + docs/interface/dashboards.md | 46 ++ frontend/scripts/capture-screenshots.mjs | 52 +- frontend/src/client/schemas.gen.ts | 22 + frontend/src/client/types.gen.ts | 6 + .../components/Dashboard/DashboardEditor.tsx | 81 ++- .../components/Dashboard/DashboardPalette.tsx | 20 + .../components/Dashboard/DashboardView.tsx | 153 ++++- .../src/components/Dashboard/PanelRail.tsx | 36 +- .../src/components/Dashboard/PanelSurface.tsx | 17 +- .../src/components/Dashboard/RingWidget.tsx | 524 ++++++++++++++++++ frontend/src/components/Dashboard/panels.tsx | 97 +++- .../src/components/Dashboard/ui/core/arc.ts | 25 +- .../src/components/Dashboard/ui/core/core.css | 85 +++ .../src/components/Dashboard/ui/core/ring.ts | 113 ++++ .../Dashboard/ui/fluksio/fluksio.css | 23 + .../components/Dashboard/ui/glass/glass.css | 24 + .../Dashboard/ui/material/material.css | 19 + frontend/src/routes/panel.$id.tsx | 1 + frontend/tests/ring.spec.ts | 104 ++++ 21 files changed, 1447 insertions(+), 57 deletions(-) create mode 100644 frontend/src/components/Dashboard/RingWidget.tsx create mode 100644 frontend/src/components/Dashboard/ui/core/ring.ts create mode 100644 frontend/tests/ring.spec.ts diff --git a/backend/fluksio/flow/dashboards.py b/backend/fluksio/flow/dashboards.py index 1e40be7..dceb8d8 100644 --- a/backend/fluksio/flow/dashboards.py +++ b/backend/fluksio/flow/dashboards.py @@ -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") diff --git a/backend/tests/flow/test_dashboards.py b/backend/tests/flow/test_dashboards.py index 52bc7de..2ce2258 100644 --- a/backend/tests/flow/test_dashboards.py +++ b/backend/tests/flow/test_dashboards.py @@ -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( diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index b2d6473..2bbe0f5 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -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: `): 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. diff --git a/frontend/scripts/capture-screenshots.mjs b/frontend/scripts/capture-screenshots.mjs index 57f7a17..e9ee57b 100644 --- a/frontend/scripts/capture-screenshots.mjs +++ b/frontend/scripts/capture-screenshots.mjs @@ -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. diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index d543b85..15229f3 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -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', diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index f2c055e..e52ce6b 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -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; 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; settings?: { diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index fd4e78c..995a97e 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -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} ) : ( - + { + setSettingsOpen(false) + setSelected(id) + }} + /> + } + > {(scale) => !edit ? ( @@ -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) => (
{pickable(widget, true)}
))} @@ -626,6 +660,26 @@ export function DashboardEditor({ Reflow + ) : cornered.length > 0 && !stacked ? ( + // Not a fault and nothing to re-pack: the tile still shows, + // less what the circle cuts off it. + + + {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.`} + + ) : null} @@ -729,6 +783,7 @@ export function DashboardEditor({ <> + 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)} diff --git a/frontend/src/components/Dashboard/DashboardPalette.tsx b/frontend/src/components/Dashboard/DashboardPalette.tsx index a83a4c0..fb31f15 100644 --- a/frontend/src/components/Dashboard/DashboardPalette.tsx +++ b/frontend/src/components/Dashboard/DashboardPalette.tsx @@ -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([ + "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({ {WIDGET_LABELS[kind]} {WIDGET_DESCRIPTIONS[kind]} + {round && POOR_ON_ROUND.has(kind) + ? " · Poor fit for round" + : ""} diff --git a/frontend/src/components/Dashboard/DashboardView.tsx b/frontend/src/components/Dashboard/DashboardView.tsx index bb94d40..5f81389 100644 --- a/frontend/src/components/Dashboard/DashboardView.tsx +++ b/frontend/src/components/Dashboard/DashboardView.tsx @@ -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} + {round ? face : null} + {round && dots ? ( + + ) : null} ) : 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 ( + + + + {ring ? ( + + ) : null} + + ) +} + +/** + * 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 = ( diff --git a/frontend/src/components/Dashboard/PanelRail.tsx b/frontend/src/components/Dashboard/PanelRail.tsx index a48126b..b13854a 100644 --- a/frontend/src/components/Dashboard/PanelRail.tsx +++ b/frontend/src/components/Dashboard/PanelRail.tsx @@ -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(null) @@ -69,7 +76,30 @@ export function PanelRail({ // places the rail itself. return (
- + {dots ? ( + // 16px targets centred 12px above the face's bottom edge, each with + // a 6px dot. + + ) : ( + + )}
) } diff --git a/frontend/src/components/Dashboard/PanelSurface.tsx b/frontend/src/components/Dashboard/PanelSurface.tsx index 881da05..92255c7 100644 --- a/frontend/src/components/Dashboard/PanelSurface.tsx +++ b/frontend/src/components/Dashboard/PanelSurface.tsx @@ -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 (
- {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. - + } + touch={touch} + > {canvas} )} {/* Outside the canvas, so it needs the look stated for it. */} - {stacked && rail ? ( + {stack && rail ? ( // Lifted clear of the rail floating along the bottom.
diff --git a/frontend/src/components/Dashboard/RingWidget.tsx b/frontend/src/components/Dashboard/RingWidget.tsx new file mode 100644 index 0000000..6ed033c --- /dev/null +++ b/frontend/src/components/Dashboard/RingWidget.tsx @@ -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 = { + 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 = ( + onPick(ring.id))} + /> + ) + return editing ? ( + face + ) : ( + {face} + ) +} + +/** + * 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(null) + const face = useRef(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 ( +
+ {/* biome-ignore lint/a11y/noSvgWithoutTitle: named by `aria-label` in the role spread below, which the rule cannot see. */} + { + const by = ARROWS[event.key] + if (!by || !turns) return + event.preventDefault() + turn(by) + }} + > + + + {dial.hue ? ( + + ) : ( + + )} + {/* 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 ? ( + + ) : dial.fill && !dial.notch ? ( + + ) : null} + {dial.marks?.map((mark, index) => { + const point = ringPoint(size, mark) + return ( + + ) + })} + {handle ? ( + + ) : null} + + {selected ? ( + + ) : null} + {turns || editing ? ( + { + 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} + + + {/* The gap at the bottom: the title, and the reading unless it is + already written large in the middle. */} +
+ {title ? ( + + {title} + + ) : null} + {!alone && reading ? ( + + {reading} + {dial.unit ?? ""} + + ) : null} +
+ + {alone ? ( +
+ {dial.hue ? ( + + ) : ( + + {reading} + {dial.unit ? ( + + {dial.unit} + + ) : null} + + )} +
+ ) : null} +
+ ) +} + +/** + * 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 ( + + ) + })} + + ) +} diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index 7684fe5..9e1d7de 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -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) => void onDelete: () => void onClose: () => void @@ -600,6 +610,20 @@ export function WidgetPanel({ />
+ {round && RING_WIDGETS.has(widget.type) ? ( +
+ + On the ring + + onRingChange?.(on)} + /> +
+ ) : null} + {widget.type === "embed" ? ( <>
@@ -1416,6 +1440,22 @@ export function WidgetPanel({ }) } /> + {onRing ? ( +
+ + Hue step + + + set({ hue_step: Number(event.target.value) || 5 }) + } + /> +
+ ) : null}
) : null} @@ -1513,6 +1553,12 @@ export function WidgetPanel({ onChange={(style) => set({ style })} /> )} + {round && !onRing && cfg.style === "segmented" ? ( +

+ Poor fit for round: a row of choices needs the width a circle + only has across its middle. The ring holds them better. +

+ ) : null}
) : null} @@ -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({ + + onChange( + shape === "round" + ? { shape: "round", canvas_height: canvas.width } + : { shape: "rect", ring: "" }, + ) + } + />
- 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 }, + ) + }} /> onChange({ canvas_height: Number(event.target.value) || 0 }) } diff --git a/frontend/src/components/Dashboard/ui/core/arc.ts b/frontend/src/components/Dashboard/ui/core/arc.ts index f51f2c2..bcf7489 100644 --- a/frontend/src/components/Dashboard/ui/core/arc.ts +++ b/frontend/src/components/Dashboard/ui/core/arc.ts @@ -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. */ diff --git a/frontend/src/components/Dashboard/ui/core/core.css b/frontend/src/components/Dashboard/ui/core/core.css index 3d8aadb..dc575d0 100644 --- a/frontend/src/components/Dashboard/ui/core/core.css +++ b/frontend/src/components/Dashboard/ui/core/core.css @@ -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; +} diff --git a/frontend/src/components/Dashboard/ui/core/ring.ts b/frontend/src/components/Dashboard/ui/core/ring.ts new file mode 100644 index 0000000..8346c69 --- /dev/null +++ b/frontend/src/components/Dashboard/ui/core/ring.ts @@ -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(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 | 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, + } +} diff --git a/frontend/src/components/Dashboard/ui/fluksio/fluksio.css b/frontend/src/components/Dashboard/ui/fluksio/fluksio.css index 9941311..150d871 100644 --- a/frontend/src/components/Dashboard/ui/fluksio/fluksio.css +++ b/frontend/src/components/Dashboard/ui/fluksio/fluksio.css @@ -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; +} diff --git a/frontend/src/components/Dashboard/ui/glass/glass.css b/frontend/src/components/Dashboard/ui/glass/glass.css index 6e56749..ad8016a 100644 --- a/frontend/src/components/Dashboard/ui/glass/glass.css +++ b/frontend/src/components/Dashboard/ui/glass/glass.css @@ -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; +} diff --git a/frontend/src/components/Dashboard/ui/material/material.css b/frontend/src/components/Dashboard/ui/material/material.css index 8f68b6f..7fb1918 100644 --- a/frontend/src/components/Dashboard/ui/material/material.css +++ b/frontend/src/components/Dashboard/ui/material/material.css @@ -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; +} diff --git a/frontend/src/routes/panel.$id.tsx b/frontend/src/routes/panel.$id.tsx index 8b88210..04b5978 100644 --- a/frontend/src/routes/panel.$id.tsx +++ b/frontend/src/routes/panel.$id.tsx @@ -113,6 +113,7 @@ function PanelRoute() { dashboards={dashboards} current={current} horizontal={stacked} + dots={dashboard?.shape === "round"} linkFor={(name) => ({ to: "/panel/$id", params: { id }, diff --git a/frontend/tests/ring.spec.ts b/frontend/tests/ring.spec.ts new file mode 100644 index 0000000..eb5cd0d --- /dev/null +++ b/frontend/tests/ring.spec.ts @@ -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]) +})