Round dashboards: shape and ring, the ring drawn and turned in every look, Knob preset and face guide in the editor
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -168,6 +168,52 @@ Background draws an image beneath the widgets. Lock disables publishing from
|
||||
controls and shows **Read-only** when viewing the dashboard. It does not
|
||||
restrict API permissions and does not disable controls in edit mode.
|
||||
|
||||
## Round dashboards
|
||||
|
||||
For a round screen such as a rotary knob, set the canvas to **Round** in the
|
||||
dashboard settings, or pick the **Knob** preset (360×360). A round dashboard is
|
||||
drawn in the circle its canvas holds, so its canvas is square (`shape: "round"`
|
||||
forces `canvas_height` to `canvas_width`). Tiles are drawn without a card, and
|
||||
there is no rail; a panel with several dashboards shows page dots instead.
|
||||
|
||||
One Slider, Selector, Colour or Gauge can go **On the ring** (`ring: <widget
|
||||
id>`): it is drawn along the edge instead of in the grid. Drag along the ring or
|
||||
use the mouse wheel; one notch is one step, one option or one hue step
|
||||
(`hue_step`, default 5°, wrapping). The value moves at once and is published
|
||||
once, 300 ms after the last notch or when the pointer is released. A gauge on
|
||||
the ring only shows. When the ring is the only widget, its reading is written
|
||||
large in the middle.
|
||||
|
||||
The editor shades what lies outside the circle and warns about tiles whose
|
||||
corners leave it. Inputs, button grids, charts, forecasts, agendas, websites
|
||||
and a segmented Selector are marked as a poor fit, but allowed.
|
||||
|
||||
### Geometry
|
||||
|
||||
Devices that draw dashboards natively use these numbers. They are canvas
|
||||
pixels, scaled with the canvas; S is the canvas width. Angles are degrees
|
||||
clockwise from three o'clock.
|
||||
|
||||
| Part | Value |
|
||||
|---|---|
|
||||
| Face | circle of diameter S at (S/2, S/2); nothing is drawn outside it |
|
||||
| Ring band | 40 along the edge, only when a ring is set |
|
||||
| Grid area | the square inset by 12, plus 40 with a ring; columns, rows and cell proportions as on a rectangular canvas. Knob, 4 columns, ring: 256×256, columns 55, rows 30 at a 42 pitch, 6 rows |
|
||||
| Safe circle | radius S/2 − 40 with a ring, S/2 without; a tile corner outside it is warned about |
|
||||
| Ring arc | start 120°, sweep 300° (ends at 60°), centre-line radius S/2 − 20, stroke 16 with round caps |
|
||||
| Slider | track over the sweep, value arc from the start to the value (none at the minimum), handle 28 across |
|
||||
| Selector | a 6 mark per option at i/(n−1) of the sweep (one option: the middle), the selected one under the handle; no wrap |
|
||||
| Colour | hue h at h/360 of the sweep, red at both ends; the handle is filled with the colour |
|
||||
| Gauge | track and value arc, no handle |
|
||||
| Gap line | centred at y = S − 28, 13 high, at most 160 wide: the title (muted), then the reading unless it is in the middle; a colour has none |
|
||||
| Page dots | 6 dots 16 apart, centred at y = S − 12; the current one in the text colour, the others muted at half opacity |
|
||||
| Centre reading | value 72 with its unit at 0.4 em, muted; a Selector's label 36; a colour swatch 96 across with a hairline border |
|
||||
| Tiles | no surface, border or shadow; padding 8; title 12, muted and centred; a single reading centred |
|
||||
|
||||
Paint follows the look: Fluksio draws a muted track, a primary value and a
|
||||
handle of the ground colour ringed in primary; Material a tonal track and a
|
||||
primary handle; Glass a translucent track, a glowing value and a white handle.
|
||||
|
||||
## Showing one
|
||||
|
||||
Open `/view/{name}` for a dashboard-only view with your normal session.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -168,6 +168,8 @@ export type DashboardDef_Input = {
|
||||
columns?: number;
|
||||
canvas_width?: number;
|
||||
canvas_height?: number;
|
||||
shape?: 'rect' | 'round';
|
||||
ring?: string;
|
||||
icon?: string;
|
||||
widgets?: Array<WidgetDef>;
|
||||
settings?: {
|
||||
@@ -177,6 +179,8 @@ export type DashboardDef_Input = {
|
||||
has_draft?: boolean;
|
||||
};
|
||||
|
||||
export type shape = 'rect' | 'round';
|
||||
|
||||
/**
|
||||
* A dashboard as stored, and as the API hands it over.
|
||||
*/
|
||||
@@ -186,6 +190,8 @@ export type DashboardDef_Output = {
|
||||
columns?: number;
|
||||
canvas_width?: number;
|
||||
canvas_height?: number;
|
||||
shape?: 'rect' | 'round';
|
||||
ring?: string;
|
||||
icon?: string;
|
||||
widgets?: Array<WidgetDef>;
|
||||
settings?: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type CSSProperties, useEffect, useRef, useState } from "react"
|
||||
import {
|
||||
GridLayout,
|
||||
type Layout,
|
||||
noCompactor,
|
||||
type Position,
|
||||
setTransform,
|
||||
} from "react-grid-layout"
|
||||
@@ -48,7 +49,10 @@ import {
|
||||
DashboardView,
|
||||
GRID_GAP,
|
||||
isPlaced,
|
||||
isRound,
|
||||
offFace,
|
||||
placement,
|
||||
ringOf,
|
||||
rowHeightOf,
|
||||
rowsOf,
|
||||
widgetsOf,
|
||||
@@ -63,6 +67,7 @@ import {
|
||||
usePublishDashboard,
|
||||
useSaveDashboard,
|
||||
} from "./queries"
|
||||
import { RoundFace } from "./RingWidget"
|
||||
import { PaletteProvider } from "./settings"
|
||||
import { useUi } from "./ui"
|
||||
import { showTitle } from "./ui/core/config"
|
||||
@@ -234,8 +239,10 @@ export function DashboardEditor({
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||
// A phone reads the dashboard rather than arranges it, so the grid library
|
||||
// never mounts there. See DESIGN-GUIDELINES.md → Responsive.
|
||||
const stacked = useIsMobile()
|
||||
// never mounts there. See DESIGN-GUIDELINES.md → Responsive. A round one is
|
||||
// the exception: a knob's face is about a phone's width as it is.
|
||||
const round = isRound(draft)
|
||||
const stacked = useIsMobile() && !round
|
||||
// The dashboard's own appearance, on the surface only: the shell around the
|
||||
// canvas stays whatever the person editing chose for the app. Non-stacked,
|
||||
// `CanvasSurface` states it on the canvas box itself.
|
||||
@@ -310,6 +317,11 @@ export function DashboardEditor({
|
||||
const hasDraft = Boolean(dashboard.has_draft)
|
||||
const columns = columnsOf(draft)
|
||||
const widgets = widgetsOf(draft)
|
||||
// The ring is drawn along a round face's edge and takes no cell.
|
||||
const ring = ringOf(draft)
|
||||
const placedWidgets = ring
|
||||
? widgets.filter((widget) => widget !== ring)
|
||||
: widgets
|
||||
|
||||
const updateWidgets = (next: WidgetDef[]) => {
|
||||
commit({ ...draft, widgets: next })
|
||||
@@ -380,6 +392,7 @@ export function DashboardEditor({
|
||||
dashboards={railDashboards}
|
||||
current={draft.name}
|
||||
horizontal={stacked}
|
||||
dots={round}
|
||||
// Editing one dashboard of a panel and editing its neighbour are the
|
||||
// same job, so the rail keeps whichever mode this one is in.
|
||||
linkFor={(name) => ({
|
||||
@@ -393,13 +406,15 @@ export function DashboardEditor({
|
||||
// the rail's column when the panel carries one.
|
||||
const area = areaOf(draft, hasRail)
|
||||
|
||||
const layout = layoutOf(widgets, columns)
|
||||
const layout = layoutOf(placedWidgets, columns)
|
||||
const rows = rowsOf(draft, hasRail)
|
||||
|
||||
// maxRows below only constrains a *drag*. A stored placement is corrected
|
||||
// against `cols` and nothing else, so a canvas that shrank leaves whatever
|
||||
// now falls past its bottom edge drawn under the clip.
|
||||
const clipped = layout.filter((item) => item.y + item.h > rows)
|
||||
// A round face cuts the corners off the grid it is laid out in.
|
||||
const cornered = offFace(draft, layout)
|
||||
|
||||
/** Store what the grid ended up doing, unless it did nothing. */
|
||||
const applyLayout = (next: Layout) => {
|
||||
@@ -428,7 +443,7 @@ export function DashboardEditor({
|
||||
* room above it — it has to move sideways. */
|
||||
const reflow = () => {
|
||||
const at = new Map(layout.map((item) => [item.i, item]))
|
||||
const ordered = [...widgets].sort((a, b) => {
|
||||
const ordered = [...placedWidgets].sort((a, b) => {
|
||||
const left = at.get(a.id)
|
||||
const right = at.get(b.id)
|
||||
return (
|
||||
@@ -504,7 +519,22 @@ export function DashboardEditor({
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<CanvasSurface dashboard={draft} dots={edit} rail={rail}>
|
||||
<CanvasSurface
|
||||
dashboard={draft}
|
||||
dots={edit}
|
||||
rail={rail}
|
||||
face={
|
||||
<RoundFace
|
||||
dashboard={draft}
|
||||
editing={edit}
|
||||
selected={Boolean(ring) && ring?.id === selected}
|
||||
onPick={(id) => {
|
||||
setSettingsOpen(false)
|
||||
setSelected(id)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(scale) =>
|
||||
!edit ? (
|
||||
<DashboardView dashboard={draft} rail={hasRail} />
|
||||
@@ -514,6 +544,10 @@ export function DashboardEditor({
|
||||
layout={layout}
|
||||
onLayoutChange={applyLayout}
|
||||
positionStrategy={scaledStrategy(scale)}
|
||||
// A circle is narrowest at the top, which is where packing
|
||||
// upwards would put everything: a round face keeps each tile
|
||||
// where it was placed.
|
||||
compactor={round ? noCompactor : undefined}
|
||||
gridConfig={{
|
||||
cols: columns,
|
||||
rowHeight: rowHeightOf(draft, hasRail),
|
||||
@@ -535,7 +569,7 @@ export function DashboardEditor({
|
||||
}}
|
||||
resizeConfig={{ handles: ["e", "s", "se"] }}
|
||||
>
|
||||
{widgets.map((widget) => (
|
||||
{placedWidgets.map((widget) => (
|
||||
<div key={widget.id}>{pickable(widget, true)}</div>
|
||||
))}
|
||||
</GridLayout>
|
||||
@@ -626,6 +660,26 @@ export function DashboardEditor({
|
||||
Reflow
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : cornered.length > 0 && !stacked ? (
|
||||
// Not a fault and nothing to re-pack: the tile still shows,
|
||||
// less what the circle cuts off it.
|
||||
<motion.div
|
||||
variants={slideUp}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
transition={transitions.emphasized}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="canvas-cornered"
|
||||
className="absolute bottom-full left-1/2 mb-3 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 items-center rounded-full border border-border bg-card/80 px-4 py-1.5 shadow-e2 backdrop-blur-md"
|
||||
>
|
||||
<span className="truncate text-sm">
|
||||
{cornered.length === 1
|
||||
? "1 widget reaches past the round face — its corners will be cut off."
|
||||
: `${cornered.length} widgets reach past the round face — their corners will be cut off.`}
|
||||
</span>
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
<Tooltip>
|
||||
@@ -729,6 +783,7 @@ export function DashboardEditor({
|
||||
<>
|
||||
<DashboardPalette
|
||||
open={paletteOpen}
|
||||
round={round}
|
||||
onOpenChange={setPaletteOpen}
|
||||
onAdd={addWidget}
|
||||
onSettings={openSettings}
|
||||
@@ -737,10 +792,22 @@ export function DashboardEditor({
|
||||
|
||||
<WidgetPanel
|
||||
widget={active}
|
||||
round={round}
|
||||
onRing={Boolean(active) && draft.ring === active?.id}
|
||||
// One ring per face, so putting a widget on it takes it off any
|
||||
// other.
|
||||
onRingChange={(on) =>
|
||||
active && commit({ ...draft, ring: on ? active.id : "" })
|
||||
}
|
||||
onChange={(changes) => active && patch(active.id, changes)}
|
||||
onDelete={() => {
|
||||
if (!active) return
|
||||
updateWidgets(widgets.filter((other) => other.id !== active.id))
|
||||
// A ring naming a widget that is gone is refused on save.
|
||||
commit({
|
||||
...draft,
|
||||
widgets: widgets.filter((other) => other.id !== active.id),
|
||||
ring: draft.ring === active.id ? "" : draft.ring,
|
||||
})
|
||||
setSelected(null)
|
||||
}}
|
||||
onClose={() => setSelected(null)}
|
||||
|
||||
@@ -19,6 +19,20 @@ import {
|
||||
|
||||
const KINDS = Object.keys(WIDGET_LABELS) as WidgetKind[]
|
||||
|
||||
/**
|
||||
* What a round face draws badly: a row of controls or text, a plot or a strip
|
||||
* that wants the width a circle only has across its middle, and a page in a
|
||||
* tile. Offered all the same — a soft mark, not a refusal.
|
||||
*/
|
||||
const POOR_ON_ROUND = new Set<WidgetKind>([
|
||||
"input",
|
||||
"buttons",
|
||||
"chart",
|
||||
"forecast",
|
||||
"agenda",
|
||||
"embed",
|
||||
])
|
||||
|
||||
const GROUPS = [
|
||||
["Show a value", KINDS.filter((kind) => !INPUT_WIDGETS.has(kind))],
|
||||
["Control something", KINDS.filter((kind) => INPUT_WIDGETS.has(kind))],
|
||||
@@ -34,6 +48,7 @@ export function DashboardPalette({
|
||||
onAdd,
|
||||
onSettings,
|
||||
onPublish,
|
||||
round,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -41,6 +56,8 @@ export function DashboardPalette({
|
||||
onSettings: () => void
|
||||
/** Absent while there is nothing to publish. */
|
||||
onPublish?: () => void
|
||||
/** The dashboard is round: the types that draw badly on it say so. */
|
||||
round?: boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const { data } = useQuery({ ...dashboardsQueryOptions(), enabled: open })
|
||||
@@ -79,6 +96,9 @@ export function DashboardPalette({
|
||||
<span>{WIDGET_LABELS[kind]}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{WIDGET_DESCRIPTIONS[kind]}
|
||||
{round && POOR_ON_ROUND.has(kind)
|
||||
? " · Poor fit for round"
|
||||
: ""}
|
||||
</span>
|
||||
</span>
|
||||
</CommandItem>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useUi } from "./ui"
|
||||
import { scrolls, showTitle } from "./ui/core/config"
|
||||
import { LookProvider, useCanvasRoot, useLook } from "./ui/core/look"
|
||||
import { gridStagger, LOOK } from "./ui/core/motion"
|
||||
import { RING_INSET } from "./ui/core/ring"
|
||||
import { WidgetBody, widgetIssue } from "./widgets"
|
||||
|
||||
export type Dashboard = DashboardDef_Output
|
||||
@@ -21,7 +22,7 @@ export type Dashboard = DashboardDef_Output
|
||||
export const DEFAULT_COLUMNS = 12
|
||||
|
||||
/** What a grid size setting may be set to; a panel is matched to one of these. */
|
||||
export const COLUMN_CHOICES = [6, 8, 12, 16, 24]
|
||||
export const COLUMN_CHOICES = [4, 6, 8, 12, 16, 24]
|
||||
|
||||
/** The panel a dashboard is drawn for, if the document does not say. */
|
||||
export const DEFAULT_CANVAS = { width: 1920, height: 1080 }
|
||||
@@ -40,6 +41,9 @@ export const CANVAS_PRESETS = [
|
||||
{ label: "Full HD", width: 1920, height: 1080 },
|
||||
{ label: '10" tablet, portrait', width: 800, height: 1280 },
|
||||
{ label: "Full HD, portrait", width: 1080, height: 1920 },
|
||||
// A rotary knob's round screen. The only round one: a circle needs nothing
|
||||
// but its diameter, so any other is a custom width.
|
||||
{ label: "Knob", width: 360, height: 360, shape: "round" as const },
|
||||
]
|
||||
|
||||
/** The gap between widgets, in pixels. Matches the `gap-3` view mode uses. */
|
||||
@@ -75,10 +79,32 @@ export const widgetsOf = (dashboard: DashboardDef_Output) =>
|
||||
export const columnsOf = (dashboard: Dashboard) =>
|
||||
dashboard.columns || DEFAULT_COLUMNS
|
||||
|
||||
export const canvasOf = (dashboard: Dashboard) => ({
|
||||
width: dashboard.canvas_width || DEFAULT_CANVAS.width,
|
||||
height: dashboard.canvas_height || DEFAULT_CANVAS.height,
|
||||
})
|
||||
/** Drawn in the circle its canvas inscribes, for a round screen. */
|
||||
export const isRound = (dashboard: Dashboard) => dashboard.shape === "round"
|
||||
|
||||
/** Widget types a round dashboard may draw on its ring. Mirrors the server's
|
||||
* `RING_WIDGETS` (`flow/dashboards.py`). */
|
||||
export const RING_WIDGETS = new Set(["slider", "dropdown", "color", "gauge"])
|
||||
|
||||
/** The widget a round dashboard draws along its edge, if it has one. */
|
||||
export const ringOf = (dashboard: Dashboard): WidgetDef | null =>
|
||||
(isRound(dashboard) &&
|
||||
widgetsOf(dashboard).find(
|
||||
(widget) => widget.id === dashboard.ring && RING_WIDGETS.has(widget.type),
|
||||
)) ||
|
||||
null
|
||||
|
||||
/** A round canvas is square whatever its stored height says: a circle has one
|
||||
* size. The server squares it on save; this covers a draft not saved yet. */
|
||||
export const canvasOf = (dashboard: Dashboard) => {
|
||||
const width = dashboard.canvas_width || DEFAULT_CANVAS.width
|
||||
return {
|
||||
width,
|
||||
height: isRound(dashboard)
|
||||
? width
|
||||
: dashboard.canvas_height || DEFAULT_CANVAS.height,
|
||||
}
|
||||
}
|
||||
|
||||
/** One grid row, in pixels, on the canvas the grid was first drawn for. */
|
||||
const DEFAULT_ROW_HEIGHT = 80
|
||||
@@ -107,15 +133,23 @@ const CELL_ASPECT = DEFAULT_COLUMN_WIDTH / DEFAULT_ROW_HEIGHT
|
||||
* one: a rail is part of the screen rather than something beside it, so the
|
||||
* room it takes comes out of the canvas the same way the margin does. Which
|
||||
* cell each widget sits in never changes — only how big a cell is.
|
||||
*
|
||||
* A round dashboard has no rail — a panel shows page dots in the ring's gap
|
||||
* instead — and is inset evenly, by the ring's band as well when it has one.
|
||||
* The cells are the same maths in that square; what a circle cuts off of it
|
||||
* is the editor's to warn about (`offFace`).
|
||||
*/
|
||||
export const areaOf = (dashboard: Dashboard, rail = false) => {
|
||||
const { width, height } = canvasOf(dashboard)
|
||||
const left = GRID_INSET + (rail ? RAIL_INSET : 0)
|
||||
const round = isRound(dashboard)
|
||||
const inset = GRID_INSET + (ringOf(dashboard) ? RING_INSET : 0)
|
||||
const left = round ? inset : GRID_INSET + (rail ? RAIL_INSET : 0)
|
||||
const top = round ? inset : GRID_INSET
|
||||
return {
|
||||
left,
|
||||
top: GRID_INSET,
|
||||
width: Math.max(1, width - left - GRID_INSET),
|
||||
height: Math.max(1, height - 2 * GRID_INSET),
|
||||
top,
|
||||
width: Math.max(1, width - left - (round ? inset : GRID_INSET)),
|
||||
height: Math.max(1, height - 2 * top),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,14 +190,18 @@ export function CanvasSurface({
|
||||
dashboard,
|
||||
dots,
|
||||
rail,
|
||||
face,
|
||||
touch,
|
||||
children,
|
||||
}: {
|
||||
dashboard: Dashboard
|
||||
/** Draw the placement grid. A wall panel is not being arranged, so: no. */
|
||||
dots?: boolean
|
||||
/** The dashboard-switching rail, drawn on the panel rather than beside it. */
|
||||
/** The dashboard-switching rail, drawn on the panel rather than beside it.
|
||||
* On a round dashboard, the page dots in the ring's gap. */
|
||||
rail?: React.ReactNode
|
||||
/** What a round dashboard draws over the whole face: its ring. */
|
||||
face?: React.ReactNode
|
||||
/** Whether the panel this canvas hangs on is touched rather than pointed at. */
|
||||
touch?: boolean
|
||||
children: (scale: number) => React.ReactNode
|
||||
@@ -187,6 +225,7 @@ export function CanvasSurface({
|
||||
const { width, height } = canvasOf(dashboard)
|
||||
const scale = Math.min(box.width / width, box.height / height)
|
||||
const root = useCanvasRoot(dashboard, touch)
|
||||
const round = isRound(dashboard)
|
||||
const area = areaOf(dashboard, Boolean(rail))
|
||||
// The arrangement, held across the renders that are not about it. This
|
||||
// component reads the dashboard's own settings, so a bound one wakes it on
|
||||
@@ -208,6 +247,8 @@ export function CanvasSurface({
|
||||
data-look={root["data-look"]}
|
||||
data-touch={root["data-touch"]}
|
||||
data-palette={root["data-palette"]}
|
||||
data-shape={round ? "round" : undefined}
|
||||
data-arranging={dots ? "" : undefined}
|
||||
className={cn(
|
||||
"absolute overflow-hidden bg-background text-foreground",
|
||||
root.className,
|
||||
@@ -218,6 +259,10 @@ export function CanvasSurface({
|
||||
...root.style,
|
||||
width,
|
||||
height,
|
||||
// Round, nothing outside the circle is drawn — except while it
|
||||
// is being arranged, where a tile reaching past the edge has to
|
||||
// stay in reach to be moved back (`FaceGuide` shades it).
|
||||
clipPath: round && !dots ? "circle(50%)" : undefined,
|
||||
left: (box.width - width * scale) / 2,
|
||||
top: (box.height - height * scale) / 2,
|
||||
transform: `scale(${scale})`,
|
||||
@@ -249,6 +294,10 @@ export function CanvasSurface({
|
||||
) : null}
|
||||
{content}
|
||||
</div>
|
||||
{round ? face : null}
|
||||
{round && dots ? (
|
||||
<FaceGuide size={width} ring={Boolean(ringOf(dashboard))} />
|
||||
) : null}
|
||||
</div>
|
||||
</LookProvider>
|
||||
) : null}
|
||||
@@ -256,6 +305,83 @@ export function CanvasSurface({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The circle, while a round dashboard is being arranged.
|
||||
*
|
||||
* The canvas is not clipped then, so what lies outside the face is shaded
|
||||
* rather than hidden, and the edge the grid's tiles must keep inside — the
|
||||
* ring's inner edge, when there is one — is drawn dashed.
|
||||
*/
|
||||
function FaceGuide({ size, ring }: { size: number; ring: boolean }) {
|
||||
const half = size / 2
|
||||
const safe = half - (ring ? RING_INSET : 0)
|
||||
return (
|
||||
<svg
|
||||
role="presentation"
|
||||
width={size}
|
||||
height={size}
|
||||
className="pointer-events-none absolute inset-0 z-[5]"
|
||||
data-testid="face-guide"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
className="fill-background opacity-70"
|
||||
d={`M0 0H${size}V${size}H0Z M${half} 0a${half} ${half} 0 1 0 0.01 0Z`}
|
||||
/>
|
||||
<circle
|
||||
cx={half}
|
||||
cy={half}
|
||||
r={half - 0.5}
|
||||
fill="none"
|
||||
className="stroke-border"
|
||||
/>
|
||||
{ring ? (
|
||||
<circle
|
||||
cx={half}
|
||||
cy={half}
|
||||
r={safe}
|
||||
fill="none"
|
||||
strokeDasharray="4 4"
|
||||
className="stroke-muted-foreground opacity-50"
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which tiles reach past a round dashboard's face: any corner outside the
|
||||
* circle the grid has to keep inside, which is the ring's inner edge when
|
||||
* there is a ring. A device clips them, so the editor says so.
|
||||
*/
|
||||
export function offFace(
|
||||
dashboard: Dashboard,
|
||||
items: readonly { i: string; x: number; y: number; w: number; h: number }[],
|
||||
): string[] {
|
||||
if (!isRound(dashboard)) return []
|
||||
const { width } = canvasOf(dashboard)
|
||||
const area = areaOf(dashboard)
|
||||
const column = (area.width + GRID_GAP) / columnsOf(dashboard)
|
||||
const row = rowPitchOf(dashboard)
|
||||
const reach = width / 2 - (ringOf(dashboard) ? RING_INSET : 0)
|
||||
const out = (x: number, y: number) =>
|
||||
Math.hypot(x - width / 2, y - width / 2) > reach
|
||||
return items
|
||||
.filter((item) => {
|
||||
const left = area.left + item.x * column
|
||||
const top = area.top + item.y * row
|
||||
const right = left + item.w * column - GRID_GAP
|
||||
const bottom = top + item.h * row - GRID_GAP
|
||||
return (
|
||||
out(left, top) ||
|
||||
out(right, top) ||
|
||||
out(left, bottom) ||
|
||||
out(right, bottom)
|
||||
)
|
||||
})
|
||||
.map((item) => item.i)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the widgets sit on.
|
||||
*
|
||||
@@ -346,15 +472,18 @@ export function DashboardView({
|
||||
)
|
||||
}
|
||||
|
||||
// The ring is drawn along the face's edge (`RoundFace`), not in a cell.
|
||||
const ring = ringOf(dashboard)
|
||||
const placed = ring ? all.filter((widget) => widget !== ring) : all
|
||||
// Stacked, the arrangement becomes a reading order, so it follows the rows
|
||||
// the panel shows rather than the order widgets happened to be added in.
|
||||
const widgets = stacked
|
||||
? [...all].sort((a, b) => {
|
||||
? [...placed].sort((a, b) => {
|
||||
const left = placement(a)
|
||||
const right = placement(b)
|
||||
return (left.y ?? 0) - (right.y ?? 0) || (left.x ?? 0) - (right.x ?? 0)
|
||||
})
|
||||
: all
|
||||
: placed
|
||||
const columns = columnsOf(dashboard)
|
||||
|
||||
const grid = (
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useQueries } from "@tanstack/react-query"
|
||||
import type { LinkProps } from "@tanstack/react-router"
|
||||
import { Link, type LinkProps } from "@tanstack/react-router"
|
||||
import { useReducedMotion } from "motion/react"
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
||||
import { useUi } from "./ui"
|
||||
import { TESTID, useUi } from "./ui"
|
||||
|
||||
/**
|
||||
* Switching between the dashboards one panel was assigned.
|
||||
@@ -21,17 +21,24 @@ import { useUi } from "./ui"
|
||||
*
|
||||
* Reading the dashboards it links to is also what fills the labels, and it
|
||||
* warms the cache for the neighbours so a switch draws immediately.
|
||||
*
|
||||
* A round dashboard has no column to give: its way to the neighbours is a row
|
||||
* of page dots in the gap at the bottom of the face, the one place the ring
|
||||
* leaves free. Same links, in canvas pixels; a round device swipes instead.
|
||||
*/
|
||||
export function PanelRail({
|
||||
dashboards,
|
||||
current,
|
||||
linkFor,
|
||||
horizontal,
|
||||
dots,
|
||||
}: {
|
||||
dashboards: string[]
|
||||
current: string
|
||||
linkFor: (name: string) => LinkProps
|
||||
horizontal?: boolean
|
||||
/** Page dots for a round dashboard, instead of the rail. */
|
||||
dots?: boolean
|
||||
}) {
|
||||
const { Rail } = useUi()
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
@@ -69,7 +76,30 @@ export function PanelRail({
|
||||
// places the rail itself.
|
||||
return (
|
||||
<div ref={box} className="contents">
|
||||
<Rail entries={entries} horizontal={horizontal} />
|
||||
{dots ? (
|
||||
// 16px targets centred 12px above the face's bottom edge, each with
|
||||
// a 6px dot.
|
||||
<nav
|
||||
aria-label="Dashboards on this panel"
|
||||
data-testid={TESTID.rail}
|
||||
className="pointer-events-auto absolute inset-x-0 bottom-1 z-10 flex justify-center"
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<Link
|
||||
key={entry.name}
|
||||
{...entry.link}
|
||||
aria-label={entry.label}
|
||||
aria-current={entry.active ? "page" : undefined}
|
||||
data-testid={`panel-rail-${entry.name}`}
|
||||
className="dui-dot flex size-4 items-center justify-center"
|
||||
>
|
||||
<span className="dui-dot-mark" />
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
) : (
|
||||
<Rail entries={entries} horizontal={horizontal} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
||||
import {
|
||||
CanvasSurface,
|
||||
DashboardView,
|
||||
isRound,
|
||||
} from "@/components/Dashboard/DashboardView"
|
||||
import { RoundFace } from "@/components/Dashboard/RingWidget"
|
||||
import { useDashboardLocked } from "@/components/Dashboard/settings"
|
||||
import { useUi } from "@/components/Dashboard/ui"
|
||||
import { LookProvider } from "@/components/Dashboard/ui/core/look"
|
||||
@@ -29,7 +31,8 @@ export function PanelSurface({
|
||||
}: {
|
||||
dashboard: Dashboard
|
||||
/** A landscape arrangement scaled onto a phone comes out at about a fifth of
|
||||
* its size, which reads as nothing at all. Stack it instead. */
|
||||
* its size, which reads as nothing at all. Stack it instead. Never a round
|
||||
* one: a knob's face is about a phone's width already. */
|
||||
stacked?: boolean
|
||||
/** The way between this panel's dashboards, drawn on the canvas itself, or
|
||||
* along the bottom of the screen when stacked. */
|
||||
@@ -38,6 +41,7 @@ export function PanelSurface({
|
||||
touch?: boolean
|
||||
}) {
|
||||
const hasRail = Boolean(rail)
|
||||
const stack = stacked && !isRound(dashboard)
|
||||
// Stable, so the canvas can keep the tiles it already built when one of the
|
||||
// dashboard's own settings re-renders the route above this.
|
||||
const canvas = useCallback(
|
||||
@@ -46,7 +50,7 @@ export function PanelSurface({
|
||||
)
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
{stacked ? (
|
||||
{stack ? (
|
||||
// Nothing scales a stack, so there is no canvas element here to state
|
||||
// the look on: this branch mounts what `CanvasSurface` mounts for the
|
||||
// other one, touch included.
|
||||
@@ -67,13 +71,18 @@ export function PanelSurface({
|
||||
) : (
|
||||
// The panel's own surface, scaled to fit. No dots: nothing is being
|
||||
// arranged here.
|
||||
<CanvasSurface dashboard={dashboard} rail={rail} touch={touch}>
|
||||
<CanvasSurface
|
||||
dashboard={dashboard}
|
||||
rail={rail}
|
||||
face={<RoundFace dashboard={dashboard} />}
|
||||
touch={touch}
|
||||
>
|
||||
{canvas}
|
||||
</CanvasSurface>
|
||||
)}
|
||||
{/* Outside the canvas, so it needs the look stated for it. */}
|
||||
<LookProvider dashboard={dashboard} touch={touch}>
|
||||
{stacked && rail ? (
|
||||
{stack && rail ? (
|
||||
// Lifted clear of the rail floating along the bottom.
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 bottom-20">
|
||||
<LockNotice dashboard={dashboard} />
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
import { motion, useTransform } from "motion/react"
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
import type { WidgetDef } from "@/client"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { canvasOf, type Dashboard, ringOf, widgetsOf } from "./DashboardView"
|
||||
import { useBoundValue } from "./dataContext"
|
||||
import { usePublish } from "./publish"
|
||||
import { LockedProvider } from "./settings"
|
||||
import {
|
||||
colorFormatOf,
|
||||
cssOf,
|
||||
decodeColor,
|
||||
encodeColor,
|
||||
type Triple,
|
||||
wrap,
|
||||
} from "./ui/core/color"
|
||||
import {
|
||||
config,
|
||||
format,
|
||||
fractionOf,
|
||||
num,
|
||||
showTitle,
|
||||
text,
|
||||
} from "./ui/core/config"
|
||||
import {
|
||||
fractionAt,
|
||||
RING_HANDLE,
|
||||
RING_INSET,
|
||||
RING_MARK,
|
||||
RING_STROKE,
|
||||
RING_TITLE_RISE,
|
||||
RING_TITLE_SIZE,
|
||||
ringArc,
|
||||
ringPoint,
|
||||
useRingDraft,
|
||||
} from "./ui/core/ring"
|
||||
import { useAnimatedFraction } from "./ui/core/values"
|
||||
|
||||
/** How far apart the gap's line may run, centred: inside the face at any size. */
|
||||
const GAP_WIDTH = 160
|
||||
|
||||
/** The centre reading, drawn when the ring is all the dashboard has. */
|
||||
const CENTRE_VALUE = 72
|
||||
const CENTRE_LABEL = 36
|
||||
const CENTRE_SWATCH = 96
|
||||
|
||||
/** How much wheel a notch is: what one detent of a mouse wheel reports. */
|
||||
const WHEEL_NOTCH = 100
|
||||
|
||||
/** A key that turns the ring, as a notch would. */
|
||||
const ARROWS: Record<string, number> = {
|
||||
ArrowUp: 1,
|
||||
ArrowRight: 1,
|
||||
ArrowDown: -1,
|
||||
ArrowLeft: -1,
|
||||
}
|
||||
|
||||
/** How many strokes the colour ring's hue is drawn in: 2° each. */
|
||||
const HUE_SEGMENTS = 150
|
||||
|
||||
/**
|
||||
* What one type of ring does with its value.
|
||||
*
|
||||
* The value is whatever the type turns — a number, an option's index, a colour
|
||||
* — and the rest says where it sits along the sweep and how a notch or a
|
||||
* pointer moves it. No `notch` means a ring that only shows.
|
||||
*/
|
||||
type Dial = {
|
||||
value: unknown
|
||||
/** Where it sits along the sweep, 0..1; null draws no handle. */
|
||||
at: (value: unknown) => number | null
|
||||
notch?: (value: unknown, by: number) => unknown
|
||||
pick?: (fraction: number, value: unknown) => unknown
|
||||
/** What it publishes, in the shape the same widget sends from the grid. */
|
||||
wire: (value: unknown) => unknown
|
||||
/** Written out; empty for a colour, whose handle is the reading. */
|
||||
reading: (value: unknown) => string
|
||||
unit?: string
|
||||
/** A value arc from the start to the reading: a slider and a gauge. */
|
||||
fill?: boolean
|
||||
handle?: boolean
|
||||
marks?: number[]
|
||||
hue?: boolean
|
||||
}
|
||||
|
||||
function dialOf(widget: WidgetDef, live: unknown): Dial {
|
||||
const cfg = config(widget)
|
||||
const unit = cfg.unit ? text(cfg.unit) : undefined
|
||||
|
||||
if (widget.type === "dropdown") {
|
||||
const options = (cfg.options ?? []) as { label?: string; value?: unknown }[]
|
||||
const last = options.length - 1
|
||||
const at = (index: unknown) =>
|
||||
(index as number) < 0 ? null : last > 0 ? (index as number) / last : 0.5
|
||||
return {
|
||||
value: options.findIndex((option) => text(option.value) === text(live)),
|
||||
at,
|
||||
// No wrap: the first and last options are the ring's two ends.
|
||||
notch:
|
||||
last < 0
|
||||
? undefined
|
||||
: (index, by) => Math.min(last, Math.max(0, (index as number) + by)),
|
||||
pick: (fraction) => Math.round(fraction * last),
|
||||
wire: (index) =>
|
||||
options[index as number]?.value ?? options[index as number]?.label,
|
||||
reading: (index) => {
|
||||
const option = options[index as number]
|
||||
return option ? (option.label ?? text(option.value)) : "—"
|
||||
},
|
||||
handle: true,
|
||||
marks: options.map((_, index) => at(index) ?? 0.5),
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.type === "color") {
|
||||
const form = colorFormatOf(widget)
|
||||
const step = num(cfg.hue_step, 5)
|
||||
// Turning sets the hue alone; saturation and brightness stay what they
|
||||
// were, or full when nothing has been sent yet.
|
||||
const hsv: Triple = decodeColor(live, form) ?? [0, 100, 100]
|
||||
return {
|
||||
value: hsv,
|
||||
at: (value) => (value as Triple)[0] / 360,
|
||||
notch: (value, by) => {
|
||||
const [h, s, v] = value as Triple
|
||||
return [wrap(h + by * step), s, v]
|
||||
},
|
||||
pick: (fraction, value) => {
|
||||
const [, s, v] = value as Triple
|
||||
return [wrap(fraction * 360), s, v]
|
||||
},
|
||||
wire: (value) => encodeColor(value as Triple, form),
|
||||
reading: () => "",
|
||||
handle: true,
|
||||
hue: true,
|
||||
}
|
||||
}
|
||||
|
||||
const min = num(cfg.min, 0)
|
||||
const max = num(cfg.max, 100)
|
||||
|
||||
if (widget.type === "gauge") {
|
||||
const precision = cfg.precision === undefined ? 1 : num(cfg.precision, 1)
|
||||
return {
|
||||
value: typeof live === "number" ? live : null,
|
||||
at: (value) => fractionOf(value as number | null, min, max),
|
||||
wire: (value) => value,
|
||||
reading: (value) => format(value, precision),
|
||||
unit,
|
||||
fill: true,
|
||||
}
|
||||
}
|
||||
|
||||
// A slider. Its value is written to the step's own decimals, as the
|
||||
// slider in the grid labels its scale.
|
||||
const step = num(cfg.step, 1) || 1
|
||||
const digits = (String(step).split(".")[1] ?? "").length
|
||||
const fit = (value: number) =>
|
||||
Number(Math.min(max, Math.max(min, value)).toFixed(digits))
|
||||
return {
|
||||
value: typeof live === "number" ? live : min,
|
||||
at: (value) => fractionOf(value as number, min, max),
|
||||
notch: (value, by) => fit((value as number) + by * step),
|
||||
pick: (fraction) =>
|
||||
fit(min + Math.round((fraction * (max - min)) / step) * step),
|
||||
wire: (value) => value,
|
||||
reading: (value) => format(value, digits),
|
||||
unit,
|
||||
fill: true,
|
||||
handle: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A round dashboard's edge: its ring, and what the ring says.
|
||||
*
|
||||
* Drawn over the whole face rather than in a cell, at canvas pixels — the
|
||||
* geometry is in `ui/core/ring.ts`. Nothing for a dashboard without a ring.
|
||||
* Arranging, a press on the ring picks it as a press on a tile does, and it is
|
||||
* never locked, for the reason the grid is not.
|
||||
*/
|
||||
export function RoundFace({
|
||||
dashboard,
|
||||
editing,
|
||||
selected,
|
||||
onPick,
|
||||
}: {
|
||||
dashboard: Dashboard
|
||||
editing?: boolean
|
||||
selected?: boolean
|
||||
onPick?: (id: string) => void
|
||||
}) {
|
||||
const ring = ringOf(dashboard)
|
||||
if (!ring) return null
|
||||
const face = (
|
||||
<Ring
|
||||
widget={ring}
|
||||
dashboard={dashboard.name}
|
||||
size={canvasOf(dashboard).width}
|
||||
alone={widgetsOf(dashboard).length === 1}
|
||||
editing={editing}
|
||||
selected={selected}
|
||||
onPick={onPick && (() => onPick(ring.id))}
|
||||
/>
|
||||
)
|
||||
return editing ? (
|
||||
face
|
||||
) : (
|
||||
<LockedProvider dashboard={dashboard}>{face}</LockedProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One widget, drawn along the edge of a round face.
|
||||
*
|
||||
* A slider, a selector, a colour or a gauge — each is one value along a range,
|
||||
* which is the one thing a knob's ring can set and a circle's edge can show.
|
||||
* Turned by a drag along it or by the mouse wheel, a notch a step, an option
|
||||
* or a hue step; the value moves at once and one publish follows once the
|
||||
* turning stops (`useRingDraft`).
|
||||
*/
|
||||
function Ring({
|
||||
widget,
|
||||
dashboard,
|
||||
size,
|
||||
alone,
|
||||
editing,
|
||||
selected,
|
||||
onPick,
|
||||
}: {
|
||||
widget: WidgetDef
|
||||
dashboard: string
|
||||
size: number
|
||||
/** Nothing else on the face: the reading is written large in the middle. */
|
||||
alone: boolean
|
||||
editing?: boolean
|
||||
selected?: boolean
|
||||
onPick?: () => void
|
||||
}) {
|
||||
const { value, send, locked } = usePublish(widget, dashboard)
|
||||
// A gauge reads its message rather than publishing to a target.
|
||||
const message = text(config(widget).message)
|
||||
const bound = useBoundValue(widget.type === "gauge" ? message : undefined)
|
||||
const dial = dialOf(widget, widget.type === "gauge" ? bound?.value : value)
|
||||
const { current, latest, move, commit } = useRingDraft(dial.value, (next) =>
|
||||
send(dial.wire(next)),
|
||||
)
|
||||
const svg = useRef<SVGSVGElement>(null)
|
||||
const face = useRef<HTMLDivElement>(null)
|
||||
const spin = useRef(0)
|
||||
const turns = Boolean(dial.notch) && !locked
|
||||
const at = dial.at(current)
|
||||
const animated = useAnimatedFraction(at ?? 0)
|
||||
const shown = useTransform(animated, (fraction) => (fraction > 0 ? 1 : 0))
|
||||
|
||||
const turn = (by: number) => {
|
||||
if (turns && dial.notch) move(dial.notch(latest(), by), true)
|
||||
}
|
||||
|
||||
// On the whole face rather than the ring: a knob turns wherever it is held.
|
||||
// Up — away from you — is clockwise, which is up the scale.
|
||||
const onWheel = useRef<(event: WheelEvent) => void>(() => {})
|
||||
onWheel.current = (event) => {
|
||||
spin.current += event.deltaMode === 1 ? event.deltaY * 33 : event.deltaY
|
||||
if (Math.abs(spin.current) < WHEEL_NOTCH / 2) return
|
||||
const notches = Math.max(
|
||||
1,
|
||||
Math.round(Math.abs(spin.current) / WHEEL_NOTCH),
|
||||
)
|
||||
turn(spin.current < 0 ? notches : -notches)
|
||||
spin.current = 0
|
||||
}
|
||||
useEffect(() => {
|
||||
const canvas = face.current?.parentElement
|
||||
const listener = (event: WheelEvent) => onWheel.current(event)
|
||||
canvas?.addEventListener("wheel", listener, { passive: true })
|
||||
return () => canvas?.removeEventListener("wheel", listener)
|
||||
}, [])
|
||||
|
||||
const aim = (event: React.PointerEvent) => {
|
||||
const box = svg.current?.getBoundingClientRect()
|
||||
if (!box || !dial.pick) return
|
||||
move(
|
||||
dial.pick(
|
||||
fractionAt(
|
||||
event.clientX - (box.left + box.width / 2),
|
||||
event.clientY - (box.top + box.height / 2),
|
||||
),
|
||||
latest(),
|
||||
),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
const title = showTitle(widget) ? widget.title || "" : ""
|
||||
const reading = dial.reading(current)
|
||||
const handle = dial.handle && at !== null ? ringPoint(size, at) : null
|
||||
const whole = ringArc(size, 0, 1)
|
||||
const name = widget.title || text(config(widget).target, widget.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={face}
|
||||
className="pointer-events-none absolute inset-0"
|
||||
data-testid="ring"
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noSvgWithoutTitle: named by `aria-label` in the role spread below, which the rule cannot see. */}
|
||||
<svg
|
||||
ref={svg}
|
||||
width={size}
|
||||
height={size}
|
||||
className="dui-ring absolute inset-0"
|
||||
// A control that turns, or a reading that only shows.
|
||||
{...(dial.notch
|
||||
? {
|
||||
role: "slider",
|
||||
"aria-label": name,
|
||||
"aria-valuetext": `${reading}${dial.unit ?? ""}` || undefined,
|
||||
"aria-valuenow":
|
||||
typeof current === "number" ? current : undefined,
|
||||
"aria-disabled": locked || undefined,
|
||||
}
|
||||
: {
|
||||
role: "img",
|
||||
"aria-label": `${name}: ${reading}${dial.unit ?? ""}`,
|
||||
})}
|
||||
tabIndex={turns ? 0 : undefined}
|
||||
onKeyDown={(event) => {
|
||||
const by = ARROWS[event.key]
|
||||
if (!by || !turns) return
|
||||
event.preventDefault()
|
||||
turn(by)
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d={whole}
|
||||
fill="none"
|
||||
strokeWidth={RING_STROKE + 8}
|
||||
strokeLinecap="round"
|
||||
className="dui-ring-focus"
|
||||
/>
|
||||
<g className={cn("dui-ring-arc", locked && "opacity-45")}>
|
||||
{dial.hue ? (
|
||||
<HueTrack size={size} />
|
||||
) : (
|
||||
<path
|
||||
d={whole}
|
||||
fill="none"
|
||||
strokeWidth={RING_STROKE}
|
||||
strokeLinecap="round"
|
||||
className="dui-ring-track"
|
||||
/>
|
||||
)}
|
||||
{/* A slider's reading follows the hand, so it is drawn where it
|
||||
is. A gauge eases to its reading, revealed rather than re-pathed
|
||||
as the gauge tile's arc is, and transparent at the minimum where
|
||||
its round caps would still paint a dot. */}
|
||||
{dial.fill && dial.notch && at ? (
|
||||
<path
|
||||
d={ringArc(size, 0, at)}
|
||||
fill="none"
|
||||
strokeWidth={RING_STROKE}
|
||||
strokeLinecap="round"
|
||||
className="dui-ring-fill"
|
||||
/>
|
||||
) : dial.fill && !dial.notch ? (
|
||||
<motion.path
|
||||
d={whole}
|
||||
fill="none"
|
||||
strokeWidth={RING_STROKE}
|
||||
strokeLinecap="round"
|
||||
className="dui-ring-fill"
|
||||
style={{ pathLength: animated, opacity: shown }}
|
||||
/>
|
||||
) : null}
|
||||
{dial.marks?.map((mark, index) => {
|
||||
const point = ringPoint(size, mark)
|
||||
return (
|
||||
<circle
|
||||
// Two options may share a label; position is the identity.
|
||||
key={`mark-${index}`}
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
r={RING_MARK / 2}
|
||||
className="dui-ring-mark"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{handle ? (
|
||||
<circle
|
||||
cx={handle.x}
|
||||
cy={handle.y}
|
||||
r={RING_HANDLE / 2}
|
||||
className={cn("dui-ring-handle", dial.hue && "dui-ring-swatch")}
|
||||
style={dial.hue ? { fill: cssOf(current as Triple) } : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</g>
|
||||
{selected ? (
|
||||
<path
|
||||
d={whole}
|
||||
fill="none"
|
||||
strokeWidth={RING_INSET}
|
||||
className="stroke-primary opacity-15"
|
||||
/>
|
||||
) : null}
|
||||
{turns || editing ? (
|
||||
<path
|
||||
d={whole}
|
||||
fill="none"
|
||||
stroke="transparent"
|
||||
strokeWidth={RING_INSET}
|
||||
className="dui-ring-hit"
|
||||
data-testid="ring-hit"
|
||||
onPointerDown={(event) => {
|
||||
if (editing) {
|
||||
onPick?.()
|
||||
return
|
||||
}
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
aim(event)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
aim(event)
|
||||
}
|
||||
}}
|
||||
// Every way a drag can end publishes, as the grid's slider does:
|
||||
// a browser taking the gesture over ends it in a cancel.
|
||||
onPointerUp={commit}
|
||||
onPointerCancel={commit}
|
||||
onLostPointerCapture={commit}
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
|
||||
{/* The gap at the bottom: the title, and the reading unless it is
|
||||
already written large in the middle. */}
|
||||
<div
|
||||
className="absolute flex items-center justify-center gap-1.5 whitespace-nowrap"
|
||||
style={{
|
||||
left: size / 2 - GAP_WIDTH / 2,
|
||||
width: GAP_WIDTH,
|
||||
top: size - RING_TITLE_RISE - RING_TITLE_SIZE,
|
||||
height: RING_TITLE_SIZE * 2,
|
||||
fontSize: RING_TITLE_SIZE,
|
||||
}}
|
||||
>
|
||||
{title ? (
|
||||
<span className="min-w-0 truncate text-muted-foreground">
|
||||
{title}
|
||||
</span>
|
||||
) : null}
|
||||
{!alone && reading ? (
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{reading}
|
||||
{dial.unit ?? ""}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{alone ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{dial.hue ? (
|
||||
<span
|
||||
data-testid="ring-centre"
|
||||
className="rounded-full border border-border"
|
||||
style={{
|
||||
width: CENTRE_SWATCH,
|
||||
height: CENTRE_SWATCH,
|
||||
background: cssOf(current as Triple),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
data-testid="ring-centre"
|
||||
className="flex items-baseline gap-1 leading-none tabular-nums"
|
||||
style={{
|
||||
fontSize:
|
||||
widget.type === "dropdown" ? CENTRE_LABEL : CENTRE_VALUE,
|
||||
}}
|
||||
>
|
||||
{reading}
|
||||
{dial.unit ? (
|
||||
<span className="text-[0.4em] text-muted-foreground">
|
||||
{dial.unit}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour ring's track: every hue in order along the sweep, red at both
|
||||
* ends. Drawn as short strokes — an SVG stroke takes no conic gradient — each
|
||||
* a hair longer than its share so no seam shows between two.
|
||||
*/
|
||||
function HueTrack({ size }: { size: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: HUE_SEGMENTS }, (_, index) => {
|
||||
const from = index / HUE_SEGMENTS
|
||||
const to = Math.min(1, (index + 1.5) / HUE_SEGMENTS)
|
||||
const end = index === 0 || index === HUE_SEGMENTS - 1
|
||||
return (
|
||||
<path
|
||||
// A fixed run of strokes; the index is the identity.
|
||||
key={`hue-${index}`}
|
||||
d={ringArc(size, from, to)}
|
||||
fill="none"
|
||||
strokeWidth={RING_STROKE}
|
||||
strokeLinecap={end ? "round" : "butt"}
|
||||
stroke={`hsl(${((index + 0.5) / HUE_SEGMENTS) * 360} 100% 50%)`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -55,6 +55,8 @@ import {
|
||||
canvasOf,
|
||||
columnsOf,
|
||||
type Dashboard,
|
||||
isRound,
|
||||
RING_WIDGETS,
|
||||
} from "./DashboardView"
|
||||
import { ICON_COLORS, ICON_NAMES, ICONS } from "./icons"
|
||||
import { messageCatalogQueryOptions } from "./queries"
|
||||
@@ -489,11 +491,19 @@ function coerce(raw: string): unknown {
|
||||
*/
|
||||
export function WidgetPanel({
|
||||
widget,
|
||||
round,
|
||||
onRing,
|
||||
onRingChange,
|
||||
onChange,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: {
|
||||
widget: WidgetDef | null
|
||||
/** The dashboard is round, so a widget of the right type may go on its ring. */
|
||||
round?: boolean
|
||||
/** This widget is the one drawn on the ring. */
|
||||
onRing?: boolean
|
||||
onRingChange?: (on: boolean) => void
|
||||
onChange: (changes: Partial<WidgetDef>) => void
|
||||
onDelete: () => void
|
||||
onClose: () => void
|
||||
@@ -600,6 +610,20 @@ export function WidgetPanel({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{round && RING_WIDGETS.has(widget.type) ? (
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<FieldLabel help="Drawn along the round face's edge instead of in the grid, and turned by dragging along it or with the wheel. A knob turns it with its ring. One widget per dashboard: this takes it from any other.">
|
||||
On the ring
|
||||
</FieldLabel>
|
||||
<Switch
|
||||
checked={Boolean(onRing)}
|
||||
aria-label="On the ring"
|
||||
data-testid="widget-on-ring"
|
||||
onCheckedChange={(on) => onRingChange?.(on)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{widget.type === "embed" ? (
|
||||
<>
|
||||
<div className="grid gap-1.5">
|
||||
@@ -1416,6 +1440,22 @@ export function WidgetPanel({
|
||||
})
|
||||
}
|
||||
/>
|
||||
{onRing ? (
|
||||
<div className="grid gap-1.5">
|
||||
<FieldLabel help="How far one notch of the ring turns the hue, in degrees. It wraps round from violet to red.">
|
||||
Hue step
|
||||
</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={180}
|
||||
value={str(cfg.hue_step ?? 5)}
|
||||
onChange={(event) =>
|
||||
set({ hue_step: Number(event.target.value) || 5 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1513,6 +1553,12 @@ export function WidgetPanel({
|
||||
onChange={(style) => set({ style })}
|
||||
/>
|
||||
)}
|
||||
{round && !onRing && cfg.style === "segmented" ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Poor fit for round: a row of choices needs the width a circle
|
||||
only has across its middle. The ring holds them better.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1520,9 +1566,10 @@ export function WidgetPanel({
|
||||
)
|
||||
}
|
||||
|
||||
/** Presets are matched on their size, so a custom one simply matches none. */
|
||||
const sizeKey = (size: { width: number; height: number }) =>
|
||||
`${size.width}x${size.height}`
|
||||
/** Presets are matched on their size and shape, so a custom one simply
|
||||
* matches none. */
|
||||
const sizeKey = (size: { width: number; height: number; shape?: string }) =>
|
||||
`${size.width}x${size.height}${size.shape === "round" ? "o" : ""}`
|
||||
|
||||
/**
|
||||
* The optional half of a setting: which message, if any, drives it.
|
||||
@@ -1706,6 +1753,7 @@ export function DashboardPanel({
|
||||
}) {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const canvas = canvasOf(dashboard)
|
||||
const round = isRound(dashboard)
|
||||
const theme = settingOf(dashboard, "theme")
|
||||
const locked = settingOf(dashboard, "locked")
|
||||
const look = settingOf(dashboard, "look")
|
||||
@@ -1783,14 +1831,16 @@ export function DashboardPanel({
|
||||
|
||||
<PanelSection
|
||||
title="Canvas"
|
||||
help="The size of the panel this is drawn for, in pixels. Editing and viewing both scale it to fit, so the arrangement looks the same everywhere."
|
||||
help="The size of the panel this is drawn for, in pixels. Editing and viewing both scale it to fit, so the arrangement looks the same everywhere. Round draws it in the circle the canvas holds, for a round screen such as a knob, which makes the canvas square."
|
||||
>
|
||||
<Select
|
||||
value={
|
||||
CANVAS_PRESETS.some(
|
||||
(preset) => sizeKey(preset) === sizeKey(canvas),
|
||||
(preset) =>
|
||||
sizeKey(preset) ===
|
||||
sizeKey({ ...canvas, shape: dashboard.shape }),
|
||||
)
|
||||
? sizeKey(canvas)
|
||||
? sizeKey({ ...canvas, shape: dashboard.shape })
|
||||
: ""
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
@@ -1801,6 +1851,10 @@ export function DashboardPanel({
|
||||
onChange({
|
||||
canvas_width: preset.width,
|
||||
canvas_height: preset.height,
|
||||
// A preset is a screen, and a screen has a shape. Only a
|
||||
// round one carries a ring.
|
||||
shape: preset.shape ?? "rect",
|
||||
...(preset.shape ? {} : { ring: "" }),
|
||||
})
|
||||
}}
|
||||
>
|
||||
@@ -1815,21 +1869,44 @@ export function DashboardPanel({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Segmented
|
||||
value={round ? "round" : "rect"}
|
||||
options={[
|
||||
["rect", "Rectangle"],
|
||||
["round", "Round"],
|
||||
]}
|
||||
label="Canvas shape"
|
||||
testId="dashboard-shape"
|
||||
onChange={(shape) =>
|
||||
onChange(
|
||||
shape === "round"
|
||||
? { shape: "round", canvas_height: canvas.width }
|
||||
: { shape: "rect", ring: "" },
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
aria-label="Canvas width"
|
||||
aria-label={round ? "Canvas diameter" : "Canvas width"}
|
||||
data-testid="canvas-width"
|
||||
value={canvas.width}
|
||||
onChange={(event) =>
|
||||
onChange({ canvas_width: Number(event.target.value) || 0 })
|
||||
}
|
||||
onChange={(event) => {
|
||||
const width = Number(event.target.value) || 0
|
||||
onChange(
|
||||
round
|
||||
? { canvas_width: width, canvas_height: width }
|
||||
: { canvas_width: width },
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
aria-label="Canvas height"
|
||||
data-testid="canvas-height"
|
||||
value={canvas.height}
|
||||
// A circle's height is its width.
|
||||
disabled={round}
|
||||
onChange={(event) =>
|
||||
onChange({ canvas_height: Number(event.target.value) || 0 })
|
||||
}
|
||||
|
||||
@@ -3,23 +3,34 @@
|
||||
*
|
||||
* A 240° arc starting at the lower left, which is the shape a gauge is
|
||||
* expected to have. Both looks draw the same sweep; only the stroke differs.
|
||||
*
|
||||
* Angles are degrees clockwise from three o'clock. A round dashboard's ring
|
||||
* (`ring.ts`) draws with the same function, on a circle of its own.
|
||||
*/
|
||||
|
||||
export const GAUGE_SWEEP = 240
|
||||
export const GAUGE_START = 150
|
||||
const RADIUS = 42
|
||||
|
||||
const point = (angle: number) => {
|
||||
const point = (angle: number, radius: number, centre: number) => {
|
||||
const radians = (angle * Math.PI) / 180
|
||||
return [50 + RADIUS * Math.cos(radians), 50 + RADIUS * Math.sin(radians)]
|
||||
return [
|
||||
centre + radius * Math.cos(radians),
|
||||
centre + radius * Math.sin(radians),
|
||||
]
|
||||
}
|
||||
|
||||
/** One arc of the dial, as an SVG path in a `0 0 100 78` box. */
|
||||
export function arcPath(from: number, to: number): string {
|
||||
const [x1, y1] = point(from)
|
||||
const [x2, y2] = point(to)
|
||||
/** One arc of the dial, as an SVG path — by default in a `0 0 100 78` box. */
|
||||
export function arcPath(
|
||||
from: number,
|
||||
to: number,
|
||||
radius = RADIUS,
|
||||
centre = 50,
|
||||
): string {
|
||||
const [x1, y1] = point(from, radius, centre)
|
||||
const [x2, y2] = point(to, radius, centre)
|
||||
const large = Math.abs(to - from) > 180 ? 1 : 0
|
||||
return `M ${x1} ${y1} A ${RADIUS} ${RADIUS} 0 ${large} 1 ${x2} ${y2}`
|
||||
return `M ${x1} ${y1} A ${radius} ${radius} 0 ${large} 1 ${x2} ${y2}`
|
||||
}
|
||||
|
||||
/** The whole track, which the reading is drawn over a fraction of. */
|
||||
|
||||
@@ -542,3 +542,88 @@
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* A round dashboard (`data-shape="round"` on the canvas).
|
||||
*
|
||||
* A circle has no corners for a card to sit in, so a tile drops the surface
|
||||
* its look paints and keeps only what it shows: no fill, no border, no shadow,
|
||||
* a smaller muted title and half the padding. A face is read about its
|
||||
* vertical axis, so a title and a single reading are centred on it. Arranging,
|
||||
* a dashed hairline still says where each tile is.
|
||||
*/
|
||||
[data-shape="round"] .dui-frame {
|
||||
border-color: transparent;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Glass's light along the pane's top edge, which has no pane to fall on. */
|
||||
[data-shape="round"] .dui-frame::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
[data-shape="round"] .dui-frame-head,
|
||||
[data-shape="round"] .dui-frame-body > [data-testid="readout"] {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-shape="round"] .dui-frame-title {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
[data-shape="round"][data-arranging] .dui-frame {
|
||||
border: 1px dashed
|
||||
color-mix(in srgb, var(--muted-foreground) 40%, transparent);
|
||||
}
|
||||
|
||||
/*
|
||||
* The ring along a round face's edge (`RingWidget.tsx`). Its geometry is in
|
||||
* `ring.ts`; each look paints the track, the reading and the handle.
|
||||
* Only the band itself takes a pointer, so the tiles inside stay pressable.
|
||||
*/
|
||||
.dui-ring {
|
||||
overflow: visible;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dui-ring-hit {
|
||||
pointer-events: stroke;
|
||||
cursor: pointer;
|
||||
/* The drag is the control, for the reason the slider's input says. */
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.dui-ring[aria-disabled] .dui-ring-hit {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dui-ring-focus {
|
||||
stroke: color-mix(in srgb, var(--ring) 50%, transparent);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dui-ring:focus-visible .dui-ring-focus {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dui-ring-mark {
|
||||
fill: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Page dots in the ring's gap, where a rect dashboard has its rail. */
|
||||
.dui-dot-mark {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted-foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.dui-dot[aria-current="page"] .dui-dot-mark {
|
||||
background: var(--foreground);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* A round dashboard's ring: where it runs, and what turning it does.
|
||||
*
|
||||
* Everything is in canvas pixels on a face whose diameter `size` is the
|
||||
* canvas width, and scales with the canvas as the grid's gap does. Angles are
|
||||
* degrees clockwise from three o'clock — what `arc.ts` and LVGL both use — so
|
||||
* a device drawing the same dashboard natively ports these numbers as they
|
||||
* stand. `app/docs/interface/dashboards.md` lists them for that purpose.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import { arcPath } from "./arc"
|
||||
|
||||
/** The band along the face's edge the ring takes; the grid is inset by it. */
|
||||
export const RING_INSET = 40
|
||||
/** Where the sweep begins: the lower left. */
|
||||
export const RING_START = 120
|
||||
/** Leaves a 60° gap centred at the bottom, which holds the title. */
|
||||
export const RING_SWEEP = 300
|
||||
/** The track's and the reading's stroke. */
|
||||
export const RING_STROKE = 16
|
||||
/** The handle's diameter. */
|
||||
export const RING_HANDLE = 28
|
||||
/** A dropdown's option marks, as a diameter. */
|
||||
export const RING_MARK = 6
|
||||
/** How long the ring waits after the last notch before it publishes. */
|
||||
export const RING_SETTLE_MS = 300
|
||||
/** The gap's line — title, then the reading — centred this far above the
|
||||
* bottom of the face, at this size. */
|
||||
export const RING_TITLE_RISE = 28
|
||||
export const RING_TITLE_SIZE = 13
|
||||
|
||||
/** The ring's centre line, on a face of diameter `size`. */
|
||||
export const ringRadius = (size: number) => size / 2 - RING_INSET / 2
|
||||
|
||||
/** An arc of the ring from `from` to `to`, both fractions of the sweep. */
|
||||
export const ringArc = (size: number, from: number, to: number) =>
|
||||
arcPath(
|
||||
RING_START + from * RING_SWEEP,
|
||||
RING_START + to * RING_SWEEP,
|
||||
ringRadius(size),
|
||||
size / 2,
|
||||
)
|
||||
|
||||
/** A point on the ring's centre line, `at` a fraction along the sweep. */
|
||||
export function ringPoint(size: number, at: number) {
|
||||
const radians = ((RING_START + at * RING_SWEEP) * Math.PI) / 180
|
||||
const radius = ringRadius(size)
|
||||
return {
|
||||
x: size / 2 + radius * Math.cos(radians),
|
||||
y: size / 2 + radius * Math.sin(radians),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How far along the sweep a pointer is, from its offset to the face's centre.
|
||||
*
|
||||
* In the gap it snaps to the nearer end, so a drag carried past either end
|
||||
* holds there rather than jumping across to the other.
|
||||
*/
|
||||
export function fractionAt(dx: number, dy: number): number {
|
||||
const angle = (Math.atan2(dy, dx) * 180) / Math.PI
|
||||
const along = (((angle - RING_START) % 360) + 360) % 360
|
||||
if (along <= RING_SWEEP) return along / RING_SWEEP
|
||||
return along < (360 + RING_SWEEP) / 2 ? 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Turning a ring: the value moves at once, and one publish follows.
|
||||
*
|
||||
* A notch — a wheel, an arrow key, an encoder's detent — moves the draft and
|
||||
* restarts a `RING_SETTLE_MS` wait; a drag moves it until the pointer lets go.
|
||||
* Either way exactly one value is sent, the one the ring came to rest on, so a
|
||||
* fast turn is not a burst of publishes for a lamp to stutter through. Without
|
||||
* a draft the ring follows the engine, through the caller's hold-until-echo.
|
||||
*/
|
||||
export function useRingDraft<T>(value: T, send: (value: T) => void) {
|
||||
const [draft, setDraft] = useState<{ value: T } | null>(null)
|
||||
// Read between renders: notches arrive faster than React draws, and each
|
||||
// one steps from the last rather than from what was last drawn.
|
||||
const pending = useRef<{ value: T } | null>(null)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const sender = useRef(send)
|
||||
sender.current = send
|
||||
|
||||
const commit = () => {
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = null
|
||||
const next = pending.current
|
||||
pending.current = null
|
||||
setDraft(null)
|
||||
if (next) sender.current(next.value)
|
||||
}
|
||||
|
||||
// A turn still settling when the ring goes away is sent rather than lost.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: on unmount only.
|
||||
useEffect(() => commit, [])
|
||||
|
||||
return {
|
||||
current: draft ? draft.value : value,
|
||||
/** The value a further notch steps from. */
|
||||
latest: () => (pending.current ? pending.current.value : value),
|
||||
/** Move the draft; `settle` publishes once nothing has moved it for a while. */
|
||||
move: (next: T, settle: boolean) => {
|
||||
pending.current = { value: next }
|
||||
setDraft({ value: next })
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = settle ? setTimeout(commit, RING_SETTLE_MS) : null
|
||||
},
|
||||
/** Publish the draft now: the pointer let go. */
|
||||
commit,
|
||||
}
|
||||
}
|
||||
@@ -315,3 +315,26 @@
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
|
||||
}
|
||||
|
||||
/* --- a round dashboard's ring -------------------------------------------- */
|
||||
|
||||
[data-look="fluksio"] .dui-ring-track {
|
||||
stroke: var(--muted);
|
||||
}
|
||||
|
||||
[data-look="fluksio"] .dui-ring-fill {
|
||||
stroke: var(--primary);
|
||||
}
|
||||
|
||||
/* The slider's handle: a ring of primary around the page colour. */
|
||||
[data-look="fluksio"] .dui-ring-handle {
|
||||
fill: var(--background);
|
||||
stroke: var(--primary);
|
||||
stroke-width: 2px;
|
||||
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.2));
|
||||
}
|
||||
|
||||
[data-look="fluksio"] .dui-ring-swatch {
|
||||
stroke: var(--card);
|
||||
stroke-width: 3px;
|
||||
}
|
||||
|
||||
@@ -415,3 +415,27 @@
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* --- a round dashboard's ring -------------------------------------------- */
|
||||
|
||||
[data-look="glass"] .dui-ring-track {
|
||||
stroke: var(--gl-track);
|
||||
}
|
||||
|
||||
/* Lit, as the gauge's arc is. */
|
||||
[data-look="glass"] .dui-ring-fill {
|
||||
stroke: var(--primary);
|
||||
filter: drop-shadow(0 0 4px var(--primary));
|
||||
}
|
||||
|
||||
[data-look="glass"] .dui-ring-handle {
|
||||
fill: #fff;
|
||||
stroke: var(--gl-border);
|
||||
stroke-width: 1px;
|
||||
filter: drop-shadow(0 2px 8px rgb(0 0 0 / 0.35));
|
||||
}
|
||||
|
||||
[data-look="glass"] .dui-ring-swatch {
|
||||
stroke: rgb(255 255 255 / 0.85);
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
@@ -319,3 +319,22 @@
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* --- a round dashboard's ring -------------------------------------------- */
|
||||
|
||||
[data-look="material"] .dui-ring-track {
|
||||
stroke: var(--m3-sc);
|
||||
}
|
||||
|
||||
[data-look="material"] .dui-ring-fill {
|
||||
stroke: var(--primary);
|
||||
}
|
||||
|
||||
[data-look="material"] .dui-ring-handle {
|
||||
fill: var(--primary);
|
||||
}
|
||||
|
||||
[data-look="material"] .dui-ring-swatch {
|
||||
stroke: var(--card);
|
||||
stroke-width: 3px;
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ function PanelRoute() {
|
||||
dashboards={dashboards}
|
||||
current={current}
|
||||
horizontal={stacked}
|
||||
dots={dashboard?.shape === "round"}
|
||||
linkFor={(name) => ({
|
||||
to: "/panel/$id",
|
||||
params: { id },
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
Reference in New Issue
Block a user