Bar widget: horizontal level with its reading on the fill and a nested bar
This commit is contained in:
@@ -1,6 +1,97 @@
|
||||
import { useLiveValue } from "@/components/Flow/liveStore"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { WidgetProps } from "./widgets"
|
||||
|
||||
/** A bar — a stub until the bar widget is written. */
|
||||
export function BarWidget(_props: WidgetProps) {
|
||||
return <p className="text-sm text-muted-foreground">—</p>
|
||||
// Local copies: `widgets.tsx` imports this module, so its helpers cannot be
|
||||
// imported back without closing the cycle (`ChartWidget.tsx` does the same).
|
||||
const config = (widget: WidgetProps["widget"]) =>
|
||||
(widget.config ?? {}) as Record<string, unknown>
|
||||
|
||||
const text = (value: unknown) => (value == null ? "" : String(value))
|
||||
|
||||
const num = (value: unknown, fallback: number) => {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
/** Below this the reading no longer fits on the fill and moves off its end. */
|
||||
const FITS = 0.3
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function BarWidget({ widget }: WidgetProps) {
|
||||
const cfg = config(widget)
|
||||
const message = text(cfg.message)
|
||||
const nested = text(cfg.inner)
|
||||
const outer = useLiveValue(message || undefined)
|
||||
const inner = useLiveValue(nested || undefined)
|
||||
if (!message)
|
||||
return <p className="text-sm text-muted-foreground">Pick a message.</p>
|
||||
|
||||
const min = num(cfg.min, 0)
|
||||
const max = num(cfg.max, 100)
|
||||
const span = max - min || 1
|
||||
const precision = cfg.precision === undefined ? 1 : num(cfg.precision, 1)
|
||||
const unit = cfg.unit ? text(cfg.unit) : ""
|
||||
|
||||
const reading = (live: unknown) => (typeof live === "number" ? live : null)
|
||||
const fractionOf = (value: number | null) =>
|
||||
value === null ? 0 : Math.min(1, Math.max(0, (value - min) / span))
|
||||
const write = (value: number | null) =>
|
||||
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
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid gap-1.5"
|
||||
role="img"
|
||||
aria-label={
|
||||
nested
|
||||
? `${write(value)} of ${max}${unit}, ${write(innerValue)} of it from ${nested}`
|
||||
: `${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"
|
||||
style={{ width: `${fraction * 100}%` }}
|
||||
/>
|
||||
{innerValue === null ? null : (
|
||||
<div
|
||||
data-testid="bar-inner"
|
||||
className="absolute inset-y-1 left-0 rounded-full bg-chart-5"
|
||||
style={{ width: `${fractionOf(innerValue) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inset-y-0 flex items-center px-2 text-sm tabular-nums",
|
||||
fits ? "text-primary-foreground" : "text-foreground",
|
||||
)}
|
||||
style={
|
||||
fits
|
||||
? { right: `${(1 - fraction) * 100}%` }
|
||||
: { left: `${fraction * 100}%` }
|
||||
}
|
||||
>
|
||||
{write(value)}
|
||||
</span>
|
||||
</div>
|
||||
{innerValue === null ? null : (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{nested} {write(innerValue)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user