Stack a bar's readings, and stop widgets taking the phone sideways
A bar drew its nested reading on top of the outer one in --chart-5, which measures 2.53:1 against --primary and lost the 3:1 guideline for non-text. The readings now partition the fill end to end, up to three of them, in a token of their own: --primary-nested, the primary hue a few steps deeper, 3.14:1 light and 3.12:1 dark. It cannot also clear 3:1 against --muted — in dark those two are 5.82:1 apart and a colour 3:1 from both would need a 9:1 gap — so a segment is drawn inside a gutter of outer fill rather than ever bordering the track, which is what separates neighbours too, and what caps the count at three. A nested value larger than its outer used to spill onto the track; it is clamped. `inner` still reads as a single binding, so no dashboard needs migrating. On a phone, .widget-grid took its width from the widest thing any widget held — a truncating flex item still offers its whole unwrapped line as a min-content contribution — and a handful of widgets had no floor of their own: the uPlot legend is a table, a fieldset carries min-inline-size: min-content from the UA sheet, and buttons are whitespace-nowrap. Each is capped now. A widget's body scrolls rather than clipping, so long text stops painting over the title. Gauges and bars move between readings instead of jumping, and a segmented control slides one thumb rather than recolouring cells. The gauge arc is drawn whole and revealed by its dash, because `d` cannot be transitioned. UplotChart pushed new readings only when the point count changed, so once a rolling window was full a refetch left the old values on screen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
This commit is contained in:
@@ -18,22 +18,61 @@ const num = (value: unknown, fallback: number) => {
|
||||
/** Below this the reading no longer fits on the fill and moves off its end. */
|
||||
const FITS = 0.3
|
||||
|
||||
/**
|
||||
* How many readings a bar nests, and a hard ceiling.
|
||||
*
|
||||
* The limit is contrast rather than layout. No slot of the chart ramp reaches
|
||||
* 3:1 against `--primary`, so every segment is drawn in the one token that
|
||||
* does — `--primary-nested` — and neighbours are told apart by the gutter of
|
||||
* outer fill left between them. Within the ramp only `--chart-1` against
|
||||
* `--chart-5` clears 3:1 (3.28 light / 3.37 dark) and only slots 1-3 clear it
|
||||
* against the `--muted` track, so a fourth segment could not be told from its
|
||||
* neighbour without breaking the very guideline this stacking exists to fix.
|
||||
*/
|
||||
export const MAX_SEGMENTS = 3
|
||||
|
||||
/** Outer fill left around a segment, as `inset-y-1` leaves it above and below. */
|
||||
const GUTTER = "2px"
|
||||
|
||||
export type Segment = { message?: string; dtype?: string }
|
||||
|
||||
/** A segment as drawn: where it runs on the fill, and what it reads. */
|
||||
type Band = { start: number; end: number; level: number; name: string }
|
||||
|
||||
/**
|
||||
* The nested readings, in either shape a document may carry them: one binding,
|
||||
* as a bar was written before it stacked, or an ordered list of them.
|
||||
*/
|
||||
export const segmentsOf = (widget: WidgetProps["widget"]): Segment[] => {
|
||||
const cfg = config(widget)
|
||||
if (Array.isArray(cfg.inner))
|
||||
return (cfg.inner as Segment[]).slice(0, MAX_SEGMENTS)
|
||||
return cfg.inner
|
||||
? [{ message: text(cfg.inner), dtype: text(cfg.inner_dtype) }]
|
||||
: []
|
||||
}
|
||||
|
||||
/**
|
||||
* A level, drawn as a horizontal bar with its reading written on it.
|
||||
*
|
||||
* A second message can be nested inside the first — the PV share of an
|
||||
* inverter's input — and is drawn on top of the fill on the same scale, so
|
||||
* containment is what the picture shows rather than something to work out.
|
||||
* Further messages can be nested inside the first — the PV share of an
|
||||
* inverter's input — and are drawn on the fill on the same scale, stacked end
|
||||
* to end so that they partition the reading rather than cover one another.
|
||||
* None of them leaves the fill, so containment is still what the picture
|
||||
* shows rather than something to work out.
|
||||
*/
|
||||
export function BarWidget({ widget }: WidgetProps) {
|
||||
const cfg = config(widget)
|
||||
const message = text(cfg.message)
|
||||
const nested = text(cfg.inner)
|
||||
// The panel already carries the widget's title, so the caption names the
|
||||
// reading by its port rather than repeating the flow it comes from.
|
||||
const innerName = nested ? displayName(flowOf(nested), nested) : ""
|
||||
const segments = segmentsOf(widget)
|
||||
const outer = useLiveValue(message || undefined)
|
||||
const inner = useLiveValue(nested || undefined)
|
||||
// One hook per slot rather than one per segment, so the number of hooks
|
||||
// React sees never moves with the configuration. MAX_SEGMENTS is its length.
|
||||
const nested = [
|
||||
useLiveValue(segments[0]?.message || undefined),
|
||||
useLiveValue(segments[1]?.message || undefined),
|
||||
useLiveValue(segments[2]?.message || undefined),
|
||||
]
|
||||
if (!message)
|
||||
return <p className="text-sm text-muted-foreground">Pick a message.</p>
|
||||
|
||||
@@ -50,36 +89,66 @@ export function BarWidget({ widget }: WidgetProps) {
|
||||
value === null ? "—" : `${value.toFixed(precision)}${unit}`
|
||||
|
||||
const value = reading(outer?.value)
|
||||
const innerValue = nested ? reading(inner?.value) : null
|
||||
const fraction = fractionOf(value)
|
||||
const fits = fraction >= FITS
|
||||
|
||||
// Each segment starts where the one before it ended, and the run is clamped
|
||||
// to the outer fill: a nested value larger than the reading it is part of
|
||||
// used to spill onto the track and read as more than the whole.
|
||||
const drawn: Band[] = []
|
||||
let cursor = 0
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
const name = segment.message ?? ""
|
||||
const level = name ? reading(nested[index]?.value) : null
|
||||
if (level === null) continue
|
||||
const start = cursor
|
||||
cursor = Math.min(fraction, start + fractionOf(level))
|
||||
drawn.push({
|
||||
start,
|
||||
end: cursor,
|
||||
level,
|
||||
// The panel already carries the widget's title, so the caption names the
|
||||
// reading by its port rather than repeating the flow it comes from.
|
||||
name: displayName(flowOf(name), name),
|
||||
})
|
||||
}
|
||||
const detail = drawn
|
||||
.map((band) => `${write(band.level)} of it from ${band.name}`)
|
||||
.join(", ")
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid gap-1.5"
|
||||
role="img"
|
||||
aria-label={
|
||||
nested
|
||||
? `${write(value)} of ${max}${unit}, ${write(innerValue)} of it from ${innerName}`
|
||||
detail
|
||||
? `${write(value)} of ${max}${unit}, ${detail}`
|
||||
: `${write(value)} of ${max}${unit}`
|
||||
}
|
||||
>
|
||||
<div className="relative h-8 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
data-testid="bar-fill"
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-primary"
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-primary motion-safe:transition-[width] motion-safe:duration-200 motion-safe:ease-[var(--ease-standard)]"
|
||||
style={{ width: `${fraction * 100}%` }}
|
||||
/>
|
||||
{innerValue === null ? null : (
|
||||
{drawn.map((band, index) => (
|
||||
<div
|
||||
// Position is the only identity a segment has, as with chart series.
|
||||
key={`segment-${index}`}
|
||||
data-testid="bar-inner"
|
||||
className="absolute inset-y-1 left-0 rounded-full bg-chart-5"
|
||||
style={{ width: `${fractionOf(innerValue) * 100}%` }}
|
||||
className="absolute inset-y-1 rounded-full bg-primary-nested motion-safe:transition-[left,width] motion-safe:duration-200 motion-safe:ease-[var(--ease-standard)]"
|
||||
style={{
|
||||
left: `${band.start * 100}%`,
|
||||
// Segments share a fill, so the gutter is what tells one from the
|
||||
// next: what shows between them is the outer fill they sit on.
|
||||
width: `calc(${(band.end - band.start) * 100}% - ${GUTTER})`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inset-y-0 flex items-center px-2 text-sm tabular-nums",
|
||||
"absolute inset-y-0 flex items-center px-2 text-sm tabular-nums motion-safe:transition-[left,right] motion-safe:duration-200 motion-safe:ease-[var(--ease-standard)]",
|
||||
fits ? "text-primary-foreground" : "text-foreground",
|
||||
)}
|
||||
style={
|
||||
@@ -91,9 +160,9 @@ export function BarWidget({ widget }: WidgetProps) {
|
||||
{write(value)}
|
||||
</span>
|
||||
</div>
|
||||
{innerValue === null ? null : (
|
||||
{drawn.length === 0 ? null : (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{innerName} {write(innerValue)}
|
||||
{drawn.map((band) => `${band.name} ${write(band.level)}`).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user