"use client";
import * as React from "react";
export interface RadarAxis {
/** Key used to look up each series' value. */
key: string;
label: string;
}
export interface RadarSeries {
/** Stable identity: colour and springs follow the id. */
id: string;
label: string;
/** Values by axis key; missing keys count as 0. */
values: Record<string, number>;
/** Series colour; falls back to the categorical palette (accent first). */
color?: string;
}
export interface RadarSpring {
stiffness: number;
damping: number;
mass: number;
}
export interface RadarChartProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
axes: RadarAxis[];
/** One to three series. */
series: RadarSeries[];
/** Accessible name; also the caption of the screen-reader table. */
label: string;
/** Value at the outer ring; 0 picks a rounded maximum from the data. */
max?: number;
/** Number of grid rings. */
levels?: number;
/** Grid ring shape. */
gridShape?: "polygon" | "circle";
/** Spring used when values change, series toggle or the chart enters. */
spring?: RadarSpring;
/** Opacity of the series fill, 0–1. */
fillOpacity?: number;
/** Draw a dot on every vertex. */
showDots?: boolean;
/** Value formatter for the tooltip, ring labels and table. */
format?: (value: number) => string;
/** Show the legend (its buttons toggle series). */
legend?: boolean;
/** Maximum chart size in px; the chart shrinks with its container. */
maxSize?: number;
/** Colour of the first series without its own colour. */
accent?: string;
}
/* --------------------------- reduced-motion hook --------------------------- */
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia(REDUCED_QUERY);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia(REDUCED_QUERY).matches,
() => false,
);
}
/* ---------------------------------- utils --------------------------------- */
// Three categorical slots, validated together for colour-vision deficiency, stepped for dark mode.
const PALETTE_VARS = "[--rc-2:#2a78d6] [--rc-3:#1baf7a] [--rc-rest:#a1a1aa] dark:[--rc-2:#3987e5] dark:[--rc-3:#199e70] dark:[--rc-rest:#71717a]";
function colorFor(s: RadarSeries, index: number) {
if (s.color) return s.color;
return index === 0 ? "var(--rc-accent)" : index < 3 ? `var(--rc-${index + 1})` : "var(--rc-rest)";
}
/** Smallest "nice" number (1, 2, 2.5, 5 × 10ⁿ) at or above `v`. */
function niceCeil(v: number) {
if (v <= 0) return 1;
const mag = Math.pow(10, Math.floor(Math.log10(v)));
for (const step of [1, 2, 2.5, 5, 10]) if (step * mag >= v) return step * mag;
return 10 * mag;
}
const DEFAULT_SPRING: RadarSpring = { stiffness: 170, damping: 20, mass: 1 };
interface SpringState {
x: number;
v: number;
}
/* -------------------------------- component -------------------------------- */
/**
* A radar (spider) chart for one to three series. Every vertex rides its own spring, so new
* data, toggled series and the entry all morph the polygons organically. Pointer, tap or
* keyboard focus on an axis highlights it and shows a tooltip; a screen-reader table mirrors the
* data. Size follows the container via ResizeObserver.
*/
export function RadarChart({
axes,
series,
label,
max = 0,
levels = 4,
gridShape = "polygon",
spring = DEFAULT_SPRING,
fillOpacity = 0.15,
showDots = true,
format,
legend = true,
maxSize = 440,
accent = "#FF7448",
className = "",
style,
...props
}: RadarChartProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const boxRef = React.useRef<HTMLDivElement>(null);
const springsRef = React.useRef<Map<string, SpringState>>(new Map());
const [width, setWidth] = React.useState(360);
const [hidden, setHidden] = React.useState<ReadonlySet<string>>(() => new Set());
const [active, setActive] = React.useState<number | null>(null);
const [focusIndex, setFocusIndex] = React.useState(0);
const hintId = React.useId();
const fmt = React.useMemo(() => {
if (format) return format;
const nf = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 });
return (v: number) => nf.format(v);
}, [format]);
// Responsive: follow the container width.
React.useEffect(() => {
const box = boxRef.current;
if (!box) return;
const ro = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width)));
ro.observe(box);
return () => ro.disconnect();
}, []);
const n = axes.length;
const size = Math.max(200, Math.min(maxSize, width || maxSize));
const pad = Math.max(40, size * 0.17);
const r = size / 2 - pad;
const c = size / 2;
const angleOf = (i: number) => (i / Math.max(1, n)) * Math.PI * 2 - Math.PI / 2;
const at = (i: number, radius: number) => ({ x: c + Math.cos(angleOf(i)) * radius, y: c + Math.sin(angleOf(i)) * radius });
const list = series.map((s, i) => ({ ...s, color: colorFor(s, i) }));
const dataMax = Math.max(0, ...series.flatMap((s) => axes.map((a) => s.values[a.key] ?? 0)));
const top = max > 0 ? max : niceCeil(dataMax);
// Spring targets as a plain, serialisable map: "seriesId|axisKey" → 0..1 of the radius.
const targets: [string, number][] = [];
for (const s of list) {
for (const a of axes) {
const value = hidden.has(s.id) ? 0 : Math.max(0, Math.min(1, (s.values[a.key] ?? 0) / top));
targets.push([`${s.id}|${a.key}`, value]);
}
}
const targetKey = JSON.stringify(targets);
const axisKeys = axes.map((a) => a.key).join("\u0000");
const seriesIds = list.map((s) => s.id).join("\u0000");
const { stiffness, damping, mass } = spring;
// Spring loop: integrates every vertex and writes polygon points / dot positions directly.
React.useEffect(() => {
const root = rootRef.current;
if (!root) return;
const goal = new Map<string, number>(JSON.parse(targetKey) as [string, number][]);
const springs = springsRef.current;
for (const key of goal.keys()) if (!springs.has(key)) springs.set(key, { x: 0, v: 0 });
const keys = axisKeys.split("\u0000");
const ids = seriesIds.split("\u0000");
const count = keys.length;
const angle = (i: number) => (i / Math.max(1, count)) * Math.PI * 2 - Math.PI / 2;
const polys = new Map<string, SVGPolygonElement>();
root.querySelectorAll<SVGPolygonElement>("[data-rc-poly]").forEach((el) => polys.set(el.dataset.rcPoly ?? "", el));
const dots = new Map<string, SVGCircleElement>();
root.querySelectorAll<SVGCircleElement>("[data-rc-dot]").forEach((el) => dots.set(el.dataset.rcDot ?? "", el));
const draw = () => {
for (const id of ids) {
const pts: string[] = [];
keys.forEach((key, i) => {
const k = `${id}|${key}`;
const radius = Math.max(0, springs.get(k)?.x ?? 0) * r;
const x = c + Math.cos(angle(i)) * radius;
const y = c + Math.sin(angle(i)) * radius;
pts.push(`${x.toFixed(2)},${y.toFixed(2)}`);
const dot = dots.get(k);
if (dot) {
dot.setAttribute("cx", x.toFixed(2));
dot.setAttribute("cy", y.toFixed(2));
}
});
polys.get(id)?.setAttribute("points", pts.join(" "));
}
};
if (reduceMotion) {
for (const [key, value] of goal) springs.set(key, { x: value, v: 0 });
draw();
return;
}
let frame = 0;
let visible = true;
let last = 0; // set by wake() before the first frame
const k = Math.max(1, stiffness);
const d = Math.max(0, damping);
const m = Math.max(0.1, mass);
const tick = (now: number) => {
const elapsed = Math.min(0.064, (now - last) / 1000);
last = now;
let moving = false;
// Fixed sub-steps keep stiff springs stable regardless of frame rate.
const steps = Math.max(1, Math.ceil(elapsed / (1 / 240)));
const h = elapsed / steps;
for (const [key, target] of goal) {
const s = springs.get(key);
if (!s) continue;
for (let i = 0; i < steps; i++) {
const a = (-k * (s.x - target) - d * s.v) / m;
s.v += a * h;
s.x += s.v * h;
}
if (Math.abs(s.x - target) > 1e-4 || Math.abs(s.v) > 1e-4) moving = true;
else {
s.x = target;
s.v = 0;
}
}
draw();
frame = moving && visible && !document.hidden ? requestAnimationFrame(tick) : 0;
};
const wake = () => {
if (frame || !visible || document.hidden) return;
last = performance.now();
frame = requestAnimationFrame(tick);
};
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
wake();
});
io.observe(root);
document.addEventListener("visibilitychange", wake);
draw();
wake();
return () => {
cancelAnimationFrame(frame);
io.disconnect();
document.removeEventListener("visibilitychange", wake);
};
}, [targetKey, axisKeys, seriesIds, stiffness, damping, mass, reduceMotion, r, c]);
const shownSeries = list.filter((s) => !hidden.has(s.id));
const tabIndexAxis = Math.min(focusIndex, Math.max(0, n - 1));
const describeAxis = (i: number) => {
const axis = axes[i];
const parts = shownSeries.map((s) => `${s.label} ${fmt(s.values[axis.key] ?? 0)}`);
return `${axis.label}: ${parts.length ? parts.join(", ") : "no series shown"}`;
};
const onKeyDown = (event: React.KeyboardEvent<SVGPathElement>, i: number) => {
let next = -1;
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (i + 1) % n;
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (i - 1 + n) % n;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = n - 1;
else if (event.key === "Escape") setActive(null);
if (next < 0) return;
event.preventDefault();
rootRef.current?.querySelector<SVGPathElement>(`[data-rc-axis="${next}"]`)?.focus();
};
const ring = (fraction: number) => {
if (gridShape === "circle") return null;
return axes.map((_, i) => {
const p = at(i, r * fraction);
return `${p.x.toFixed(2)},${p.y.toFixed(2)}`;
}).join(" ");
};
// Tooltip sits just outside the active axis label (above it on the top half, below on the
// bottom half), so it never covers the polygons or the label it describes.
let tooltip: { left: number; top: number; transform: string } | null = null;
if (active !== null && axes[active]) {
const p = at(active, r + 14);
const cos = Math.cos(angleOf(active));
const sin = Math.sin(angleOf(active));
const tx = cos > 0.3 ? "0%" : cos < -0.3 ? "-100%" : "-50%";
const ty = sin > 0.3 ? "22px" : sin < -0.3 ? "calc(-100% - 12px)" : "14px";
tooltip = { left: p.x, top: p.y, transform: `translate(${tx}, ${ty})` };
}
const half = Math.PI / Math.max(1, n);
return (
<div
ref={rootRef}
role="group"
aria-label={label}
className={`w-full text-zinc-950 dark:text-zinc-50 ${PALETTE_VARS} ${className}`}
style={{ ["--rc-accent" as string]: accent, ...style }}
{...props}
>
<div ref={boxRef} className="w-full" style={{ maxWidth: maxSize, marginInline: "auto" }}>
<div className="relative mx-auto" style={{ width: size, height: size }}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="block overflow-visible" onPointerLeave={(e) => e.pointerType === "mouse" && setActive(null)}>
{/* Grid rings and spokes (recessive). */}
<g aria-hidden="true" fill="none" className="stroke-zinc-900/10 dark:stroke-white/10">
{Array.from({ length: Math.max(1, levels) }, (_, l) => {
const f = (l + 1) / Math.max(1, levels);
return gridShape === "circle" ? (
<circle key={l} cx={c} cy={c} r={r * f} />
) : (
<polygon key={l} points={ring(f) ?? ""} />
);
})}
{axes.map((a, i) => {
const p = at(i, r);
return <line key={a.key} x1={c} y1={c} x2={p.x} y2={p.y} />;
})}
</g>
{/* Ring values along the first spoke. */}
<g aria-hidden="true" className="fill-zinc-400 font-mono text-[9px] tabular-nums dark:fill-zinc-500">
{Array.from({ length: Math.max(1, levels) }, (_, l) => {
const f = (l + 1) / Math.max(1, levels);
return (
<text key={l} x={c + 4} y={c - r * f - 3}>
{fmt(top * f)}
</text>
);
})}
</g>
{/* Active axis highlight. */}
{active !== null && axes[active] ? (
<line
aria-hidden="true"
x1={c}
y1={c}
x2={at(active, r).x}
y2={at(active, r).y}
className="stroke-zinc-900/45 dark:stroke-white/45"
strokeWidth={1.5}
/>
) : null}
{/* Series polygons (points written by the spring loop). */}
<g aria-hidden="true">
{list.map((s) => (
<polygon
key={s.id}
data-rc-poly={s.id}
fill={s.color}
fillOpacity={Math.max(0, Math.min(1, fillOpacity))}
stroke={s.color}
strokeWidth={2}
strokeLinejoin="round"
className={`transition-opacity duration-300 ${hidden.has(s.id) ? "opacity-0" : "opacity-100"}`}
/>
))}
{showDots
? list.map((s) =>
axes.map((a, i) => (
<circle
key={`${s.id}|${a.key}`}
data-rc-dot={`${s.id}|${a.key}`}
r={active === i ? 5.5 : 4}
fill={s.color}
strokeWidth={1.5}
className={`stroke-white transition-[r,opacity] duration-200 dark:stroke-zinc-900 ${hidden.has(s.id) ? "opacity-0" : "opacity-100"}`}
/>
)),
)
: null}
</g>
{/* Axis labels. */}
<g aria-hidden="true" className="font-mono text-[10px] uppercase tracking-[0.12em]">
{axes.map((a, i) => {
const p = at(i, r + 14);
const cos = Math.cos(angleOf(i));
const sin = Math.sin(angleOf(i));
return (
<text
key={a.key}
x={p.x}
y={p.y}
textAnchor={cos > 0.3 ? "start" : cos < -0.3 ? "end" : "middle"}
dominantBaseline={sin > 0.3 ? "hanging" : sin < -0.3 ? "auto" : "middle"}
className={active === i ? "fill-zinc-950 dark:fill-white" : "fill-zinc-500 dark:fill-zinc-400"}
>
{a.label}
</text>
);
})}
</g>
{/* Axis hit areas: one wedge per axis; a single tab stop with arrow keys between axes. */}
<g role="list" aria-label={`${label} axes`} aria-describedby={hintId}>
{axes.map((a, i) => {
const outer = r + 10;
const p1 = at(i, 0);
const a1 = angleOf(i) - half;
const a2 = angleOf(i) + half;
const q1 = { x: c + Math.cos(a1) * outer, y: c + Math.sin(a1) * outer };
const q2 = { x: c + Math.cos(a2) * outer, y: c + Math.sin(a2) * outer };
return (
<path
key={a.key}
role="listitem"
data-rc-axis={i}
aria-label={describeAxis(i)}
tabIndex={i === tabIndexAxis ? 0 : -1}
d={`M ${p1.x} ${p1.y} L ${q1.x} ${q1.y} A ${outer} ${outer} 0 0 1 ${q2.x} ${q2.y} Z`}
fill="transparent"
className="cursor-crosshair outline-none focus-visible:stroke-zinc-950/70 focus-visible:[stroke-width:1.5] focus-visible:[stroke-dasharray:3_4] dark:focus-visible:stroke-white/70"
onPointerEnter={(e) => e.pointerType === "mouse" && setActive(i)}
onPointerDown={() => setActive(i)}
onFocus={() => {
setFocusIndex(i);
setActive(i);
}}
onBlur={() => setActive(null)}
onKeyDown={(e) => onKeyDown(e, i)}
/>
);
})}
</g>
</svg>
{tooltip && active !== null ? (
<div
aria-hidden="true"
className="pointer-events-none absolute z-10 min-w-32 rounded-md border border-zinc-900/10 bg-white/95 px-2.5 py-2 text-xs shadow-[0_8px_24px_-12px_rgba(0,0,0,0.35)] dark:border-white/10 dark:bg-zinc-900/95"
style={{
left: tooltip.left,
top: tooltip.top,
transform: tooltip.transform,
}}
>
<div className="mb-1 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">{axes[active].label}</div>
{shownSeries.length ? (
shownSeries.map((s) => (
<div key={s.id} className="flex items-center justify-between gap-4">
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-full" style={{ backgroundColor: s.color }} />
{s.label}
</span>
<span className="font-mono tabular-nums">{fmt(s.values[axes[active].key] ?? 0)}</span>
</div>
))
) : (
<div className="text-zinc-500">No series shown</div>
)}
</div>
) : null}
</div>
</div>
{legend && list.length > 1 ? (
<ul className="mt-4 flex flex-wrap justify-center gap-1.5" aria-label={`${label} series`}>
{list.map((s) => {
const off = hidden.has(s.id);
return (
<li key={s.id}>
<button
type="button"
aria-pressed={!off}
onClick={() =>
setHidden((prev) => {
const next = new Set(prev);
if (next.has(s.id)) next.delete(s.id);
else next.add(s.id);
return next;
})
}
className={`inline-flex h-8 items-center gap-2 rounded-full border px-3 text-sm transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--rc-accent)] ${
off
? "border-dashed border-zinc-900/20 text-zinc-400 dark:border-white/20 dark:text-zinc-500"
: "border-zinc-900/15 hover:border-zinc-900/40 dark:border-white/15 dark:hover:border-white/40"
}`}
>
<span
aria-hidden="true"
className={`size-2.5 rounded-full border ${off ? "border-current" : "border-transparent"}`}
style={off ? undefined : { backgroundColor: s.color }}
/>
<span className={off ? "line-through" : ""}>{s.label}</span>
</button>
</li>
);
})}
</ul>
) : null}
<p id={hintId} className="sr-only">
Use the arrow keys to move between axes.
</p>
<table className="sr-only">
<caption>{label}</caption>
<thead>
<tr>
<th scope="col">Axis</th>
{list.map((s) => (
<th key={s.id} scope="col">
{s.label}
</th>
))}
</tr>
</thead>
<tbody>
{axes.map((a) => (
<tr key={a.key}>
<th scope="row">{a.label}</th>
{list.map((s) => (
<td key={s.id}>{fmt(s.values[a.key] ?? 0)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}