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:
@@ -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'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user