Files
app/frontend/src/components/Dashboard/BarWidget.tsx
T

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>
)
}