"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export interface ChartDatum {
label: string;
value: number;
}
export interface InteractiveChartProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
data: ChartDatum[];
/** Line, dot and area colour. */
accent?: string;
curve?: "linear" | "smooth";
showArea?: boolean;
showGrid?: boolean;
/** Draw a marker on every data point. */
showDots?: boolean;
/** Chart height in px (width follows the container). */
height?: number;
/** Draw the line in on mount. */
animate?: boolean;
/** Show a button that reveals the data table visually (it is always available to screen readers). */
showTableToggle?: boolean;
/** Accessible name and table caption. */
label?: string;
/** Formats values in the tooltip, table and announcements. */
valueFormatter?: (value: number) => string;
/** Formats y-axis ticks (defaults to compact notation). */
tickFormatter?: (value: number) => string;
}
const MARGIN = { top: 16, right: 16, bottom: 28, left: 44 };
const TOOLTIP_W = 152;
const TOOLTIP_H = 66;
const defaultValueFormat = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 });
const defaultTickFormat = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 });
const percentFormat = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 1, signDisplay: "exceptZero" });
const percentAbsFormat = new Intl.NumberFormat("en-US", { style: "percent", maximumFractionDigits: 1 });
function niceStep(raw: number) {
if (!Number.isFinite(raw) || raw <= 0) return 1;
const pow = Math.pow(10, Math.floor(Math.log10(raw)));
const err = raw / pow;
return (err >= 7.5 ? 10 : err >= 3.5 ? 5 : err >= 1.5 ? 2 : 1) * pow;
}
type Point = [number, number];
function linearPath(points: Point[]) {
return points.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(2)},${y.toFixed(2)}`).join("");
}
/** Monotone cubic interpolation (Fritsch–Carlson): smooth, but never overshoots the data. */
function monotonePath(points: Point[]) {
const n = points.length;
if (n < 3) return linearPath(points);
const secants: number[] = [];
for (let i = 0; i < n - 1; i++) {
const dx = points[i + 1][0] - points[i][0];
secants.push(dx === 0 ? 0 : (points[i + 1][1] - points[i][1]) / dx);
}
const tangents: number[] = [secants[0]];
for (let i = 1; i < n - 1; i++) {
tangents.push(secants[i - 1] * secants[i] <= 0 ? 0 : (secants[i - 1] + secants[i]) / 2);
}
tangents.push(secants[n - 2]);
for (let i = 0; i < n - 1; i++) {
if (secants[i] === 0) {
tangents[i] = 0;
tangents[i + 1] = 0;
continue;
}
const a = tangents[i] / secants[i];
const b = tangents[i + 1] / secants[i];
const s = a * a + b * b;
if (s > 9) {
const t = 3 / Math.sqrt(s);
tangents[i] = t * a * secants[i];
tangents[i + 1] = t * b * secants[i];
}
}
let d = `M${points[0][0].toFixed(2)},${points[0][1].toFixed(2)}`;
for (let i = 0; i < n - 1; i++) {
const [x0, y0] = points[i];
const [x1, y1] = points[i + 1];
const h = (x1 - x0) / 3;
d += `C${(x0 + h).toFixed(2)},${(y0 + tangents[i] * h).toFixed(2)},${(x1 - h).toFixed(2)},${(y1 - tangents[i + 1] * h).toFixed(2)},${x1.toFixed(2)},${y1.toFixed(2)}`;
}
return d;
}
/**
* A responsive SVG line/area chart with a tracking crosshair, spring-animated selection dot and a
* custom tooltip. Fully operable from the keyboard and backed by a data table for assistive technology.
*/
export function InteractiveChart({
data,
accent = "#FF7448",
curve = "smooth",
showArea = true,
showGrid = true,
showDots = false,
height = 260,
animate = true,
showTableToggle = true,
label = "Chart",
valueFormatter,
tickFormatter,
className = "",
style,
...props
}: InteractiveChartProps) {
const reduceMotion = useReducedMotion();
const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
const plotRef = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(0);
const [selected, setSelected] = React.useState<number | null>(null);
const [tableOpen, setTableOpen] = React.useState(false);
React.useEffect(() => {
const node = plotRef.current;
if (!node) return;
const observer = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width)));
observer.observe(node);
return () => observer.disconnect();
}, []);
const formatValue = valueFormatter ?? ((v: number) => defaultValueFormat.format(v));
const formatTick = tickFormatter ?? ((v: number) => defaultTickFormat.format(v));
const n = data.length;
const values = data.map((d) => d.value);
const rawMin = n ? Math.min(0, ...values) : 0;
const rawMax = n ? Math.max(...values) : 1;
const step = niceStep((rawMax - rawMin || 1) / 4);
const yMin = Math.floor(rawMin / step) * step;
const yMax = Math.max(yMin + step, Math.ceil(rawMax / step) * step);
const ticks: number[] = [];
for (let t = yMin; t <= yMax + step / 2; t += step) ticks.push(Number(t.toFixed(10)));
const innerW = Math.max(0, width - MARGIN.left - MARGIN.right);
const innerH = Math.max(0, height - MARGIN.top - MARGIN.bottom);
const xAt = (i: number) => MARGIN.left + (n <= 1 ? innerW / 2 : (i / (n - 1)) * innerW);
const yAt = (v: number) => MARGIN.top + innerH - ((v - yMin) / (yMax - yMin)) * innerH;
const points: Point[] = data.map((d, i) => [xAt(i), yAt(d.value)]);
const linePath = n ? (curve === "smooth" ? monotonePath(points) : linearPath(points)) : "";
const baseY = yAt(Math.max(yMin, Math.min(0, yMax)));
const areaPath = n
? `${linePath}L${points[n - 1][0].toFixed(2)},${baseY.toFixed(2)}L${points[0][0].toFixed(2)},${baseY.toFixed(2)}Z`
: "";
// Thin x labels so they stay at least ~64px apart.
const labelEvery = Math.max(1, Math.ceil(n / Math.max(1, Math.floor(innerW / 64))));
const active = selected !== null && selected < n ? selected : null;
const deltaOf = (i: number) => {
if (i <= 0) return null;
const prev = data[i - 1].value;
return prev === 0 ? null : (data[i].value - prev) / Math.abs(prev);
};
const announce = (i: number) => {
const delta = deltaOf(i);
const change =
delta === null ? "" : `, ${delta > 0 ? "up" : delta < 0 ? "down" : "unchanged"} ${percentAbsFormat.format(Math.abs(delta))} from ${data[i - 1].label}`;
return `${data[i].label}: ${formatValue(data[i].value)}${change}`;
};
const indexFromPointer = (event: React.PointerEvent<SVGSVGElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const x = event.clientX - rect.left - MARGIN.left;
if (n <= 1) return 0;
return Math.min(n - 1, Math.max(0, Math.round((x / innerW) * (n - 1))));
};
const handlePointerMove = (event: React.PointerEvent<SVGSVGElement>) => {
if (!n || innerW <= 0) return;
const i = indexFromPointer(event);
// Only re-render when the nearest point actually changes.
if (i !== selected) setSelected(i);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!n) return;
const current = active ?? n - 1;
const next: Record<string, number> = {
ArrowLeft: Math.max(0, current - 1),
ArrowRight: Math.min(n - 1, current + 1),
Home: 0,
End: n - 1,
};
if (event.key in next) {
event.preventDefault();
setSelected(next[event.key]);
} else if (event.key === "Escape") {
setSelected(null);
}
};
const transition = reduceMotion ? { duration: 0 } : ({ type: "spring", stiffness: 420, damping: 34, mass: 0.6 } as const);
const px = active !== null ? points[active][0] : 0;
const py = active !== null ? points[active][1] : 0;
const tipX = Math.min(Math.max(px - TOOLTIP_W / 2, 0), Math.max(0, width - TOOLTIP_W));
const tipY = py - TOOLTIP_H - 14 < 0 ? py + 14 : py - TOOLTIP_H - 14;
const activeDelta = active !== null ? deltaOf(active) : null;
const descriptionId = `${uid}-desc`;
const tableId = `${uid}-table`;
const minIndex = values.indexOf(Math.min(...values));
const maxIndex = values.indexOf(Math.max(...values));
return (
<div
className={`w-full text-zinc-900 dark:text-zinc-100 ${className}`}
style={{ ["--ch-accent" as string]: accent, ...style }}
{...props}
>
<div
ref={plotRef}
tabIndex={n ? 0 : -1}
role="group"
aria-roledescription="interactive chart"
aria-label={label}
aria-describedby={descriptionId}
onKeyDown={handleKeyDown}
onFocus={(e) => {
if (e.target === e.currentTarget && n) setSelected((s) => s ?? n - 1);
}}
onBlur={() => setSelected(null)}
className="relative w-full rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-[var(--ch-accent)] focus-visible:ring-offset-4 focus-visible:ring-offset-transparent"
style={{ height }}
>
<p id={descriptionId} className="sr-only">
{n
? `${n} points from ${data[0].label} to ${data[n - 1].label}. Lowest ${data[minIndex].label} at ${formatValue(data[minIndex].value)}, highest ${data[maxIndex].label} at ${formatValue(data[maxIndex].value)}. Use the left and right arrow keys to inspect values.`
: "No data."}
</p>
<p className="sr-only" aria-live="polite" aria-atomic="true">
{active !== null ? announce(active) : ""}
</p>
{width > 0 ? (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="block touch-pan-y select-none overflow-visible"
aria-hidden="true"
onPointerMove={handlePointerMove}
onPointerDown={handlePointerMove}
onPointerLeave={(e) => {
if (e.pointerType === "mouse") setSelected(null);
}}
>
<defs>
<linearGradient id={`${uid}-fill`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={accent} stopOpacity={0.28} />
<stop offset="100%" stopColor={accent} stopOpacity={0} />
</linearGradient>
</defs>
{/* Grid and y-axis */}
{ticks.map((t) => (
<g key={t}>
{showGrid ? (
<line
x1={MARGIN.left}
x2={width - MARGIN.right}
y1={yAt(t)}
y2={yAt(t)}
className="stroke-zinc-900/10 dark:stroke-white/10"
strokeDasharray={t === yMin ? undefined : "2 4"}
/>
) : null}
<text
x={MARGIN.left - 10}
y={yAt(t)}
dy="0.32em"
textAnchor="end"
className="fill-zinc-500 font-mono text-[10px] tabular-nums dark:fill-zinc-400"
>
{formatTick(t)}
</text>
</g>
))}
{/* X labels */}
{data.map((d, i) =>
// Always label the last point; drop a regular label that would collide with it.
(i % labelEvery === 0 && (i === 0 || n - 1 - i >= labelEvery * 0.6)) || i === n - 1 ? (
<text
key={`${d.label}-${i}`}
x={xAt(i)}
y={height - 8}
textAnchor={i === 0 ? "start" : i === n - 1 ? "end" : "middle"}
className={`font-mono text-[10px] uppercase tracking-[0.08em] transition-colors ${
i === active ? "fill-zinc-900 dark:fill-zinc-100" : "fill-zinc-500 dark:fill-zinc-400"
}`}
>
{d.label}
</text>
) : null,
)}
{showArea && n > 1 ? (
<motion.path
d={areaPath}
fill={`url(#${uid}-fill)`}
initial={animate && !reduceMotion ? { opacity: 0 } : false}
animate={{ opacity: 1 }}
transition={{ duration: 0.8, delay: 0.3 }}
/>
) : null}
{n > 1 ? (
<motion.path
d={linePath}
fill="none"
stroke={accent}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
initial={animate && !reduceMotion ? { pathLength: 0 } : false}
animate={{ pathLength: 1 }}
transition={{ duration: 1.1, ease: [0.22, 1, 0.36, 1] }}
/>
) : null}
{showDots
? points.map(([x, y], i) => (
<circle
key={i}
cx={x}
cy={y}
r={2.5}
className="fill-white dark:fill-zinc-900"
stroke={accent}
strokeWidth={1.5}
/>
))
: null}
<AnimatePresence>
{active !== null ? (
<motion.g
key="selection"
initial={{ opacity: 0, x: px }}
animate={{ opacity: 1, x: px }}
exit={{ opacity: 0 }}
transition={transition}
>
<line
x1={0}
x2={0}
y1={MARGIN.top}
y2={MARGIN.top + innerH}
className="stroke-zinc-900/30 dark:stroke-white/30"
strokeDasharray="3 3"
/>
<motion.g initial={{ y: py }} animate={{ y: py }} transition={transition}>
<circle r={9} fill={accent} opacity={0.18} />
<circle r={4.5} fill={accent} className="stroke-white dark:stroke-zinc-950" strokeWidth={2} />
</motion.g>
</motion.g>
) : null}
</AnimatePresence>
</svg>
) : null}
<AnimatePresence>
{active !== null && width > 0 ? (
<motion.div
key="tooltip"
aria-hidden="true"
className="pointer-events-none absolute top-0 left-0 rounded-lg border border-zinc-900/10 bg-white/95 px-3 py-2 shadow-[0_12px_32px_-16px_rgba(0,0,0,0.4)] backdrop-blur-sm dark:border-white/10 dark:bg-zinc-900/95"
style={{ width: TOOLTIP_W, height: TOOLTIP_H }}
initial={{ opacity: 0, x: tipX, y: tipY + 4 }}
animate={{ opacity: 1, x: tipX, y: tipY }}
exit={{ opacity: 0, transition: { duration: 0.12 } }}
transition={transition}
>
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{data[active].label}
</div>
<div className="mt-0.5 text-lg font-semibold leading-tight tracking-tight tabular-nums">
{formatValue(data[active].value)}
</div>
{activeDelta !== null ? (
<div
className={`mt-0.5 flex items-center gap-1 font-mono text-[10px] tabular-nums ${
activeDelta > 0
? "text-emerald-600 dark:text-emerald-400"
: activeDelta < 0
? "text-rose-600 dark:text-rose-400"
: "text-zinc-500"
}`}
>
<svg viewBox="0 0 8 8" className={`size-2 ${activeDelta < 0 ? "rotate-180" : ""}`} aria-hidden="true">
{activeDelta === 0 ? <rect x="1" y="3.5" width="6" height="1" fill="currentColor" /> : <path d="M4 1 7 6H1z" fill="currentColor" />}
</svg>
{percentFormat.format(activeDelta)} vs {data[active - 1].label}
</div>
) : (
<div className="mt-0.5 font-mono text-[10px] text-zinc-500 dark:text-zinc-400">First point</div>
)}
</motion.div>
) : null}
</AnimatePresence>
</div>
{showTableToggle ? (
<div className="mt-3 flex justify-end">
<button
type="button"
aria-expanded={tableOpen}
aria-controls={tableId}
onClick={() => setTableOpen((o) => !o)}
className="inline-flex h-7 items-center gap-1.5 rounded-full border border-zinc-900/15 px-3 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-600 transition-colors hover:border-zinc-900/40 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ch-accent)] dark:border-white/15 dark:text-zinc-300 dark:hover:border-white/40 dark:hover:text-white"
>
<svg viewBox="0 0 12 12" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden="true">
<rect x="1.5" y="1.5" width="9" height="9" rx="1" />
<path d="M1.5 4.5h9M1.5 7.5h9M5 4.5v6" />
</svg>
{tableOpen ? "Hide table" : "Show table"}
</button>
</div>
) : null}
<div id={tableId} className={tableOpen && showTableToggle ? "mt-3 max-h-56 overflow-auto rounded-lg border border-zinc-900/10 dark:border-white/10" : "sr-only"}>
<table className="w-full border-collapse text-left text-sm">
<caption className="sr-only">{label}</caption>
<thead>
<tr className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
<th scope="col" className="px-3 py-2 font-normal">
Label
</th>
<th scope="col" className="px-3 py-2 text-right font-normal">
Value
</th>
</tr>
</thead>
<tbody>
{data.map((d, i) => (
<tr key={`${d.label}-${i}`} className="border-t border-zinc-900/5 dark:border-white/5">
<th scope="row" className="px-3 py-1.5 font-normal">
{d.label}
</th>
<td className="px-3 py-1.5 text-right tabular-nums">{formatValue(d.value)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}