import { useId } from "react" import type { HistoryPoint } from "@/client" import { cn, si } from "@/lib/utils" /** Room above and below the curve for the stroke, in viewBox units. */ const PAD = 8 /** Above this ratio a linear series is all baseline and one spike. */ const LOG_RATIO = 100 /** How far the left end fades over, in viewBox units. */ const FADE = 40 /** Enough digits to tell two neighbouring readings apart. */ const READOUT_DIGITS = 4 /** * Curve and area for a series, in a 0–100 box. * * The awkward series are the point: one reading has no line to draw, a series * that never moved has no span to divide by, and one spanning decades is only * legible once the exponent is what varies. */ function shape(points: HistoryPoint[]) { const values = points.map((point) => point.value) const low = Math.min(...values) const high = Math.max(...values) // Logs need every reading on the same side of zero. const logged = low > 0 && high / low >= LOG_RATIO const project = (value: number) => (logged ? Math.log10(value) : value) const floor = project(low) const span = project(high) - floor const y = (value: number) => span === 0 ? 50 : 100 - PAD - ((project(value) - floor) / span) * (100 - 2 * PAD) const x = (index: number) => points.length === 1 ? 100 : (index / (points.length - 1)) * 100 const line = points .map( (point, index) => `${index ? "L" : "M"}${x(index).toFixed(2)},${y(point.value).toFixed(2)}`, ) .join(" ") return { low, high, line, area: `${line} L100,100 L0,100 Z`, end: y(values[values.length - 1]), } } /** * How a series has been moving — the one trend curve, drawn the same way in * the node panel, on an edge and in the flow table. * * The left end fades out, so the oldest readings read as the tail they are * rather than as an edge the series was cut at. The fade masks the curve and * its area together; the live dot sits outside the mask, since the newest * reading is the one thing that must stay solid. * * Needs at least one point: an empty series has no story, and what to say * instead is the caller's to decide. */ export function Sparkline({ points, color = "var(--primary)", height = "h-8", dot = true, readout = true, }: { points: HistoryPoint[] /** The token the curve, its area and the dot are drawn in. */ color?: string /** Tailwind height of the curve's box. */ height?: string /** A dot on the newest reading; only honest where the series is live. */ dot?: boolean /** The current value and the range, beside the curve. */ readout?: boolean }) { const id = useId() const { low, high, line, area, end } = shape(points) return ( // The gap leaves the live dot room to sit on the last reading without // touching the labels.
{/* The newest reading is always the right edge, so the dot only needs to know how high it sits. */} {dot ? ( <> ) : null}
{/* A shared minimum width, so the curves all end on the same line. */} {readout ? (
{si(points[points.length - 1].value, READOUT_DIGITS)}
{/* A series that never moved has no range worth repeating. */} {low === high ? null : (
{si(low, READOUT_DIGITS)}–{si(high, READOUT_DIGITS)}
)}
) : null}
) }