{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/donut-chart/donut-chart.tsx",
      "type": "registry:component",
      "target": "@components/xzero/donut-chart/donut-chart.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState, useSyncExternalStore, type CSSProperties, type FocusEvent, type KeyboardEvent, type MouseEvent, type PointerEvent, type Ref } from \"react\";\nimport { AnimatePresence, animate, frame, cancelFrame, motion, motionValue, useInView, useReducedMotion, type MotionValue, type Transition } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./donut-chart.module.css\";\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 Xzero = { 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 = .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 }; };\nconst reveal = physical({ visualDuration: .72, bounce: 0 });\nconst settle = physical({ visualDuration: .5, bounce: 0 });\nconst pop = physical(spring.snappy, .002);\n/** Neutral steps after the four series colors, and the quietest step for Other. */\nconst NEUTRAL = [56, 42, 32];\nconst seriesColor = (index: number) => index < 4 ? `var(--series-${index + 1})` : `color-mix(in oklab, var(--foreground) ${NEUTRAL[(index - 4) % NEUTRAL.length]}%, var(--surface))`;\nconst OTHER_COLOR = \"color-mix(in oklab, var(--foreground) 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, GROW = 2, MARGIN = 7, GAP = 3, 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 < .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(ranked.filter((item, rank) => item.value / total >= groupBelow && rank < maxSegments - 1).map(item => item.key));\n  const rest = parts.filter(item => !keep.has(item.key));\n  if (rest.length < 2) return parts.map(item => ({ ...item }));\n  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 }];\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(slices.map(item => { const arc = { start: at, end: at + (hidden.has(item.key) ? 0 : item.value / total) }; at = arc.end; return [item.key, arc]; }));\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) => `${n2(c + radius * Math.cos(angle))} ${n2(c + radius * Math.sin(angle))}`;\n  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`;\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, a1 = t1 * TAU - Math.PI / 2, half = (a1 - a0) / 2, 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) => `${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 >= .9999 ? Infinity : (s * R - p) / (1 + s)));\n  const dO = Math.asin(Math.min(1, (p + ro) / (R - ro))), 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 > .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 > .01) d += `A${n2(ro)} ${n2(ro)} 0 0 1 ${side(tO, a1, -1)}`;\n  if (s * r > p + 1e-6 || s >= .9999) {\n    const ri = Math.max(0, Math.min(corner, band, s >= .9999 ? Infinity : (s * r - p) / (1 - s)));\n    const dI = Math.asin(Math.min(1, (p + ri) / (r + ri))), 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 > .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 > .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 => { el.textContent = state.format(Number.isInteger(state.target) ? Math.round(now) : now); });\n      live.current = state;\n    }\n    const state = live.current;\n    state.format = format;\n    if (state.target === value && !reduced) { if (!state.mv.isAnimating()) el.textContent = format(value); return; }\n    state.target = value;\n    if (reduced) { state.mv.jump(value); el.textContent = format(value); return; }\n    animate(state.mv, value, physical({ visualDuration: .4, bounce: 0 }, Math.max(Math.abs(value - state.mv.get()) * .001, 1e-6)));\n  }, [value, format, reduced]);\n  useEffect(() => () => { live.current?.mv.destroy(); live.current = null; }, []);\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 = (place: number, reduced: boolean): { animate: { opacity: number; y: string }; transition: Transition } => ({\n  animate: { opacity: place === 0 ? 1 : 0, y: `${.45 * Math.sign(place)}em` },\n  transition: reduced ? { duration: 0 } : place === 0 ? { duration: duration.standard, ease: [...ease.enter] } : { duration: duration.fast, ease: [...ease.standard] },\n});\n\nconst subscribeNothing = () => () => {};\nfunction useReducedMotionSafe() {\n  const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);\n  return !!useReducedMotion() && hydrated;\n}\n\nexport function DonutChart({ data, label, unit = \"\", formatValue = value => grouped.format(value), totalLabel = \"Total\", size = 208, thickness = 24, groupBelow = .04, maxSegments = 6, otherLabel = \"Other\", activeKey, defaultActiveKey = null, onActiveChange, hiddenKeys, defaultHiddenKeys, onHiddenKeysChange, legendAction = \"toggle\", legend = true, emptyLabel = \"No data yet\", ref, className }: 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: .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) => 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.slice(0, index).reverse().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) => { if (activeKey === undefined) setOwnActive(key); onActiveChange?.(key); };\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) setAnnouncement(`${item.label} ${isHidden ? \"shown\" : \"hidden\"}. ${totalLabel} ${formatValue(nextTotal)}${suffix}.`);\n  };\n\n  // Geometry in the chart's own units; the SVG scales with its container.\n  const c = size / 2, outer = c - MARGIN, 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 () => { if (paths.current.get(key) === node) paths.current.delete(key); };\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) { node.setAttribute(\"d\", \"\"); continue; }\n      const start = segment.start.get(), end = segment.end.get(), lift = Math.max(0, segment.lift.get());\n      const R = outer + lift * GROW;\n      node.setAttribute(\"d\", sector(c, R, inner - lift * GROW * .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, distance = lift * LIFT;\n      node.setAttribute(\"transform\", `translate(${n2(Math.cos(middle) * distance)} ${n2(Math.sin(middle) * distance)})`);\n    }\n  }, []);\n  const schedule = useCallback(() => { frame.render(paint); }, [paint]);\n  useLayoutEffect(() => { geometry.current = { c, outer, inner, corner }; paint(); });\n  useEffect(() => () => { cancelFrame(paint); for (const segment of segments.current.values()) segment.off(); segments.current.clear(); }, [paint]);\n\n  const targets = useRef(new Map<string, Xzero>());\n  const latest = useRef({ drawn, slices });\n  useLayoutEffect(() => { latest.current = { drawn, slices }; });\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) => { const before = order.slice(0, index).reverse().find(item => goal.has(item.key)); return before ? goal.get(before.key)!.end : 0; };\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.slice(0, index).reverse().map(entry => segments.current.get(entry.key)).find(Boolean);\n        const from = first ? 0 : prior ? prior.end.get() : 0;\n        const start = motionValue(from), end = motionValue(from), lift = motionValue(0);\n        const offs = [start.on(\"change\", schedule), end.on(\"change\", schedule), lift.on(\"change\", schedule)];\n        segment = { start, end, lift, off: () => { offs.forEach(off => off()); start.destroy(); end.destroy(); lift.destroy(); } };\n        segments.current.set(item.key, segment);\n      }\n      if (reduced) {\n        segment.start.jump(to.start); 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: .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, x = (event.clientX - box.left) * scale - c, 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) if (arc.end > arc.start && turn >= arc.start && turn < arc.end && visible.some(item => item.key === key)) 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) => `${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) => ({ ArrowDown: index + 1, ArrowRight: index + 1, ArrowUp: index - 1, ArrowLeft: index - 1, Home: 0, End: count - 1 } as Record<string, number>)[key];\n  const onLegendKey = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n    if (event.key === \"Escape\" && selected) { event.preventDefault(); select(null); return; }\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\") { event.preventDefault(); setPreview(null); if (selected) select(null); return; }\n    if ((event.key === \"Enter\" || event.key === \" \") && current) { event.preventDefault(); select(selected === current ? null : current); return; }\n    const index = visible.findIndex(item => item.key === current);\n    const next = step(event.key, index < 0 ? (event.key.endsWith(\"Up\") || event.key.endsWith(\"Left\") ? 0 : -1) : index, visible.length);\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>) => { if (!(event.relatedTarget instanceof Node && event.currentTarget.closest(\"ul\")?.contains(event.relatedTarget))) setPreview(null); };\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 = [{ 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 }))];\n  const activeIndex = Math.max(0, readouts.findIndex(item => item.key === (identity === \"__empty\" ? \"__total\" : identity)));\n\n  const summary = total ? `${label}. ${totalLabel} ${format(total)}. ${visible.map(describe).join(\". \")}.` : `${label}. ${emptyLabel}.`;\n  const members = (item: Slice) => item.members ? item.members.length > 2 ? `${item.members.slice(0, 2).map(member => member.label).join(\", \")} and ${item.members.length - 2} more` : item.members.map(member => member.label).join(\" and \") : null;\n  const ringLabel = `${label}. Use the arrow keys to read each segment.`;\n\n  return <figure ref={figure} className={[styles.figure, className].filter(Boolean).join(\" \")} aria-label={label} data-legend={legend || undefined}>\n    <div className={styles.ring} style={{ width: size, \"--inner\": `${(inner * 2) / size * 100}%` } as CSSProperties}\n      tabIndex={legend ? undefined : 0} role={legend ? undefined : \"group\"} aria-label={legend ? undefined : ringLabel} onKeyDown={legend ? undefined : onRingKey} onBlur={legend ? undefined : () => setPreview(null)}>\n      <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}>\n        <circle className={styles.track} cx={c} cy={c} r={(outer + inner) / 2} strokeWidth={outer - inner} data-idle={(!shown || !total) || undefined} />\n        {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} />)}\n      </svg>\n      <div className={styles.center} aria-hidden=\"true\">\n        {readouts.map((item, index) => <motion.span key={item.key} className={styles.readout} initial={false} {...drum(index - activeIndex, reduced)}>\n          <span className={styles.centerLabel}>{item.label}</span>\n          <span className={styles.centerValue}>{item.value || item.meta ? <Count value={item.value} format={formatValue} reduced={reduced} /> : \" \"}</span>\n          <span className={styles.centerMeta}>{item.meta ? <><Count value={total && !hidden.has(item.key) ? item.value / total : 0} format={shareText} reduced={reduced} /> of {totalLabel.toLowerCase()}</> : unit || \" \"}</span>\n        </motion.span>)}\n      </div>\n    </div>\n    {legend && slices.length > 0 && <ul className={styles.legend} aria-label={`${label}, segments${legendAction === \"toggle\" ? \". Press a segment to show or hide it\" : \"\"}`} id={legendId} onPointerLeave={() => setPreview(null)}>\n      <AnimatePresence initial={false}>{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 <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}>\n          <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}, hidden` : describe(item)} data-active={item.key === current || undefined} data-pinned={selected === item.key || undefined} data-hidden={off || undefined} style={{ \"--slice\": colorFor(item) } as CSSProperties}\n            onClick={() => legendAction === \"toggle\" ? setHidden(item.key) : select(selected === item.key ? null : item.key)}\n            onPointerEnter={event => { if (event.pointerType === \"mouse\") setPreview(off ? null : item.key); }}\n            onFocus={event => { if (event.currentTarget.matches(\":focus-visible\")) setPreview(off ? null : item.key); }} onBlur={onLegendBlur} onKeyDown={event => onLegendKey(event, index)}>\n            <span className={styles.dot} aria-hidden=\"true\" />\n            <span className={styles.rowText}>\n              <span className={styles.rowLabel}>{item.label}</span>\n              {item.members && <span className={styles.rowMembers}>{members(item)}</span>}\n            </span>\n            <span className={styles.rowValue} aria-hidden=\"true\"><Count value={item.value} format={formatValue} reduced={reduced} /></span>\n            <span className={styles.rowShare} aria-hidden=\"true\"><Count value={off || !total ? 0 : item.value / total} format={shareText} reduced={reduced} /></span>\n          </button>\n        </motion.li>;\n      })}</AnimatePresence>\n    </ul>}\n    <p className={styles.srOnly}>{summary}</p>\n    <p className={styles.srOnly} aria-live=\"polite\">{announcement}</p>\n    {total > 0 && <div className={styles.srOnly}><table>\n      <caption>{label}</caption>\n      <thead><tr><th scope=\"col\">Segment</th><th scope=\"col\">Value</th><th scope=\"col\">Share</th></tr></thead>\n      <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>\n    </table></div>}\n  </figure>;\n}\n\nexport default DonutChart;\n"
    },
    {
      "path": "registry/components/donut-chart/donut-chart.module.css",
      "type": "registry:component",
      "target": "@components/xzero/donut-chart/donut-chart.module.css",
      "content": "/* The ring sits beside its legend when there is room, and above it on a phone. */\n.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); }\n.figure > * { min-width: 0; }\n.figure[data-legend] { gap: var(--space-6); }\n@container (min-width: 460px) {\n  .figure[data-legend] { grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: var(--space-8); }\n}\n\n.ring { position: relative; max-width: 100%; aspect-ratio: 1; justify-self: center; -webkit-tap-highlight-color: transparent; }\n.svg { display: block; width: 100%; height: 100%; overflow: visible; cursor: default; }\n.svg[data-hovering] { cursor: pointer; }\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.track { fill: none; stroke: var(--surface-muted); transition: opacity var(--duration-considered, .48s) var(--ease-standard); opacity: 0; }\n.track[data-idle] { opacity: 1; }\n/* Shapes are written every frame from script; only colour and dimming ease here, on the compositor. */\n.segment { fill: var(--slice); shape-rendering: geometricPrecision; transition: fill var(--duration-standard) var(--ease-standard), opacity var(--duration-standard) var(--ease-standard); }\n.segment[data-dim] { opacity: .45; }\n\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.center { position: absolute; top: 50%; left: 50%; display: grid; width: calc(var(--inner) - 16px); transform: translate(-50%, -50%); pointer-events: none; }\n.readout { grid-area: 1 / 1; display: grid; min-width: 0; justify-items: center; gap: 2px; text-align: center; }\n.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; }\n.centerLabel { color: var(--text-secondary); }\n.centerMeta { color: var(--text-muted); font-variant-numeric: tabular-nums; }\n.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; }\n\n/* Legend rows mirror the segments: hovering either lights both. */\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 { display: grid; margin: 0 0 -2px; padding: 0; list-style: none; }\n.item { overflow: hidden; }\n.item > .row { margin-bottom: 2px; }\n.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); }\n.row:active { transform: scale(.985); }\n.row[data-active], .row[data-pinned] { background: color-mix(in oklab, var(--foreground) 5%, transparent); }\n.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); }\n/* A hidden segment keeps its row: the dot empties to a ring and the text quiets, so it can be shown again. */\n.row[data-hidden] .dot { background: transparent; }\n.row[data-hidden] .rowLabel, .row[data-hidden] .rowValue, .row[data-hidden] .rowShare { color: var(--text-muted); }\n.rowLabel, .rowValue, .rowShare { transition: color var(--duration-standard) var(--ease-standard); }\n.rowText { display: grid; min-width: 0; }\n.rowLabel, .rowMembers { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.rowMembers { color: var(--text-muted); font-size: var(--text-xs); }\n.rowValue { justify-self: end; color: var(--text-secondary); font-variant-numeric: tabular-nums; }\n.rowShare { justify-self: end; color: var(--foreground); font-weight: 500; font-variant-numeric: tabular-nums; }\n@media (hover: hover) and (pointer: fine) { .row:hover { background: color-mix(in oklab, var(--foreground) 5%, transparent); } }\n\n.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; }\n@media (prefers-reduced-motion: reduce) { .segment, .track, .row, .dot, .rowLabel, .rowValue, .rowShare { transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/donut-chart",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/donut-chart",
    "markdown": "https://ui.xzero.co.uk/components/donut-chart/markdown"
  }
}