"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
/* Theme-aware categorical palette (validated for light + dark and colour-vision deficiency). */
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 DonutSlice = { id?: string; label: string; value: number; color?: string };
export type DonutChartProps = {
data: DonutSlice[];
title?: string;
description?: string;
/** Caption above the centre value when nothing is hovered. */
centerLabel?: string;
/** Max diameter in px (shrinks to fit the container). */
size?: number;
thickness?: number;
/** Gap between segments, in degrees. */
padAngle?: number;
valueFormatter?: (v: number) => string;
/** Formatter for the big centre value (defaults to valueFormatter). */
centerFormatter?: (v: number) => string;
showLegend?: boolean;
/** Clicking a legend row hides that slice and re-balances the rest. */
toggleable?: boolean;
className?: string;
};
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;
}
/** 0 → 1 over `ms`, eased; jumps to 1 when motion is reduced. */
function useDrawProgress(ms: number, skip: boolean) {
const [p, setP] = React.useState(skip ? 1 : 0);
React.useEffect(() => {
if (skip) return;
let raf = 0;
const t0 = performance.now();
const tick = (t: number) => {
const k = Math.min(1, (t - t0) / ms);
setP(1 - Math.pow(1 - k, 3));
if (k < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [ms, skip]);
return skip ? 1 : p;
}
/** Tweens an array of numbers toward `target` whenever it changes. */
function useTweened(target: number[], ms: number, skip: boolean) {
const key = target.join(",");
const [value, setValue] = React.useState(target);
const current = React.useRef(target);
React.useEffect(() => {
const to = key.split(",").map(Number);
const from = current.current;
if (skip || from.length !== to.length) {
current.current = to;
return;
}
let raf = 0;
const t0 = performance.now();
const tick = (t: number) => {
const k = Math.min(1, (t - t0) / ms);
const e = 1 - Math.pow(1 - k, 3);
const next = to.map((v, i) => from[i] + (v - from[i]) * e);
current.current = next;
setValue(next);
if (k < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [key, ms, skip]);
return skip || value.length !== target.length ? target : value;
}
function polar(cx: number, cy: number, r: number, a: number): [number, number] {
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
}
function arcPath(cx: number, cy: number, r0: number, r1: number, a0: number, a1: number) {
const sweep = Math.min(a1 - a0, Math.PI * 2 - 1e-4);
if (sweep <= 0) return "";
const end = a0 + sweep;
const large = sweep > Math.PI ? 1 : 0;
const [x0, y0] = polar(cx, cy, r1, a0);
const [x1, y1] = polar(cx, cy, r1, end);
const [x2, y2] = polar(cx, cy, r0, end);
const [x3, y3] = polar(cx, cy, r0, a0);
return `M${x0},${y0}A${r1},${r1},0,${large},1,${x1},${y1}L${x2},${y2}A${r0},${r0},0,${large},0,${x3},${y3}Z`;
}
const fmt = new Intl.NumberFormat("en-US");
export function DonutChart({
data,
title,
description,
centerLabel = "Total",
size = 220,
thickness = 26,
padAngle = 1.6,
valueFormatter = (v) => fmt.format(v),
centerFormatter,
showLegend = true,
toggleable = true,
className,
}: DonutChartProps) {
const reduce = !!useReducedMotion();
const [wrapRef, width] = useElementWidth<HTMLDivElement>();
const progress = useDrawProgress(1000, reduce);
const [active, setActive] = React.useState<string | null>(null);
const [hidden, setHidden] = React.useState<Set<string>>(new Set());
const slices = data.map((d, i) => ({ ...d, key: d.id ?? d.label, color: d.color ?? `var(--chart-${(i % 8) + 1})` }));
const shown = slices.filter((s) => !hidden.has(s.key));
const total = shown.reduce((a, s) => a + Math.max(0, s.value), 0);
const side = width >= 460;
const diameter = Math.max(120, Math.min(size, side ? width * 0.48 : width));
const cx = diameter / 2;
const cy = diameter / 2;
const explode = 6;
const r1 = diameter / 2 - explode - 2;
const r0 = Math.max(8, r1 - thickness);
const pad = shown.length > 1 ? (padAngle * Math.PI) / 180 : 0;
const targetFracs = slices.map((s) => (!hidden.has(s.key) && total > 0 ? Math.max(0, s.value) / total : 0));
const fracs = useTweened(targetFracs, 450, reduce);
const starts: number[] = [];
for (let i = 0, acc = -Math.PI / 2; i < slices.length; i++) {
starts.push(acc);
acc += fracs[i] * Math.PI * 2 * progress;
}
const arcs = slices.map((s, i) => {
const on = !hidden.has(s.key);
const frac = targetFracs[i];
const sweep = fracs[i] * Math.PI * 2 * progress;
const a0 = starts[i];
const mid = a0 + sweep / 2;
const inset = sweep > pad ? pad / 2 : sweep / 2;
return { ...s, on, frac, a0: a0 + inset, a1: a0 + sweep - inset, mid };
});
const activeSlice = arcs.find((a) => a.key === active && a.on) ?? null;
const pctFmt = (f: number) => `${(f * 100).toFixed(f < 0.1 ? 1 : 0)}%`;
const toggle = (key: string) =>
setHidden((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else if (slices.length - next.size > 1) next.add(key);
return next;
});
return (
<figure className={cn("w-full rounded-xl border bg-card p-4 text-card-foreground shadow-xs sm:p-5", PALETTE, className)}>
{(title || description) && (
<div className="mb-4">
{title && <figcaption className="text-sm font-medium">{title}</figcaption>}
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
</div>
)}
<div ref={wrapRef} className={cn("flex w-full items-center gap-6", side ? "flex-row" : "flex-col")}>
{width > 0 && (
<div className="relative shrink-0" style={{ width: diameter, height: diameter }}>
<svg width={diameter} height={diameter} role="img" aria-label={`${title ?? "Donut chart"}: ${shown.map((s) => `${s.label} ${pctFmt(total ? s.value / total : 0)}`).join(", ")}`}>
<circle cx={cx} cy={cy} r={(r0 + r1) / 2} fill="none" className="stroke-muted" strokeWidth={r1 - r0} opacity={0.6} />
{arcs.map((a) => {
if (a.a1 <= a.a0) return null;
const isActive = activeSlice?.key === a.key;
const dx = isActive ? Math.cos(a.mid) * explode : 0;
const dy = isActive ? Math.sin(a.mid) * explode : 0;
return (
<motion.path
key={a.key}
d={arcPath(cx, cy, r0, r1, a.a0, a.a1)}
fill={a.color}
initial={false}
animate={{ x: dx, y: dy, opacity: activeSlice && !isActive ? 0.45 : 1 }}
transition={{ type: "spring", stiffness: 420, damping: 28 }}
onPointerEnter={() => setActive(a.key)}
onPointerLeave={() => setActive(null)}
className="cursor-pointer"
/>
);
})}
</svg>
<div className="pointer-events-none absolute inset-0 grid place-items-center text-center">
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={activeSlice?.key ?? "__total"}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.18 }}
className="px-2"
style={{ maxWidth: r0 * 1.7 }}
>
<p className="truncate text-xs text-muted-foreground">{activeSlice ? activeSlice.label : centerLabel}</p>
<p className={cn("font-semibold tracking-tight tabular-nums", diameter > 180 ? "text-2xl" : "text-lg")}>
{(centerFormatter ?? valueFormatter)(activeSlice ? activeSlice.value : total)}
</p>
{activeSlice && <p className="text-xs font-medium text-muted-foreground tabular-nums">{pctFmt(activeSlice.frac)}</p>}
</motion.div>
</AnimatePresence>
</div>
</div>
)}
{showLegend && (
<ul className="w-full min-w-0 flex-1 space-y-0.5" aria-label="Legend">
{arcs.map((a) => {
const isActive = activeSlice?.key === a.key;
return (
<li key={a.key}>
<button
type="button"
aria-pressed={toggleable ? a.on : undefined}
onClick={toggleable ? () => toggle(a.key) : undefined}
onPointerEnter={() => setActive(a.key)}
onPointerLeave={() => setActive(null)}
onFocus={() => setActive(a.key)}
onBlur={() => setActive(null)}
aria-label={`${a.label}: ${valueFormatter(a.value)}${a.on ? `, ${pctFmt(a.frac)}` : ", hidden"}`}
className={cn(
"group flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
isActive ? "bg-muted" : "hover:bg-muted/60",
!a.on && "text-muted-foreground",
)}
>
<span aria-hidden className={cn("size-2.5 shrink-0 rounded-[3px] transition-opacity", !a.on && "opacity-25")} style={{ background: a.color }} />
<span className={cn("min-w-0 flex-1 truncate", !a.on && "line-through")}>{a.label}</span>
<span className="font-medium tabular-nums">{valueFormatter(a.value)}</span>
<span className="w-12 text-right text-xs text-muted-foreground tabular-nums">{a.on ? pctFmt(a.frac) : "—"}</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</figure>
);
}