From 66e6c4b6ec2b38efa9ffec8524b4226985703975 Mon Sep 17 00:00:00 2001 From: Melvin Strobl Date: Wed, 14 Oct 2026 16:00:53 +0200 Subject: [PATCH] Slider: the set value to its step's decimals; the reading marked on the track and on the ring; mark geometry in the docs --- docs/interface/dashboards.md | 20 +++++++-- .../src/components/Dashboard/RingWidget.tsx | 23 ++++++++++ .../components/Dashboard/ui/core/contract.ts | 3 ++ .../components/Dashboard/ui/core/controls.ts | 4 ++ .../src/components/Dashboard/ui/core/core.css | 45 +++++++++++++++++++ .../src/components/Dashboard/ui/core/ring.ts | 12 +++-- .../Dashboard/ui/fluksio/Controls.tsx | 11 ++++- .../Dashboard/ui/glass/Controls.tsx | 11 ++++- .../Dashboard/ui/material/Controls.tsx | 11 ++++- frontend/src/components/Dashboard/widgets.tsx | 11 +++-- frontend/tests/widgets.spec.ts | 4 +- 11 files changed, 141 insertions(+), 14 deletions(-) diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 922052e..b43906e 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -69,9 +69,18 @@ A Slider can also show what it sets against, such as the room's temperature beside a setpoint: **Beside it** binds `reading: {message, dtype, label}` to a `float` or `int`. The reading is only shown, never published. It is written in the slider's unit and to its step's decimals (a step of 0.5 writes 21 as -`21.0`); `—` stands in for the value until one arrives. On a round face it -takes the middle (see [Round dashboards](#round-dashboards)); in a tile it is a -line under the slider's value. +`21.0`), as the slider writes its own value, so the two always match; `—` +stands in for the value until one arrives. On a round face it takes the middle +(see [Round dashboards](#round-dashboards)); in a tile it is a line under the +slider's value. + +It is also marked where it sits on the slider's scale, as a thermostat marks +the room's temperature: the handle is what is set, the mark what is. The mark +is a bar across the track or the ring, in the text colour, at the reading's +fraction of the scale, (reading − min) / (max − min) held to 0–1, so a reading +outside the range sits at that end. It is drawn over the track and its fill +and under the handle, and is longer than the handle, so it still shows where +the two meet. It takes no pointer, and there is none until a value arrives. Sizes are CSS pixels of the canvas, as in the round geometry below. The slider's own value is at the tile's text size (14; 16 on a touch-friendly @@ -83,6 +92,9 @@ panel), centred on a line as tall as the control (32; 48 touch-friendly). | Row | directly under the slider's value, right-aligned with it, in the column right of the track (12 apart). With ticks the line shares the ticks' row, so the tile grows no taller; without them it adds 16 | | Row width | the value column is as wide as its wider line, but at most half the row; the track takes the rest. What does not fit is cut from the label; the value and unit never are | | Fader | directly under the slider's value, both centred under the track, 8 below it; the line no wider than the tile inside its padding, cut from the label first | +| Mark (row) | a bar 2 wide and the handle's diameter + 8 long (16 + 8 = 24; 24 + 8 = 32 touch-friendly), centred on the track's midline (the track is 8 thick). Its centre sits on the handle's travel: handle ÷ 2 + fraction × (track length − handle) from the track's left end, so mark and handle line up at the same value | +| Mark (fader) | the same turned: 2 high, handle + 8 wide, centred on the track, measured up from its bottom end | +| Locked | the mark at 0.45 opacity, as the handle | ## Charts @@ -249,7 +261,7 @@ look's muted text colour. | Ring band | 40 along the edge | | Ring arc | start 120°, sweep 300° (ends at 60°), centre-line radius c − 20, stroke 16 with round caps | | Slider | track over the sweep, value arc from the start to the value (none at the minimum), handle 28 across; the value as the centre reading | -| Slider with a reading | the reading as the centre reading; the gap line holds the title (muted) and the slider's value | +| Slider with a reading | the reading as the centre reading; the gap line holds the title (muted) and the slider's value. The reading's mark: a bar 36 long and 3 wide, square-ended, in the text colour, along the radius at the reading's fraction of the sweep (the same angle the handle has at that value), centred on the ring's centre line, so from radius c − 38 to c − 2; over the track and value arc, under the handle | | Selector | a 6 mark per option at i/(n−1) of the sweep (one option: the middle), the selected one under the handle; its label as the centre reading, at most 36 | | Colour | hue h at h/360 of the sweep in 150 strokes of 2°, red at both ends; the handle filled with the colour; a swatch 96 across in the middle, with a hairline border | | Gauge | track and value arc, no handle; the value as the centre reading | diff --git a/frontend/src/components/Dashboard/RingWidget.tsx b/frontend/src/components/Dashboard/RingWidget.tsx index c77fb31..d5b8fe2 100644 --- a/frontend/src/components/Dashboard/RingWidget.tsx +++ b/frontend/src/components/Dashboard/RingWidget.tsx @@ -33,6 +33,8 @@ import { RING_HANDLE, RING_INSET, RING_MARK, + RING_READING, + RING_READING_STROKE, RING_STROKE, ringArc, ringPoint, @@ -326,6 +328,16 @@ export function Ring({ // against each other. const other = beside.message ? dial.reading(read?.value ?? null) : null const handle = dial.handle && at !== null ? ringPoint(size, at) : null + // The same reading, placed: a bar across the band where it sits on the + // slider's scale, held to the sweep at either end. + const actual = typeof read?.value === "number" ? dial.at(read.value) : null + const across = + actual === null + ? null + : [ + ringPoint(size, actual, -RING_READING / 2), + ringPoint(size, actual, RING_READING / 2), + ] const whole = ringArc(size, 0, 1) const name = widget.title || text(cfg.target, widget.id) @@ -417,6 +429,17 @@ export function Ring({ /> ) })} + {/* Under the handle, so where the two meet the value being set wins. */} + {across ? ( + + ) : null} {handle ? ( void } diff --git a/frontend/src/components/Dashboard/ui/core/controls.ts b/frontend/src/components/Dashboard/ui/core/controls.ts index 2cfa0cd..7b2ef07 100644 --- a/frontend/src/components/Dashboard/ui/core/controls.ts +++ b/frontend/src/components/Dashboard/ui/core/controls.ts @@ -185,6 +185,7 @@ export function useSliderDrag({ disabled, orientation = "horizontal", ticks = false, + mark = null, onCommit, }: { value: number @@ -196,6 +197,7 @@ export function useSliderDrag({ disabled?: boolean orientation?: "horizontal" | "vertical" ticks?: boolean + mark?: number | null onCommit: (value: number) => void }) { const [draft, setDraft] = useState(null) @@ -245,6 +247,8 @@ export function useSliderDrag({ /** Goes on the slider, whose width is the scale's width. */ trackRef, fraction: fractionOf(current, min, max), + /** Where the mark sits along the track, 0..1 and held to it; null for none. */ + markAt: mark === null ? null : fractionOf(mark, min, max), inputProps: { type: "range" as const, min, diff --git a/frontend/src/components/Dashboard/ui/core/core.css b/frontend/src/components/Dashboard/ui/core/core.css index f6e7854..7e0650a 100644 --- a/frontend/src/components/Dashboard/ui/core/core.css +++ b/frontend/src/components/Dashboard/ui/core/core.css @@ -412,6 +412,46 @@ translate: -50% 50%; } +/* A second value on the track, such as what a setpoint is set against: a bar + across the track, on the handle's own travel so the two line up at the same + value, and longer than the handle so it still shows when they meet. Under + the input, so the hit area is the input's alone. */ +.dui-slider-mark { + position: absolute; + background: var(--foreground); + pointer-events: none; +} + +.dui-slider[data-orientation="horizontal"] .dui-slider-mark { + top: 50%; + left: calc( + var(--dui-thumb) / + 2 + + var(--dui-mark, 0) * + (100% - var(--dui-thumb)) + ); + width: 2px; + height: calc(var(--dui-thumb) + 0.5rem); + translate: -50% -50%; +} + +.dui-slider[data-orientation="vertical"] .dui-slider-mark { + left: 50%; + bottom: calc( + var(--dui-thumb) / + 2 + + var(--dui-mark, 0) * + (100% - var(--dui-thumb)) + ); + width: calc(var(--dui-thumb) + 0.5rem); + height: 2px; + translate: -50% 50%; +} + +.dui-slider-input:disabled ~ .dui-slider-mark { + opacity: 0.45; +} + .dui-slider-input:focus-visible ~ .dui-slider-thumb { outline: 2px solid var(--ring); outline-offset: 2px; @@ -649,6 +689,11 @@ fill: var(--muted-foreground); } +/* What a slider reads beside its value, as the tile's track marks it. */ +.dui-ring-reading { + stroke: var(--foreground); +} + /* Page dots, one per face, in the ring's gap. */ .dui-dot-mark { border-radius: 50%; diff --git a/frontend/src/components/Dashboard/ui/core/ring.ts b/frontend/src/components/Dashboard/ui/core/ring.ts index 2636a72..9fed5ea 100644 --- a/frontend/src/components/Dashboard/ui/core/ring.ts +++ b/frontend/src/components/Dashboard/ui/core/ring.ts @@ -24,6 +24,11 @@ export const RING_STROKE = 16 export const RING_HANDLE = 28 /** A dropdown's option marks, as a diameter. */ export const RING_MARK = 6 +/** What a slider reads beside its value, marked on the ring: a bar across the + * band this long and this wide, longer than the handle so it still shows + * where the two meet. */ +export const RING_READING = RING_HANDLE + 8 +export const RING_READING_STROKE = 3 /** How long the ring waits after the last notch before it publishes. */ export const RING_SETTLE_MS = 300 /** The gap's line — a slider's title and value, when what it reads is in @@ -93,10 +98,11 @@ export const ringArc = (size: number, from: number, to: number) => size / 2, ) -/** A point on the ring's centre line, `at` a fraction along the sweep. */ -export function ringPoint(size: number, at: number) { +/** A point on the ring's centre line, `at` a fraction along the sweep, or + * `out` beyond it (inward when negative). */ +export function ringPoint(size: number, at: number, out = 0) { const radians = ((RING_START + at * RING_SWEEP) * Math.PI) / 180 - const radius = ringRadius(size) + const radius = ringRadius(size) + out return { x: size / 2 + radius * Math.cos(radians), y: size / 2 + radius * Math.sin(radians), diff --git a/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx b/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx index f09ebd5..844dfa2 100644 --- a/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx +++ b/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx @@ -65,7 +65,7 @@ export function Switch(props: SwitchProps) { } export function Slider(props: SliderProps) { - const { fraction, inputProps, marks, trackRef } = useSliderDrag(props) + const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props) const orientation = props.orientation ?? "horizontal" return (
+ {/* Under the handle, so where the two meet the value being set wins; + after the input only for its `:disabled`, as it takes no pointer. */} + {markAt === null ? null : ( + + )}
{marks.length > 0 ? ( diff --git a/frontend/src/components/Dashboard/ui/glass/Controls.tsx b/frontend/src/components/Dashboard/ui/glass/Controls.tsx index 333ce8c..cc6ab99 100644 --- a/frontend/src/components/Dashboard/ui/glass/Controls.tsx +++ b/frontend/src/components/Dashboard/ui/glass/Controls.tsx @@ -76,7 +76,7 @@ export function Switch(props: SwitchProps) { } export function Slider(props: SliderProps) { - const { fraction, inputProps, marks, trackRef } = useSliderDrag(props) + const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props) const orientation = props.orientation ?? "horizontal" return (
+ {/* Under the handle, so where the two meet the value being set wins; + after the input only for its `:disabled`, as it takes no pointer. */} + {markAt === null ? null : ( + + )}
{marks.length > 0 ? ( diff --git a/frontend/src/components/Dashboard/ui/material/Controls.tsx b/frontend/src/components/Dashboard/ui/material/Controls.tsx index a5e78f7..5b7ba46 100644 --- a/frontend/src/components/Dashboard/ui/material/Controls.tsx +++ b/frontend/src/components/Dashboard/ui/material/Controls.tsx @@ -105,7 +105,7 @@ export function Switch(props: SwitchProps) { } export function Slider(props: SliderProps) { - const { fraction, inputProps, marks, trackRef } = useSliderDrag(props) + const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props) const orientation = props.orientation ?? "horizontal" return (
+ {/* Under the handle, so where the two meet the value being set wins; + after the input only for its `:disabled`, as it takes no pointer. */} + {markAt === null ? null : ( + + )}
{marks.length > 0 ? ( diff --git a/frontend/src/components/Dashboard/widgets.tsx b/frontend/src/components/Dashboard/widgets.tsx index 0767d6c..67a37f9 100644 --- a/frontend/src/components/Dashboard/widgets.tsx +++ b/frontend/src/components/Dashboard/widgets.tsx @@ -674,6 +674,9 @@ function SliderWidget({ widget, dashboard }: WidgetProps) { const unit = cfg.unit ? text(cfg.unit) : undefined const vertical = cfg.orientation === "vertical" const reading = typeof value === "number" ? value : min + // Both the value and what it is set against are written to the step's + // decimals, as a round face writes them, so the two always match. + const digits = decimalsOf(num(cfg.step, 1)) const control = ( ) const readout = ( - + ) // A line under the value, smaller, so the value being set is still the one // that reads. In the slider's unit and to its step's decimals, as a round - // face writes it: the two are read against each other. + // face writes it: the two are read against each other. Its mark on the + // track is the same reading, placed. const against = beside.message ? ( diff --git a/frontend/tests/widgets.spec.ts b/frontend/tests/widgets.spec.ts index 7de9fc7..5dc381a 100644 --- a/frontend/tests/widgets.spec.ts +++ b/frontend/tests/widgets.spec.ts @@ -184,7 +184,7 @@ test.beforeAll(async ({ browser }) => { config: {}, }, { - // No precision configured, so what it is worth is what decides how it is + // No precision configured, so its step's decimals decide how it is // written — including on the way there. id: "aim", type: "slider", @@ -432,6 +432,8 @@ test("a slider writes what it sets against under its value", async ({ await expect(page.getByTestId("slider-reading")).toHaveText( /^Level\s*80\.0\s*kW$/, ) + // And placed on the track, as a mark beside the handle. + await expect(page.locator(".dui-slider-mark")).toBeVisible() }) test("a reading is written to its own precision while it is moving", async ({