{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "donut-chart",
  "type": "registry:ui",
  "title": "Donut Chart",
  "description": "An interactive donut: arcs sweep in, segments slide out on hover or focus, the legend re-flows the ring.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/donut-chart.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface DonutDatum {\n  /** Stable identity: colour and animation follow the id, not the position. */\n  id: string;\n  label: string;\n  value: number;\n  /** Segment colour; falls back to the categorical palette (accent first). */\n  color?: string;\n}\n\nexport interface DonutChartProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  data: DonutDatum[];\n  /** Accessible name; also the caption of the screen-reader table. */\n  label: string;\n  /** Value formatter used in the centre, legend, labels and table. */\n  format?: (value: number) => string;\n  /** Ring thickness in px (at the 240px reference size). */\n  thickness?: number;\n  /** Gap between segments, in degrees. */\n  padAngle?: number;\n  /** Rounded segment ends. */\n  rounded?: boolean;\n  /** Arc tween duration for entry and re-flow, in ms. */\n  duration?: number;\n  /** How far the active segment slides out, in px. */\n  expand?: number;\n  /** Legend placement; the legend buttons toggle segments. */\n  legend?: \"right\" | \"bottom\" | \"none\";\n  /** Caption under the total when no segment is active. */\n  centerLabel?: string;\n  /** Maximum chart diameter in px. */\n  size?: number;\n  /** Colour of the first segment without its own colour. */\n  accent?: string;\n}\n\n/* --------------------------- reduced-motion hook --------------------------- */\n\nconst REDUCED_QUERY = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribeReducedMotion(onChange: () => void) {\n  const mq = window.matchMedia(REDUCED_QUERY);\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(REDUCED_QUERY).matches,\n    () => false,\n  );\n}\n\n/* --------------------------------- palette -------------------------------- */\n\n// Categorical slots, stepped separately for light and dark surfaces (CVD-checked as a set).\n// Colour follows the datum's position in `data`, so hiding a segment never repaints the others.\nconst PALETTE_VARS =\n  \"[--dc-2:#2a78d6] [--dc-3:#1baf7a] [--dc-4:#eda100] [--dc-5:#e87ba4] [--dc-6:#4a3aa7] [--dc-rest:#a1a1aa] \" +\n  \"dark:[--dc-2:#3987e5] dark:[--dc-3:#199e70] dark:[--dc-4:#c98500] dark:[--dc-5:#d55181] dark:[--dc-6:#9085e9] dark:[--dc-rest:#71717a]\";\n\nfunction colorFor(d: DonutDatum, index: number) {\n  if (d.color) return d.color;\n  if (index === 0) return \"var(--dc-accent)\";\n  // Past six slots, segments fold into a neutral instead of cycling hues.\n  return index < 6 ? `var(--dc-${index + 1})` : \"var(--dc-rest)\";\n}\n\n/* -------------------------------- geometry -------------------------------- */\n\nconst VB = 240;\nconst C = VB / 2;\nconst TAU_DEG = 360;\n\ninterface Span {\n  start: number;\n  end: number;\n}\n\nfunction point(angle: number, r: number) {\n  const rad = ((angle - 90) * Math.PI) / 180;\n  return `${(C + r * Math.cos(rad)).toFixed(3)} ${(C + r * Math.sin(rad)).toFixed(3)}`;\n}\n\n/** Stroke path and width for an arc from `start` to `end` (degrees clockwise from 12 o'clock). */\nfunction arc(span: Span, r: number, thickness: number, rounded: boolean) {\n  const sweep = span.end - span.start;\n  if (sweep <= 0.01) return { d: \"\", width: 0 };\n  // Round caps stick out by half the thickness; pull the ends in so the gap stays true.\n  const cap = rounded ? ((thickness / 2 / r) * 180) / Math.PI : 0;\n  if (sweep <= cap * 2) {\n    // Too small for a full cap: draw a dot that grows with the segment.\n    const p = point((span.start + span.end) / 2, r);\n    return { d: `M ${p} L ${p}`, width: thickness * (sweep / (cap * 2)) };\n  }\n  const s = span.start + cap;\n  const e = span.end - cap;\n  if (e - s >= 359.99) {\n    return { d: `M ${point(0, r)} A ${r} ${r} 0 1 1 ${point(180, r)} A ${r} ${r} 0 1 1 ${point(359.99, r)}`, width: thickness };\n  }\n  return { d: `M ${point(s, r)} A ${r} ${r} 0 ${e - s > 180 ? 1 : 0} 1 ${point(e, r)}`, width: thickness };\n}\n\nconst easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);\n\n/* -------------------------------- component -------------------------------- */\n\n/**\n * An interactive donut chart. Segments sweep in, re-flow smoothly when the legend hides one,\n * and the hovered or focused segment slides out while its value takes the centre. One tab stop\n * with arrow keys between segments, plus a screen-reader table of the full data.\n */\nexport function DonutChart({\n  data,\n  label,\n  format,\n  thickness = 28,\n  padAngle = 1.5,\n  rounded = true,\n  duration = 900,\n  expand = 6,\n  legend = \"right\",\n  centerLabel = \"Total\",\n  size = 260,\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  ...props\n}: DonutChartProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const spansRef = React.useRef<Map<string, Span>>(new Map());\n  const [hidden, setHidden] = React.useState<ReadonlySet<string>>(() => new Set());\n  const [active, setActive] = React.useState<string | null>(null);\n  const [focusId, setFocusId] = React.useState<string | null>(null);\n  const hintId = React.useId();\n\n  const fmt = React.useMemo(() => {\n    if (format) return format;\n    const nf = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 1 });\n    return (v: number) => nf.format(v);\n  }, [format]);\n  const pct = (v: number, of: number) => (of > 0 ? `${((v / of) * 100).toFixed(1)}%` : \"0%\");\n\n  const items = data.map((d, i) => ({ ...d, value: Math.max(0, d.value), color: colorFor(d, i) }));\n  const shown = items.filter((d) => !hidden.has(d.id) && d.value > 0);\n  const total = shown.reduce((sum, d) => sum + d.value, 0);\n  const grandTotal = items.reduce((sum, d) => sum + d.value, 0);\n\n  // Target spans: visible segments share 360° minus the gaps; hidden ones collapse in place.\n  const gaps = shown.length > 1 ? shown.length * padAngle : 0;\n  const available = Math.max(0, TAU_DEG - gaps);\n  const half = shown.length > 1 ? padAngle / 2 : 0;\n  const targets = new Map<string, Span>();\n  let cursor = 0;\n  for (const d of items) {\n    if (shown.includes(d)) {\n      const sweep = total > 0 ? (d.value / total) * available : 0;\n      const start = cursor + half;\n      targets.set(d.id, { start, end: start + sweep });\n      cursor = start + sweep + half;\n    } else {\n      targets.set(d.id, { start: cursor, end: cursor });\n    }\n  }\n  const targetKey = JSON.stringify([...targets]);\n\n  const r = C - thickness / 2 - Math.max(0, expand) - 2;\n  const ms = reduceMotion ? 0 : duration;\n\n  // Arc tween: paths are written imperatively from the spans on screen to the new targets.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const nextSpans = new Map<string, Span>(JSON.parse(targetKey) as [string, Span][]);\n    const from = new Map(spansRef.current);\n    const paths = Array.from(root.querySelectorAll<SVGPathElement>(\"[data-dc-id]\"));\n\n    const draw = (t: number) => {\n      const current = new Map<string, Span>();\n      for (const [id, to] of nextSpans) {\n        const f = from.get(id) ?? { start: 0, end: 0 };\n        current.set(id, { start: f.start + (to.start - f.start) * t, end: f.end + (to.end - f.end) * t });\n      }\n      spansRef.current = current;\n      for (const path of paths) {\n        const span = current.get(path.dataset.dcId ?? \"\");\n        if (!span) continue;\n        const { d, width } = arc(span, r, thickness, rounded);\n        const ring = path.dataset.dcRing !== undefined;\n        path.setAttribute(\"d\", d);\n        path.setAttribute(\"stroke-width\", String(ring && width > 0 ? width + 5 : width));\n      }\n    };\n\n    if (ms <= 0) {\n      draw(1);\n      return;\n    }\n    let frame = 0;\n    let start = -1; // set from the first frame's timestamp\n    const tick = (now: number) => {\n      if (start < 0) start = now;\n      const t = Math.min(1, (now - start) / ms);\n      draw(easeOutCubic(t));\n      if (t < 1) frame = requestAnimationFrame(tick);\n    };\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [targetKey, r, thickness, rounded, ms]);\n\n  const tabId = shown.some((d) => d.id === focusId) ? focusId : (shown[0]?.id ?? null);\n  const activeItem = shown.find((d) => d.id === active) ?? null;\n\n  const focusSegment = (id: string) => {\n    rootRef.current?.querySelector<SVGPathElement>(`[data-dc-seg=\"${CSS.escape(id)}\"]`)?.focus();\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent<SVGPathElement>, id: string) => {\n    const i = shown.findIndex((d) => d.id === id);\n    if (i < 0 || !shown.length) return;\n    let next = -1;\n    if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") next = (i + 1) % shown.length;\n    else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") next = (i - 1 + shown.length) % shown.length;\n    else if (event.key === \"Home\") next = 0;\n    else if (event.key === \"End\") next = shown.length - 1;\n    else if (event.key === \"Escape\") setActive(null);\n    if (next < 0) return;\n    event.preventDefault();\n    focusSegment(shown[next].id);\n  };\n\n  const toggle = (id: string) => {\n    setHidden((prev) => {\n      const next = new Set(prev);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      return next;\n    });\n    if (active === id) setActive(null);\n  };\n\n  const legendLayout = legend === \"right\" ? \"@lg:flex-row @lg:items-center\" : \"\";\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"group\"\n      aria-label={label}\n      className={`@container w-full text-zinc-950 dark:text-zinc-50 ${PALETTE_VARS} ${className}`}\n      style={{ [\"--dc-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <div className={`flex flex-col items-center gap-6 ${legendLayout}`}>\n        <div className=\"relative aspect-square w-full shrink-0 @container\" style={{ maxWidth: size }}>\n          <svg viewBox={`0 0 ${VB} ${VB}`} className=\"absolute inset-0 size-full overflow-visible\" onPointerLeave={(e) => e.pointerType === \"mouse\" && setActive(null)}>\n            {/* Recessive track behind the segments. */}\n            <circle cx={C} cy={C} r={r} fill=\"none\" strokeWidth={thickness} className=\"stroke-zinc-900/[0.05] dark:stroke-white/[0.06]\" />\n            <g role=\"list\" aria-label={`${label} segments`} aria-describedby={hintId}>\n              {items.map((d) => {\n                const isShown = shown.includes(d);\n                const span = targets.get(d.id);\n                const mid = span ? (span.start + span.end) / 2 : 0;\n                const rad = ((mid - 90) * Math.PI) / 180;\n                const out = active === d.id ? expand : 0;\n                return (\n                  <g\n                    key={d.id}\n                    className=\"group transition-transform duration-200 ease-out motion-reduce:transition-none\"\n                    style={{ transform: `translate(${(Math.cos(rad) * out).toFixed(2)}px, ${(Math.sin(rad) * out).toFixed(2)}px)` }}\n                  >\n                    {/* Focus ring: a wider arc behind the segment, shown for keyboard focus only. */}\n                    <path\n                      data-dc-id={d.id}\n                      data-dc-ring=\"\"\n                      aria-hidden=\"true\"\n                      fill=\"none\"\n                      strokeLinecap={rounded ? \"round\" : \"butt\"}\n                      className=\"pointer-events-none stroke-zinc-950 opacity-0 group-has-[:focus-visible]:opacity-100 dark:stroke-white\"\n                    />\n                    <path\n                      data-dc-id={d.id}\n                      data-dc-seg={d.id}\n                      role={isShown ? \"listitem\" : undefined}\n                      aria-hidden={isShown ? undefined : true}\n                      aria-label={isShown ? `${d.label}: ${fmt(d.value)}, ${pct(d.value, total)}` : undefined}\n                      tabIndex={isShown ? (tabId === d.id ? 0 : -1) : undefined}\n                      fill=\"none\"\n                      stroke={d.color}\n                      strokeLinecap={rounded ? \"round\" : \"butt\"}\n                      className={`cursor-pointer outline-none transition-opacity duration-200 ${\n                        active && active !== d.id ? \"opacity-45\" : \"opacity-100\"\n                      }`}\n                      onPointerEnter={(e) => e.pointerType === \"mouse\" && isShown && setActive(d.id)}\n                      onClick={() => isShown && setActive((a) => (a === d.id ? null : d.id))}\n                      onFocus={() => {\n                        setFocusId(d.id);\n                        setActive(d.id);\n                      }}\n                      onBlur={() => setActive(null)}\n                      onKeyDown={(e) => onKeyDown(e, d.id)}\n                    />\n                  </g>\n                );\n              })}\n            </g>\n          </svg>\n          {/* Centre read-out (decorative: the focused segment's label carries the same text). */}\n          <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center\">\n            <span className=\"max-w-[46cqw] truncate font-mono text-[max(9px,4.2cqw)] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n              {activeItem ? activeItem.label : centerLabel}\n            </span>\n            <span className=\"text-[max(18px,13cqw)] leading-none font-semibold tracking-[-0.04em] tabular-nums\">\n              {activeItem ? fmt(activeItem.value) : fmt(total)}\n            </span>\n            <span className=\"mt-1 h-[1.4em] font-mono text-[max(9px,4.2cqw)] tabular-nums text-zinc-500 dark:text-zinc-400\">\n              {activeItem ? pct(activeItem.value, total) : shown.length ? `${shown.length} of ${items.length}` : \"No data\"}\n            </span>\n          </div>\n        </div>\n\n        {legend !== \"none\" ? (\n          <ul className=\"flex w-full min-w-0 flex-col gap-0.5 @lg:max-w-64\" aria-label={`${label} legend`}>\n            {items.map((d) => {\n              const off = hidden.has(d.id);\n              return (\n                <li key={d.id}>\n                  <button\n                    type=\"button\"\n                    aria-pressed={!off}\n                    onClick={() => toggle(d.id)}\n                    onPointerEnter={(e) => e.pointerType === \"mouse\" && !off && setActive(d.id)}\n                    onPointerLeave={(e) => e.pointerType === \"mouse\" && setActive(null)}\n                    className={`grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-zinc-900/[0.04] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--dc-accent)] dark:hover:bg-white/[0.05] ${\n                      off ? \"text-zinc-400 dark:text-zinc-500\" : \"\"\n                    } ${active === d.id ? \"bg-zinc-900/[0.04] dark:bg-white/[0.05]\" : \"\"}`}\n                  >\n                    <span\n                      aria-hidden=\"true\"\n                      className={`size-2.5 rounded-[3px] border transition-colors ${off ? \"border-current bg-transparent\" : \"border-transparent\"}`}\n                      style={off ? undefined : { backgroundColor: d.color }}\n                    />\n                    <span className={`truncate ${off ? \"line-through decoration-1\" : \"\"}`}>{d.label}</span>\n                    <span className=\"font-mono text-[11px] tabular-nums text-zinc-500 dark:text-zinc-400\">\n                      {off ? \"hidden\" : `${fmt(d.value)} · ${pct(d.value, total)}`}\n                    </span>\n                  </button>\n                </li>\n              );\n            })}\n          </ul>\n        ) : null}\n      </div>\n\n      <p id={hintId} className=\"sr-only\">\n        Use the arrow keys to move between segments.\n      </p>\n      <table className=\"sr-only\">\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          {items.map((d) => (\n            <tr key={d.id}>\n              <th scope=\"row\">{d.label}</th>\n              <td>{fmt(d.value)}</td>\n              <td>{pct(d.value, grandTotal)}</td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/donut-chart"
}
