98 lines
3.3 KiB
TypeScript
98 lines
3.3 KiB
TypeScript
import { useLiveValue } from "@/components/Flow/liveStore"
|
|
import { cn } from "@/lib/utils"
|
|
import type { WidgetProps } from "./widgets"
|
|
|
|
// 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>
|
|
)
|
|
}
|