From 21c7961d49bb76c8fcfae14f72077b1fba4c0bc4 Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Fri, 16 Oct 2026 08:58:42 +0200 Subject: [PATCH] Counter widget: one to three linked counters are the digits of one number, published on one message in the finest counter's unit (hours and minutes send 07:30 as 450, 07:55 + 5 min carries to 08:00); wraps or stops at the coarsest's min/max. The arithmetic is one pure module, ui/core/counter.ts, with counter.check.ts. A tile steps each counter with a press up and down and publishes once 300 ms after the last; a round face turns the selected counter on the ring (wheel, keys, a drag counted in steps), its arc where that counter stands in its turn, and a press on a counter's digits selects it. Default config is the time picker. Editor rows, widgetIssue, server type and dtype, docs --- backend/fluksio/flow/dashboards.py | 17 ++ docs/interface/dashboards.md | 70 +++++++- docs/reference/payload-types.md | 2 +- frontend/src/client/schemas.gen.ts | 12 +- frontend/src/client/types.gen.ts | 12 +- .../src/components/Common/DashboardMosaic.tsx | 1 + .../components/Dashboard/CounterWidget.tsx | 85 +++++++++ .../src/components/Dashboard/RingWidget.tsx | 169 ++++++++++++++++-- .../src/components/Dashboard/RoundFace.tsx | 1 + frontend/src/components/Dashboard/panels.tsx | 137 ++++++++++++++ .../components/Dashboard/ui/core/config.ts | 15 ++ .../src/components/Dashboard/ui/core/core.css | 16 ++ .../Dashboard/ui/core/counter.check.ts | 83 +++++++++ .../components/Dashboard/ui/core/counter.ts | 128 +++++++++++++ .../src/components/Dashboard/ui/core/ring.ts | 6 + frontend/src/components/Dashboard/widgets.tsx | 24 +++ 16 files changed, 745 insertions(+), 33 deletions(-) create mode 100644 frontend/src/components/Dashboard/CounterWidget.tsx create mode 100644 frontend/src/components/Dashboard/ui/core/counter.check.ts create mode 100644 frontend/src/components/Dashboard/ui/core/counter.ts diff --git a/backend/fluksio/flow/dashboards.py b/backend/fluksio/flow/dashboards.py index a1c34ba..45888b8 100644 --- a/backend/fluksio/flow/dashboards.py +++ b/backend/fluksio/flow/dashboards.py @@ -48,6 +48,10 @@ RUN_LINES = 5 #: legible stacked in one tile. BAR_ROWS = 8 +#: How many counters one counter widget links into its number. Mirrored in the +#: client (``ui/core/counter.ts``, ``MAX_COUNTERS``). +COUNTERS = 3 + #: Resolved out here on purpose: the store has a ``list`` method, which #: shadows the builtin for any annotation written inside the class. Bindings = list[dict[str, Any]] @@ -75,6 +79,7 @@ WidgetType = Literal[ "input", "dropdown", "color", + "counter", ] #: Widgets whose only binding is the message they publish. A player is not one: @@ -88,6 +93,7 @@ INPUT_WIDGETS = { "input", "dropdown", "color", + "counter", } #: What a colour widget puts on the wire, by the format it was configured for. @@ -108,6 +114,8 @@ WIDGET_DTYPES: dict[str, set[str]] = { # answer and a `record` request instead, checked separately below. "chart": {"float", "int"}, "slider": {"float", "int"}, + # One number, the digits of which its counters are. + "counter": {"float", "int"}, "switch": {"bool"}, "agenda": {"list"}, "notification": {"record"}, @@ -229,6 +237,12 @@ class WidgetDef(BaseModel): 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. + + A counter publishes one number to its target, of which its ``counters`` + are the digits: up to three ``{label, step, size, min, max}``, coarsest + first, so hours and minutes publish 07:30 as 450. ``wrap`` says whether it + wraps round at its ends; ``docs/interface/dashboards.md`` has the + arithmetic. """ id: str @@ -397,6 +411,9 @@ class WidgetDef(BaseModel): if self.type == "bar" and len(self.bar_rows) > BAR_ROWS: raise ValueError(f"a bar draws at most {BAR_ROWS} readings") + if self.type == "counter" and len(self.config.get("counters") or []) > COUNTERS: + raise ValueError(f"a counter links at most {COUNTERS} counters") + if self.type == "color": # The row above allows both shapes a colour travels as; the format # is what decides which of them this widget actually sends. An diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 9948097..b2039a8 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -59,6 +59,7 @@ still names the widget for accessibility and published values. | **Input** | text or number | free entry | | **Selector** | selected value | menu or row of choices | | **Colour** | list or string | HSV, RGB or hex | +| **Counter** | `int`, `float` | one number set by up to three linked counters, such as hours and minutes; see [A counter](#a-counter) | Colour defaults to `[h, s, v]`: hue 0–360°, saturation and brightness 0–100%. **Sends** can select RGB (`[r, g, b]`, 0–255) or hex (`"#rrggbb"`). @@ -125,6 +126,50 @@ dot that shows on the handle, so a reading at the setpoint is still visible. | Order | track, dots, fill, the dots again over the fill, handle, the reading again over the handle | | Locked | in a tile, the dots at 0.45 opacity, as the handle; on the ring, the whole ring at 0.45 | +### A counter + +A Counter sets one whole number with up to three linked counters. They are the +digits of that number, counted in the unit of the finest, and the widget +publishes the number on its one message: hours and minutes publish 07:30 as +450, and 07:55 plus five minutes is 08:00. + +| Field | Meaning | +|---|---| +| `counters` | 1 to 3 `{label, step, size, min, max}`, coarsest first. Absent or empty reads as the time below | +| `label` | written under the counter's digits | +| `step` | a press or a notch, in the counter's own unit: 1 hour moves the number by 60. Default 1, at least 1 | +| `size` | how many of it make one of the next coarser counter, 60 for minutes. Every counter but the coarsest needs one | +| `min`, `max` | the range, on the coarsest only and in its unit. `min` defaults to 0; no `max` is no upper bound | +| `wrap` | at either end, go round to the other (the default) or stop there | + +A new counter is a time of day: `[{label: "h", step: 1, min: 0, max: 23}, +{label: "min", step: 5, size: 60}]`, wrapping. + +For counters 0 to n − 1, coarsest first, and the number v (`ui/core/counter.ts`, +which the firmware ports as it stands): + +| Quantity | Formula | +|---|---| +| Weight wᵢ | the finest 1; each coarser one wᵢ = wᵢ₊₁ × sizeᵢ₊₁ | +| Digit dᵢ | d₀ = ⌊v / w₀⌋; dᵢ = ⌊v / wᵢ⌋ mod sizeᵢ, a remainder that is never negative | +| A step | counter i by k steps (k < 0 down): v + k × stepᵢ × wᵢ, so carry and borrow follow | +| Range | lo = min₀ × w₀. With a `max`, period = (max₀ − min₀ + 1) × w₀ and top = lo + ⌊(period − 1) / s⌋ × s, s the finest counter's step: 0–23 in 5-minute steps tops out at 1435 (23:55), one counter of step 5 up to 100 at 100 | +| Wrap | v = lo + ((v − lo) mod period): 23:55 + 5 min is 00:00, 00:00 − 1 h is 23:00 | +| Stop | v held to lo … top, as a slider is | +| No `max` | no top and nothing to wrap at: v is only held to lo or above | + +Written, the digits are joined by `:`, each counter's label under its own. +Every digit but the coarsest is zero-padded to the width of size − 1 (05); the +coarsest to the width of `max` when it has one (07), and a negative one keeps +its sign in front of the padding. The number shown is the message's, held as +sent until it comes back, as a slider's is, and lo until one arrives. + +In a tile each counter is a column: a press up, its digits, a press down and +its label, at the control sizes, so touch-friendly where the panel is. A press +moves the number at once and one publish follows 300 ms after the last press, +as a ring's does; holding a press does not repeat it. On a round face see +[Round dashboards](#round-dashboards). + ## Charts Choose the chart's data source: @@ -243,13 +288,22 @@ 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**. -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, Selector, Colour, Counter 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), one hue step (`hue_step`, default 5°, wrapping) or one +step of the counter selected. 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 Counter's ring turns one of its counters, with carry: the finest, until a +press on another's digits selects that one instead. The selection holds while +the face is shown and is the finest again once the face is left. Dragging +along its ring counts notches rather than picking a place: one per step's +share of the sweep, 300° × step / its turn (size, or max − min + 1 for the +coarsest), so the arc stays under the finger, and one per 15° for a coarsest +without `max`. The angle is counted from the last move the shorter way round, +so a drag carries on through the gap. The face has no up or down presses. A Slider's reading (**Beside it**) is written large in the middle, under its label; the slider's own title and value move to the line in the ring's gap. @@ -295,6 +349,7 @@ look's muted text colour. | Selector | a value arc from the start to the selected option (none at the first), a dot 6 across 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 | +| Counter | the digits, joined by `:`, sized as a centre reading of the whole line (07:30 is five characters: 72), each counter's label under its digits at 17, muted, 4 apart; the block centred at (c, c). With two or more counters, the one the ring turns is in `--primary`, digits and label, and pressing a counter's digits selects it. A value arc from the start to where the selected counter stands in its turn: dᵢ / sizeᵢ, as a minute hand stands, the coarsest (d₀ − min) / (max − min + 1); no arc for a coarsest without `max`. No handle and no dots | | Gap line | centred at y = S − 28, 15, at most 140 wide, clear of the ring's end caps | | Value | the reading as the centre reading, to its precision; a word shrinks like any other | | Bar | an arc per row, the first outermost, each over the ring's sweep (start 120°, 300°) with round caps: a track in the look's track paint and the row's value arc in its data colour, on its own scale (none at the minimum). n rows share a band 64 thick inward from c − 12, 3 apart: stroke s = min(16, (64 − 3(n − 1)) / n), centre-line radius rᵢ = c − 12 − s/2 − i(s + 3), so up to three rows are 16 thick and the first is on the ring (c − 20). The middle is the legend, a line per row in order: a dot 8 across in the row's colour, 8, the label (muted, cut with an ellipsis), 8, the value and unit to the row's precision (— for none). Lines of 22 at 17 for up to 5 rows, 18 at 15 for 6–7, 16 at 13 for 8; the block centred at (c, c), as wide as its widest line but at most 240 and at most the width of the disc inside the innermost arc (radius c − 12 − n·s − 3(n − 1)) at the block's top and bottom, less 16. The title is at most that disc's width at the title's line (y = c − 89), less 8 | @@ -342,6 +397,7 @@ dashboard's look's. | Ring value set by a notch, a key or the engine | the value arc's end, the handle (slider, selector, colour) | from where it is drawn to the new fraction of the sweep | spring | each notch retargets from where it is, at the speed it has | | Ring value set by a drag | the same | follows the pointer exactly | none | — | | Hue across red | the colour handle | a change of more than half the sweep jumps to the other end | none | — | +| Counter across its turn | the value arc | a change of more than half the sweep (a carry, a wrap, another counter selected) jumps | none | — | | Slider's reading on the ring, gauge on the ring | the reading's dot; the value arc | from where it is drawn to the new fraction; a dot's colour follows the fill and the handle as they pass | spring | retargets | | Numbers on the ring | centre reading, gap line | switch at once, with the notch | none | — | | Value face, Value and Gauge tiles | the number | counts to the new value, written to its precision | spring | retargets | diff --git a/docs/reference/payload-types.md b/docs/reference/payload-types.md index 0359822..ecca900 100644 --- a/docs/reference/payload-types.md +++ b/docs/reference/payload-types.md @@ -129,7 +129,7 @@ literal text in run parameters. | Widget | Accepts | |---|---| -| Gauge, live Chart, Slider, Bar | `float`, `int` | +| Gauge, live Chart, Slider, Bar, Counter | `float`, `int` | | Query Chart | `series` response, `record` request | | Switch | `bool` | | Agenda, Forecast | `list` | diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 222746c..f8f54a1 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -4642,7 +4642,7 @@ export const WidgetDefSchema = { }, type: { type: 'string', - enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'media', 'player', 'embed', 'button', 'buttons', 'switch', 'slider', 'input', 'dropdown', 'color'], + enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'media', 'player', 'embed', 'button', 'buttons', 'switch', 'slider', 'input', 'dropdown', 'color', 'counter'], title: 'Type' }, title: { @@ -4694,7 +4694,13 @@ The first two are a \`\`list\`\` message, the third a \`\`str\`\`, which is what 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.` +held to the same numbers its target is. + +A counter publishes one number to its target, of which its \`\`counters\`\` +are the digits: up to three \`\`{label, step, size, min, max}\`\`, coarsest +first, so hours and minutes publish 07:30 as 450. \`\`wrap\`\` says whether it +wraps round at its ends; \`\`docs/interface/dashboards.md\`\` has the +arithmetic.` } as const; export const WidgetFootprintSchema = { @@ -4721,7 +4727,7 @@ export const WidgetFootprintSchema = { }, type: { type: 'string', - enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'media', 'player', 'embed', 'button', 'buttons', 'switch', 'slider', 'input', 'dropdown', 'color'], + enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'bar', 'icon', 'forecast', 'clock', 'media', 'player', 'embed', 'button', 'buttons', 'switch', 'slider', 'input', 'dropdown', 'color', 'counter'], title: 'Type' } }, diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 4bde084..4beda3a 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -1522,10 +1522,16 @@ export type WebPushKey = { * 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. + * + * A counter publishes one number to its target, of which its ``counters`` + * are the digits: up to three ``{label, step, size, min, max}``, coarsest + * first, so hours and minutes publish 07:30 as 450. ``wrap`` says whether it + * wraps round at its ends; ``docs/interface/dashboards.md`` has the + * arithmetic. */ export type WidgetDef = { id: string; - type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'buttons' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color'; + type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'buttons' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color' | 'counter'; title?: string; layout?: { [key: string]: Placement; @@ -1535,7 +1541,7 @@ export type WidgetDef = { }; }; -export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'buttons' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color'; +export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'buttons' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color' | 'counter'; /** * A widget reduced to the shape it draws: what it is, and where it sits. @@ -1550,7 +1556,7 @@ export type WidgetFootprint = { y?: number; w?: number; h?: number; - type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'buttons' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color'; + type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'bar' | 'icon' | 'forecast' | 'clock' | 'media' | 'player' | 'embed' | 'button' | 'buttons' | 'switch' | 'slider' | 'input' | 'dropdown' | 'color' | 'counter'; }; export type WorkerInfo = { diff --git a/frontend/src/components/Common/DashboardMosaic.tsx b/frontend/src/components/Common/DashboardMosaic.tsx index cd623eb..c3bd5bd 100644 --- a/frontend/src/components/Common/DashboardMosaic.tsx +++ b/frontend/src/components/Common/DashboardMosaic.tsx @@ -17,6 +17,7 @@ const INPUT = new Set([ "input", "dropdown", "color", + "counter", ]) const shade = (type: string) => diff --git a/frontend/src/components/Dashboard/CounterWidget.tsx b/frontend/src/components/Dashboard/CounterWidget.tsx new file mode 100644 index 0000000..1b9e677 --- /dev/null +++ b/frontend/src/components/Dashboard/CounterWidget.tsx @@ -0,0 +1,85 @@ +import { ChevronDown, ChevronUp } from "lucide-react" +import { Fragment } from "react" + +import { usePublish } from "./publish" +import { useUi } from "./ui" +import { countersOf, wrapsOf } from "./ui/core/config" +import { rangeOf, stepCounter, writtenOf } from "./ui/core/counter" +import { useRingDraft } from "./ui/core/ring" +import type { WidgetProps } from "./widgets" + +/** + * One number set by its digits: hours and minutes, say, published as minutes. + * + * A column per counter, a press up over its digits and one down under them, + * its label beneath. A press moves the value at once, carrying into the + * counter beside it, and one publish follows once the presses stop, as a + * round face's ring does (`useRingDraft`). + */ +export function CounterWidget({ widget, dashboard }: WidgetProps) { + const { Button } = useUi() + const { target, value, send, pulse, locked } = usePublish(widget, dashboard) + const counters = countersOf(widget) + const { current, latest, move } = useRingDraft( + typeof value === "number" ? value : rangeOf(counters).lo, + send, + ) + if (!target) return

Pick a message.

+ + const written = writtenOf(counters, current) + const press = (index: number, by: number) => + move(stepCounter(counters, wrapsOf(widget), latest(), index, by), true) + return ( +
+ {pulse} + {counters.map((counter, index) => { + const column = 2 * index + 1 + const name = counter.label || `Counter ${index + 1}` + return ( + // Two counters may share a label; position is the identity. + + {index > 0 ? ( + + : + + ) : null} +
+ +
+ + {written[index]} + +
+ +
+ + {counter.label} + +
+ ) + })} +
+ ) +} diff --git a/frontend/src/components/Dashboard/RingWidget.tsx b/frontend/src/components/Dashboard/RingWidget.tsx index 524140c..5a2d1f7 100644 --- a/frontend/src/components/Dashboard/RingWidget.tsx +++ b/frontend/src/components/Dashboard/RingWidget.tsx @@ -1,6 +1,6 @@ import { Lock } from "lucide-react" import { type MotionValue, motion, useTransform } from "motion/react" -import { useEffect, useId, useRef } from "react" +import { Fragment, useEffect, useId, useRef, useState } from "react" import type { WidgetDef } from "@/client" import { cn } from "@/lib/utils" @@ -17,15 +17,26 @@ import { } from "./ui/core/color" import { config, + countersOf, format, fractionOf, num, showTitle, text, + wrapsOf, } from "./ui/core/config" import { TESTID } from "./ui/core/contract" import { tickIntervals } from "./ui/core/controls" import { + type Counter, + cycleAt, + cycleOf, + rangeOf, + stepCounter, + writtenOf, +} from "./ui/core/counter" +import { + CENTRE_CAPTION, CENTRE_LABEL, CENTRE_SWATCH, CENTRE_VALUE, @@ -34,11 +45,13 @@ import { GAP_RISE, GAP_SIZE, GAP_WIDTH, + RING_DRAG_STEP, RING_HANDLE, RING_INSET, RING_MARK, RING_READING, RING_STROKE, + RING_SWEEP, ringArc, ringPoint, TITLE_RISE, @@ -76,6 +89,9 @@ type Dial = { at: (value: unknown) => number | null notch?: (value: unknown, by: number) => unknown pick?: (fraction: number, value: unknown) => unknown + /** A drag that counts notches rather than picking a place: one per this + * many degrees along the ring, either way. */ + drag?: number /** 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. */ @@ -89,9 +105,13 @@ type Dial = { * slider's ticks. */ stops?: number[] hue?: boolean + /** Where it stands in a turn rather than along a range, so a move across + * the seam jumps, as a hue does. */ + wraps?: boolean } -function dialOf(widget: WidgetDef, live: unknown): Dial { +/** `selected` is the counter a counter's ring turns. */ +function dialOf(widget: WidgetDef, live: unknown, selected: number): Dial { const cfg = config(widget) const unit = cfg.unit ? text(cfg.unit) : undefined @@ -145,6 +165,26 @@ function dialOf(widget: WidgetDef, live: unknown): Dial { } } + if (widget.type === "counter") { + // The selected counter, stepped with carry; its arc is where it stands + // in its turn, as a minute hand stands in the hour. + const counters = countersOf(widget) + const wrap = wrapsOf(widget) + const cycle = cycleOf(counters, selected) + const step = counters[selected].step ?? 1 + return { + value: typeof live === "number" ? live : rangeOf(counters).lo, + at: (value) => cycleAt(counters, value as number, selected), + notch: (value, by) => + stepCounter(counters, wrap, value as number, selected, by), + drag: cycle === null ? RING_DRAG_STEP : (RING_SWEEP * step) / cycle, + wire: (value) => value, + reading: (value) => writtenOf(counters, value as number).join(":"), + fill: true, + wraps: true, + } + } + const min = num(cfg.min, 0) const max = num(cfg.max, 100) @@ -260,7 +300,7 @@ export function CentreReading({ /** * 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, + * A slider, a selector, a colour, a gauge or a counter — each is one value, * 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 @@ -297,7 +337,16 @@ export function Ring({ 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) + // The counter the ring turns: the finest until another is pressed, and + // again once the face is left. + const counters = widget.type === "counter" ? countersOf(widget) : [] + const [chosen, choose] = useState(null) + const selected = Math.min(chosen ?? Infinity, counters.length - 1) + const dial = dialOf( + widget, + widget.type === "gauge" ? bound?.value : value, + selected, + ) const { current, held, latest, move, commit } = useRingDraft( dial.value, (next) => send(dial.wire(next)), @@ -305,9 +354,12 @@ export function Ring({ const svg = useRef(null) const face = useRef(null) const spin = useRef(0) + // Where a counting drag last was, and how far it has gone since its last + // notch, in degrees. + const dragged = useRef({ angle: 0, by: 0 }) const turns = Boolean(dial.notch) && !locked const at = dial.at(current) - const along = useEased(at ?? 0, held, Boolean(dial.hue)) + const along = useEased(at ?? 0, held, Boolean(dial.hue || dial.wraps)) // At the minimum the value arc is a zero-length dash whose round caps would // still paint a dot, so it is not drawn there. const shown = useTransform(along, (fraction) => (fraction > 0 ? 1 : 0)) @@ -338,19 +390,25 @@ export function Ring({ return () => canvas?.removeEventListener("wheel", listener) }, []) - const aim = (event: React.PointerEvent) => { + const aim = (event: React.PointerEvent, start = false) => { 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, - ) + if (!box) return + const dx = event.clientX - (box.left + box.width / 2) + const dy = event.clientY - (box.top + box.height / 2) + if (dial.drag && dial.notch) { + // Counted as a knob's ring counts its detents, the shorter way round + // from the last move, so a drag carries on through the gap. + const angle = (Math.atan2(dy, dx) * 180) / Math.PI + const last = dragged.current + last.by = start ? 0 : last.by + ((angle - last.angle + 540) % 360) - 180 + last.angle = angle + const steps = Math.trunc(last.by / dial.drag) + if (!steps) return + last.by -= steps * dial.drag + move(dial.notch(latest(), steps), false) + return + } + if (dial.pick) move(dial.pick(fractionAt(dx, dy), latest()), false) } const title = showTitle(widget) ? widget.title || "" : "" @@ -495,7 +553,7 @@ export function Ring({ return } event.currentTarget.setPointerCapture(event.pointerId) - aim(event) + aim(event, true) }} onPointerMove={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) { @@ -554,7 +612,15 @@ export function Ring({ )} - {dial.hue ? ( + {widget.type === "counter" ? ( + 1 && turns ? choose : undefined} + /> + ) : dial.hue ? (
void +}) { + const written = writtenOf(counters, value) + const fontSize = fitSize(written.join(":"), CENTRE_VALUE) + return ( +
+
+ {counters.map((counter, index) => { + const marked = onSelect && index === selected + return ( + // Two counters may share a label; position is the identity. + + {index > 0 ? ( + + : + + ) : null} + + + ) + })} +
+
+ ) +} + /** * The ring's dots: its stops, and what a slider reads beside its value. * diff --git a/frontend/src/components/Dashboard/RoundFace.tsx b/frontend/src/components/Dashboard/RoundFace.tsx index 7289b3b..5241459 100644 --- a/frontend/src/components/Dashboard/RoundFace.tsx +++ b/frontend/src/components/Dashboard/RoundFace.tsx @@ -87,6 +87,7 @@ export const RING_TYPES = new Set([ "dropdown", "color", "gauge", + "counter", ]) /** diff --git a/frontend/src/components/Dashboard/panels.tsx b/frontend/src/components/Dashboard/panels.tsx index 871dcfd..7f26b65 100644 --- a/frontend/src/components/Dashboard/panels.tsx +++ b/frontend/src/components/Dashboard/panels.tsx @@ -71,10 +71,13 @@ import { import { type BarRow, type ButtonEntry, + countersOf, MAX_ROWS, rowsOf, showTitle, + wrapsOf, } from "./ui/core/config" +import { type Counter, MAX_COUNTERS } from "./ui/core/counter" import { parsePalette, roleLabel } from "./ui/core/theme" import { acceptsDtype, @@ -546,6 +549,15 @@ export function WidgetPanel({ const written = (cfg.buttons ?? []) as ButtonEntry[] const buttons: ButtonEntry[] = written.length ? written : [{}] const setButtons = (next: ButtonEntry[]) => set({ buttons: next }) + // A counter's counters, coarsest first. Position is the identity, and the + // meaning: the first carries the range, the others a size. + const counters = countersOf(widget) + const setCounter = (index: number, changes: Partial) => + set({ + counters: counters.map((other, at) => + at === index ? { ...other, ...changes } : other, + ), + }) const querying = cfg.source === "query" const pinnedRuns = cfg.source === "runs" // What this chart would refresh at with nothing configured. The viewer can @@ -1325,6 +1337,131 @@ export function WidgetPanel({
) : null} + {widget.type === "counter" ? ( +
+ Counters + + Coarsest first, each a label and a step, then the range on the + first and a size on the others: how many of it make one of the + counter before it. Together they are one number in the last + one's unit, so hours and minutes send 07:30 as 450. + + {counters.map((counter, index) => ( +
+ + setCounter(index, { label: event.target.value }) + } + /> + + setCounter(index, { + step: numberOrNone(event.target.value), + }) + } + /> + {index === 0 ? ( + <> + + setCounter(index, { + min: numberOrNone(event.target.value), + }) + } + /> + + setCounter(index, { + max: numberOrNone(event.target.value), + }) + } + /> + + ) : ( + + setCounter(index, { + size: numberOrNone(event.target.value), + }) + } + /> + )} + {counters.length > 1 ? ( + + ) : null} +
+ ))} + {counters.length < MAX_COUNTERS ? ( + + ) : null} +
+ + Wrap round + + set({ wrap })} + /> +
+
+ ) : null} + {widget.type === "icon" ? (
Mapping diff --git a/frontend/src/components/Dashboard/ui/core/config.ts b/frontend/src/components/Dashboard/ui/core/config.ts index 5d2197a..53d8039 100644 --- a/frontend/src/components/Dashboard/ui/core/config.ts +++ b/frontend/src/components/Dashboard/ui/core/config.ts @@ -7,6 +7,7 @@ * ends the duplication — nothing in `ui/` may import a widget. */ import type { WidgetDef } from "@/client" +import { type Counter, DEFAULT_COUNTERS, MAX_COUNTERS } from "./counter" export const config = (widget: WidgetDef): Record => (widget.config ?? {}) as Record @@ -149,3 +150,17 @@ export function rowsOf(widget: WidgetDef): BarRow[] { : [] return [...outer, ...inner].slice(0, MAX_ROWS) } + +/** A counter widget's counters, coarsest first: a time of day when it stores + * none (`DEFAULT_COUNTERS`). */ +export function countersOf(widget: WidgetDef): Counter[] { + const stored = config(widget).counters + return Array.isArray(stored) && stored.length + ? (stored as Counter[]).slice(0, MAX_COUNTERS) + : DEFAULT_COUNTERS +} + +/** Whether a counter wraps round at its ends rather than stopping; absent is + * yes, as a time of day does. */ +export const wrapsOf = (widget: WidgetDef): boolean => + config(widget).wrap !== false diff --git a/frontend/src/components/Dashboard/ui/core/core.css b/frontend/src/components/Dashboard/ui/core/core.css index e7d01f4..6c08cf6 100644 --- a/frontend/src/components/Dashboard/ui/core/core.css +++ b/frontend/src/components/Dashboard/ui/core/core.css @@ -185,6 +185,22 @@ height: auto; } +/* A counter: a column per counter, its press up, its digits, its press down + and its label, with a colon between two columns' digits. */ +.dui-counter { + display: grid; + justify-content: center; + justify-items: center; + align-items: center; + gap: 0.25rem 0.5rem; +} + +.dui-counter-digits { + font-size: clamp(1.25rem, min(22cqh, 12cqw), 4rem); + line-height: 1; + font-variant-numeric: tabular-nums; +} + /* A chart is mostly plot; its legend is the hover readout and goes first. */ @container (max-height: 150px) { .dui-chart .u-legend { diff --git a/frontend/src/components/Dashboard/ui/core/counter.check.ts b/frontend/src/components/Dashboard/ui/core/counter.check.ts new file mode 100644 index 0000000..3acfec0 --- /dev/null +++ b/frontend/src/components/Dashboard/ui/core/counter.check.ts @@ -0,0 +1,83 @@ +/** + * The counter's arithmetic, checked: the edges the firmware port must agree on. + * + * cd frontend && bun run src/components/Dashboard/ui/core/counter.check.ts + */ +import assert from "node:assert/strict" +import { + type Counter, + cycleAt, + DEFAULT_COUNTERS, + digitsOf, + rangeOf, + stepCounter, + weightsOf, + writtenOf, +} from "./counter" + +const time = DEFAULT_COUNTERS +const HOUR = 0 +const MIN = 1 +const at = (h: number, m: number) => h * 60 + m + +// Digits and how they are written. +assert.deepEqual(weightsOf(time), [60, 1]) +assert.deepEqual(digitsOf(time, at(7, 30)), [7, 30]) +assert.deepEqual(writtenOf(time, at(7, 5)), ["07", "05"]) + +// Carry and borrow. +assert.equal(stepCounter(time, true, at(7, 55), MIN, 1), at(8, 0)) +assert.equal(stepCounter(time, true, at(8, 0), MIN, -1), at(7, 55)) +assert.equal(stepCounter(time, true, at(7, 30), HOUR, 1), at(8, 30)) + +// The top is the last value the finest step reaches: 23:55. +assert.equal(rangeOf(time).top, at(23, 55)) + +// Wrap: round the day either way. +assert.equal(stepCounter(time, true, at(23, 55), MIN, 1), 0) +assert.equal(stepCounter(time, true, 0, HOUR, -1), at(23, 0)) +assert.equal(stepCounter(time, true, 0, MIN, -1), at(23, 55)) + +// Stop: held at either end, as a slider is. +assert.equal(stepCounter(time, false, at(23, 55), MIN, 1), at(23, 55)) +assert.equal(stepCounter(time, false, at(23, 30), HOUR, 1), at(23, 55)) +assert.equal(stepCounter(time, false, at(0, 30), HOUR, -1), 0) + +// One counter, step 5 to 100: the top is 100 itself, padded to max's width. +const single: Counter[] = [{ step: 5, max: 100 }] +assert.equal(rangeOf(single).top, 100) +assert.equal(stepCounter(single, false, 100, 0, 1), 100) +assert.equal(stepCounter(single, true, 100, 0, 1), 4) +assert.deepEqual(writtenOf(single, 5), ["005"]) + +// No max: no top, nothing to wrap at, still held at min. +const open: Counter[] = [{ step: 1 }, { step: 15, size: 60 }] +assert.equal(stepCounter(open, true, at(99, 45), 1, 1), at(100, 0)) +assert.equal(stepCounter(open, true, 0, 1, -1), 0) +assert.deepEqual(writtenOf(open, at(100, 0)), ["100", "00"]) +assert.equal(cycleAt(open, 0, 0), null) + +// Three counters: hours, minutes, seconds. +const clock: Counter[] = [ + { step: 1, max: 23 }, + { step: 1, size: 60 }, + { step: 10, size: 60 }, +] +assert.deepEqual(weightsOf(clock), [3600, 60, 1]) +assert.equal(stepCounter(clock, true, 3599, 2, 1), 3609) +assert.equal(rangeOf(clock).top, 86_390) + +// Below zero the digits still add up: floor, and a remainder never negative. +const signed: Counter[] = [ + { step: 1, min: -5, max: 5 }, + { step: 5, size: 60 }, +] +assert.deepEqual(digitsOf(signed, -5), [-1, 55]) +assert.deepEqual(writtenOf(signed, -5), ["-1", "55"]) +assert.equal(stepCounter(signed, true, at(5, 55), 1, 1), -300) + +// The ring's arc: the finest like a minute hand, the coarsest across its range. +assert.equal(cycleAt(time, at(7, 30), MIN), 0.5) +assert.equal(cycleAt(time, at(6, 0), HOUR), 0.25) + +console.log("counter: ok") diff --git a/frontend/src/components/Dashboard/ui/core/counter.ts b/frontend/src/components/Dashboard/ui/core/counter.ts new file mode 100644 index 0000000..ccbc4f9 --- /dev/null +++ b/frontend/src/components/Dashboard/ui/core/counter.ts @@ -0,0 +1,128 @@ +/** + * A counter widget's arithmetic: one to three counters that are the digits of + * one integer, counted in the unit of the finest. Hours and minutes at 07:30 + * are 450, and 07:55 stepped five minutes on is 08:00. + * + * Pure and on its own because the openpanel firmware ports it line for line: + * a knob and a browser that disagreed on an edge would set the same face to + * two different times. `app/docs/interface/dashboards.md` states the same + * formulas. + */ + +/** One counter, as the document stores it. Counters are ordered coarsest + * first. */ +export type Counter = { + label?: string + /** One press or notch, in this counter's own unit. */ + step?: number + /** How many of it make one of the next coarser counter: 60 minutes. Every + * counter but the coarsest has one. */ + size?: number + /** The range, on the coarsest only and in its own unit. No `max` is no + * upper bound. */ + min?: number + max?: number +} + +/** What a counter widget is until it says otherwise: a time of day. */ +export const DEFAULT_COUNTERS: Counter[] = [ + { label: "h", step: 1, min: 0, max: 23 }, + { label: "min", step: 5, size: 60 }, +] + +/** How many counters one widget links. Mirrored server-side as `COUNTERS` + * (`app/flow/dashboards.py`). */ +export const MAX_COUNTERS = 3 + +/** The remainder that is never negative, as a clock's is. */ +const mod = (a: number, b: number) => ((a % b) + b) % b + +/** What one unit of each counter is worth in the finest's: the finest 1, each + * coarser one the next finer one's weight times that one's size. */ +export function weightsOf(counters: Counter[]): number[] { + const weights = counters.map(() => 1) + for (let i = counters.length - 2; i >= 0; i--) { + weights[i] = weights[i + 1] * (counters[i + 1].size ?? 1) + } + return weights +} + +/** Each counter's digit of `value`: floor(value / weight) mod size, and the + * coarsest, which has no size, floor(value / weight). */ +export function digitsOf(counters: Counter[], value: number): number[] { + const weights = weightsOf(counters) + return counters.map((counter, i) => { + const digit = Math.floor(value / weights[i]) + return i === 0 ? digit : mod(digit, counter.size ?? 1) + }) +} + +/** + * The value's range: `lo`, the highest value the finest step reaches from it + * (`top`), and the `period` a wrap counts in. Without a `max` there is no top + * and nothing to wrap at. + */ +export function rangeOf(counters: Counter[]) { + const coarsest = counters[0] + const weight = weightsOf(counters)[0] + const min = coarsest.min ?? 0 + const lo = min * weight + if (coarsest.max == null) return { lo, top: Infinity, period: null } + const period = (coarsest.max - min + 1) * weight + const fine = counters[counters.length - 1].step ?? 1 + return { lo, top: lo + Math.floor((period - 1) / fine) * fine, period } +} + +/** + * `value` with counter `index` stepped `by` steps, carrying into and borrowing + * from the others. At the ends it wraps round the period, or stops at `lo` and + * `top`; without a `max` it only stops at `lo`. + */ +export function stepCounter( + counters: Counter[], + wrap: boolean, + value: number, + index: number, + by: number, +): number { + const next = + value + by * (counters[index].step ?? 1) * weightsOf(counters)[index] + const { lo, top, period } = rangeOf(counters) + if (wrap && period !== null) return lo + mod(next - lo, period) + return Math.min(top, Math.max(lo, next)) +} + +/** + * Every counter's digit of `value` as written: zero-padded to the width of + * size − 1 (05), the coarsest to the width of its `max` when it has one (07). + */ +export function writtenOf(counters: Counter[], value: number): string[] { + return digitsOf(counters, value).map((digit, i) => { + const widest = i > 0 ? (counters[i].size ?? 1) - 1 : counters[0].max + const width = widest == null ? 0 : String(widest).length + const padded = String(Math.abs(digit)).padStart(width, "0") + return digit < 0 ? `-${padded}` : padded + }) +} + +/** How many of counter `index` make one turn of it: its size, or the + * coarsest's range. Null for a coarsest without `max`, which has none. */ +export function cycleOf(counters: Counter[], index: number): number | null { + if (index > 0) return counters[index].size ?? 1 + const { min = 0, max } = counters[0] + return max == null ? null : max - min + 1 +} + +/** Where counter `index` stands in its turn, 0..1: digit / size, as a minute + * hand stands, and the coarsest (digit − min) / (max − min + 1). */ +export function cycleAt( + counters: Counter[], + value: number, + index: number, +): number | null { + const cycle = cycleOf(counters, index) + if (cycle === null) return null + const from = index > 0 ? 0 : (counters[0].min ?? 0) + const at = (digitsOf(counters, value)[index] - from) / cycle + return Math.min(1, Math.max(0, at)) +} diff --git a/frontend/src/components/Dashboard/ui/core/ring.ts b/frontend/src/components/Dashboard/ui/core/ring.ts index b2bba1a..54d50cb 100644 --- a/frontend/src/components/Dashboard/ui/core/ring.ts +++ b/frontend/src/components/Dashboard/ui/core/ring.ts @@ -31,6 +31,10 @@ export const RING_MARK = 6 export const RING_READING = 12 /** How long the ring waits after the last notch before it publishes. */ export const RING_SETTLE_MS = 300 +/** How far a drag along the ring turns a counter that has no arc, a step at + * a time; one with an arc takes a step per step's share of the sweep, so the + * arc stays under the finger. */ +export const RING_DRAG_STEP = 15 /** The gap's line — a slider's title and value, when what it reads is in * the middle — centred this far above the bottom of the face, at this size, * and at most this wide: clear of the ring's end caps at any size. */ @@ -56,6 +60,8 @@ export const CENTRE_VALUE = 72 export const CENTRE_LABEL = 36 export const CENTRE_SWATCH = 96 export const CENTRE_FLOOR = 24 +/** A line under a centre reading: a counter's labels. */ +export const CENTRE_CAPTION = 17 /** A switch's or a button's disc, as a diameter. */ export const PRESS = 128 diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx index 67a37f9..78c4b6d 100644 --- a/frontend/src/components/Dashboard/widgets.tsx +++ b/frontend/src/components/Dashboard/widgets.tsx @@ -8,6 +8,7 @@ import { BarWidget } from "./BarWidget" import { ChartWidget } from "./ChartWidget" import { ClockWidget } from "./ClockWidget" import { ColorWidget } from "./ColorWidget" +import { CounterWidget } from "./CounterWidget" import { useBoundValue } from "./dataContext" // The grid's own rules live beside the components that use them; CSS is // chunked per entry, so the sheet is pulled in wherever a widget is drawn. @@ -22,6 +23,7 @@ import { COLOR_DTYPES, colorFormatOf } from "./ui/core/color" import { buttonsOf, config, + countersOf, decimalsOf, num, rowsOf, @@ -37,6 +39,7 @@ export const INPUT_WIDGETS = new Set([ "input", "dropdown", "color", + "counter", ]) export type WidgetKind = WidgetDef["type"] @@ -55,6 +58,8 @@ export const WIDGET_DTYPES: Partial> = { // answer and a `record` request instead, checked in `widgetIssue`. chart: ["float", "int"], slider: ["float", "int"], + // One number, the digits of which its counters are. + counter: ["float", "int"], switch: ["bool"], agenda: ["list"], notification: ["record"], @@ -102,6 +107,7 @@ export const WIDGET_LABELS: Record = { input: "Input", dropdown: "Selector", color: "Colour", + counter: "Counter", } /** One line per type for the add palette, which searches these too. */ @@ -126,6 +132,7 @@ export const WIDGET_DESCRIPTIONS: Record = { input: "Sends what is typed", dropdown: "Picks one of a list of options", color: "Picks a colour or brightness", + counter: "Sets a number by its digits, such as a time", } /** Default footprint per type, in grid units. */ @@ -150,6 +157,7 @@ export const WIDGET_SIZES: Record = { input: { w: 4, h: 2 }, dropdown: { w: 4, h: 2 }, color: { w: 4, h: 4 }, + counter: { w: 3, h: 3 }, } /** One line of a chart, as the document stores it. */ @@ -269,6 +277,21 @@ export function widgetIssue(widget: WidgetDef): string | null { if (widget.type === "icon" && !(cfg.rules as unknown[] | undefined)?.length) { return "This icon has nothing mapped yet." } + if (widget.type === "counter") { + const counters = countersOf(widget) + const name = (index: number) => + counters[index].label || `Counter ${index + 1}` + // A size is what carries into the counter before it; without one there + // is nothing to carry at. + const unsized = counters.findIndex( + (counter, index) => index > 0 && !(Number(counter.size) >= 1), + ) + if (unsized > 0) return `${name(unsized)} needs a size.` + const stalled = counters.findIndex( + (counter) => Number(counter.step ?? 1) < 1, + ) + if (stalled >= 0) return `${name(stalled)} needs a step of 1 or more.` + } return null } @@ -869,6 +892,7 @@ const RENDERERS: Partial< input: InputWidget, dropdown: DropdownWidget, color: ColorWidget, + counter: CounterWidget, } function WidgetBodyComponent({ widget, dashboard }: WidgetProps) {