{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "donut-chart",
  "type": "registry:ui",
  "title": "Donut chart",
  "description": "A donut whose arcs morph between datasets, with the active value rolling into the center.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/donut-chart/donut-chart.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useLayoutEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type CSSProperties,\n  type FocusEvent,\n  type KeyboardEvent,\n  type MouseEvent,\n  type PointerEvent,\n  type Ref,\n} from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  frame,\n  cancelFrame,\n  motion,\n  motionValue,\n  useInView,\n  useReducedMotion,\n  type MotionValue,\n  type Transition,\n} from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface DonutChartDatum {\n  /** Stable identity. A segment whose key survives a data change keeps its place and color, so switching datasets morphs the arcs. */\n  key: string;\n  label: string;\n  value: number;\n  /** Any CSS color. Segments default to the shared --series-* palette in data order, then neutral steps. */\n  color?: string;\n}\n\n/** Use a donut chart for a few parts of one whole, when the share of each part matters more than precise comparison. */\nexport interface DonutChartProps {\n  data: DonutChartDatum[];\n  /** What the whole is, such as \"Visits by source\". Names the chart for assistive technology. */\n  label: string;\n  /** Unit after values, such as \"visits\". */\n  unit?: string;\n  formatValue?: (value: number) => string;\n  /** Center label at rest, above the total. */\n  totalLabel?: string;\n  /** Diameter in pixels. The chart scales down to fit narrower containers. */\n  size?: number;\n  /** Ring thickness in pixels. */\n  thickness?: number;\n  /** Segments below this share of the total join \"Other\". */\n  groupBelow?: number;\n  /** The most segments drawn, counting \"Other\". The smallest parts beyond it are grouped. */\n  maxSegments?: number;\n  otherLabel?: string;\n  /** Controlled selected segment key. Hover and focus preview other segments without changing it. */\n  activeKey?: string | null;\n  defaultActiveKey?: string | null;\n  onActiveChange?: (key: string | null) => void;\n  /** Controlled hidden segment keys. A hidden segment closes and the rest of the ring redistributes. */\n  hiddenKeys?: string[];\n  defaultHiddenKeys?: string[];\n  onHiddenKeysChange?: (keys: string[]) => void;\n  /** What clicking a legend row does: show or hide its segment, or pin it as the selected segment. */\n  legendAction?: \"toggle\" | \"select\";\n  /** The synced legend beside or below the ring. */\n  legend?: boolean;\n  emptyLabel?: string;\n  ref?: Ref<HTMLElement>;\n  className?: string;\n}\n\ntype Slice = { key: string; label: string; value: number; color?: string; members?: DonutChartDatum[] };\ntype Arc = { start: number; end: number };\ntype Segment = { start: MotionValue<number>; end: MotionValue<number>; lift: MotionValue<number>; off: () => void };\n\nconst OTHER = \"__other\";\nconst TAU = Math.PI * 2;\nconst { spring, ease, stagger, duration } = motionTokens;\n/** Motion drops inherited velocity on time-defined springs, so values that retarget mid-flight run the same springs written as stiffness and damping. */\nconst physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }, restDelta = 0.0005) => {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  return {\n    type: \"spring\" as const,\n    stiffness: root * root,\n    damping: 2 * (1 - bounce) * root,\n    restDelta,\n    restSpeed: restDelta * 2,\n  };\n};\nconst reveal = physical({ visualDuration: 0.72, bounce: 0 });\nconst settle = physical({ visualDuration: 0.5, bounce: 0 });\nconst pop = physical(spring.snappy, 0.002);\n/** Neutral steps after the four series colors, and the quietest step for Other. */\nconst NEUTRAL = [56, 42, 32];\nconst seriesColor = (index: number) =>\n  index < 4\n    ? `var(--series-${index + 1})`\n    : `color-mix(in oklab, var(--ink) ${NEUTRAL[(index - 4) % NEUTRAL.length]}%, var(--surface))`;\nconst OTHER_COLOR = \"color-mix(in oklab, var(--ink) 26%, var(--surface))\";\n/** Room left outside the ring for the lift; the gap between segments and their corner radius, in pixels. */\nconst LIFT = 4,\n  GROW = 2,\n  MARGIN = 7,\n  GAP = 3,\n  CORNER = 4;\nconst grouped = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 1 });\nconst percent = new Intl.NumberFormat(\"en-US\", { style: \"percent\", maximumFractionDigits: 0 });\nconst shareText = (share: number) => (share > 0 && share < 0.01 ? \"<1%\" : percent.format(share));\n\n/** Small parts join one \"Other\" segment, but only when at least two would; grouping a single part hides it for nothing. */\nfunction sliceData(data: DonutChartDatum[], groupBelow: number, maxSegments: number, otherLabel: string): Slice[] {\n  const parts = data.filter((item) => item.value > 0);\n  const total = parts.reduce((sum, item) => sum + item.value, 0);\n  if (!total) return [];\n  const ranked = [...parts].sort((a, b) => b.value - a.value);\n  const keep = new Set(\n    ranked.filter((item, rank) => item.value / total >= groupBelow && rank < maxSegments - 1).map((item) => item.key),\n  );\n  const rest = parts.filter((item) => !keep.has(item.key));\n  if (rest.length < 2) return parts.map((item) => ({ ...item }));\n  return [\n    ...parts.filter((item) => keep.has(item.key)).map((item) => ({ ...item })),\n    { key: OTHER, label: otherLabel, value: rest.reduce((sum, item) => sum + item.value, 0), members: rest },\n  ];\n}\n\n/** Turns of the ring for each slice in data order; a hidden slice closes to a point where it sits. */\nfunction layout(slices: Slice[], hidden: Set<string>) {\n  const total = slices.reduce((sum, item) => sum + (hidden.has(item.key) ? 0 : item.value), 0) || 1;\n  let at = 0;\n  return new Map(\n    slices.map((item) => {\n      const arc = { start: at, end: at + (hidden.has(item.key) ? 0 : item.value / total) };\n      at = arc.end;\n      return [item.key, arc];\n    }),\n  );\n}\n\n/** 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. */\nfunction breadth(R: number, t0: number, t1: number, gap: number) {\n  const turns = t1 - t0;\n  if (turns >= 1 - 1e-6) return Infinity;\n  return Math.sin(Math.min(turns * Math.PI, Math.PI / 2)) * R - Math.min(gap / 2, ((1 - turns) * TAU * R) / 2);\n}\n\nconst n2 = (value: number) => (Math.round(value * 100) / 100).toString();\n/**\n * An annular sector between two turns of the ring, measured clockwise from the top, with rounded corners.\n * 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.\n * A thin sector narrows to a wedge and then to nothing, so a closing segment never pops.\n */\nfunction sector(c: number, R: number, r: number, t0: number, t1: number, gap: number, corner: number) {\n  const turns = t1 - t0;\n  if (turns <= 1e-6) return \"\";\n  const at = (radius: number, angle: number) =>\n    `${n2(c + radius * Math.cos(angle))} ${n2(c + radius * Math.sin(angle))}`;\n  if (turns >= 1 - 1e-6)\n    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`;\n  // 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.\n  const p = Math.min(gap / 2, ((1 - turns) * TAU * R) / 2);\n  const a0 = t0 * TAU - Math.PI / 2,\n    a1 = t1 * TAU - Math.PI / 2,\n    half = (a1 - a0) / 2,\n    s = Math.sin(Math.min(half, Math.PI / 2));\n  if (s * R <= p + 1e-6) return \"\";\n  const side = (t: number, angle: number, sign: number) =>\n    `${n2(c + t * Math.cos(angle) - sign * p * Math.sin(angle))} ${n2(c + t * Math.sin(angle) + sign * p * Math.cos(angle))}`;\n  const band = (R - r) / 2;\n  const ro = Math.max(0, Math.min(corner, band, s >= 0.9999 ? Infinity : (s * R - p) / (1 + s)));\n  const dO = Math.asin(Math.min(1, (p + ro) / (R - ro))),\n    tO = Math.sqrt(Math.max(0, (R - ro) ** 2 - (p + ro) ** 2));\n  const bigO = a1 - a0 - 2 * dO > Math.PI ? 1 : 0;\n  let d = `M${side(tO, a0, 1)}`;\n  if (ro > 0.01) d += `A${n2(ro)} ${n2(ro)} 0 0 1 ${at(R, a0 + dO)}`;\n  d += `A${n2(R)} ${n2(R)} 0 ${bigO} 1 ${at(R, a1 - dO)}`;\n  if (ro > 0.01) d += `A${n2(ro)} ${n2(ro)} 0 0 1 ${side(tO, a1, -1)}`;\n  if (s * r > p + 1e-6 || s >= 0.9999) {\n    const ri = Math.max(0, Math.min(corner, band, s >= 0.9999 ? Infinity : (s * r - p) / (1 - s)));\n    const dI = Math.asin(Math.min(1, (p + ri) / (r + ri))),\n      tI = Math.sqrt(Math.max(0, (r + ri) ** 2 - (p + ri) ** 2));\n    const bigI = a1 - a0 - 2 * dI > Math.PI ? 1 : 0;\n    d += `L${side(tI, a1, -1)}`;\n    if (ri > 0.01) d += `A${n2(ri)} ${n2(ri)} 0 0 1 ${at(r, a1 - dI)}`;\n    d += `A${n2(r)} ${n2(r)} 0 ${bigI} 0 ${at(r, a0 + dI)}`;\n    if (ri > 0.01) d += `A${n2(ri)} ${n2(ri)} 0 0 1 ${side(tI, a0, 1)}`;\n  } else d += `L${at(p / s, (a0 + a1) / 2)}`;\n  return `${d}Z`;\n}\n\n/** 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. */\nfunction Count({ value, format, reduced }: { value: number; format: (value: number) => string; reduced: boolean }) {\n  const node = useRef<HTMLSpanElement>(null);\n  const [initial] = useState(() => format(value));\n  const live = useRef<{ mv: MotionValue<number>; format: (value: number) => string; target: number } | null>(null);\n  useLayoutEffect(() => {\n    const el = node.current;\n    if (!el) return;\n    if (!live.current) {\n      const state = { mv: motionValue(value), format, target: value };\n      state.mv.on(\"change\", (now) => {\n        el.textContent = state.format(Number.isInteger(state.target) ? Math.round(now) : now);\n      });\n      live.current = state;\n    }\n    const state = live.current;\n    state.format = format;\n    if (state.target === value && !reduced) {\n      if (!state.mv.isAnimating()) el.textContent = format(value);\n      return;\n    }\n    state.target = value;\n    if (reduced) {\n      state.mv.jump(value);\n      el.textContent = format(value);\n      return;\n    }\n    animate(\n      state.mv,\n      value,\n      physical({ visualDuration: 0.4, bounce: 0 }, Math.max(Math.abs(value - state.mv.get()) * 0.001, 1e-6)),\n    );\n  }, [value, format, reduced]);\n  useEffect(\n    () => () => {\n      live.current?.mv.destroy();\n      live.current = null;\n    },\n    [],\n  );\n  return <span ref={node}>{initial}</span>;\n}\n\n/** 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. */\nconst drum = (\n  place: number,\n  reduced: boolean,\n): { animate: { opacity: number; y: string }; transition: Transition } => ({\n  animate: { opacity: place === 0 ? 1 : 0, y: `${0.45 * Math.sign(place)}em` },\n  transition: reduced\n    ? { duration: 0 }\n    : place === 0\n      ? { duration: duration.standard, ease: [...ease.enter] }\n      : { duration: duration.fast, ease: [...ease.standard] },\n});\n\nconst subscribeNothing = () => () => {};\nfunction useReducedMotionSafe() {\n  const hydrated = useSyncExternalStore(\n    subscribeNothing,\n    () => true,\n    () => false,\n  );\n  return !!useReducedMotion() && hydrated;\n}\n\nexport function DonutChart({\n  data,\n  label,\n  unit = \"\",\n  formatValue = (value) => grouped.format(value),\n  totalLabel = \"Total\",\n  size = 208,\n  thickness = 24,\n  groupBelow = 0.04,\n  maxSegments = 6,\n  otherLabel = \"Other\",\n  activeKey,\n  defaultActiveKey = null,\n  onActiveChange,\n  hiddenKeys,\n  defaultHiddenKeys,\n  onHiddenKeysChange,\n  legendAction = \"toggle\",\n  legend = true,\n  emptyLabel = \"No data yet\",\n  ref,\n  className,\n}: DonutChartProps) {\n  const reduced = useReducedMotionSafe();\n  const figure = useRef<HTMLElement>(null);\n  useImperativeHandle(ref, () => figure.current as HTMLElement);\n  const inView = useInView(figure, { once: true, amount: 0.35 });\n  const legendId = useId();\n  const slices = sliceData(data, groupBelow, maxSegments, otherLabel);\n\n  // Hidden segments are controlled or not, like selection.\n  const [ownHidden, setOwnHidden] = useState<string[]>(defaultHiddenKeys ?? []);\n  const hiddenList = hiddenKeys ?? ownHidden;\n  const hidden = new Set(hiddenList.filter((key) => slices.some((item) => item.key === key)));\n  const visible = slices.filter((item) => !hidden.has(item.key));\n  const total = visible.reduce((sum, item) => sum + item.value, 0);\n  const signature = `${slices.map((item) => `${item.key}:${item.value}${hidden.has(item.key) ? \"h\" : \"\"}`).join(\"|\")}`;\n  const suffix = unit ? ` ${unit}` : \"\";\n\n  // Colors follow a key from the first time it is seen, so a segment keeps its color through every dataset.\n  const [palette, setPalette] = useState(() => new Map(data.map((item, index) => [item.key, index])));\n  if (data.some((item) => !palette.has(item.key))) {\n    const next = new Map(palette);\n    for (const item of data) if (!next.has(item.key)) next.set(item.key, next.size);\n    setPalette(next);\n  }\n  const colorFor = (item: Slice) =>\n    item.color ?? (item.key === OTHER ? OTHER_COLOR : seriesColor(palette.get(item.key) ?? 0));\n\n  // Leaving segments stay drawn in their old place while they close, then drop out once they have.\n  const [drawn, setDrawn] = useState<Slice[]>(slices);\n  const [seenSignature, setSeenSignature] = useState(signature);\n  if (seenSignature !== signature) {\n    setSeenSignature(signature);\n    const next = [...slices];\n    drawn.forEach((item, index) => {\n      if (next.some((slice) => slice.key === item.key)) return;\n      const before = drawn\n        .slice(0, index)\n        .reverse()\n        .find((prior) => next.some((slice) => slice.key === prior.key));\n      next.splice(before ? next.findIndex((slice) => slice.key === before.key) + 1 : 0, 0, { ...item, value: 0 });\n    });\n    setDrawn(next);\n  }\n\n  // Selection is controlled or not; hover and focus preview on top of it.\n  const [ownActive, setOwnActive] = useState<string | null>(defaultActiveKey);\n  const selected = activeKey !== undefined ? activeKey : ownActive;\n  const [preview, setPreview] = useState<string | null>(null);\n  const select = (key: string | null) => {\n    if (activeKey === undefined) setOwnActive(key);\n    onActiveChange?.(key);\n  };\n  const current = [preview, selected].find((key) => key && visible.some((item) => item.key === key)) ?? null;\n  const activeSlice = visible.find((item) => item.key === current) ?? null;\n\n  const [announcement, setAnnouncement] = useState(\"\");\n  const setHidden = (key: string) => {\n    const isHidden = hidden.has(key);\n    if (!isHidden && visible.length <= 1) return;\n    const next = isHidden ? hiddenList.filter((item) => item !== key) : [...hiddenList, key];\n    if (hiddenKeys === undefined) setOwnHidden(next);\n    onHiddenKeysChange?.(next);\n    const item = slices.find((slice) => slice.key === key);\n    const nextTotal = slices.reduce((sum, slice) => sum + (next.includes(slice.key) ? 0 : slice.value), 0);\n    if (item)\n      setAnnouncement(\n        `${item.label} ${isHidden ? \"shown\" : \"hidden\"}. ${totalLabel} ${formatValue(nextTotal)}${suffix}.`,\n      );\n  };\n\n  // Geometry in the chart's own units; the SVG scales with its container.\n  const c = size / 2,\n    outer = c - MARGIN,\n    inner = Math.max(8, outer - thickness);\n  const corner = Math.min(CORNER, thickness / 4);\n  const geometry = useRef({ c, outer, inner, corner });\n  const segments = useRef(new Map<string, Segment>());\n  const paths = useRef(new Map<string, SVGPathElement>());\n  const registerPath = useCallback((node: SVGPathElement | null) => {\n    const key = node?.dataset.key;\n    if (!node || !key) return;\n    paths.current.set(key, node);\n    return () => {\n      if (paths.current.get(key) === node) paths.current.delete(key);\n    };\n  }, []);\n\n  // One paint per frame, whatever moved: every path's shape and lift are written straight to the DOM.\n  const paint = useCallback(() => {\n    const { c, outer, inner, corner } = geometry.current;\n    for (const [key, node] of paths.current) {\n      const segment = segments.current.get(key);\n      if (!segment) {\n        node.setAttribute(\"d\", \"\");\n        continue;\n      }\n      const start = segment.start.get(),\n        end = segment.end.get(),\n        lift = Math.max(0, segment.lift.get());\n      const R = outer + lift * GROW;\n      node.setAttribute(\"d\", sector(c, R, inner - lift * GROW * 0.5, start, end, GAP, corner));\n      node.setAttribute(\"fill-opacity\", n2(Math.min(1, Math.max(0, (breadth(R, start, end, GAP) - 1) / 3))));\n      const middle = ((start + end) / 2) * TAU - Math.PI / 2,\n        distance = lift * LIFT;\n      node.setAttribute(\n        \"transform\",\n        `translate(${n2(Math.cos(middle) * distance)} ${n2(Math.sin(middle) * distance)})`,\n      );\n    }\n  }, []);\n  const schedule = useCallback(() => {\n    frame.render(paint);\n  }, [paint]);\n  useLayoutEffect(() => {\n    geometry.current = { c, outer, inner, corner };\n    paint();\n  });\n  useEffect(\n    () => () => {\n      cancelFrame(paint);\n      for (const segment of segments.current.values()) segment.off();\n      segments.current.clear();\n    },\n    [paint],\n  );\n\n  const targets = useRef(new Map<string, Arc>());\n  const latest = useRef({ drawn, slices });\n  useLayoutEffect(() => {\n    latest.current = { drawn, slices };\n  });\n\n  // 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.\n  const shown = inView || reduced;\n  useEffect(() => {\n    if (!shown) return;\n    const { drawn: order, slices: now } = latest.current;\n    const goal = layout(now, hidden);\n    targets.current = goal;\n    const first = segments.current.size === 0;\n    const place = (index: number) => {\n      const before = order\n        .slice(0, index)\n        .reverse()\n        .find((item) => goal.has(item.key));\n      return before ? goal.get(before.key)!.end : 0;\n    };\n    const drop = (key: string) => {\n      if (latest.current.slices.some((item) => item.key === key)) return;\n      segments.current.get(key)?.off();\n      segments.current.delete(key);\n      setDrawn((list) => list.filter((item) => item.key !== key));\n    };\n    order.forEach((item, index) => {\n      const leaving = !goal.has(item.key);\n      const to = goal.get(item.key) ?? { start: place(index), end: place(index) };\n      let segment = segments.current.get(item.key);\n      if (!segment) {\n        // A new segment opens from the edge of the one before it, as it is right now.\n        const prior = order\n          .slice(0, index)\n          .reverse()\n          .map((entry) => segments.current.get(entry.key))\n          .find(Boolean);\n        const from = first ? 0 : prior ? prior.end.get() : 0;\n        const start = motionValue(from),\n          end = motionValue(from),\n          lift = motionValue(0);\n        const offs = [start.on(\"change\", schedule), end.on(\"change\", schedule), lift.on(\"change\", schedule)];\n        segment = {\n          start,\n          end,\n          lift,\n          off: () => {\n            offs.forEach((off) => off());\n            start.destroy();\n            end.destroy();\n            lift.destroy();\n          },\n        };\n        segments.current.set(item.key, segment);\n      }\n      if (reduced) {\n        segment.start.jump(to.start);\n        segment.end.jump(to.end);\n        if (leaving) drop(item.key);\n        return;\n      }\n      if (first) {\n        // One sweep: every segment leaves the top together, and each trailing edge follows a beat behind the one before.\n        animate(segment.start, to.start, reveal);\n        animate(segment.end, to.end, { ...reveal, delay: 0.04 + index * stagger.item });\n        return;\n      }\n      animate(segment.start, to.start, settle);\n      animate(segment.end, to.end, { ...settle, onComplete: leaving ? () => drop(item.key) : undefined });\n    });\n    schedule();\n  }, [shown, signature, reduced, schedule]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  // The active segment slides out along its middle and thickens a little; the one it replaces settles back at the same time.\n  useEffect(() => {\n    for (const [key, segment] of segments.current) {\n      const to = key === current ? 1 : 0;\n      if (reduced) segment.lift.jump(to);\n      else if (segment.lift.get() !== to || segment.lift.isAnimating()) animate(segment.lift, to, pop);\n    }\n  }, [current, reduced, drawn, shown]);\n\n  // One pointer handler for the whole ring: the angle picks the segment, so crossing a gap never drops the hover.\n  const svg = useRef<SVGSVGElement>(null);\n  const hit = (event: { clientX: number; clientY: number }) => {\n    const box = svg.current?.getBoundingClientRect();\n    if (!box || !box.width) return undefined;\n    const scale = size / box.width,\n      x = (event.clientX - box.left) * scale - c,\n      y = (event.clientY - box.top) * scale - c;\n    const radius = Math.hypot(x, y);\n    if (radius < inner - 6 || radius > c) return null;\n    let turn = (Math.atan2(y, x) + Math.PI / 2) / TAU;\n    if (turn < 0) turn += 1;\n    for (const [key, arc] of targets.current)\n      if (arc.end > arc.start && turn >= arc.start && turn < arc.end && visible.some((item) => item.key === key))\n        return key;\n    return undefined;\n  };\n  const onRingMove = (event: PointerEvent<SVGSVGElement>) => {\n    if (event.pointerType !== \"mouse\") return;\n    const key = hit(event);\n    if (key !== undefined && key !== preview) setPreview(key);\n  };\n  const onRingClick = (event: MouseEvent<SVGSVGElement>) => {\n    const key = hit(event);\n    if (key) select(selected === key ? null : key);\n  };\n\n  const format = (value: number) => `${formatValue(value)}${suffix}`;\n  const describe = (item: Slice) =>\n    `${item.label}, ${format(item.value)}, ${shareText(item.value / (total || 1))}${item.members ? `. Includes ${item.members.map((member) => member.label).join(\", \")}` : \"\"}`;\n\n  // Arrow keys walk the segments, in the legend or on the ring itself when there is no legend.\n  const rows = useRef<(HTMLButtonElement | null)[]>([]);\n  const step = (key: string, index: number, count: number) =>\n    (\n      {\n        ArrowDown: index + 1,\n        ArrowRight: index + 1,\n        ArrowUp: index - 1,\n        ArrowLeft: index - 1,\n        Home: 0,\n        End: count - 1,\n      } as Record<string, number>\n    )[key];\n  const onLegendKey = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n    if (event.key === \"Escape\" && selected) {\n      event.preventDefault();\n      select(null);\n      return;\n    }\n    const next = step(event.key, index, slices.length);\n    if (next === undefined) return;\n    event.preventDefault();\n    rows.current[(next + slices.length) % slices.length]?.focus();\n  };\n  const onRingKey = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (!visible.length) return;\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      setPreview(null);\n      if (selected) select(null);\n      return;\n    }\n    if ((event.key === \"Enter\" || event.key === \" \") && current) {\n      event.preventDefault();\n      select(selected === current ? null : current);\n      return;\n    }\n    const index = visible.findIndex((item) => item.key === current);\n    const next = step(\n      event.key,\n      index < 0 ? (event.key.endsWith(\"Up\") || event.key.endsWith(\"Left\") ? 0 : -1) : index,\n      visible.length,\n    );\n    if (next === undefined) return;\n    event.preventDefault();\n    const item = visible[(next + visible.length) % visible.length]!;\n    setPreview(item.key);\n    setAnnouncement(describe(item));\n  };\n  const onLegendBlur = (event: FocusEvent<HTMLButtonElement>) => {\n    if (!(event.relatedTarget instanceof Node && event.currentTarget.closest(\"ul\")?.contains(event.relatedTarget)))\n      setPreview(null);\n  };\n\n  // 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.\n  const identity = activeSlice ? activeSlice.key : total ? \"__total\" : \"__empty\";\n  const readouts = [\n    { key: \"__total\", label: total ? totalLabel : emptyLabel, value: total, meta: null as Slice | null },\n    ...slices.map((item) => ({ key: item.key, label: item.label, value: item.value, meta: item as Slice | null })),\n  ];\n  const activeIndex = Math.max(\n    0,\n    readouts.findIndex((item) => item.key === (identity === \"__empty\" ? \"__total\" : identity)),\n  );\n\n  const summary = total\n    ? `${label}. ${totalLabel} ${format(total)}. ${visible.map(describe).join(\". \")}.`\n    : `${label}. ${emptyLabel}.`;\n  const members = (item: Slice) =>\n    item.members\n      ? item.members.length > 2\n        ? `${item.members\n            .slice(0, 2)\n            .map((member) => member.label)\n            .join(\", \")} and ${item.members.length - 2} more`\n        : item.members.map((member) => member.label).join(\" and \")\n      : null;\n  const ringLabel = `${label}. Use the arrow keys to read each segment.`;\n\n  // The ring sits beside its legend when there is room, and above it on a phone.\n  return (\n    <figure\n      ref={figure}\n      className={cn(\n        \"@container m-0 grid min-w-0 font-body tracking-body text-ink *:min-w-0 data-legend:gap-6 @min-[460px]:data-legend:grid-cols-[auto_minmax(0,1fr)] @min-[460px]:data-legend:items-center @min-[460px]:data-legend:gap-8\",\n        className,\n      )}\n      aria-label={label}\n      data-legend={legend || undefined}\n    >\n      {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- without a legend the ring is a focusable group that reads segments by arrow key; with one, the legend rows are the keyboard path */}\n      <div\n        className=\"relative aspect-square max-w-full justify-self-center [-webkit-tap-highlight-color:transparent]\"\n        style={{ width: size, \"--inner\": `${((inner * 2) / size) * 100}%` } as CSSProperties}\n        tabIndex={legend ? undefined : 0}\n        role={legend ? undefined : \"group\"}\n        aria-label={legend ? undefined : ringLabel}\n        onKeyDown={legend ? undefined : onRingKey}\n        onBlur={legend ? undefined : () => setPreview(null)}\n      >\n        <svg\n          ref={svg}\n          className=\"block size-full cursor-default overflow-visible data-hovering:cursor-pointer\"\n          viewBox={`0 0 ${size} ${size}`}\n          aria-hidden=\"true\"\n          focusable=\"false\"\n          onPointerMove={onRingMove}\n          onPointerLeave={() => setPreview(null)}\n          onClick={onRingClick}\n          data-hovering={current !== null || undefined}\n        >\n          {/* The track shows only before the first sweep and when there is nothing to draw, so the gaps between segments stay the page colour. */}\n          <circle\n            className=\"fill-none stroke-hover opacity-0 transition-opacity duration-considered ease-standard data-idle:opacity-100 motion-reduce:transition-none\"\n            cx={c}\n            cy={c}\n            r={(outer + inner) / 2}\n            strokeWidth={outer - inner}\n            data-idle={!shown || !total || undefined}\n          />\n          {drawn.map((item) => (\n            <path\n              key={item.key}\n              ref={registerPath}\n              data-key={item.key}\n              className=\"fill-(--slice) [shape-rendering:geometricPrecision] transition-[fill,opacity] duration-standard ease-standard data-dim:opacity-45 motion-reduce:transition-none\"\n              data-active={item.key === current || undefined}\n              data-dim={(current !== null && item.key !== current) || undefined}\n              style={{ \"--slice\": colorFor(item) } as CSSProperties}\n            />\n          ))}\n        </svg>\n        {/* 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. */}\n        <div\n          className=\"pointer-events-none absolute top-1/2 left-1/2 grid w-[calc(var(--inner)-16px)] -translate-x-1/2 -translate-y-1/2\"\n          aria-hidden=\"true\"\n        >\n          {readouts.map((item, index) => (\n            <motion.span\n              key={item.key}\n              className=\"grid min-w-0 justify-items-center gap-0.5 text-center [grid-area:1/1]\"\n              initial={false}\n              {...drum(index - activeIndex, reduced)}\n            >\n              <span className=\"block min-h-[calc(1em*var(--leading-body))] w-full truncate text-xs leading-body text-ink-2\">\n                {item.label}\n              </span>\n              <span className=\"block min-h-[calc(1em*var(--leading-display))] max-w-full truncate font-display text-2xl leading-display font-medium tracking-display tabular-nums\">\n                {item.value || item.meta ? <Count value={item.value} format={formatValue} reduced={reduced} /> : \" \"}\n              </span>\n              <span className=\"block min-h-[calc(1em*var(--leading-body))] w-full truncate text-xs leading-body text-ink-3 tabular-nums\">\n                {item.meta ? (\n                  <>\n                    <Count\n                      value={total && !hidden.has(item.key) ? item.value / total : 0}\n                      format={shareText}\n                      reduced={reduced}\n                    />{\" \"}\n                    of {totalLabel.toLowerCase()}\n                  </>\n                ) : (\n                  unit || \" \"\n                )}\n              </span>\n            </motion.span>\n          ))}\n        </div>\n      </div>\n      {/* 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. */}\n      {legend && slices.length > 0 && (\n        <ul\n          className=\"mx-0 mt-0 -mb-0.5 grid list-none p-0\"\n          aria-label={`${label}, segments${legendAction === \"toggle\" ? \". Press a segment to show or hide it\" : \"\"}`}\n          id={legendId}\n          onPointerLeave={() => setPreview(null)}\n        >\n          <AnimatePresence initial={false}>\n            {slices.map((item, index) => {\n              const off = hidden.has(item.key);\n              // 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.\n              return (\n                <motion.li\n                  key={item.key}\n                  className=\"overflow-hidden\"\n                  initial={{ height: 0, opacity: 0 }}\n                  animate={{ height: \"auto\", opacity: 1 }}\n                  exit={{ height: 0, opacity: 0 }}\n                  transition={reduced ? { duration: 0 } : spring.smooth}\n                >\n                  <button\n                    ref={(node) => {\n                      if (node) rows.current[index] = node;\n                    }}\n                    type=\"button\"\n                    className={cn(\n                      \"mb-0.5 grid min-h-10 w-full cursor-pointer grid-cols-[10px_minmax(0,1fr)_auto_3.25em] items-center gap-x-[10px] rounded-button border-0 bg-transparent px-[10px] py-1.5 text-left text-sm leading-body text-ink [-webkit-tap-highlight-color:transparent] [transition:background-color_var(--duration-fast)_var(--ease-standard),transform_var(--duration-instant)_var(--ease-standard)] hover:bg-[color-mix(in_oklab,var(--ink)_5%,transparent)] active:[transform:scale(.985)] data-active:bg-[color-mix(in_oklab,var(--ink)_5%,transparent)] data-pinned:bg-[color-mix(in_oklab,var(--ink)_5%,transparent)] motion-reduce:transition-none\",\n                    )}\n                    aria-pressed={legendAction === \"toggle\" ? !off : selected === item.key}\n                    aria-label={off ? `${item.label}, hidden` : describe(item)}\n                    data-active={item.key === current || undefined}\n                    data-pinned={selected === item.key || undefined}\n                    data-hidden={off || undefined}\n                    style={{ \"--slice\": colorFor(item) } as CSSProperties}\n                    onClick={() =>\n                      legendAction === \"toggle\" ? setHidden(item.key) : select(selected === item.key ? null : item.key)\n                    }\n                    onPointerEnter={(event) => {\n                      if (event.pointerType === \"mouse\") setPreview(off ? null : item.key);\n                    }}\n                    onFocus={(event) => {\n                      if (event.currentTarget.matches(\":focus-visible\")) setPreview(off ? null : item.key);\n                    }}\n                    onBlur={onLegendBlur}\n                    onKeyDown={(event) => onLegendKey(event, index)}\n                  >\n                    {/* A hidden segment keeps its row: the dot empties to a ring and the text quiets, so it can be shown again. */}\n                    <span\n                      className={cn(\n                        \"size-[10px] rounded-full shadow-[inset_0_0_0_1.5px_var(--slice)] transition-[background-color,box-shadow] duration-standard ease-standard motion-reduce:transition-none\",\n                        off ? \"bg-transparent\" : \"bg-(--slice)\",\n                      )}\n                      aria-hidden=\"true\"\n                    />\n                    <span className=\"grid min-w-0\">\n                      <span\n                        className={cn(\n                          \"truncate transition-[color] duration-standard ease-standard motion-reduce:transition-none\",\n                          off && \"text-ink-3\",\n                        )}\n                      >\n                        {item.label}\n                      </span>\n                      {item.members && (\n                        <span className=\"truncate text-(length:--text-xs) text-ink-3\">{members(item)}</span>\n                      )}\n                    </span>\n                    <span\n                      className={cn(\n                        \"justify-self-end tabular-nums transition-[color] duration-standard ease-standard motion-reduce:transition-none\",\n                        off ? \"text-ink-3\" : \"text-ink-2\",\n                      )}\n                      aria-hidden=\"true\"\n                    >\n                      <Count value={item.value} format={formatValue} reduced={reduced} />\n                    </span>\n                    <span\n                      className={cn(\n                        \"justify-self-end font-medium tabular-nums transition-[color] duration-standard ease-standard motion-reduce:transition-none\",\n                        off ? \"text-ink-3\" : \"text-ink\",\n                      )}\n                      aria-hidden=\"true\"\n                    >\n                      <Count value={off || !total ? 0 : item.value / total} format={shareText} reduced={reduced} />\n                    </span>\n                  </button>\n                </motion.li>\n              );\n            })}\n          </AnimatePresence>\n        </ul>\n      )}\n      <p className=\"sr-only\">{summary}</p>\n      <p className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </p>\n      {total > 0 && (\n        <div className=\"sr-only\">\n          <table>\n            <caption>{label}</caption>\n            <thead>\n              <tr>\n                <th scope=\"col\">Segment</th>\n                <th scope=\"col\">Value</th>\n                <th scope=\"col\">Share</th>\n              </tr>\n            </thead>\n            <tbody>\n              {visible\n                .flatMap((item) =>\n                  item.members\n                    ? item.members.map((member) => ({\n                        key: member.key,\n                        label: `${member.label} (${otherLabel})`,\n                        value: member.value,\n                      }))\n                    : [item],\n                )\n                .map((item) => (\n                  <tr key={item.key}>\n                    <th scope=\"row\">{item.label}</th>\n                    <td>{format(item.value)}</td>\n                    <td>{shareText(item.value / total)}</td>\n                  </tr>\n                ))}\n            </tbody>\n          </table>\n        </div>\n      )}\n    </figure>\n  );\n}\n\nexport default DonutChart;\n",
      "type": "registry:component",
      "target": "@components/iris/donut-chart/donut-chart.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/donut-chart",
  "meta": {
    "kind": "component",
    "group": "Charts",
    "export": "DonutChart",
    "tags": [
      "data",
      "new"
    ],
    "docs": "https://irisui.dev/components/donut-chart"
  }
}
