From 2008150692b3c4c7a85190be1156f5daa9287040 Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Wed, 14 Oct 2026 15:24:25 +0200 Subject: [PATCH] Round dashboards: one face at a time, a face per widget type, paged by swipe, dots and keys; slider reading; face row in the editor --- backend/fluksio/flow/dashboards.py | 52 +- backend/tests/api/routes/test_panels.py | 13 +- backend/tests/flow/test_dashboards.py | 37 +- docs/interface/dashboards.md | 99 ++-- frontend/scripts/capture-screenshots.mjs | 33 +- frontend/src/client/schemas.gen.ts | 16 +- frontend/src/client/types.gen.ts | 6 +- .../src/components/Dashboard/ClockWidget.tsx | 30 +- .../components/Dashboard/DashboardEditor.tsx | 254 +++++---- .../components/Dashboard/DashboardPalette.tsx | 19 +- .../components/Dashboard/DashboardView.tsx | 177 ++----- .../src/components/Dashboard/PanelRail.tsx | 36 +- .../src/components/Dashboard/PanelSurface.tsx | 22 +- .../src/components/Dashboard/RingWidget.tsx | 245 +++++---- .../src/components/Dashboard/RoundFace.tsx | 482 ++++++++++++++++++ frontend/src/components/Dashboard/panels.tsx | 134 +++-- .../src/components/Dashboard/ui/core/core.css | 83 ++- .../src/components/Dashboard/ui/core/ring.ts | 63 ++- frontend/src/routes/panel.$id.tsx | 24 +- frontend/tests/ring.spec.ts | 17 +- 20 files changed, 1238 insertions(+), 604 deletions(-) create mode 100644 frontend/src/components/Dashboard/RoundFace.tsx diff --git a/backend/fluksio/flow/dashboards.py b/backend/fluksio/flow/dashboards.py index dceb8d8..01319a6 100644 --- a/backend/fluksio/flow/dashboards.py +++ b/backend/fluksio/flow/dashboards.py @@ -90,12 +90,6 @@ INPUT_WIDGETS = { "color", } -#: What a round dashboard may draw on its ring: the widgets that are one value -#: along a range, which is what turning a knob's ring can set or a circle's -#: edge can show. Mirrored in the client -#: (``frontend/src/components/Dashboard/DashboardView.tsx``). -RING_WIDGETS = {"slider", "dropdown", "color", "gauge"} - #: What a colour widget puts on the wire, by the format it was configured for. #: The default is what the Node-RED instance this ports already sends its #: DMX encoders — ``[h, s, v]``, hue in degrees and the other two in percent — @@ -231,6 +225,10 @@ class WidgetDef(BaseModel): The first two are a ``list`` message, the third a ``str``, which is what ``COLOR_DTYPES`` records and the check below holds a binding to. + + A slider may also show what it is setting: ``reading`` binds + ``{message, dtype, label}`` — a room's temperature beside its setpoint — + held to the same numbers its target is. """ id: str @@ -297,6 +295,12 @@ class WidgetDef(BaseModel): ) return [*outer, *self.inner_bindings] + @property + def reading(self) -> dict[str, Any]: + """What a slider reads beside the value it sets; empty for anything else.""" + reading = self.config.get("reading") if self.type == "slider" else None + return reading if isinstance(reading, dict) else {} + @property def messages(self) -> list[str]: """Every message name this widget reads.""" @@ -318,8 +322,8 @@ class WidgetDef(BaseModel): return [ str(row.get("message")) for row in self.bar_rows if row.get("message") ] - name = self.config.get("message") - return [str(name)] if name else [] + names = [self.config.get("message"), self.reading.get("message")] + return [str(name) for name in names if name] @property def target(self) -> str: @@ -358,7 +362,10 @@ class WidgetDef(BaseModel): ] if self.type == "bar": return [str(row.get("dtype") or "") for row in self.bar_rows] - return [str(self.config.get("dtype") or "")] + return [ + str(self.config.get("dtype") or ""), + str(self.reading.get("dtype") or ""), + ] @model_validator(mode="after") def _check_binding(self) -> WidgetDef: @@ -481,14 +488,11 @@ class DashboardDef(BaseModel): #: "unset" and the client falls back to its default. canvas_width: int = Field(default=1920, ge=0, le=7680) canvas_height: int = Field(default=1080, ge=0, le=4320) - #: ``round`` draws the dashboard in the circle inscribed in its canvas, for - #: a round screen such as a rotary knob. A circle has one size, so a round - #: canvas is square: its height is its width. + #: ``round`` draws the dashboard on the circle inscribed in its canvas, for + #: a round screen such as a rotary knob: one widget at a time, each a whole + #: face, in the order of ``widgets`` (``layout`` is not read). A circle has + #: one size, so a round canvas is square: its height is its width. shape: Literal["rect", "round"] = "rect" - #: The widget a round dashboard draws along its edge instead of in the grid - #: — one of ``RING_WIDGETS``, by id — which a knob's ring turns. Empty - #: draws no ring. - ring: str = "" #: A lucide icon name, drawn on the panel rail; empty falls back to two #: letters of the title. icon: str = "" @@ -550,20 +554,9 @@ class DashboardDef(BaseModel): @model_validator(mode="after") def _check_shape(self) -> DashboardDef: - """Square a round canvas, and hold the ring to a widget it can draw.""" + """Square a round canvas.""" if self.shape == "round": self.canvas_height = self.canvas_width - if not self.ring: - return self - if self.shape != "round": - raise ValueError("only a round dashboard has a ring") - widget = next((w for w in self.widgets if w.id == self.ring), None) - if widget is None: - raise ValueError( - f"the ring names '{self.ring}', which is not a widget here" - ) - if widget.type not in RING_WIDGETS: - raise ValueError(f"a '{widget.type}' widget cannot be drawn on the ring") return self @property @@ -581,9 +574,6 @@ class DashboardDef(BaseModel): """ shapes = [] for widget in self.widgets: - # Drawn along the edge, so it takes no place in the grid. - if widget.id == self.ring: - continue at = ( widget.layout.get("lg") or widget.layout.get("md") diff --git a/backend/tests/api/routes/test_panels.py b/backend/tests/api/routes/test_panels.py index 2e1c5cd..a2622ed 100644 --- a/backend/tests/api/routes/test_panels.py +++ b/backend/tests/api/routes/test_panels.py @@ -252,7 +252,11 @@ PANEL_WIDGETS = [ { "id": "w_slider", "type": "slider", - "config": {"target": "demo.slider", "dtype": "float"}, + "config": { + "target": "demo.slider", + "dtype": "float", + "reading": {"message": "demo.slider_reading", "dtype": "float"}, + }, }, {"id": "w_input", "type": "input", "config": {"target": "demo.input"}}, {"id": "w_dropdown", "type": "dropdown", "config": {"target": "demo.dropdown"}}, @@ -333,7 +337,12 @@ def test_a_panel_speaks_only_its_own_widgets_messages( ), target # And reads the history of everything its widgets bind to. - for name in ("demo.temperature", "demo.switch_state", "demo.query_series"): + for name in ( + "demo.temperature", + "demo.switch_state", + "demo.slider_reading", + "demo.query_series", + ): assert ( client.get(f"{messages}/{name}/history", headers=panel_headers).status_code == 200 diff --git a/backend/tests/flow/test_dashboards.py b/backend/tests/flow/test_dashboards.py index 2ce2258..858b0da 100644 --- a/backend/tests/flow/test_dashboards.py +++ b/backend/tests/flow/test_dashboards.py @@ -445,25 +445,28 @@ def test_a_dashboard_is_cut_into_a_sane_number_of_columns(): DashboardDef(name="house", columns=columns) -def test_a_round_dashboard_is_square_and_rings_one_of_its_own_widgets(): - dial = WidgetDef(id="dial", type="slider", layout={"lg": Placement(x=1)}) - stat = WidgetDef(id="stat", type="stat") - - knob = DashboardDef( - name="knob", - shape="round", - canvas_width=360, - canvas_height=200, - ring="dial", - widgets=[dial, stat], - ) +def test_a_round_dashboard_is_square(): + knob = DashboardDef(name="knob", shape="round", canvas_width=360, canvas_height=200) assert knob.canvas_height == 360 - # Drawn along the edge, so the outline of the grid leaves it out. - assert [shape.type for shape in knob.footprint] == ["stat"] - for shape, ring in (("rect", "dial"), ("round", "gone"), ("round", "stat")): - with pytest.raises(ValueError): - DashboardDef(name="knob", shape=shape, ring=ring, widgets=[dial, stat]) + + +def test_a_slider_reads_what_it_shows_beside_its_value(): + """The room's temperature beside its setpoint: read, and held to numbers.""" + setpoint = {"target": "house.setpoint", "dtype": "float"} + slider = WidgetDef( + id="target", + type="slider", + config={**setpoint, "reading": {"message": "house.indoor", "dtype": "float"}}, + ) + + assert slider.messages == ["house.indoor"] + with pytest.raises(ValueError): + WidgetDef( + id="target", + type="slider", + config={**setpoint, "reading": {"message": "house.mode", "dtype": "str"}}, + ) def test_a_setting_refuses_a_message_it_cannot_carry(): diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 2bbe0f5..b75f731 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -171,48 +171,89 @@ restrict API permissions and does not disable controls in edit mode. ## Round dashboards For a round screen such as a rotary knob, set the canvas to **Round** in the -dashboard settings, or pick the **Knob** preset (360×360). A round dashboard is -drawn in the circle its canvas holds, so its canvas is square (`shape: "round"` -forces `canvas_height` to `canvas_width`). Tiles are drawn without a card, and -there is no rail; a panel with several dashboards shows page dots instead. +dashboard settings, or pick the **Knob** preset (360×360). A round canvas is +square (`shape: "round"` forces `canvas_height` to `canvas_width`), and nothing +outside its circle is drawn. -One Slider, Selector, Colour or Gauge can go **On the ring** (`ring: `): it is drawn along the edge instead of in the grid. Drag along the ring or -use the mouse wheel; one notch is one step, one option or one hue step -(`hue_step`, default 5°, wrapping). The value moves at once and is published -once, 300 ms after the last notch or when the pointer is released. A gauge on -the ring only shows. When the ring is the only widget, its reading is written -large in the middle. +A round dashboard has no grid. It shows one widget at a time, each drawn as the +whole face, in the order of `widgets`; `layout` is not read. In the editor, the +faces are a row above the dock: press one to show it and open its settings, +drag it along the row (or Alt+←/→) to move it, and add more with **Add widget**. -The editor shades what lies outside the circle and warns about tiles whose -corners leave it. Inputs, button grids, charts, forecasts, agendas, websites -and a segmented Selector are marked as a poor fit, but allowed. +A Slider, Selector, Colour or Gauge draws on the **ring** along the edge, which +a knob's own ring turns. In a browser, drag along the ring or use the mouse +wheel (or the arrow keys once it has focus): one notch is one step, one option +(no wrap) or one hue step (`hue_step`, default 5°, wrapping). The value moves +at once and is published once, 300 ms after the last notch or when the pointer +is released. A Gauge only shows. On any other face, turning a knob only wakes +the screen. + +A Slider can also show what it sets against, such as the room's temperature +beside a setpoint: **Beside it** binds `reading: {message, dtype, label}` to a +`float` or `int`. That reading is written large in the middle, in the slider's +unit and to its step's decimals, under its label; the slider's own title and +value move to the line in the ring's gap. + +Value, Switch, Button, Clock, Icon, Notification and Text have faces of their +own. Every other type is drawn as its rectangular body in the middle of the +face, and the editor marks it **Poor fit for round**. + +### Moving between faces + +| Gesture | Effect | +|---|---| +| Swipe left / right | next / previous face of this dashboard; stops at either end | +| Swipe up / down | next / previous dashboard of the panel (`/panel/{id}` only); stops at either end | +| Page dot | that face | +| PageDown / PageUp | next / previous face | +| Shift+PageDown / Shift+PageUp | next / previous dashboard of the panel | +| Arrow keys | turn the ring, not the page | + +A swipe is a drag of at least 40 canvas pixels, decided by its longer axis; a +drag that starts on the ring turns the ring instead. A dashboard reopens on the +face it was last left on in this session, otherwise on its first. Landing on +another dashboard shows its title and icon in the middle of the face for one +second; a round panel has no rail. ### Geometry Devices that draw dashboards natively use these numbers. They are canvas -pixels, scaled with the canvas; S is the canvas width. Angles are degrees -clockwise from three o'clock. +pixels, scaled with the canvas; S is the canvas width and c = S/2. Angles are +degrees clockwise from three o'clock. Text sizes are font sizes; muted is the +look's muted text colour. | Part | Value | |---|---| -| Face | circle of diameter S at (S/2, S/2); nothing is drawn outside it | -| Ring band | 40 along the edge, only when a ring is set | -| Grid area | the square inset by 12, plus 40 with a ring; columns, rows and cell proportions as on a rectangular canvas. Knob, 4 columns, ring: 256×256, columns 55, rows 30 at a 42 pitch, 6 rows | -| Safe circle | radius S/2 − 40 with a ring, S/2 without; a tile corner outside it is warned about | -| Ring arc | start 120°, sweep 300° (ends at 60°), centre-line radius S/2 − 20, stroke 16 with round caps | -| Slider | track over the sweep, value arc from the start to the value (none at the minimum), handle 28 across | -| Selector | a 6 mark per option at i/(n−1) of the sweep (one option: the middle), the selected one under the handle; no wrap | -| Colour | hue h at h/360 of the sweep, red at both ends; the handle is filled with the colour | -| Gauge | track and value arc, no handle | -| Gap line | centred at y = S − 28, 13 high, at most 160 wide: the title (muted), then the reading unless it is in the middle; a colour has none | -| Page dots | 6 dots 16 apart, centred at y = S − 12; the current one in the text colour, the others muted at half opacity | -| Centre reading | value 72 with its unit at 0.4 em, muted; a Selector's label 36; a colour swatch 96 across with a hairline border | -| Tiles | no surface, border or shadow; padding 8; title 12, muted and centred; a single reading centred | +| Face | circle of diameter S at (c, c) | +| Title | one line centred at y = c − 80, 15, muted, at most 200 wide; the widget's title, or the label of a slider's reading | +| Middle | a 240 × 128 box centred at (c, c); what a face draws in its middle is centred in it and clipped to it | +| Centre reading | centred at (c, c), line height 1, its unit after it at 0.4 em, muted. Size: 72, or 240 ÷ (0.6 × the characters written) rounded down where that is smaller, never below 24 | +| Ring band | 40 along the edge | +| Ring arc | start 120°, sweep 300° (ends at 60°), centre-line radius c − 20, stroke 16 with round caps | +| Slider | track over the sweep, value arc from the start to the value (none at the minimum), handle 28 across; the value as the centre reading | +| Slider with a reading | the reading as the centre reading; the gap line holds the title (muted) and the slider's value | +| Selector | a 6 mark per option at i/(n−1) of the sweep (one option: the middle), the selected one under the handle; its label as the centre reading, at most 36 | +| Colour | hue h at h/360 of the sweep in 150 strokes of 2°, red at both ends; the handle filled with the colour; a swatch 96 across in the middle, with a hairline border | +| Gauge | track and value arc, no handle; the value as the centre reading | +| Gap line | centred at y = S − 28, 13, at most 160 wide | +| Value | the reading as the centre reading, to its precision; a word shrinks like any other | +| Switch | a disc 128 across centred at (c, c): the look's button, filled when on and tonal when off, with a power glyph 40 over the word On or Off at 15; a press toggles | +| Button | a disc 128 across centred at (c, c), tonal, its label at 15 over at most three lines; a press sends its value | +| Clock | the time (hours and minutes, the device's locale) sized as a centre reading, over the date (weekday, day, month) at 15, muted, 4 apart; the pair centred at (c, c) | +| Icon | the glyph 96 across over its label at 15, muted, 4 apart; the pair centred at (c, c). Nothing matched: a dash at 72, muted | +| Notification | the title at 15, medium (an error in the destructive colour, led by ⚠), over the body at 15, muted; centred lines in the middle | +| Text | centred lines at 15: `#` at 1.35 em and `##` medium, `###` muted, list items led by a bullet; in the middle | +| Not wired | the problem, at 15, muted, in the middle; a ring type draws no ring | +| Any other type | its rectangular body, without a card, from y = c − 64 to c + S/(2√2), x = c ± S/(2√2) (Knob: 53–307 × 116–307) | +| Page dots | one per face, from two faces: dots 6 across on targets 16 apart (closer when more than 128 wide would be needed), centred at y = S − 12; the current one in the text colour, the others muted at half opacity | +| Dashboard name | a pill centred at (c, c), 36 high: the icon 16 and the title at 15, medium, at most 220 wide, on the frosted surface; for 1000 ms | +| Paging | a new face slides in 48 from the side it was paged towards and fades in; the old one leaves the other way | +| Locked | controls do nothing and read as disabled; the ring at 0.45 opacity | Paint follows the look: Fluksio draws a muted track, a primary value and a handle of the ground colour ringed in primary; Material a tonal track and a primary handle; Glass a translucent track, a glowing value and a white handle. +A Switch or Button face wears the look's own button. ## Showing one diff --git a/frontend/scripts/capture-screenshots.mjs b/frontend/scripts/capture-screenshots.mjs index e9ee57b..831baba 100644 --- a/frontend/scripts/capture-screenshots.mjs +++ b/frontend/scripts/capture-screenshots.mjs @@ -127,21 +127,24 @@ async function captureRuns(page, dir) { // The API is its own host here, unless the stack serves it under the SPA's // own origin (a LAN stack), whose CSP then refuses the other host. const apiUrl = APP_URL.replace("//app.", "//api.") - const results = await page.evaluate(async (bases) => { - for (const base of bases) { - const answer = await fetch(`${base}/api/v1/dashboards/`, { - headers: { - Authorization: `Bearer ${localStorage.getItem("access_token")}`, - }, - }).catch(() => null) - if (!answer?.ok) continue - const body = await answer.json() - return (body.data || []) - .map((one) => one.name) - .find((n) => n.endsWith("_results")) - } - return null - }, [apiUrl, ""]) + const results = await page.evaluate( + async (bases) => { + for (const base of bases) { + const answer = await fetch(`${base}/api/v1/dashboards/`, { + headers: { + Authorization: `Bearer ${localStorage.getItem("access_token")}`, + }, + }).catch(() => null) + if (!answer?.ok) continue + const body = await answer.json() + return (body.data || []) + .map((one) => one.name) + .find((n) => n.endsWith("_results")) + } + return null + }, + [apiUrl, ""], + ) // A dashboard read against two runs: the same page a live run is watched // on, showing finished ones. This is the seam the feature exists for — and diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 15229f3..3349cee 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -682,11 +682,6 @@ export const DashboardDef_InputSchema = { title: 'Shape', default: 'rect' }, - ring: { - type: 'string', - title: 'Ring', - default: '' - }, icon: { type: 'string', title: 'Icon', @@ -761,11 +756,6 @@ export const DashboardDef_OutputSchema = { title: 'Shape', default: 'rect' }, - ring: { - type: 'string', - title: 'Ring', - default: '' - }, icon: { type: 'string', title: 'Icon', @@ -4504,7 +4494,11 @@ differ and a change node per tile is not the answer: - \`\`hex\`\` — \`\`"#rrggbb"\`\`, lowercase. Conventional likewise. The first two are a \`\`list\`\` message, the third a \`\`str\`\`, which is what -\`\`COLOR_DTYPES\`\` records and the check below holds a binding to.` +\`\`COLOR_DTYPES\`\` records and the check below holds a binding to. + +A slider may also show what it is setting: \`\`reading\`\` binds +\`\`{message, dtype, label}\`\` — a room's temperature beside its setpoint — +held to the same numbers its target is.` } as const; export const WidgetFootprintSchema = { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index e52ce6b..0f8c144 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -169,7 +169,6 @@ export type DashboardDef_Input = { canvas_width?: number; canvas_height?: number; shape?: 'rect' | 'round'; - ring?: string; icon?: string; widgets?: Array; settings?: { @@ -191,7 +190,6 @@ export type DashboardDef_Output = { canvas_width?: number; canvas_height?: number; shape?: 'rect' | 'round'; - ring?: string; icon?: string; widgets?: Array; settings?: { @@ -1462,6 +1460,10 @@ export type WebPushKey = { * * The first two are a ``list`` message, the third a ``str``, which is what * ``COLOR_DTYPES`` records and the check below holds a binding to. + * + * A slider may also show what it is setting: ``reading`` binds + * ``{message, dtype, label}`` — a room's temperature beside its setpoint — + * held to the same numbers its target is. */ export type WidgetDef = { id: string; diff --git a/frontend/src/components/Dashboard/ClockWidget.tsx b/frontend/src/components/Dashboard/ClockWidget.tsx index 1da9a46..8df6120 100644 --- a/frontend/src/components/Dashboard/ClockWidget.tsx +++ b/frontend/src/components/Dashboard/ClockWidget.tsx @@ -8,7 +8,7 @@ import type { WidgetProps } from "./widgets" * The tick is a plain one-second interval and the formatting is the browser's * own locale, so there is no 12/24-hour setting to carry. */ -export function ClockWidget(_props: WidgetProps) { +export function useClock() { const [now, setNow] = useState(() => new Date()) useEffect(() => { @@ -16,21 +16,27 @@ export function ClockWidget(_props: WidgetProps) { return () => clearInterval(timer) }, []) + return { + time: now.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + }), + date: now.toLocaleDateString(undefined, { + weekday: "long", + day: "numeric", + month: "long", + }), + } +} + +export function ClockWidget(_props: WidgetProps) { + const { time, date } = useClock() return (

- {now.toLocaleTimeString(undefined, { - hour: "2-digit", - minute: "2-digit", - })} -

-

- {now.toLocaleDateString(undefined, { - weekday: "long", - day: "numeric", - month: "long", - })} + {time}

+

{date}

) } diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index 995a97e..78bb103 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -1,12 +1,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useNavigate } from "@tanstack/react-router" import { Check, ExternalLink, Pencil, Plus, Settings2 } from "lucide-react" -import { motion } from "motion/react" +import { motion, Reorder } from "motion/react" import { type CSSProperties, useEffect, useRef, useState } from "react" import { GridLayout, type Layout, - noCompactor, type Position, setTransform, } from "react-grid-layout" @@ -44,15 +43,14 @@ import { DashboardPalette } from "./DashboardPalette" import { areaOf, CanvasSurface, + canvasOf, columnsOf, type Dashboard, DashboardView, GRID_GAP, isPlaced, isRound, - offFace, placement, - ringOf, rowHeightOf, rowsOf, widgetsOf, @@ -67,7 +65,7 @@ import { usePublishDashboard, useSaveDashboard, } from "./queries" -import { RoundFace } from "./RingWidget" +import { ROUND_TYPES, RoundView } from "./RoundFace" import { PaletteProvider } from "./settings" import { useUi } from "./ui" import { showTitle } from "./ui/core/config" @@ -116,6 +114,91 @@ function Pickable({ ) } +/** + * A round dashboard's faces, in the order a panel pages through them. + * + * Its arrangement, since a round dashboard has no grid: press a face to show + * it and open its settings, and drag it along the row — or Alt+←/→ on it — to + * move it. A type drawn as a rectangle in the middle of the face says it is a + * poor fit. Floating chrome over the canvas, as the dock is. + */ +function FaceRow({ + widgets, + shown, + reorder, + onPick, + onOrder, +}: { + widgets: WidgetDef[] + shown?: string + /** Dragging is a desktop affordance (DESIGN-GUIDELINES.md → Responsive). */ + reorder: boolean + onPick: (id: string) => void + onOrder: (ids: string[]) => void +}) { + const ids = widgets.map((widget) => widget.id) + const move = (from: number, to: number) => { + if (to < 0 || to >= ids.length) return + const next = [...ids] + next.splice(to, 0, ...next.splice(from, 1)) + onOrder(next) + } + return ( + + {widgets.map((widget, at) => ( + + + + ))} + + ) +} + /** How long to sit on edits before saving, so typing is not a save per key. */ const AUTOSAVE_MS = 800 @@ -140,7 +223,8 @@ const INTERACTIVE = * still working on it. The grid item covers both; the frame is what a stacked * dashboard has instead. */ -const ON_WIDGET = "[data-testid=widget-frame], .react-grid-item" +const ON_WIDGET = + "[data-testid=widget-frame], .react-grid-item, [data-testid=round-face]" function nextId(dashboard: DashboardDef_Output, type: string): string { const taken = new Set(widgetsOf(dashboard).map((widget) => widget.id)) @@ -236,13 +320,17 @@ export function DashboardEditor({ }) { const [draft, setDraft] = useState(dashboard) const [selected, setSelected] = useState(null) + // The face a round dashboard shows: the widget picked, or the one last + // picked or paged to once its settings are put away. + const [face, setFace] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) const [paletteOpen, setPaletteOpen] = useState(false) // A phone reads the dashboard rather than arranges it, so the grid library - // never mounts there. See DESIGN-GUIDELINES.md → Responsive. A round one is - // the exception: a knob's face is about a phone's width as it is. + // never mounts there. See DESIGN-GUIDELINES.md → Responsive. A round one has + // no grid to arrange, and a knob's face is about a phone's width as it is. const round = isRound(draft) - const stacked = useIsMobile() && !round + const mobile = useIsMobile() + const stacked = mobile && !round // The dashboard's own appearance, on the surface only: the shell around the // canvas stays whatever the person editing chose for the app. Non-stacked, // `CanvasSurface` states it on the canvas box itself. @@ -266,6 +354,10 @@ export function DashboardEditor({ version.current = dashboard.version }, [dashboard.version]) + useEffect(() => { + if (selected) setFace(selected) + }, [selected]) + const store = (next: Dashboard) => { const request = save .mutateAsync({ ...next, version: version.current }) @@ -317,11 +409,6 @@ export function DashboardEditor({ const hasDraft = Boolean(dashboard.has_draft) const columns = columnsOf(draft) const widgets = widgetsOf(draft) - // The ring is drawn along a round face's edge and takes no cell. - const ring = ringOf(draft) - const placedWidgets = ring - ? widgets.filter((widget) => widget !== ring) - : widgets const updateWidgets = (next: WidgetDef[]) => { commit({ ...draft, widgets: next }) @@ -386,35 +473,39 @@ export function DashboardEditor({ (panel.dashboards ?? []).length > 1 && (panel.dashboards ?? []).includes(draft.name), )?.dashboards ?? null - const hasRail = Boolean(railDashboards) && !stacked - const rail = railDashboards ? ( - ({ - to: "/dashboards/$name", - params: { name }, - search: edit ? { edit: true } : {}, - })} - /> - ) : null + // A round face has no room for one: a panel swipes between those instead. + const hasRail = Boolean(railDashboards) && !stacked && !round + const rail = + railDashboards && !round ? ( + ({ + to: "/dashboards/$name", + params: { name }, + search: edit ? { edit: true } : {}, + })} + /> + ) : null // What the arrangement is laid out in: the canvas less its margin, and less // the rail's column when the panel carries one. const area = areaOf(draft, hasRail) - const layout = layoutOf(placedWidgets, columns) + const layout = layoutOf(widgets, columns) const rows = rowsOf(draft, hasRail) // maxRows below only constrains a *drag*. A stored placement is corrected // against `cols` and nothing else, so a canvas that shrank leaves whatever - // now falls past its bottom edge drawn under the clip. - const clipped = layout.filter((item) => item.y + item.h > rows) - // A round face cuts the corners off the grid it is laid out in. - const cornered = offFace(draft, layout) + // now falls past its bottom edge drawn under the clip. A round face has no + // grid to fall off. + const clipped = round ? [] : layout.filter((item) => item.y + item.h > rows) + const shown = Math.max( + 0, + widgets.findIndex((widget) => widget.id === (selected ?? face)), + ) /** Store what the grid ended up doing, unless it did nothing. */ const applyLayout = (next: Layout) => { @@ -443,7 +534,7 @@ export function DashboardEditor({ * room above it — it has to move sideways. */ const reflow = () => { const at = new Map(layout.map((item) => [item.i, item])) - const ordered = [...placedWidgets].sort((a, b) => { + const ordered = [...widgets].sort((a, b) => { const left = at.get(a.id) const right = at.get(b.id) return ( @@ -519,35 +610,32 @@ export function DashboardEditor({ ) : null} ) : ( - { - setSettingsOpen(false) - setSelected(id) - }} - /> - } - > + {(scale) => !edit ? ( + ) : round ? ( + // One face at a time, the one picked in the row of faces below. + { + setFace(widgets[index].id) + if (selected) setSelected(widgets[index].id) + }} + onPick={(id) => { + setSettingsOpen(false) + setSelected(id) + }} + /> ) : ( - {placedWidgets.map((widget) => ( + {widgets.map((widget) => (
{pickable(widget, true)}
))}
@@ -591,6 +679,8 @@ export function DashboardEditor({ className={cn( "absolute inset-0 overflow-hidden px-4 pb-24 pt-20 transition-[padding] duration-200", panelOpen && "md:pr-[27rem]", + // Clear of the row of faces above the dock. + edit && round && "pb-40", )} data-testid="dashboard-canvas" onClick={(event) => { @@ -660,26 +750,23 @@ export function DashboardEditor({ Reflow - ) : 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.`} - - + ) : round && widgets.length > 0 ? ( + { + setSettingsOpen(false) + setSelected(id) + }} + onOrder={(ids) => + updateWidgets( + ids.flatMap((id) => + widgets.filter((widget) => widget.id === id), + ), + ) + } + /> ) : null} @@ -793,21 +880,10 @@ export function DashboardEditor({ - active && commit({ ...draft, ring: on ? active.id : "" }) - } onChange={(changes) => active && patch(active.id, changes)} onDelete={() => { if (!active) return - // A ring naming a widget that is gone is refused on save. - commit({ - ...draft, - widgets: widgets.filter((other) => other.id !== active.id), - ring: draft.ring === active.id ? "" : draft.ring, - }) + updateWidgets(widgets.filter((other) => other.id !== active.id)) setSelected(null) }} onClose={() => setSelected(null)} diff --git a/frontend/src/components/Dashboard/DashboardPalette.tsx b/frontend/src/components/Dashboard/DashboardPalette.tsx index fb31f15..4008449 100644 --- a/frontend/src/components/Dashboard/DashboardPalette.tsx +++ b/frontend/src/components/Dashboard/DashboardPalette.tsx @@ -10,6 +10,7 @@ import { CommandList, } from "@/components/ui/command" import { dashboardsQueryOptions } from "./queries" +import { ROUND_TYPES } from "./RoundFace" import { INPUT_WIDGETS, WIDGET_DESCRIPTIONS, @@ -19,20 +20,6 @@ import { const KINDS = Object.keys(WIDGET_LABELS) as WidgetKind[] -/** - * What a round face draws badly: a row of controls or text, a plot or a strip - * that wants the width a circle only has across its middle, and a page in a - * tile. Offered all the same — a soft mark, not a refusal. - */ -const POOR_ON_ROUND = new Set([ - "input", - "buttons", - "chart", - "forecast", - "agenda", - "embed", -]) - const GROUPS = [ ["Show a value", KINDS.filter((kind) => !INPUT_WIDGETS.has(kind))], ["Control something", KINDS.filter((kind) => INPUT_WIDGETS.has(kind))], @@ -56,7 +43,7 @@ export function DashboardPalette({ onSettings: () => void /** Absent while there is nothing to publish. */ onPublish?: () => void - /** The dashboard is round: the types that draw badly on it say so. */ + /** The dashboard is round: the types with no face of their own say so. */ round?: boolean }) { const navigate = useNavigate() @@ -96,7 +83,7 @@ export function DashboardPalette({ {WIDGET_LABELS[kind]} {WIDGET_DESCRIPTIONS[kind]} - {round && POOR_ON_ROUND.has(kind) + {round && !ROUND_TYPES.has(kind) ? " · Poor fit for round" : ""} diff --git a/frontend/src/components/Dashboard/DashboardView.tsx b/frontend/src/components/Dashboard/DashboardView.tsx index 5f81389..92bcff6 100644 --- a/frontend/src/components/Dashboard/DashboardView.tsx +++ b/frontend/src/components/Dashboard/DashboardView.tsx @@ -4,6 +4,7 @@ import type { DashboardDef_Output, Placement, WidgetDef } from "@/client" import { cn } from "@/lib/utils" import "./dashboard.css" +import { RoundView } from "./RoundFace" import { LockedProvider, PaletteProvider, @@ -13,7 +14,6 @@ import { useUi } from "./ui" import { scrolls, showTitle } from "./ui/core/config" import { LookProvider, useCanvasRoot, useLook } from "./ui/core/look" import { gridStagger, LOOK } from "./ui/core/motion" -import { RING_INSET } from "./ui/core/ring" import { WidgetBody, widgetIssue } from "./widgets" export type Dashboard = DashboardDef_Output @@ -22,7 +22,7 @@ export type Dashboard = DashboardDef_Output export const DEFAULT_COLUMNS = 12 /** What a grid size setting may be set to; a panel is matched to one of these. */ -export const COLUMN_CHOICES = [4, 6, 8, 12, 16, 24] +export const COLUMN_CHOICES = [6, 8, 12, 16, 24] /** The panel a dashboard is drawn for, if the document does not say. */ export const DEFAULT_CANVAS = { width: 1920, height: 1080 } @@ -79,21 +79,10 @@ export const widgetsOf = (dashboard: DashboardDef_Output) => export const columnsOf = (dashboard: Dashboard) => dashboard.columns || DEFAULT_COLUMNS -/** Drawn in the circle its canvas inscribes, for a round screen. */ +/** Drawn on the circle its canvas inscribes, one widget at a time, for a + * round screen. */ export const isRound = (dashboard: Dashboard) => dashboard.shape === "round" -/** Widget types a round dashboard may draw on its ring. Mirrors the server's - * `RING_WIDGETS` (`flow/dashboards.py`). */ -export const RING_WIDGETS = new Set(["slider", "dropdown", "color", "gauge"]) - -/** The widget a round dashboard draws along its edge, if it has one. */ -export const ringOf = (dashboard: Dashboard): WidgetDef | null => - (isRound(dashboard) && - widgetsOf(dashboard).find( - (widget) => widget.id === dashboard.ring && RING_WIDGETS.has(widget.type), - )) || - null - /** A round canvas is square whatever its stored height says: a circle has one * size. The server squares it on save; this covers a draft not saved yet. */ export const canvasOf = (dashboard: Dashboard) => { @@ -134,22 +123,18 @@ const CELL_ASPECT = DEFAULT_COLUMN_WIDTH / DEFAULT_ROW_HEIGHT * room it takes comes out of the canvas the same way the margin does. Which * cell each widget sits in never changes — only how big a cell is. * - * A round dashboard has no rail — a panel shows page dots in the ring's gap - * instead — and is inset evenly, by the ring's band as well when it has one. - * The cells are the same maths in that square; what a circle cuts off of it - * is the editor's to warn about (`offFace`). + * A round dashboard has no grid and no rail: it is one face at a time, drawn + * on the whole canvas (`RoundView`). */ export const areaOf = (dashboard: Dashboard, rail = false) => { const { width, height } = canvasOf(dashboard) - const round = isRound(dashboard) - const inset = GRID_INSET + (ringOf(dashboard) ? RING_INSET : 0) - const left = round ? inset : GRID_INSET + (rail ? RAIL_INSET : 0) - const top = round ? inset : GRID_INSET + if (isRound(dashboard)) return { left: 0, top: 0, width, height } + const left = GRID_INSET + (rail ? RAIL_INSET : 0) return { left, - top, - width: Math.max(1, width - left - (round ? inset : GRID_INSET)), - height: Math.max(1, height - 2 * top), + top: GRID_INSET, + width: Math.max(1, width - left - GRID_INSET), + height: Math.max(1, height - 2 * GRID_INSET), } } @@ -190,18 +175,14 @@ export function CanvasSurface({ dashboard, dots, rail, - face, touch, children, }: { dashboard: Dashboard /** Draw the placement grid. A wall panel is not being arranged, so: no. */ dots?: boolean - /** The dashboard-switching rail, drawn on the panel rather than beside it. - * On a round dashboard, the page dots in the ring's gap. */ + /** The dashboard-switching rail, drawn on the panel rather than beside it. */ rail?: React.ReactNode - /** What a round dashboard draws over the whole face: its ring. */ - face?: React.ReactNode /** Whether the panel this canvas hangs on is touched rather than pointed at. */ touch?: boolean children: (scale: number) => React.ReactNode @@ -248,7 +229,6 @@ export function CanvasSurface({ data-touch={root["data-touch"]} data-palette={root["data-palette"]} data-shape={round ? "round" : undefined} - data-arranging={dots ? "" : undefined} className={cn( "absolute overflow-hidden bg-background text-foreground", root.className, @@ -259,10 +239,8 @@ export function CanvasSurface({ ...root.style, width, height, - // Round, nothing outside the circle is drawn — except while it - // is being arranged, where a tile reaching past the edge has to - // stay in reach to be moved back (`FaceGuide` shades it). - clipPath: round && !dots ? "circle(50%)" : undefined, + // Round, nothing outside the circle is drawn. + clipPath: round ? "circle(50%)" : undefined, left: (box.width - width * scale) / 2, top: (box.height - height * scale) / 2, transform: `scale(${scale})`, @@ -289,15 +267,11 @@ export function CanvasSurface({ > {/* Over the ground rather than under it: the dots are what is being arranged against, and a wallpaper must not hide them. */} - {dots ? ( + {dots && !round ? (
) : null} {content}
- {round ? face : null} - {round && dots ? ( - - ) : null} ) : null} @@ -305,83 +279,6 @@ export function CanvasSurface({ ) } -/** - * The circle, while a round dashboard is being arranged. - * - * The canvas is not clipped then, so what lies outside the face is shaded - * rather than hidden, and the edge the grid's tiles must keep inside — the - * ring's inner edge, when there is one — is drawn dashed. - */ -function FaceGuide({ size, ring }: { size: number; ring: boolean }) { - const half = size / 2 - const safe = half - (ring ? RING_INSET : 0) - return ( - - - - {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. * @@ -449,6 +346,7 @@ export function DashboardView({ stacked, rail, editing, + onDashboard, }: { dashboard: Dashboard renderWidget?: (widget: WidgetDef) => React.ReactNode @@ -459,12 +357,19 @@ export function DashboardView({ /** Being arranged rather than used, so the lock is not mounted — the * stacked editor draws through here, where the canvas draws its own grid. */ editing?: boolean + /** A round one's way to the panel's other dashboards: next (1) or + * previous (-1). */ + onDashboard?: (by: number) => void }) { const all = widgetsOf(dashboard) + const round = isRound(dashboard) if (all.length === 0) { return (

Nothing on this dashboard yet. Edit it to add a widget. @@ -472,30 +377,38 @@ export function DashboardView({ ) } - // The ring is drawn along the face's edge (`RoundFace`), not in a cell. - const ring = ringOf(dashboard) - const placed = ring ? all.filter((widget) => widget !== ring) : all // Stacked, the arrangement becomes a reading order, so it follows the rows // the panel shows rather than the order widgets happened to be added in. const widgets = stacked - ? [...placed].sort((a, b) => { + ? [...all].sort((a, b) => { const left = placement(a) const right = placement(b) return (left.y ?? 0) - (right.y ?? 0) || (left.x ?? 0) - (right.x ?? 0) }) - : placed + : all const columns = columnsOf(dashboard) const grid = ( - + {round ? ( + // Its own face memory per dashboard, so a panel switching to another + // starts that one where it was left. + + ) : ( + + )} ) diff --git a/frontend/src/components/Dashboard/PanelRail.tsx b/frontend/src/components/Dashboard/PanelRail.tsx index b13854a..a48126b 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 { Link, type LinkProps } from "@tanstack/react-router" +import type { LinkProps } from "@tanstack/react-router" import { useReducedMotion } from "motion/react" import { useEffect, useRef } from "react" import { dashboardQueryOptions } from "@/components/Dashboard/queries" -import { TESTID, useUi } from "./ui" +import { useUi } from "./ui" /** * Switching between the dashboards one panel was assigned. @@ -21,24 +21,17 @@ import { TESTID, useUi } from "./ui" * * Reading the dashboards it links to is also what fills the labels, and it * warms the cache for the neighbours so a switch draws immediately. - * - * A round dashboard has no column to give: its way to the neighbours is a row - * of page dots in the gap at the bottom of the face, the one place the ring - * leaves free. Same links, in canvas pixels; a round device swipes instead. */ export function PanelRail({ dashboards, current, linkFor, horizontal, - dots, }: { dashboards: string[] current: string linkFor: (name: string) => LinkProps horizontal?: boolean - /** Page dots for a round dashboard, instead of the rail. */ - dots?: boolean }) { const { Rail } = useUi() const box = useRef(null) @@ -76,30 +69,7 @@ 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 92255c7..20336e5 100644 --- a/frontend/src/components/Dashboard/PanelSurface.tsx +++ b/frontend/src/components/Dashboard/PanelSurface.tsx @@ -6,7 +6,6 @@ import { DashboardView, isRound, } from "@/components/Dashboard/DashboardView" -import { RoundFace } from "@/components/Dashboard/RingWidget" import { useDashboardLocked } from "@/components/Dashboard/settings" import { useUi } from "@/components/Dashboard/ui" import { LookProvider } from "@/components/Dashboard/ui/core/look" @@ -28,6 +27,7 @@ export function PanelSurface({ stacked, rail, touch, + onDashboard, }: { dashboard: Dashboard /** A landscape arrangement scaled onto a phone comes out at about a fifth of @@ -39,14 +39,23 @@ export function PanelSurface({ rail?: React.ReactNode /** Whether the screen this hangs on is touched. Stated by the panel route. */ touch?: boolean + /** A round dashboard has no rail: it is swiped up and down to the panel's + * others instead. */ + onDashboard?: (by: number) => void }) { const hasRail = Boolean(rail) const stack = stacked && !isRound(dashboard) // Stable, so the canvas can keep the tiles it already built when one of the // dashboard's own settings re-renders the route above this. const canvas = useCallback( - () => , - [dashboard, hasRail], + () => ( + + ), + [dashboard, hasRail, onDashboard], ) return (
@@ -71,12 +80,7 @@ export function PanelSurface({ ) : ( // The panel's own surface, scaled to fit. No dots: nothing is being // arranged here. - } - touch={touch} - > + {canvas} )} diff --git a/frontend/src/components/Dashboard/RingWidget.tsx b/frontend/src/components/Dashboard/RingWidget.tsx index 6ed033c..c77fb31 100644 --- a/frontend/src/components/Dashboard/RingWidget.tsx +++ b/frontend/src/components/Dashboard/RingWidget.tsx @@ -3,10 +3,8 @@ import { useEffect, useRef } from "react" import type { WidgetDef } from "@/client" import { cn } from "@/lib/utils" -import { canvasOf, type Dashboard, ringOf, widgetsOf } from "./DashboardView" import { useBoundValue } from "./dataContext" import { usePublish } from "./publish" -import { LockedProvider } from "./settings" import { colorFormatOf, cssOf, @@ -24,26 +22,27 @@ import { text, } from "./ui/core/config" import { + CENTRE_LABEL, + CENTRE_SWATCH, + CENTRE_VALUE, + fitSize, fractionAt, + GAP_RISE, + GAP_SIZE, + GAP_WIDTH, RING_HANDLE, RING_INSET, RING_MARK, RING_STROKE, - RING_TITLE_RISE, - RING_TITLE_SIZE, ringArc, ringPoint, + TITLE_RISE, + TITLE_SIZE, + TITLE_WIDTH, useRingDraft, } from "./ui/core/ring" import { useAnimatedFraction } from "./ui/core/values" - -/** How far apart the gap's line may run, centred: inside the face at any size. */ -const GAP_WIDTH = 160 - -/** The centre reading, drawn when the ring is all the dashboard has. */ -const CENTRE_VALUE = 72 -const CENTRE_LABEL = 36 -const CENTRE_SWATCH = 96 +import type { Series } from "./widgets" /** How much wheel a notch is: what one detent of a mouse wheel reports. */ const WHEEL_NOTCH = 100 @@ -173,41 +172,64 @@ function dialOf(widget: WidgetDef, live: unknown): Dial { } /** - * A round dashboard's edge: its ring, and what the ring says. + * A face's title: one slot, above the middle, whichever widget it is. * - * Drawn over the whole face rather than in a cell, at canvas pixels — the - * geometry is in `ui/core/ring.ts`. Nothing for a dashboard without a ring. - * Arranging, a press on the ring picks it as a press on a tile does, and it is - * never locked, for the reason the grid is not. + * What is written there names what the middle shows — which is why a slider + * reading something beside its value hands the slot to that reading. */ -export function RoundFace({ - dashboard, - editing, - selected, - onPick, +export function FaceTitle({ + size, + children, }: { - dashboard: Dashboard - editing?: boolean - selected?: boolean - onPick?: (id: string) => void + size: number + children: React.ReactNode }) { - const ring = ringOf(dashboard) - if (!ring) return null - const face = ( - onPick(ring.id))} - /> + return ( +

+ {children} +

) - return editing ? ( - face - ) : ( - {face} +} + +/** + * A reading written large in the middle of a face, its unit small beside it. + * + * Sized from what is written (`fitSize`), so a word shrinks to fit where a + * number would not have to; `children` may tween towards it meanwhile. + */ +export function CentreReading({ + written, + unit, + max = CENTRE_VALUE, + children, +}: { + written: string + unit?: string + max?: number + children?: React.ReactNode +}) { + return ( +
+ + {children ?? written} + {unit ? ( + {unit} + ) : null} + +
) } @@ -218,30 +240,35 @@ export function RoundFace({ * which is the one thing a knob's ring can set and a circle's edge can show. * Turned by a drag along it or by the mouse wheel, a notch a step, an option * or a hue step; the value moves at once and one publish follows once the - * turning stops (`useRingDraft`). + * turning stops (`useRingDraft`). Its reading is written large in the middle, + * unless a slider reads something beside it: then that is in the middle, and + * the slider's own title and value move to the gap's line. + * + * Arranging, a press on the ring picks it as a press on a tile does. */ -function Ring({ +export function Ring({ widget, dashboard, size, - alone, editing, - selected, onPick, }: { widget: WidgetDef dashboard: string size: number - /** Nothing else on the face: the reading is written large in the middle. */ - alone: boolean editing?: boolean - selected?: boolean onPick?: () => void }) { const { value, send, locked } = usePublish(widget, dashboard) + const cfg = config(widget) // A gauge reads its message rather than publishing to a target. - const message = text(config(widget).message) - const bound = useBoundValue(widget.type === "gauge" ? message : undefined) + const bound = useBoundValue( + widget.type === "gauge" ? text(cfg.message) : undefined, + ) + // What a slider sets against — a room's temperature beside its setpoint. + const beside: Series = + widget.type === "slider" ? ((cfg.reading ?? {}) as Series) : {} + const read = useBoundValue(beside.message || undefined) const dial = dialOf(widget, widget.type === "gauge" ? bound?.value : value) const { current, latest, move, commit } = useRingDraft(dial.value, (next) => send(dial.wire(next)), @@ -295,9 +322,12 @@ function Ring({ const title = showTitle(widget) ? widget.title || "" : "" const reading = dial.reading(current) + // Written to the slider's own decimals and in its unit: the two are read + // against each other. + const other = beside.message ? dial.reading(read?.value ?? null) : null const handle = dial.handle && at !== null ? ringPoint(size, at) : null const whole = ringArc(size, 0, 1) - const name = widget.title || text(config(widget).target, widget.id) + const name = widget.title || text(cfg.target, widget.id) return (
) : null} - {selected ? ( - - ) : null} {turns || editing ? ( - {/* 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 ? ( - - ) : ( - + {other === null ? ( + title ? ( + {title} + ) : null + ) : ( + <> + {beside.label ? ( + {beside.label} + ) : null} + {/* The gap at the bottom: what the ring sets, by name. */} +
+ {title ? ( + + {title} + + ) : null} + {reading} - {dial.unit ? ( - - {dial.unit} - - ) : null} + {dial.unit ?? ""} - )} +
+ + )} + + {dial.hue ? ( +
+
- ) : null} + ) : ( + + )}
) } diff --git a/frontend/src/components/Dashboard/RoundFace.tsx b/frontend/src/components/Dashboard/RoundFace.tsx new file mode 100644 index 0000000..201334d --- /dev/null +++ b/frontend/src/components/Dashboard/RoundFace.tsx @@ -0,0 +1,482 @@ +import { Power } from "lucide-react" +import { AnimatePresence, motion, type Variants } from "motion/react" +import { useEffect, useRef, useState } from "react" +import { ErrorBoundary } from "react-error-boundary" + +import type { WidgetDef } from "@/client" +import { useShortcuts } from "@/lib/shortcuts" +import { useClock } from "./ClockWidget" +import type { Dashboard } from "./DashboardView" +import { useBoundValue } from "./dataContext" +import { resolveIcon } from "./icons" +import { usePublish } from "./publish" +import { CentreReading, FaceTitle, Ring } from "./RingWidget" +import { useUi } from "./ui" +import { config, format, num, scrolls, showTitle, text } from "./ui/core/config" +import { useLook } from "./ui/core/look" +import { LOOK } from "./ui/core/motion" +import { + ANNOUNCE_MS, + BODY_HEIGHT, + BODY_WIDTH, + CENTRE_VALUE, + DOT, + DOT_PITCH, + DOTS_RISE, + DOTS_WIDTH, + fitSize, + PRESS, + SWIPE, +} from "./ui/core/ring" +import { useAnimatedNumber } from "./ui/core/values" +import { + WIDGET_LABELS, + WidgetBody, + type WidgetKind, + widgetIssue, +} from "./widgets" + +/** What a round face draws along its ring, which a knob's ring turns: one + * value along a range each. */ +export const RING_TYPES = new Set([ + "slider", + "dropdown", + "color", + "gauge", +]) + +/** + * What has a face drawn for the circle. Every other type is drawn as its + * rectangular body in the middle of the face — allowed, and marked in the + * editor as a poor fit for round. + */ +export const ROUND_TYPES = new Set([ + ...RING_TYPES, + "stat", + "switch", + "button", + "clock", + "icon", + "notification", + "markdown", +]) + +/** Drawn by their own body, centred: a glyph, a few lines. */ +const CENTRED = new Set(["icon", "notification", "markdown"]) + +/** The face each round dashboard showed last, for as long as the page lives. */ +const lastFace = new Map() + +/** The dashboard a panel showed last, so landing on another can say which. */ +let lastShown: string | null = null + +/** A face arrives from the side it was paged towards. */ +const TURN: Variants = { + enter: (way: number) => ({ opacity: 0, x: way * 48 }), + shown: { opacity: 1, x: 0 }, + leave: (way: number) => ({ opacity: 0, x: way * -48 }), +} + +/** + * A round dashboard: one widget at a time, each drawn as the whole face. + * + * Its widgets are the faces, in their order. Left and right page through + * them — a swipe, a page dot, PageUp/PageDown — stopping at either end; up and + * down are the panel's other dashboards (`onDashboard`, a swipe or + * Shift+PageUp/PageDown), and landing on one names it for a moment. Each + * dashboard comes back to the face it was left on. The arrow keys stay the + * ring's. + * + * Arranging, the editor says which face is shown (`face`, `onFace`) and a + * press on a face that is not one of its controls picks it (`onPick`). + */ +export function RoundView({ + dashboard, + size, + face, + onFace, + editing, + onPick, + onDashboard, +}: { + dashboard: Dashboard + /** The face's diameter: the canvas width. */ + size: number + face?: number + onFace?: (index: number) => void + editing?: boolean + onPick?: (id: string) => void + /** To the next (1) or the previous (-1) dashboard on this panel. */ + onDashboard?: (by: number) => void +}) { + const widgets = dashboard.widgets ?? [] + const { look } = useLook() + const [own, setOwn] = useState(() => lastFace.get(dashboard.name) ?? 0) + const index = Math.max(0, Math.min(widgets.length - 1, face ?? own)) + const widget = widgets[index] + const box = useRef(null) + // Read between renders: the face a page turn left, for the side the next + // one arrives from. + const was = useRef(index) + const way = useRef(0) + if (was.current !== index) { + way.current = index > was.current ? 1 : -1 + was.current = index + } + const swiped = useRef(false) + const [announce, setAnnounce] = useState( + () => + Boolean(onDashboard) && + lastShown !== null && + lastShown !== dashboard.name, + ) + + useEffect(() => { + if (onDashboard) lastShown = dashboard.name + }, [onDashboard, dashboard.name]) + + useEffect(() => { + if (!announce) return + const timer = setTimeout(() => setAnnounce(false), ANNOUNCE_MS) + return () => clearTimeout(timer) + }, [announce]) + + const page = (next: number) => { + if (next < 0 || next >= widgets.length || next === index) return + lastFace.set(dashboard.name, next) + setOwn(next) + onFace?.(next) + } + + useShortcuts({ + pagedown: () => (editing ? false : page(index + 1)), + pageup: () => (editing ? false : page(index - 1)), + "shift+pagedown": () => (onDashboard && !editing ? onDashboard(1) : false), + "shift+pageup": () => (onDashboard && !editing ? onDashboard(-1) : false), + }) + + /** A drag that ends far enough away is a swipe, measured in canvas pixels + * whatever the canvas is scaled to. A drag along the ring turns it. */ + const track = (event: React.PointerEvent) => { + swiped.current = false + if ((event.target as Element).closest(".dui-ring-hit")) return + const from = { x: event.clientX, y: event.clientY } + window.addEventListener( + "pointerup", + (end) => { + const scale = + (box.current?.getBoundingClientRect().width || size) / size + const dx = (end.clientX - from.x) / scale + const dy = (end.clientY - from.y) / scale + if (Math.max(Math.abs(dx), Math.abs(dy)) < SWIPE) return + swiped.current = true + // Towards the left is onwards, as a page is turned. + if (Math.abs(dx) >= Math.abs(dy)) page(index + (dx < 0 ? 1 : -1)) + else onDashboard?.(dy < 0 ? 1 : -1) + }, + { once: true }, + ) + } + + if (!widget) return null + const Glyph = resolveIcon(dashboard.icon ?? "") + // Closer together where there are more than fit the gap. + const pitch = Math.min(DOT_PITCH, DOTS_WIDTH / widgets.length) + + return ( + // A face is pressed, swiped and paged rather than focused: its controls + // and the dots below are what the keyboard reaches. + // biome-ignore lint/a11y/useKeyWithClickEvents: PageUp/PageDown page it, window-wide. + // biome-ignore lint/a11y/noStaticElementInteractions: see above. +
{ + if (!swiped.current) return + swiped.current = false + event.stopPropagation() + event.preventDefault() + }} + onClick={(event) => { + if (!onPick) return + const target = event.target as Element + if (!target.closest("button, a, input, [role='slider']")) { + onPick(widget.id) + } + }} + > + + + This face could not be drawn. + } + resetKeys={[widget.id]} + > + onPick(widget.id))} + /> + + + + + {widgets.length > 1 ? ( + + ) : null} + + + {announce ? ( + + + {Glyph ? : null} + + {dashboard.title || dashboard.name} + + + + ) : null} + +
+ ) +} + +/** + * One widget, as a whole face: its title in the face's one title slot, and + * what it shows or does in the middle. A ring type draws along the edge too. + */ +function Face({ + widget, + dashboard, + size, + editing, + onPick, +}: { + widget: WidgetDef + dashboard: string + size: number + editing?: boolean + onPick?: () => void +}) { + const issue = widgetIssue(widget) + if (RING_TYPES.has(widget.type) && !issue) { + return ( + + ) + } + const title = showTitle(widget) ? widget.title : "" + return ( + <> + {title ? {title} : null} + {issue ? ( + +

{issue}

+
+ ) : widget.type === "stat" ? ( + + ) : widget.type === "switch" ? ( + + + + ) : widget.type === "button" ? ( + + + + ) : widget.type === "clock" ? ( + + + + ) : CENTRED.has(widget.type) ? ( + + + + ) : ( + + )} + + ) +} + +/** The face's middle: `BODY_WIDTH` by `BODY_HEIGHT`, centred on it. */ +function Middle({ + size, + children, +}: { + size: number + children: React.ReactNode +}) { + return ( +
+ {children} +
+ ) +} + +/** + * A type with no face of its own: its rectangular body, in the square the + * circle inscribes, from just under the title slot down. Frameless, but in a + * frame all the same, which is what its controls and type are measured off. + */ +function Inscribed({ + widget, + dashboard, + size, +}: WidgetProps & { size: number }) { + const { Frame } = useUi() + const half = size / Math.SQRT2 / 2 + return ( +
+ + + +
+ ) +} + +type WidgetProps = { widget: WidgetDef; dashboard: string } + +function StatFace({ widget }: { widget: WidgetDef }) { + const cfg = config(widget) + const live = useBoundValue(text(cfg.message) || undefined) + const precision = cfg.precision === undefined ? null : num(cfg.precision, 1) + const { label, numeric } = useAnimatedNumber(live?.value, precision) + return ( + + {numeric ? {label} : undefined} + + ) +} + +/** The time as large as a reading, shrunk to fit as one is, over the date. */ +function ClockFace() { + const { time, date } = useClock() + return ( +
+

+ {time} +

+

{date}

+
+ ) +} + +/** A latch, as one large round press that says its state in a word. */ +function SwitchFace({ widget, dashboard }: WidgetProps) { + const { Button } = useUi() + const { target, value, send, locked } = usePublish(widget, dashboard) + const on = value === true + return ( +
+ +
+ ) +} + +function ButtonFace({ widget, dashboard }: WidgetProps) { + const { Button } = useUi() + const { send, pending, locked } = usePublish(widget, dashboard) + return ( +
+ +
+ ) +} diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index 9e1d7de..a7cebcc 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -56,7 +56,6 @@ import { columnsOf, type Dashboard, isRound, - RING_WIDGETS, } from "./DashboardView" import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons" import { messageCatalogQueryOptions } from "./queries" @@ -492,18 +491,13 @@ function coerce(raw: string): unknown { export function WidgetPanel({ widget, round, - onRing, - onRingChange, onChange, onDelete, onClose, }: { widget: WidgetDef | null - /** The dashboard is round, so a widget of the right type may go on its ring. */ + /** The dashboard is round: what only a round face draws is set here too. */ round?: boolean - /** This widget is the one drawn on the ring. */ - onRing?: boolean - onRingChange?: (on: boolean) => void onChange: (changes: Partial) => void onDelete: () => void onClose: () => void @@ -517,6 +511,8 @@ export function WidgetPanel({ onChange({ config: { ...cfg, ...changes } }) const series = seriesOf(widget) + // What a slider on a round face reads beside its own value. + const reading = (cfg.reading ?? {}) as Series const runsPick = (cfg.runs ?? {}) as RunsPick const setSeries = (next: Series[]) => set({ series: next }) // A bar's readings. An empty row stands in for none, so a fresh bar offers @@ -610,20 +606,6 @@ export function WidgetPanel({ />
- {round && RING_WIDGETS.has(widget.type) ? ( -
- - On the ring - - onRingChange?.(on)} - /> -
- ) : null} - {widget.type === "embed" ? ( <>
@@ -1130,7 +1112,10 @@ export function WidgetPanel({
) : null} - {widget.type === "slider" ? ( + {/* A round face draws a slider on its ring, and a switch or a + selector one way only, so how they are drawn is not a choice + there. */} + {widget.type === "slider" && !round ? (
Drawn as @@ -1148,6 +1133,52 @@ export function WidgetPanel({
) : null} + {widget.type === "slider" && round ? ( +
+ + Beside it + +
+
+ acceptsDtype("slider", message.dtype)} + onPick={(message, dtype) => + set({ reading: { ...reading, message, dtype } }) + } + /> +
+ + set({ reading: { ...reading, label: event.target.value } }) + } + /> + {reading.message ? ( + + ) : null} +
+
+ ) : null} + {widget.type === "button" ? (
@@ -1440,7 +1471,7 @@ export function WidgetPanel({ }) } /> - {onRing ? ( + {round ? (
Hue step @@ -1527,7 +1558,7 @@ export function WidgetPanel({
) : null} - {widget.type === "switch" || widget.type === "dropdown" ? ( + {(widget.type === "switch" || widget.type === "dropdown") && !round ? (
{widget.type === "switch" ? ( @@ -1553,12 +1584,6 @@ 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}
@@ -1808,30 +1833,33 @@ export function DashboardPanel({ /> - - - + + + )}