본문으로 건너뛰기
컴포넌트

도넛 차트

비율을 고리 모양으로 보여 주고 범례로 구간을 켜고 끌 수 있는 차트예요.

  • #arc
  • #component
  • #chart
  • #donut
  • #legend
  • #data
  • #visualization

설치

이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.

터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/donut-chart.json

donut-chart.module.csscss
/* The ring sits beside its legend when there is room, and above it on a phone. */
.figure { container-type: inline-size; display: grid; min-width: 0; margin: 0; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
.figure > * { min-width: 0; }
.figure[data-legend] { gap: var(--space-6); }
@container (min-width: 460px) {
  .figure[data-legend] { grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: var(--space-8); }
}

.ring { position: relative; max-width: 100%; aspect-ratio: 1; justify-self: center; -webkit-tap-highlight-color: transparent; }
.svg { display: block; width: 100%; height: 100%; overflow: visible; cursor: default; }
.svg[data-hovering] { cursor: pointer; }
/* The track shows only before the first sweep and when there is nothing to draw, so the gaps between segments stay the page colour. */
.track { fill: none; stroke: var(--surface-muted); transition: opacity var(--duration-considered, .48s) var(--ease-standard); opacity: 0; }
.track[data-idle] { opacity: 1; }
/* Shapes are written every frame from script; only colour and dimming ease here, on the compositor. */
.segment { fill: var(--slice); shape-rendering: geometricPrecision; transition: fill var(--duration-standard) var(--ease-standard), opacity var(--duration-standard) var(--ease-standard); }
.segment[data-dim] { opacity: .45; }

/* The centre reads the active segment, or the total at rest. Old and new readouts share one grid cell, so a swap never moves or resizes anything. */
.center { position: absolute; top: 50%; left: 50%; display: grid; width: calc(var(--inner) - 16px); transform: translate(-50%, -50%); pointer-events: none; }
.readout { grid-area: 1 / 1; display: grid; min-width: 0; justify-items: center; gap: 2px; text-align: center; }
.centerLabel, .centerMeta { display: block; width: 100%; min-height: calc(1em * var(--leading-body)); overflow: hidden; font-size: var(--text-xs); line-height: var(--leading-body); text-overflow: ellipsis; white-space: nowrap; }
.centerLabel { color: var(--text-secondary); }
.centerMeta { color: var(--text-muted); font-variant-numeric: tabular-nums; }
.centerValue { display: block; max-width: 100%; min-height: calc(1em * var(--leading-display)); overflow: hidden; font-family: var(--font-display); font-size: var(--text-2xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }

/* Legend rows mirror the segments: hovering either lights both. */
/* Rows carry their own 2px spacing so a row closing its height takes its gap with it; the list's negative margin drops the last one. */
.legend { display: grid; margin: 0 0 -2px; padding: 0; list-style: none; }
.item { overflow: hidden; }
.item > .row { margin-bottom: 2px; }
.row { display: grid; width: 100%; min-height: 40px; grid-template-columns: 10px minmax(0, 1fr) auto 3.25em; align-items: center; column-gap: 10px; padding: 6px 10px; border: 0; border-radius: 12px; background: transparent; color: var(--foreground); cursor: pointer; font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); text-align: left; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
.row:active { transform: scale(.985); }
.row[data-active], .row[data-pinned] { background: color-mix(in oklab, var(--foreground) 5%, transparent); }
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--slice); box-shadow: inset 0 0 0 1.5px var(--slice); transition: background-color var(--duration-standard) var(--ease-standard), box-shadow var(--duration-standard) var(--ease-standard); }
/* A hidden segment keeps its row: the dot empties to a ring and the text quiets, so it can be shown again. */
.row[data-hidden] .dot { background: transparent; }
.row[data-hidden] .rowLabel, .row[data-hidden] .rowValue, .row[data-hidden] .rowShare { color: var(--text-muted); }
.rowLabel, .rowValue, .rowShare { transition: color var(--duration-standard) var(--ease-standard); }
.rowText { display: grid; min-width: 0; }
.rowLabel, .rowMembers { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rowMembers { color: var(--text-muted); font-size: var(--text-xs); }
.rowValue { justify-self: end; color: var(--text-secondary); font-variant-numeric: tabular-nums; }
.rowShare { justify-self: end; color: var(--foreground); font-weight: 500; font-variant-numeric: tabular-nums; }
@media (hover: hover) and (pointer: fine) { .row:hover { background: color-mix(in oklab, var(--foreground) 5%, transparent); } }

.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .segment, .track, .row, .dot, .rowLabel, .rowValue, .rowShare { transition: none; } }
donut-chart.tsxtsx
"use client";

import { useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState, useSyncExternalStore, type CSSProperties, type FocusEvent, type KeyboardEvent, type MouseEvent, type PointerEvent, type Ref } from "react";
import { AnimatePresence, animate, frame, cancelFrame, motion, motionValue, useInView, type MotionValue, type Transition } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./donut-chart.module.css";

export interface DonutChartDatum {
  /** Stable identity. A segment whose key survives a data change keeps its place and color, so switching datasets morphs the arcs. */
  key: string;
  label: string;
  value: number;
  /** Any CSS color. Segments default to the shared --series-* palette in data order, then neutral steps. */
  color?: string;
}

/** Use a donut chart for a few parts of one whole, when the share of each part matters more than precise comparison. */
export interface DonutChartProps {
  data: DonutChartDatum[];
  /** What the whole is, such as "유입 경로별 방문". Names the chart for assistive technology. */
  label: string;
  /** Unit after values, such as "회". */
  unit?: string;
  formatValue?: (value: number) => string;
  /** Center label at rest, above the total. */
  totalLabel?: string;
  /** Diameter in pixels. The chart scales down to fit narrower containers. */
  size?: number;
  /** Ring thickness in pixels. */
  thickness?: number;
  /** Segments below this share of the total join "기타". */
  groupBelow?: number;
  /** The most segments drawn, counting "기타". The smallest parts beyond it are grouped. */
  maxSegments?: number;
  otherLabel?: string;
  /** Controlled selected segment key. Hover and focus preview other segments without changing it. */
  activeKey?: string | null;
  defaultActiveKey?: string | null;
  onActiveChange?: (key: string | null) => void;
  /** Controlled hidden segment keys. A hidden segment closes and the rest of the ring redistributes. */
  hiddenKeys?: string[];
  defaultHiddenKeys?: string[];
  onHiddenKeysChange?: (keys: string[]) => void;
  /** What clicking a legend row does: show or hide its segment, or pin it as the selected segment. */
  legendAction?: "toggle" | "select";
  /** The synced legend beside or below the ring. */
  legend?: boolean;
  emptyLabel?: string;
  ref?: Ref<HTMLElement>;
  className?: string;
}

type Slice = { key: string; label: string; value: number; color?: string; members?: DonutChartDatum[] };
type Arc = { start: number; end: number };
type Segment = { start: MotionValue<number>; end: MotionValue<number>; lift: MotionValue<number>; off: () => void };

const OTHER = "__other";
const TAU = Math.PI * 2;
const { spring, ease, stagger, duration } = motionTokens;
/** Motion drops inherited velocity on time-defined springs, so values that retarget mid-flight run the same springs written as stiffness and damping. */
const physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }, restDelta = .0005) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: "spring" as const, stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta, restSpeed: restDelta * 2 }; };
const reveal = physical({ visualDuration: .72, bounce: 0 });
const settle = physical({ visualDuration: .5, bounce: 0 });
const pop = physical(spring.snappy, .002);
/** Neutral steps after the four series colors, and the quietest step for Other. */
const NEUTRAL = [56, 42, 32];
const seriesColor = (index: number) => index < 4 ? `var(--series-${index + 1})` : `color-mix(in oklab, var(--foreground) ${NEUTRAL[(index - 4) % NEUTRAL.length]}%, var(--surface))`;
const OTHER_COLOR = "color-mix(in oklab, var(--foreground) 26%, var(--surface))";
/** Room left outside the ring for the lift; the gap between segments and their corner radius, in pixels. */
const LIFT = 4, GROW = 2, MARGIN = 7, GAP = 3, CORNER = 4;
const grouped = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 1 });
const percent = new Intl.NumberFormat("ko-KR", { style: "percent", maximumFractionDigits: 0 });
const shareText = (share: number) => share > 0 && share < .01 ? "<1%" : percent.format(share);

/** Small parts join one "Other" segment, but only when at least two would; grouping a single part hides it for nothing. */
function sliceData(data: DonutChartDatum[], groupBelow: number, maxSegments: number, otherLabel: string): Slice[] {
  const parts = data.filter(item => item.value > 0);
  const total = parts.reduce((sum, item) => sum + item.value, 0);
  if (!total) return [];
  const ranked = [...parts].sort((a, b) => b.value - a.value);
  const keep = new Set(ranked.filter((item, rank) => item.value / total >= groupBelow && rank < maxSegments - 1).map(item => item.key));
  const rest = parts.filter(item => !keep.has(item.key));
  if (rest.length < 2) return parts.map(item => ({ ...item }));
  return [...parts.filter(item => keep.has(item.key)).map(item => ({ ...item })), { key: OTHER, label: otherLabel, value: rest.reduce((sum, item) => sum + item.value, 0), members: rest }];
}

/** Turns of the ring for each slice in data order; a hidden slice closes to a point where it sits. */
function layout(slices: Slice[], hidden: Set<string>) {
  const total = slices.reduce((sum, item) => sum + (hidden.has(item.key) ? 0 : item.value), 0) || 1;
  let at = 0;
  return new Map(slices.map(item => { const arc = { start: at, end: at + (hidden.has(item.key) ? 0 : item.value / total) }; at = arc.end; return [item.key, arc]; }));
}

/** Half the width, in pixels, of a sector's outer edge once the gap is cut; below a couple of pixels a segment fades rather than drawing a hairline. */
function breadth(R: number, t0: number, t1: number, gap: number) {
  const turns = t1 - t0;
  if (turns >= 1 - 1e-6) return Infinity;
  return Math.sin(Math.min(turns * Math.PI, Math.PI / 2)) * R - Math.min(gap / 2, ((1 - turns) * TAU * R) / 2);
}

const n2 = (value: number) => (Math.round(value * 100) / 100).toString();
/**
 * An annular sector between two turns of the ring, measured clockwise from the top, with rounded corners.
 * Its sides run parallel to the radius at a fixed distance, so the gap between neighbours is the same number of pixels from the inner edge to the outer one.
 * A thin sector narrows to a wedge and then to nothing, so a closing segment never pops.
 */
function sector(c: number, R: number, r: number, t0: number, t1: number, gap: number, corner: number) {
  const turns = t1 - t0;
  if (turns <= 1e-6) return "";
  const at = (radius: number, angle: number) => `${n2(c + radius * Math.cos(angle))} ${n2(c + radius * Math.sin(angle))}`;
  if (turns >= 1 - 1e-6) return `M${at(R, -Math.PI / 2)}A${n2(R)} ${n2(R)} 0 1 1 ${at(R, Math.PI / 2)}A${n2(R)} ${n2(R)} 0 1 1 ${at(R, -Math.PI / 2)}ZM${at(r, -Math.PI / 2)}A${n2(r)} ${n2(r)} 0 1 0 ${at(r, Math.PI / 2)}A${n2(r)} ${n2(r)} 0 1 0 ${at(r, -Math.PI / 2)}Z`;
  // Half the gap on each side; a lone segment closes its gap as the rest of the ring empties, so it becomes a whole ring without a jump.
  const p = Math.min(gap / 2, ((1 - turns) * TAU * R) / 2);
  const a0 = t0 * TAU - Math.PI / 2, a1 = t1 * TAU - Math.PI / 2, half = (a1 - a0) / 2, s = Math.sin(Math.min(half, Math.PI / 2));
  if (s * R <= p + 1e-6) return "";
  const side = (t: number, angle: number, sign: number) => `${n2(c + t * Math.cos(angle) - sign * p * Math.sin(angle))} ${n2(c + t * Math.sin(angle) + sign * p * Math.cos(angle))}`;
  const band = (R - r) / 2;
  const ro = Math.max(0, Math.min(corner, band, s >= .9999 ? Infinity : (s * R - p) / (1 + s)));
  const dO = Math.asin(Math.min(1, (p + ro) / (R - ro))), tO = Math.sqrt(Math.max(0, (R - ro) ** 2 - (p + ro) ** 2));
  const bigO = a1 - a0 - 2 * dO > Math.PI ? 1 : 0;
  let d = `M${side(tO, a0, 1)}`;
  if (ro > .01) d += `A${n2(ro)} ${n2(ro)} 0 0 1 ${at(R, a0 + dO)}`;
  d += `A${n2(R)} ${n2(R)} 0 ${bigO} 1 ${at(R, a1 - dO)}`;
  if (ro > .01) d += `A${n2(ro)} ${n2(ro)} 0 0 1 ${side(tO, a1, -1)}`;
  if (s * r > p + 1e-6 || s >= .9999) {
    const ri = Math.max(0, Math.min(corner, band, s >= .9999 ? Infinity : (s * r - p) / (1 - s)));
    const dI = Math.asin(Math.min(1, (p + ri) / (r + ri))), tI = Math.sqrt(Math.max(0, (r + ri) ** 2 - (p + ri) ** 2));
    const bigI = a1 - a0 - 2 * dI > Math.PI ? 1 : 0;
    d += `L${side(tI, a1, -1)}`;
    if (ri > .01) d += `A${n2(ri)} ${n2(ri)} 0 0 1 ${at(r, a1 - dI)}`;
    d += `A${n2(r)} ${n2(r)} 0 ${bigI} 0 ${at(r, a0 + dI)}`;
    if (ri > .01) d += `A${n2(ri)} ${n2(ri)} 0 0 1 ${side(tI, a0, 1)}`;
  } else d += `L${at(p / s, (a0 + a1) / 2)}`;
  return `${d}Z`;
}

/** A number that counts to each new value, written straight to the DOM so no frame re-renders React. Whole targets count in whole steps. */
function Count({ value, format, reduced }: { value: number; format: (value: number) => string; reduced: boolean }) {
  const node = useRef<HTMLSpanElement>(null);
  const [initial] = useState(() => format(value));
  const live = useRef<{ mv: MotionValue<number>; format: (value: number) => string; target: number } | null>(null);
  useLayoutEffect(() => {
    const el = node.current;
    if (!el) return;
    if (!live.current) {
      const state = { mv: motionValue(value), format, target: value };
      state.mv.on("change", now => { el.textContent = state.format(Number.isInteger(state.target) ? Math.round(now) : now); });
      live.current = state;
    }
    const state = live.current;
    state.format = format;
    if (state.target === value && !reduced) { if (!state.mv.isAnimating()) el.textContent = format(value); return; }
    state.target = value;
    if (reduced) { state.mv.jump(value); el.textContent = format(value); return; }
    animate(state.mv, value, physical({ visualDuration: .4, bounce: 0 }, Math.max(Math.abs(value - state.mv.get()) * .001, 1e-6)));
  }, [value, format, reduced]);
  useEffect(() => () => { live.current?.mv.destroy(); live.current = null; }, []);
  return <span ref={node}>{initial}</span>;
}

/** Center readouts stay mounted in one grid cell and roll like a drum: the ones before the active segment wait above, the ones after it below. */
const drum = (place: number, reduced: boolean): { animate: { opacity: number; y: string }; transition: Transition } => ({
  animate: { opacity: place === 0 ? 1 : 0, y: `${.45 * Math.sign(place)}em` },
  transition: reduced ? { duration: 0 } : place === 0 ? { duration: duration.standard, ease: [...ease.enter] } : { duration: duration.fast, ease: [...ease.standard] },
});

const subscribeNothing = () => () => {};
function useReducedMotionSafe() {
  const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);
  return !!useReducedMotion() && hydrated;
}

export function DonutChart({ data, label, unit = "", formatValue = value => grouped.format(value), totalLabel = "합계", size = 208, thickness = 24, groupBelow = .04, maxSegments = 6, otherLabel = "기타", activeKey, defaultActiveKey = null, onActiveChange, hiddenKeys, defaultHiddenKeys, onHiddenKeysChange, legendAction = "toggle", legend = true, emptyLabel = "아직 데이터가 없어요", ref, className }: DonutChartProps) {
  const reduced = useReducedMotionSafe();
  const figure = useRef<HTMLElement>(null);
  useImperativeHandle(ref, () => figure.current as HTMLElement);
  const inView = useInView(figure, { once: true, amount: .35 });
  const legendId = useId();
  const slices = sliceData(data, groupBelow, maxSegments, otherLabel);

  // Hidden segments are controlled or not, like selection.
  const [ownHidden, setOwnHidden] = useState<string[]>(defaultHiddenKeys ?? []);
  const hiddenList = hiddenKeys ?? ownHidden;
  const hidden = new Set(hiddenList.filter(key => slices.some(item => item.key === key)));
  const visible = slices.filter(item => !hidden.has(item.key));
  const total = visible.reduce((sum, item) => sum + item.value, 0);
  const signature = `${slices.map(item => `${item.key}:${item.value}${hidden.has(item.key) ? "h" : ""}`).join("|")}`;
  const suffix = unit ? ` ${unit}` : "";

  // Colors follow a key from the first time it is seen, so a segment keeps its color through every dataset.
  const [palette, setPalette] = useState(() => new Map(data.map((item, index) => [item.key, index])));
  if (data.some(item => !palette.has(item.key))) {
    const next = new Map(palette);
    for (const item of data) if (!next.has(item.key)) next.set(item.key, next.size);
    setPalette(next);
  }
  const colorFor = (item: Slice) => item.color ?? (item.key === OTHER ? OTHER_COLOR : seriesColor(palette.get(item.key) ?? 0));

  // Leaving segments stay drawn in their old place while they close, then drop out once they have.
  const [drawn, setDrawn] = useState<Slice[]>(slices);
  const [seenSignature, setSeenSignature] = useState(signature);
  if (seenSignature !== signature) {
    setSeenSignature(signature);
    const next = [...slices];
    drawn.forEach((item, index) => {
      if (next.some(slice => slice.key === item.key)) return;
      const before = drawn.slice(0, index).reverse().find(prior => next.some(slice => slice.key === prior.key));
      next.splice(before ? next.findIndex(slice => slice.key === before.key) + 1 : 0, 0, { ...item, value: 0 });
    });
    setDrawn(next);
  }

  // Selection is controlled or not; hover and focus preview on top of it.
  const [ownActive, setOwnActive] = useState<string | null>(defaultActiveKey);
  const selected = activeKey !== undefined ? activeKey : ownActive;
  const [preview, setPreview] = useState<string | null>(null);
  const select = (key: string | null) => { if (activeKey === undefined) setOwnActive(key); onActiveChange?.(key); };
  const current = [preview, selected].find(key => key && visible.some(item => item.key === key)) ?? null;
  const activeSlice = visible.find(item => item.key === current) ?? null;

  const [announcement, setAnnouncement] = useState("");
  const setHidden = (key: string) => {
    const isHidden = hidden.has(key);
    if (!isHidden && visible.length <= 1) return;
    const next = isHidden ? hiddenList.filter(item => item !== key) : [...hiddenList, key];
    if (hiddenKeys === undefined) setOwnHidden(next);
    onHiddenKeysChange?.(next);
    const item = slices.find(slice => slice.key === key);
    const nextTotal = slices.reduce((sum, slice) => sum + (next.includes(slice.key) ? 0 : slice.value), 0);
    if (item) setAnnouncement(`${item.label} ${isHidden ? "다시 보여요" : "숨겼어요"}. ${totalLabel} ${formatValue(nextTotal)}${suffix}.`);
  };

  // Geometry in the chart's own units; the SVG scales with its container.
  const c = size / 2, outer = c - MARGIN, inner = Math.max(8, outer - thickness);
  const corner = Math.min(CORNER, thickness / 4);
  const geometry = useRef({ c, outer, inner, corner });
  const segments = useRef(new Map<string, Segment>());
  const paths = useRef(new Map<string, SVGPathElement>());
  const registerPath = useCallback((node: SVGPathElement | null) => {
    const key = node?.dataset.key;
    if (!node || !key) return;
    paths.current.set(key, node);
    return () => { if (paths.current.get(key) === node) paths.current.delete(key); };
  }, []);

  // One paint per frame, whatever moved: every path's shape and lift are written straight to the DOM.
  const paint = useCallback(() => {
    const { c, outer, inner, corner } = geometry.current;
    for (const [key, node] of paths.current) {
      const segment = segments.current.get(key);
      if (!segment) { node.setAttribute("d", ""); continue; }
      const start = segment.start.get(), end = segment.end.get(), lift = Math.max(0, segment.lift.get());
      const R = outer + lift * GROW;
      node.setAttribute("d", sector(c, R, inner - lift * GROW * .5, start, end, GAP, corner));
      node.setAttribute("fill-opacity", n2(Math.min(1, Math.max(0, (breadth(R, start, end, GAP) - 1) / 3))));
      const middle = ((start + end) / 2) * TAU - Math.PI / 2, distance = lift * LIFT;
      node.setAttribute("transform", `translate(${n2(Math.cos(middle) * distance)} ${n2(Math.sin(middle) * distance)})`);
    }
  }, []);
  const schedule = useCallback(() => { frame.render(paint); }, [paint]);
  useLayoutEffect(() => { geometry.current = { c, outer, inner, corner }; paint(); });
  useEffect(() => () => { cancelFrame(paint); for (const segment of segments.current.values()) segment.off(); segments.current.clear(); }, [paint]);

  const targets = useRef(new Map<string, Arc>());
  const latest = useRef({ drawn, slices });
  useLayoutEffect(() => { latest.current = { drawn, slices }; });

  // New data, or a segment shown or hidden, moves every arc from wherever it is on screen, keeping its velocity if it was already moving.
  const shown = inView || reduced;
  useEffect(() => {
    if (!shown) return;
    const { drawn: order, slices: now } = latest.current;
    const goal = layout(now, hidden);
    targets.current = goal;
    const first = segments.current.size === 0;
    const place = (index: number) => { const before = order.slice(0, index).reverse().find(item => goal.has(item.key)); return before ? goal.get(before.key)!.end : 0; };
    const drop = (key: string) => {
      if (latest.current.slices.some(item => item.key === key)) return;
      segments.current.get(key)?.off();
      segments.current.delete(key);
      setDrawn(list => list.filter(item => item.key !== key));
    };
    order.forEach((item, index) => {
      const leaving = !goal.has(item.key);
      const to = goal.get(item.key) ?? { start: place(index), end: place(index) };
      let segment = segments.current.get(item.key);
      if (!segment) {
        // A new segment opens from the edge of the one before it, as it is right now.
        const prior = order.slice(0, index).reverse().map(entry => segments.current.get(entry.key)).find(Boolean);
        const from = first ? 0 : prior ? prior.end.get() : 0;
        const start = motionValue(from), end = motionValue(from), lift = motionValue(0);
        const offs = [start.on("change", schedule), end.on("change", schedule), lift.on("change", schedule)];
        segment = { start, end, lift, off: () => { offs.forEach(off => off()); start.destroy(); end.destroy(); lift.destroy(); } };
        segments.current.set(item.key, segment);
      }
      if (reduced) {
        segment.start.jump(to.start); segment.end.jump(to.end);
        if (leaving) drop(item.key);
        return;
      }
      if (first) {
        // One sweep: every segment leaves the top together, and each trailing edge follows a beat behind the one before.
        animate(segment.start, to.start, reveal);
        animate(segment.end, to.end, { ...reveal, delay: .04 + index * stagger.item });
        return;
      }
      animate(segment.start, to.start, settle);
      animate(segment.end, to.end, { ...settle, onComplete: leaving ? () => drop(item.key) : undefined });
    });
    schedule();
  }, [shown, signature, reduced, schedule]); // eslint-disable-line react-hooks/exhaustive-deps

  // The active segment slides out along its middle and thickens a little; the one it replaces settles back at the same time.
  useEffect(() => {
    for (const [key, segment] of segments.current) {
      const to = key === current ? 1 : 0;
      if (reduced) segment.lift.jump(to);
      else if (segment.lift.get() !== to || segment.lift.isAnimating()) animate(segment.lift, to, pop);
    }
  }, [current, reduced, drawn, shown]);

  // One pointer handler for the whole ring: the angle picks the segment, so crossing a gap never drops the hover.
  const svg = useRef<SVGSVGElement>(null);
  const hit = (event: { clientX: number; clientY: number }) => {
    const box = svg.current?.getBoundingClientRect();
    if (!box || !box.width) return undefined;
    const scale = size / box.width, x = (event.clientX - box.left) * scale - c, y = (event.clientY - box.top) * scale - c;
    const radius = Math.hypot(x, y);
    if (radius < inner - 6 || radius > c) return null;
    let turn = (Math.atan2(y, x) + Math.PI / 2) / TAU;
    if (turn < 0) turn += 1;
    for (const [key, arc] of targets.current) if (arc.end > arc.start && turn >= arc.start && turn < arc.end && visible.some(item => item.key === key)) return key;
    return undefined;
  };
  const onRingMove = (event: PointerEvent<SVGSVGElement>) => {
    if (event.pointerType !== "mouse") return;
    const key = hit(event);
    if (key !== undefined && key !== preview) setPreview(key);
  };
  const onRingClick = (event: MouseEvent<SVGSVGElement>) => {
    const key = hit(event);
    if (key) select(selected === key ? null : key);
  };

  const format = (value: number) => `${formatValue(value)}${suffix}`;
  const describe = (item: Slice) => `${item.label}, ${format(item.value)}, ${shareText(item.value / (total || 1))}${item.members ? `. 포함: ${item.members.map(member => member.label).join(", ")}` : ""}`;

  // Arrow keys walk the segments, in the legend or on the ring itself when there is no legend.
  const rows = useRef<(HTMLButtonElement | null)[]>([]);
  const step = (key: string, index: number, count: number) => ({ ArrowDown: index + 1, ArrowRight: index + 1, ArrowUp: index - 1, ArrowLeft: index - 1, Home: 0, End: count - 1 } as Record<string, number>)[key];
  const onLegendKey = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
    if (event.key === "Escape" && selected) { event.preventDefault(); select(null); return; }
    const next = step(event.key, index, slices.length);
    if (next === undefined) return;
    event.preventDefault();
    rows.current[(next + slices.length) % slices.length]?.focus();
  };
  const onRingKey = (event: KeyboardEvent<HTMLDivElement>) => {
    if (!visible.length) return;
    if (event.key === "Escape") { event.preventDefault(); setPreview(null); if (selected) select(null); return; }
    if ((event.key === "Enter" || event.key === " ") && current) { event.preventDefault(); select(selected === current ? null : current); return; }
    const index = visible.findIndex(item => item.key === current);
    const next = step(event.key, index < 0 ? (event.key.endsWith("Up") || event.key.endsWith("Left") ? 0 : -1) : index, visible.length);
    if (next === undefined) return;
    event.preventDefault();
    const item = visible[(next + visible.length) % visible.length]!;
    setPreview(item.key);
    setAnnouncement(describe(item));
  };
  const onLegendBlur = (event: FocusEvent<HTMLButtonElement>) => { if (!(event.relatedTarget instanceof Node && event.currentTarget.closest("ul")?.contains(event.relatedTarget))) setPreview(null); };

  // One readout per segment plus the total; the active one rolls in from the side it sits on, so moving clockwise rolls the drum forward.
  const identity = activeSlice ? activeSlice.key : total ? "__total" : "__empty";
  const readouts = [{ key: "__total", label: total ? totalLabel : emptyLabel, value: total, meta: null as Slice | null }, ...slices.map(item => ({ key: item.key, label: item.label, value: item.value, meta: item as Slice | null }))];
  const activeIndex = Math.max(0, readouts.findIndex(item => item.key === (identity === "__empty" ? "__total" : identity)));

  const summary = total ? `${label}. ${totalLabel} ${format(total)}. ${visible.map(describe).join(". ")}.` : `${label}. ${emptyLabel}.`;
  const members = (item: Slice) => item.members ? item.members.length > 2 ? `${item.members.slice(0, 2).map(member => member.label).join(", ")} 외 ${item.members.length - 2}개` : item.members.map(member => member.label).join(", ") : null;
  const ringLabel = `${label}. 화살표 키로 항목을 하나씩 읽어요.`;

  return <figure ref={figure} className={[styles.figure, className].filter(Boolean).join(" ")} aria-label={label} data-legend={legend || undefined}>
    <div className={styles.ring} style={{ width: size, "--inner": `${(inner * 2) / size * 100}%` } as CSSProperties}
      tabIndex={legend ? undefined : 0} role={legend ? undefined : "group"} aria-label={legend ? undefined : ringLabel} onKeyDown={legend ? undefined : onRingKey} onBlur={legend ? undefined : () => setPreview(null)}>
      <svg ref={svg} className={styles.svg} viewBox={`0 0 ${size} ${size}`} aria-hidden="true" focusable="false" onPointerMove={onRingMove} onPointerLeave={() => setPreview(null)} onClick={onRingClick} data-hovering={current !== null || undefined}>
        <circle className={styles.track} cx={c} cy={c} r={(outer + inner) / 2} strokeWidth={outer - inner} data-idle={(!shown || !total) || undefined} />
        {drawn.map(item => <path key={item.key} ref={registerPath} data-key={item.key} className={styles.segment} data-active={item.key === current || undefined} data-dim={(current !== null && item.key !== current) || undefined} style={{ "--slice": colorFor(item) } as CSSProperties} />)}
      </svg>
      <div className={styles.center} aria-hidden="true">
        {readouts.map((item, index) => <motion.span key={item.key} className={styles.readout} initial={false} {...drum(index - activeIndex, reduced)}>
          <span className={styles.centerLabel}>{item.label}</span>
          <span className={styles.centerValue}>{item.value || item.meta ? <Count value={item.value} format={formatValue} reduced={reduced} /> : " "}</span>
          <span className={styles.centerMeta}>{item.meta ? <>{totalLabel}의 <Count value={total && !hidden.has(item.key) ? item.value / total : 0} format={shareText} reduced={reduced} /></> : unit || " "}</span>
        </motion.span>)}
      </div>
    </div>
    {legend && slices.length > 0 && <ul className={styles.legend} aria-label={`${label}, 항목${legendAction === "toggle" ? ". 항목을 누르면 보이거나 숨겨요" : ""}`} id={legendId} onPointerLeave={() => setPreview(null)}>
      <AnimatePresence initial={false}>{slices.map((item, index) => {
        const off = hidden.has(item.key);
        // A row that joins or leaves (a part folding into Other, say) opens or closes its height, so the rows below and the card around them glide instead of jumping.
        return <motion.li key={item.key} className={styles.item} initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={reduced ? { duration: 0 } : spring.smooth}>
          <button ref={node => { if (node) rows.current[index] = node; }} type="button" className={styles.row} aria-pressed={legendAction === "toggle" ? !off : selected === item.key} aria-label={off ? `${item.label}, 숨김` : describe(item)} data-active={item.key === current || undefined} data-pinned={selected === item.key || undefined} data-hidden={off || undefined} style={{ "--slice": colorFor(item) } as CSSProperties}
            onClick={() => legendAction === "toggle" ? setHidden(item.key) : select(selected === item.key ? null : item.key)}
            onPointerEnter={event => { if (event.pointerType === "mouse") setPreview(off ? null : item.key); }}
            onFocus={event => { if (event.currentTarget.matches(":focus-visible")) setPreview(off ? null : item.key); }} onBlur={onLegendBlur} onKeyDown={event => onLegendKey(event, index)}>
            <span className={styles.dot} aria-hidden="true" />
            <span className={styles.rowText}>
              <span className={styles.rowLabel}>{item.label}</span>
              {item.members && <span className={styles.rowMembers}>{members(item)}</span>}
            </span>
            <span className={styles.rowValue} aria-hidden="true"><Count value={item.value} format={formatValue} reduced={reduced} /></span>
            <span className={styles.rowShare} aria-hidden="true"><Count value={off || !total ? 0 : item.value / total} format={shareText} reduced={reduced} /></span>
          </button>
        </motion.li>;
      })}</AnimatePresence>
    </ul>}
    <p className={styles.srOnly}>{summary}</p>
    <p className={styles.srOnly} aria-live="polite">{announcement}</p>
    {total > 0 && <div className={styles.srOnly}><table>
      <caption>{label}</caption>
      <thead><tr><th scope="col">항목</th><th scope="col">값</th><th scope="col">비율</th></tr></thead>
      <tbody>{visible.flatMap(item => item.members ? item.members.map(member => ({ key: member.key, label: `${member.label} (${otherLabel})`, value: member.value })) : [item]).map(item => <tr key={item.key}><th scope="row">{item.label}</th><td>{format(item.value)}</td><td>{shareText(item.value / total)}</td></tr>)}</tbody>
    </table></div>}
  </figure>;
}

export default DonutChart;