"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
/* ------------------------------------------------------------------ */
/* Theme-aware categorical palette (validated for light + dark, CVD) */
/* ------------------------------------------------------------------ */
const PALETTE =
"[--chart-1:#2a78d6] [--chart-2:#eb6834] [--chart-3:#1baf7a] [--chart-4:#eda100] [--chart-5:#e87ba4] [--chart-6:#008300] [--chart-7:#4a3aa7] [--chart-8:#e34948] " +
"dark:[--chart-1:#3987e5] dark:[--chart-2:#d95926] dark:[--chart-3:#199e70] dark:[--chart-4:#c98500] dark:[--chart-5:#d55181] dark:[--chart-7:#9085e9] dark:[--chart-8:#e66767]";
export type AreaSeries = {
id: string;
name: string;
data: number[];
/** Any CSS colour. Defaults to the palette slot for the series index. */
color?: string;
/** Start hidden (can be toggled on in the legend). */
hidden?: boolean;
};
export type AreaChartProps = {
labels: string[];
series: AreaSeries[];
title?: string;
description?: string;
/** Big number shown under the title (e.g. a total). */
headline?: React.ReactNode;
curve?: "smooth" | "linear";
/** Draw gradient fills under the lines. */
area?: boolean;
height?: number;
showGrid?: boolean;
showLegend?: boolean;
/** Include zero in the y-domain (default true). */
zeroBaseline?: boolean;
valueFormatter?: (value: number) => string;
/** Formats the tooltip header / announcement for an x label. */
labelFormatter?: (label: string, index: number) => string;
/** Shows a total row in the tooltip. */
showTotal?: boolean;
actions?: React.ReactNode;
className?: string;
};
const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 });
const defaultFormat = (v: number) => compact.format(v);
function useElementWidth<T extends HTMLElement>() {
const ref = React.useRef<T>(null);
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver((entries) => setWidth(Math.floor(entries[0].contentRect.width)));
ro.observe(el);
return () => ro.disconnect();
}, []);
return [ref, width] as const;
}
function niceStep(raw: number) {
if (raw <= 0 || !Number.isFinite(raw)) return 1;
const exp = 10 ** Math.floor(Math.log10(raw));
const f = raw / exp;
return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10) * exp;
}
function niceTicks(min: number, max: number, count = 5) {
if (min === max) max = min + 1;
const step = niceStep((max - min) / count);
const lo = Math.floor(min / step) * step;
const hi = Math.ceil(max / step) * step;
const ticks: number[] = [];
for (let v = lo; v <= hi + step / 2; v += step) ticks.push(Number(v.toFixed(10)));
return ticks;
}
/** Monotone cubic interpolation — smooth, never overshoots the data. */
function smoothPath(pts: [number, number][]) {
const n = pts.length;
if (n < 2) return n ? `M${pts[0][0]},${pts[0][1]}` : "";
const dx: number[] = [];
const m: number[] = [];
for (let i = 0; i < n - 1; i++) {
dx[i] = pts[i + 1][0] - pts[i][0];
m[i] = dx[i] ? (pts[i + 1][1] - pts[i][1]) / dx[i] : 0;
}
const t: number[] = [m[0]];
for (let i = 1; i < n - 1; i++) t[i] = m[i - 1] * m[i] <= 0 ? 0 : (m[i - 1] + m[i]) / 2;
t[n - 1] = m[n - 2];
for (let i = 0; i < n - 1; i++) {
if (m[i] === 0) {
t[i] = 0;
t[i + 1] = 0;
continue;
}
const a = t[i] / m[i];
const b = t[i + 1] / m[i];
const s = a * a + b * b;
if (s > 9) {
const k = 3 / Math.sqrt(s);
t[i] = k * a * m[i];
t[i + 1] = k * b * m[i];
}
}
let d = `M${pts[0][0]},${pts[0][1]}`;
for (let i = 0; i < n - 1; i++) {
const h = dx[i] / 3;
d += `C${pts[i][0] + h},${pts[i][1] + t[i] * h},${pts[i + 1][0] - h},${pts[i + 1][1] - t[i + 1] * h},${pts[i + 1][0]},${pts[i + 1][1]}`;
}
return d;
}
/** Linear path written with the same command count as the smooth one so both can morph. */
function linearPath(pts: [number, number][]) {
if (pts.length < 2) return pts.length ? `M${pts[0][0]},${pts[0][1]}` : "";
let d = `M${pts[0][0]},${pts[0][1]}`;
for (let i = 1; i < pts.length; i++) {
const [x0, y0] = pts[i - 1];
const [x1, y1] = pts[i];
d += `C${x0 + (x1 - x0) / 3},${y0 + (y1 - y0) / 3},${x0 + ((x1 - x0) * 2) / 3},${y0 + ((y1 - y0) * 2) / 3},${x1},${y1}`;
}
return d;
}
const MARGIN = { top: 12, right: 12, bottom: 28 };
export function AreaChart({
labels,
series,
title,
description,
headline,
curve = "smooth",
area = true,
height = 280,
showGrid = true,
showLegend = true,
zeroBaseline = true,
valueFormatter = defaultFormat,
labelFormatter = (l) => l,
showTotal = false,
actions,
className,
}: AreaChartProps) {
const reduce = useReducedMotion();
const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
const [wrapRef, width] = useElementWidth<HTMLDivElement>();
const [hidden, setHidden] = React.useState<Set<string>>(() => new Set(series.filter((s) => s.hidden).map((s) => s.id)));
const [active, setActive] = React.useState<number | null>(null);
const [focused, setFocused] = React.useState(false);
const [drawn, setDrawn] = React.useState(false);
const colored = series.map((s, i) => ({ ...s, color: s.color ?? `var(--chart-${(i % 8) + 1})` }));
const visible = colored.filter((s) => !hidden.has(s.id));
const values = visible.flatMap((s) => s.data);
const dataMin = values.length ? Math.min(...values) : 0;
const dataMax = values.length ? Math.max(...values) : 1;
const ticks = niceTicks(zeroBaseline ? Math.min(0, dataMin) : dataMin, dataMax, height < 200 ? 3 : 5);
const yMin = ticks[0];
const yMax = ticks[ticks.length - 1];
const longestTick = Math.max(...ticks.map((t) => valueFormatter(t).length));
const left = Math.max(28, longestTick * 6.6 + 12);
const innerW = Math.max(0, width - left - MARGIN.right);
const innerH = Math.max(0, height - MARGIN.top - MARGIN.bottom);
const n = labels.length;
const x = (i: number) => left + (n <= 1 ? innerW / 2 : (i / (n - 1)) * innerW);
const y = (v: number) => MARGIN.top + innerH - ((v - yMin) / (yMax - yMin || 1)) * innerH;
const baseY = y(Math.max(yMin, Math.min(0, yMax)));
const labelEvery = Math.max(1, Math.ceil((n * 56) / Math.max(innerW, 1)));
const paths = colored.map((s) => {
const pts = s.data.map((v, i) => [x(i), y(v)] as [number, number]);
const line = curve === "smooth" ? smoothPath(pts) : linearPath(pts);
const fill = pts.length ? `${line}L${pts[pts.length - 1][0]},${baseY}L${pts[0][0]},${baseY}Z` : "";
return { ...s, line, fill };
});
const toggle = (id: string) =>
setHidden((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else if (colored.length - next.size > 1) next.add(id);
return next;
});
const indexFromPointer = (clientX: number, rect: DOMRect) => {
const px = clientX - rect.left - left;
return Math.max(0, Math.min(n - 1, Math.round((px / Math.max(innerW, 1)) * (n - 1))));
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (!n) return;
const cur = active ?? n - 1;
let next: number | null = cur;
if (e.key === "ArrowRight") next = Math.min(n - 1, cur + 1);
else if (e.key === "ArrowLeft") next = Math.max(0, cur - 1);
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = n - 1;
else if (e.key === "Escape") next = null;
else return;
e.preventDefault();
setActive(next);
};
const tooltipRows = active === null ? [] : visible.map((s) => ({ ...s, value: s.data[active] ?? 0 }));
const total = tooltipRows.reduce((a, r) => a + r.value, 0);
const announce =
active === null ? "" : `${labelFormatter(labels[active], active)}: ${tooltipRows.map((r) => `${r.name} ${valueFormatter(r.value)}`).join(", ")}`;
const flip = active !== null && x(active) > width * 0.6;
const ready = width > 0;
return (
<figure className={cn("w-full rounded-xl border bg-card p-4 text-card-foreground shadow-xs sm:p-5", PALETTE, className)}>
{(title || headline || showLegend || actions) && (
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
{title && <figcaption className="text-sm font-medium text-muted-foreground">{title}</figcaption>}
{headline && <div className="mt-1 text-2xl font-semibold tracking-tight">{headline}</div>}
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
</div>
<div className="flex flex-wrap items-center gap-2">
{showLegend && colored.length > 1 && (
<div role="group" aria-label="Toggle series" className="flex flex-wrap items-center gap-1">
{colored.map((s) => {
const on = !hidden.has(s.id);
return (
<button
key={s.id}
type="button"
aria-pressed={on}
onClick={() => toggle(s.id)}
className={cn(
"inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring",
on ? "text-foreground" : "text-muted-foreground/70 line-through decoration-muted-foreground/50",
)}
>
<span
aria-hidden
className={cn("h-2 w-2 rounded-full transition-opacity", !on && "opacity-30")}
style={{ background: s.color }}
/>
{s.name}
</button>
);
})}
</div>
)}
{actions}
</div>
</div>
)}
<div
ref={wrapRef}
tabIndex={0}
role="group"
aria-label={`${title ?? "Chart"}. Use the left and right arrow keys to inspect values.`}
onKeyDown={onKeyDown}
onFocus={() => setFocused(true)}
onBlur={() => {
setFocused(false);
setActive(null);
}}
className="relative w-full rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-card"
style={{ height }}
>
{ready && (
<svg
width={width}
height={height}
className="block touch-pan-y overflow-visible select-none"
aria-hidden
onPointerMove={(e) => setActive(indexFromPointer(e.clientX, e.currentTarget.getBoundingClientRect()))}
onPointerLeave={() => {
if (!focused) setActive(null);
}}
>
<defs>
{paths.map((s) => (
<linearGradient key={s.id} id={`${uid}-g-${s.id}`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" style={{ stopColor: s.color, stopOpacity: 0.28 }} />
<stop offset="95%" style={{ stopColor: s.color, stopOpacity: 0 }} />
</linearGradient>
))}
<clipPath id={`${uid}-clip`}>
<motion.rect
x={0}
y={0}
height={height}
initial={{ width: reduce ? width : 0 }}
animate={{ width }}
transition={{ duration: reduce ? 0 : 1.1, ease: [0.22, 1, 0.36, 1] }}
onAnimationComplete={() => setDrawn(true)}
/>
</clipPath>
</defs>
{/* grid + y axis */}
{ticks.map((t) => (
<g key={t}>
{showGrid && (
<line
x1={left}
x2={left + innerW}
y1={y(t)}
y2={y(t)}
className={cn("stroke-border", t !== 0 && "[stroke-dasharray:2_4]")}
strokeWidth={1}
/>
)}
<text x={left - 10} y={y(t)} dy="0.32em" textAnchor="end" className="fill-muted-foreground text-[11px] tabular-nums">
{valueFormatter(t)}
</text>
</g>
))}
{/* x labels */}
{labels.map((l, i) =>
i % labelEvery === 0 ? (
<text
key={`${l}-${i}`}
x={x(i)}
y={height - 8}
textAnchor={i === 0 ? "start" : i === n - 1 ? "end" : "middle"}
className={cn("fill-muted-foreground text-[11px] transition-colors", active === i && "fill-foreground")}
>
{l}
</text>
) : null,
)}
{/* series */}
<g clipPath={drawn ? undefined : `url(#${uid}-clip)`}>
{paths.map((s) => {
const on = !hidden.has(s.id);
return (
<g key={s.id}>
{area && (
<motion.path
initial={false}
animate={{ d: s.fill, opacity: on ? 1 : 0 }}
transition={{ duration: reduce ? 0 : 0.5, ease: [0.22, 1, 0.36, 1] }}
fill={`url(#${uid}-g-${s.id})`}
/>
)}
<motion.path
initial={false}
animate={{ d: s.line, opacity: on ? 1 : 0 }}
transition={{ duration: reduce ? 0 : 0.5, ease: [0.22, 1, 0.36, 1] }}
fill="none"
stroke={s.color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
);
})}
</g>
{/* crosshair */}
{active !== null && (
<g pointerEvents="none">
<line
x1={x(active)}
x2={x(active)}
y1={MARGIN.top}
y2={MARGIN.top + innerH}
className="stroke-muted-foreground/40"
strokeWidth={1}
/>
{visible.map((s) => (
<circle
key={s.id}
cx={x(active)}
cy={y(s.data[active] ?? 0)}
r={4.5}
fill={s.color}
className="stroke-card"
strokeWidth={2}
/>
))}
</g>
)}
{/* hit area */}
<rect x={left} y={0} width={innerW} height={height} fill="transparent" />
</svg>
)}
{ready && active !== null && (
<div
className="pointer-events-none absolute top-1 z-10 min-w-40 rounded-lg border bg-popover/95 px-3 py-2 text-xs text-popover-foreground shadow-lg backdrop-blur-sm"
style={flip ? { right: width - x(active) + 12 } : { left: x(active) + 12 }}
>
<p className="mb-1.5 font-medium">{labelFormatter(labels[active], active)}</p>
<ul className="space-y-1">
{tooltipRows.map((r) => (
<li key={r.id} className="flex items-center gap-2">
<span aria-hidden className="h-2.5 w-1 rounded-full" style={{ background: r.color }} />
<span className="text-muted-foreground">{r.name}</span>
<span className="ml-auto pl-4 font-medium tabular-nums">{valueFormatter(r.value)}</span>
</li>
))}
{showTotal && tooltipRows.length > 1 && (
<li className="mt-1 flex items-center gap-2 border-t pt-1.5">
<span className="text-muted-foreground">Total</span>
<span className="ml-auto pl-4 font-semibold tabular-nums">{valueFormatter(total)}</span>
</li>
)}
</ul>
</div>
)}
<p aria-live="polite" className="sr-only">
{announce}
</p>
</div>
{/* Screen-reader data table */}
<table className="sr-only">
<caption>{title ?? "Chart data"}</caption>
<thead>
<tr>
<th scope="col">Label</th>
{colored.map((s) => (
<th key={s.id} scope="col">
{s.name}
</th>
))}
</tr>
</thead>
<tbody>
{labels.map((l, i) => (
<tr key={`${l}-${i}`}>
<th scope="row">{labelFormatter(l, i)}</th>
{colored.map((s) => (
<td key={s.id}>{valueFormatter(s.data[i] ?? 0)}</td>
))}
</tr>
))}
</tbody>
</table>
</figure>
);
}