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

This commit is contained in:
Melvin Strobl
2026-10-16 08:58:42 +02:00
parent 4ce50b5c3a
commit 21c7961d49
16 changed files with 745 additions and 33 deletions
@@ -17,6 +17,7 @@ const INPUT = new Set([
"input",
"dropdown",
"color",
"counter",
])
const shade = (type: string) =>
@@ -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 <p className="text-muted-foreground">Pick a message.</p>
const written = writtenOf(counters, current)
const press = (index: number, by: number) =>
move(stepCounter(counters, wrapsOf(widget), latest(), index, by), true)
return (
<fieldset aria-label={widget.title || target} className="dui-counter">
{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.
<Fragment key={`counter-${index}`}>
{index > 0 ? (
<span
aria-hidden
className="dui-counter-digits"
style={{ gridColumn: column - 1, gridRow: 2 }}
>
:
</span>
) : null}
<div style={{ gridColumn: column, gridRow: 1 }}>
<Button
label={`${name} up`}
disabled={locked}
onClick={() => press(index, 1)}
>
<ChevronUp className="size-5" aria-hidden />
</Button>
</div>
<span
className="dui-counter-digits"
style={{ gridColumn: column, gridRow: 2 }}
>
{written[index]}
</span>
<div style={{ gridColumn: column, gridRow: 3 }}>
<Button
label={`${name} down`}
disabled={locked}
onClick={() => press(index, -1)}
>
<ChevronDown className="size-5" aria-hidden />
</Button>
</div>
<span
className="text-muted-foreground"
style={{ gridColumn: column, gridRow: 4 }}
>
{counter.label}
</span>
</Fragment>
)
})}
</fieldset>
)
}
+150 -19
View File
@@ -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<number | null>(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<SVGSVGElement>(null)
const face = useRef<HTMLDivElement>(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" ? (
<CounterCentre
counters={counters}
value={current as number}
selected={selected}
// Nothing to choose between with one, or with a ring that is locked.
onSelect={counters.length > 1 && turns ? choose : undefined}
/>
) : dial.hue ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span
data-testid="face-centre"
@@ -577,6 +643,71 @@ export function Ring({
)
}
/**
* A counter's digits, written as large as a reading, each over its label.
*
* The digits are sized as one reading (`fitSize` of all of them, colons and
* all), and the pair is centred on the face as the clock's time and date are.
* A press on a counter's digits picks it for the ring, and the one it turns
* is drawn in the primary colour.
*/
function CounterCentre({
counters,
value,
selected,
onSelect,
}: {
counters: Counter[]
value: number
selected: number
onSelect?: (index: number) => void
}) {
const written = writtenOf(counters, value)
const fontSize = fitSize(written.join(":"), CENTRE_VALUE)
return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div
data-testid="face-centre"
className="flex items-start leading-none tabular-nums"
>
{counters.map((counter, index) => {
const marked = onSelect && index === selected
return (
// Two counters may share a label; position is the identity.
<Fragment key={`counter-${index}`}>
{index > 0 ? (
<span aria-hidden style={{ fontSize }}>
:
</span>
) : null}
<button
type="button"
data-testid={`counter-${index}`}
aria-pressed={onSelect ? index === selected : undefined}
disabled={!onSelect}
className={cn(
"flex flex-col items-center gap-1",
onSelect && "pointer-events-auto",
marked && "text-primary",
)}
onClick={() => onSelect?.(index)}
>
<span style={{ fontSize }}>{written[index]}</span>
<span
className={cn(!marked && "text-muted-foreground")}
style={{ fontSize: CENTRE_CAPTION, lineHeight: 1.35 }}
>
{counter.label}
</span>
</button>
</Fragment>
)
})}
</div>
</div>
)
}
/**
* The ring's dots: its stops, and what a slider reads beside its value.
*
@@ -87,6 +87,7 @@ export const RING_TYPES = new Set<WidgetKind>([
"dropdown",
"color",
"gauge",
"counter",
])
/**
@@ -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<Counter>) =>
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({
</div>
) : null}
{widget.type === "counter" ? (
<div className="grid gap-2">
<span className={PANEL_SECTION}>Counters</span>
<InfoTip label="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.
</InfoTip>
{counters.map((counter, index) => (
<div
// Position is the only identity a counter row has.
key={`counter-${index}`}
className="flex items-end gap-1.5"
>
<Input
className="min-w-0 flex-1"
value={counter.label ?? ""}
placeholder="Label"
aria-label="Counter label"
onChange={(event) =>
setCounter(index, { label: event.target.value })
}
/>
<Input
type="number"
className="w-16"
min={1}
placeholder="Step"
aria-label="Counter step"
value={str(counter.step)}
onChange={(event) =>
setCounter(index, {
step: numberOrNone(event.target.value),
})
}
/>
{index === 0 ? (
<>
<Input
type="number"
className="w-16"
placeholder="Min"
aria-label="Counter minimum"
value={str(counter.min)}
onChange={(event) =>
setCounter(index, {
min: numberOrNone(event.target.value),
})
}
/>
<Input
type="number"
className="w-16"
placeholder="Max"
aria-label="Counter maximum"
value={str(counter.max)}
onChange={(event) =>
setCounter(index, {
max: numberOrNone(event.target.value),
})
}
/>
</>
) : (
<Input
type="number"
className="w-16"
min={1}
placeholder="Size"
aria-label="Counter size"
value={str(counter.size)}
onChange={(event) =>
setCounter(index, {
size: numberOrNone(event.target.value),
})
}
/>
)}
{counters.length > 1 ? (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
aria-label="Remove counter"
onClick={() =>
set({
counters: counters.filter((_, at) => at !== index),
})
}
>
<X />
</Button>
) : null}
</div>
))}
{counters.length < MAX_COUNTERS ? (
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
// A finer one, after the last.
onClick={() =>
set({ counters: [...counters, { step: 1, size: 60 }] })
}
data-testid="add-counter"
>
<Plus />
Add counter
</Button>
) : null}
<div className="flex items-center justify-between gap-2 text-sm">
<FieldLabel help="Past the last value it starts again from the first, as 23:55 goes on to 00:00. Off, it stops at either end. Without a maximum it only stops at the minimum.">
Wrap round
</FieldLabel>
<Switch
checked={wrapsOf(widget)}
aria-label="Wrap round"
data-testid="counter-wrap"
onCheckedChange={(wrap) => set({ wrap })}
/>
</div>
</div>
) : null}
{widget.type === "icon" ? (
<div className="grid gap-2">
<span className={PANEL_SECTION}>Mapping</span>
@@ -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<string, unknown> =>
(widget.config ?? {}) as Record<string, unknown>
@@ -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
@@ -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 {
@@ -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")
@@ -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))
}
@@ -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
@@ -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<Record<WidgetKind, string[]>> = {
// 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<WidgetKind, string> = {
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<WidgetKind, string> = {
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<WidgetKind, { w: number; h: number }> = {
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) {