"use client"; /* A compact trend for tables, stat cards and inline text. New values morph the * line on the same springs as LineChart, and the end dot follows. With * `interactive`, hovering, dragging or arrow keys move a dot along the line and * the readout shows that sample. Reduced motion: values update in place. */ import { motion, useReducedMotion, useTransform } from "motion/react"; import { type ComponentProps, useId, useState } from "react"; import { NumberTicker } from "@/components/motion/number-ticker"; import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease"; import { useHoverCapable } from "@/lib/hooks/use-hover-capable"; import { cn } from "@/lib/utils"; import { XYInspector } from "./xy/inspector"; import { areaPath, linePath, type XYCurve } from "./xy/path"; import { useAnimatedPoints, type XYPointTarget } from "./xy/use-points"; import { useElementWidth } from "./xy/use-width"; export interface SparklineProps extends Omit, "children"> { /** Samples in display order. Missing or non-finite values leave a gap. */ data: readonly (number | null)[]; /** Optional sample labels, such as dates, used by the interactive readout. */ labels?: readonly string[]; /** Accessible name, such as "Revenue, last 30 days". */ label: string; variant?: "line" | "area"; curve?: XYCurve; /** Any CSS color. Defaults to the current text color. */ color?: string; /** Height in px. Width follows the container. */ height?: number; strokeWidth?: number; /** Show the change from the first to the last sample as a badge. */ showDelta?: boolean; /** Color a fall green and a rise red, for metrics where lower is better, such as error rate. */ invertDelta?: boolean; /** Hover, drag or arrow keys to read individual samples. Adds one tab stop. */ interactive?: boolean; formatValue?: (value: number) => string; } const number = new Intl.NumberFormat("en", { maximumFractionDigits: 2 }); const PAD = 3; export function Sparkline({ data, labels, label, variant = "line", curve = "monotone", color = "currentColor", height = 32, strokeWidth = 1.5, showDelta = false, invertDelta = false, interactive = false, formatValue = (value) => number.format(value), className, ...props }: SparklineProps) { const reduce = useReducedMotion() ?? false; const canHover = useHoverCapable(); const gradientId = useId(); const tooltipId = useId(); const { ref, width } = useElementWidth(); const [active, setActive] = useState(null); const [open, setOpen] = useState(false); // Under a finger the readout dot tracks the touch directly instead of gliding. const [touching, setTouching] = useState(false); const values = data.map((value) => typeof value === "number" && Number.isFinite(value) ? value : null, ); const present = values.filter((value): value is number => value !== null); // Fit the data tightly: at this size a zero baseline or rounded bounds would // flatten a metric that sits far from zero. The area fills to the bottom edge. const lo = present.length ? Math.min(...present) : 0; const hi = present.length ? Math.max(...present) : 1; const range = hi - lo || 1; const n = values.length; const x = (i: number) => PAD + (n === 1 ? (width - PAD * 2) / 2 : (i / (n - 1)) * (width - PAD * 2)); const y = (value: number) => hi === lo ? height / 2 : PAD + ((hi - value) / range) * (height - PAD * 2); const targets: XYPointTarget[] = []; if (width > 0) { targets.push({ key: "baseline", x: 0, y: height }); values.forEach((value, i) => { if (value !== null) targets.push({ key: String(i), x: x(i), y: y(value) }); }); } const points = useAnimatedPoints(targets, reduce, width); // Indexed by position: a live sparkline shifts every sample left as it ticks. const ordered = values.map((value, i) => (value === null ? null : (points.get(String(i)) ?? null))); const baseline = points.get("baseline"); const motionValues = [ ...(baseline ? [baseline.y] : []), ...ordered.flatMap((point) => (point ? [point.x, point.y] : [])), ]; const read = (coordinates: number[]) => { let cursor = 1; return ordered.map((point) => point ? { x: coordinates[cursor++], y: coordinates[cursor++] } : null, ); }; const line = useTransform(motionValues, (c: number[]) => (baseline ? linePath(read(c), curve) : "")); const fill = useTransform(motionValues, (c: number[]) => baseline ? areaPath(read(c), c[0], curve) : "", ); const lastIndex = values.findLastIndex((value) => value !== null); const firstIndex = values.findIndex((value) => value !== null); const last = lastIndex >= 0 ? (values[lastIndex] as number) : null; const first = firstIndex >= 0 ? (values[firstIndex] as number) : null; const delta = first !== null && last !== null && first !== 0 ? ((last - first) / Math.abs(first)) * 100 : null; const trend = delta === null || Math.abs(delta) < 0.05 ? "flat" : delta > 0 ? "up" : "down"; const tone = trend === "flat" ? "flat" : (trend === "up") !== invertDelta ? "good" : "bad"; const inspecting = interactive && open && active !== null && values[active] !== null; const dotIndex = inspecting ? (active as number) : lastIndex; const dotValue = dotIndex >= 0 ? values[dotIndex] : null; const name = (i: number) => labels?.[i] ?? `Sample ${i + 1}`; const summary = [ label, first !== null && last !== null ? `from ${formatValue(first)} to ${formatValue(last)}` : "no data", delta !== null && trend !== "flat" ? `${trend} ${Math.abs(delta).toFixed(1)}%` : null, ] .filter(Boolean) .join(", "); return (
{variant === "area" ? ( <> ) : null} {width > 0 && dotValue !== null && dotValue !== undefined ? ( ) : null} {interactive && present.length ? (
{ const value = values[i]; return `${name(i)}: ${value === null ? "no data" : formatValue(value)}`; }} mode="point" open={open} setOpen={setOpen} onTouchChange={setTouching} tooltipId={tooltipId} canHover={canHover} className="inset-0 size-full" />
) : null}
{interactive && present.length ? ( {dotValue !== null && dotValue !== undefined ? ( formatValue(dotValue)} startOnView={false} duration={0.2} stagger={0} className="whitespace-pre" /> ) : ( "No data" )} {inspecting ? name(active as number) : " "} ) : null} {showDelta && delta !== null ? ( ) : null}
); }