{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "donut",
  "title": "Donut",
  "description": "A thick ring of rounded segments with gaps, a centre figure and a legend list; point at a segment or a row and it steps out while the centre reads its value and share.",
  "registryDependencies": [
    "utils",
    "@manniche/chart-kit"
  ],
  "files": [
    {
      "path": "registry/manniche/donut/donut.tsx",
      "content": "// Donut: a thick ring of rounded segments with gaps between them, a centre figure and a legend list beside it.\n// Point at a segment or a row and the segment steps out along its middle while the others fade back, and the\n// centre reads that category's value and share; with nothing active it reads the total.\n//\n// How it works: the ring is one SVG arc per category (round caps, a fixed gap), drawn from twelve o'clock. Which\n// segment is under the pointer is worked out from the angle and distance to the centre, so a mouse, a pen and a\n// finger dragging across the ring all behave the same. The legend is a real list of buttons and the keyboard path:\n// one Tab stop, arrow keys, Home and End move between rows, and the row and its segment light each other.\n//\n// Screen readers: the ring is one image named \"<label>. Total €1,383.\"; the list is the data, so each row reads\n// \"Groceries: €415, 30 percent\" as focus lands on it. Nothing else is announced, so nothing is read twice.\n//\n// Motion: on first view the segments fade in and turn a few degrees into place, one after another (all done in\n// 540 ms); afterwards only the hovered segment moves (transform) and the rest fade (opacity). Under reduced motion\n// everything is drawn in place at once and nothing moves.\nimport {\n  useEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type HTMLAttributes,\n  type KeyboardEvent,\n  type PointerEvent,\n} from 'react'\nimport { cn } from '@/lib/utils'\nimport { BigNumber } from '@/registry/manniche/chart-kit/chart-kit'\nimport { EASE_CSS, formatValue, seriesColor, stepIndex, valueParts, type ValueFormat } from '@/registry/manniche/chart-kit/chart-utils'\nimport { useChartFrame } from '@/registry/manniche/chart-kit/use-chart'\n\nexport type DonutDatum = {\n  /** The category's name, shown in the list and read aloud. Also the row's key, so keep it unique. */\n  label: string\n  /** The amount. Negative values count as zero. */\n  value: number\n  /** Any CSS colour. Defaults to the series colours --chart-1 … --chart-5 in order. */\n  color?: string\n}\n\nexport type DonutLabels = {\n  /** The centre label when no category is active. Default \"Total\". */\n  total?: string\n  /** After the share in the centre, as in \"30% of total\". Default \"of total\". */\n  ofTotal?: string\n  /** The word after a share for screen readers, as in \"30 percent\". Default \"percent\". */\n  percent?: string\n}\n\nexport type DonutProps = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'onSelect'> & {\n  /** One entry per category, in the order the ring and the list show them: `{ label, value, color? }`. */\n  data: DonutDatum[]\n  /** What the chart shows, such as \"Spending by category, last 30 days\". Names the ring and the list. */\n  label: string\n  /** How values are written. Default: a plain whole number in en-GB. */\n  format?: ValueFormat\n  /**\n   * The whole the shares are taken of. Leave it out to use the sum of `data`. A larger total (a budget, say)\n   * leaves the rest of the ring empty.\n   */\n  total?: number\n  /** \"row\" puts the list beside the ring, \"stack\" puts it under; \"auto\" (default) picks by the width available. */\n  layout?: 'auto' | 'row' | 'stack'\n  /** The highlighted category, or null for none. Pass it to control the highlight; leave it out to let the chart. */\n  activeIndex?: number | null\n  /** The highlighted category on first render when uncontrolled. Default null. */\n  defaultActiveIndex?: number | null\n  /** Called when the highlight moves by pointer, focus or keys, with null when it clears. */\n  onActiveIndexChange?: (index: number | null) => void\n  /** Called when a row is clicked or pressed with Enter or Space, for drilling into a category. */\n  onSelect?: (index: number) => void\n  /** Words for other languages. */\n  labels?: DonutLabels\n  /** \"compact\" draws a smaller ring and tighter gaps. Inside a compact DataTile this happens on its own. */\n  density?: 'comfortable' | 'compact'\n}\n\n// The ring in a 200 × 200 box: radius 74, 30 wide, 5° between segments plus room for the round caps.\nconst C = 100\nconst R = 74\nconst SW = 30\nconst GAP = 5\nconst CAP = ((SW / 2 / R) * 180) / Math.PI\nconst rad = (deg: number) => (deg * Math.PI) / 180\nconst pt = (deg: number) => [C + R * Math.cos(rad(deg)), C + R * Math.sin(rad(deg))] as const\nconst f2 = (v: number) => v.toFixed(2)\n\ntype Seg = { d: string; mid: number; from: number; to: number }\n\nfunction geometry(values: number[], whole: number): Seg[] {\n  let a0 = -90\n  return values.map((v) => {\n    const span = whole > 0 ? (v / whole) * 360 : 0\n    const s0 = a0 + CAP + GAP / 2\n    const s1 = a0 + span - CAP - GAP / 2\n    // A slice too thin for its caps is drawn as a dot in its middle.\n    const start = s1 > s0 ? s0 : a0 + span / 2 - 0.25\n    const end = Math.max(s1, start + 0.5)\n    const [x0, y0] = pt(start)\n    const [x1, y1] = pt(end)\n    const seg = {\n      d: span > 0 ? `M${f2(x0)} ${f2(y0)}A${R} ${R} 0 ${end - start > 180 ? 1 : 0} 1 ${f2(x1)} ${f2(y1)}` : '',\n      mid: (start + end) / 2,\n      from: a0,\n      to: a0 + span,\n    }\n    a0 += span\n    return seg\n  })\n}\n\n/** The figure in the centre is sized to the ring and shrinks for long numbers, so it always fits the hole. */\nfunction centreSize(text: string) {\n  // Rough widths in em: digits 0.6, separators 0.28, the small currency sign 0.3. The hole is 52 % of the figure.\n  const em = [...text].reduce((w, ch) => w + (/\\d/.test(ch) ? 0.6 : /[\\s.,'’]/.test(ch) ? 0.28 : 0.3), 0)\n  return `clamp(18px, min(17cqi, ${f2(52 / Math.max(em, 2))}cqi), 36px)`\n}\n\nexport function Donut({\n  data,\n  label,\n  format,\n  total,\n  layout = 'auto',\n  activeIndex,\n  defaultActiveIndex = null,\n  onActiveIndexChange,\n  onSelect,\n  labels,\n  density,\n  className,\n  style,\n  ...rest\n}: DonutProps) {\n  const { total: totalWord = 'Total', ofTotal = 'of total', percent = 'percent' } = labels ?? {}\n  const { ref, drawn, reduced } = useChartFrame<HTMLDivElement>()\n  const [own, setOwn] = useState<number | null>(defaultActiveIndex)\n  const raw = activeIndex !== undefined ? activeIndex : own\n  const active = raw !== null && raw >= 0 && raw < data.length ? raw : null\n  const rootRef = useRef<HTMLDivElement | null>(null)\n  const rows = useRef<(HTMLButtonElement | null)[]>([])\n  // Where Tab lands: the last row the keyboard used, else the active row, else the first.\n  const [cursor, setCursor] = useState(0)\n  const [settled, setSettled] = useState(false)\n\n  const values = data.map((d) => Math.max(0, d.value))\n  const sum = values.reduce((a, b) => a + b, 0)\n  const whole = Math.max(total ?? sum, sum)\n  const segs = geometry(values, whole)\n  const share = (v: number) => (whole > 0 ? Math.round((v / whole) * 100) : 0)\n  const pct = (v: number) => formatValue(share(v), { locale: format?.locale, suffix: '%' })\n\n  const set = (i: number | null) => {\n    if (i === active) return\n    if (activeIndex === undefined) setOwn(i)\n    onActiveIndexChange?.(i)\n  }\n  const latest = useRef(set)\n  useEffect(() => {\n    latest.current = set\n  })\n\n  // After the entrance, hover moves drop the stagger delay.\n  useEffect(() => {\n    if (!drawn || settled) return\n    const t = window.setTimeout(() => setSettled(true), reduced ? 0 : 620)\n    return () => window.clearTimeout(t)\n  }, [drawn, settled, reduced])\n\n  // A tap that left a segment lit on a touch screen clears when the next tap lands outside the chart.\n  const sticky = active !== null\n  useEffect(() => {\n    if (!sticky) return\n    const off = (e: globalThis.PointerEvent) => {\n      const root = rootRef.current\n      if (root && !root.contains(e.target as Node)) latest.current(null)\n    }\n    document.addEventListener('pointerdown', off)\n    return () => document.removeEventListener('pointerdown', off)\n  }, [sticky])\n\n  const focusedRow = () => {\n    const i = rows.current.findIndex((r) => r && r === document.activeElement)\n    return i < 0 ? null : i\n  }\n\n  const onRingPointer = (e: PointerEvent<SVGSVGElement>) => {\n    const box = e.currentTarget.getBoundingClientRect()\n    if (!box.width) return\n    const x = ((e.clientX - box.left) / box.width) * 200 - C\n    const y = ((e.clientY - box.top) / box.height) * 200 - C\n    const dist = Math.hypot(x, y)\n    if (dist < R - SW / 2 - 6 || dist > R + SW / 2 + 10) return\n    let deg = (Math.atan2(y, x) * 180) / Math.PI\n    if (deg < -90) deg += 360\n    const i = segs.findIndex((s) => deg >= s.from && deg < s.to)\n    if (i >= 0) set(i)\n  }\n  const onRingLeave = (e: PointerEvent<SVGSVGElement>) => {\n    // A finger lifting keeps its segment; a mouse leaving lets go unless a row has focus.\n    if (e.pointerType === 'touch') return\n    set(focusedRow())\n  }\n\n  const onKey = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {\n    if (e.key === 'Escape') {\n      if (active === null) return\n      e.preventDefault()\n      set(null)\n      return\n    }\n    const key = e.key === 'ArrowDown' ? 'ArrowRight' : e.key === 'ArrowUp' ? 'ArrowLeft' : e.key\n    const to = stepIndex(key, i, data.length)\n    if (to === null) return\n    e.preventDefault()\n    rows.current[to]?.focus()\n  }\n\n  const stagger = data.length > 1 ? Math.min(60, 240 / (data.length - 1)) : 0\n  const tabStop = Math.min(active ?? cursor, Math.max(0, data.length - 1))\n  const centreValue = active === null ? whole : values[active]\n  const centreText = formatValue(centreValue, format)\n\n  return (\n    <div\n      ref={(el) => {\n        ref(el)\n        rootRef.current = el\n      }}\n      className={cn('@container w-full min-w-0', className)}\n      style={style}\n      {...rest}\n    >\n      <div\n        data-layout={layout}\n        className={cn(\n          'grid grid-cols-1 items-center justify-center justify-items-center gap-4',\n          'group-data-[density=compact]/tile:gap-3',\n          density === 'compact' && 'gap-3',\n          layout === 'row' && 'grid-cols-[minmax(150px,210px)_minmax(0,24rem)] gap-6',\n          layout === 'row' && 'group-data-[density=compact]/tile:grid-cols-[minmax(120px,168px)_minmax(0,24rem)] group-data-[density=compact]/tile:gap-4',\n          layout === 'row' && density === 'compact' && 'grid-cols-[minmax(120px,168px)_minmax(0,24rem)] gap-4',\n          layout === 'auto' && '@[26rem]:grid-cols-[minmax(150px,210px)_minmax(0,24rem)] @[26rem]:gap-6',\n          layout === 'auto' &&\n            'group-data-[density=compact]/tile:@[26rem]:grid-cols-[minmax(120px,168px)_minmax(0,24rem)] group-data-[density=compact]/tile:@[26rem]:gap-4',\n          layout === 'auto' && density === 'compact' && '@[26rem]:grid-cols-[minmax(120px,168px)_minmax(0,24rem)] @[26rem]:gap-4',\n        )}\n      >\n        <div\n          role=\"img\"\n          aria-label={`${label}. ${totalWord} ${formatValue(whole, format)}.`}\n          className={cn(\n            'relative @container aspect-square w-[min(100%,200px)] justify-self-center',\n            'group-data-[density=compact]/tile:w-[min(100%,168px)]',\n            density === 'compact' && 'w-[min(100%,168px)]',\n            layout !== 'stack' && '@[26rem]:w-full',\n            layout === 'row' && 'w-full',\n          )}\n        >\n          <svg\n            viewBox=\"0 0 200 200\"\n            aria-hidden\n            className=\"block size-full touch-pan-y overflow-visible\"\n            onPointerMove={onRingPointer}\n            onPointerDown={onRingPointer}\n            onPointerLeave={onRingLeave}\n          >\n            {segs.map((s, k) => {\n              const hot = k === active\n              const m = rad(s.mid)\n              const moves = [\n                hot ? `translate(${f2(Math.cos(m) * 6)}px, ${f2(Math.sin(m) * 6)}px)` : '',\n                drawn ? '' : 'rotate(-28deg)',\n              ].join(' ')\n              const segStyle: CSSProperties = {\n                stroke: data[k].color ?? seriesColor(k),\n                transformBox: 'view-box',\n                transformOrigin: '100px 100px',\n                transform: moves.trim() || 'none',\n                opacity: !drawn ? 0 : active !== null && !hot ? 0.26 : 1,\n                transition: reduced\n                  ? 'none'\n                  : settled\n                    ? `transform 260ms ${EASE_CSS}, opacity 200ms ${EASE_CSS}`\n                    : `transform 300ms ${EASE_CSS} ${Math.round(k * stagger)}ms, opacity 300ms ${EASE_CSS} ${Math.round(k * stagger)}ms`,\n              }\n              return s.d ? (\n                <path key={data[k].label} d={s.d} fill=\"none\" strokeWidth={SW} strokeLinecap=\"round\" style={segStyle} />\n              ) : null\n            })}\n          </svg>\n          <div aria-hidden className=\"pointer-events-none absolute inset-0 grid place-content-center justify-items-center text-center\">\n            <span className=\"max-w-[11ch] text-[12.5px] leading-[1.2] font-medium text-balance text-muted-foreground\">\n              {active === null ? totalWord : data[active].label}\n            </span>\n            <span className=\"mt-1\" style={{ ['--dn-num' as string]: centreSize(centreText) }}>\n              <BigNumber value={centreValue} format={format} size=\"md\" roll={drawn} className=\"text-[length:var(--dn-num)]\" />\n            </span>\n            <span className=\"mt-1 min-h-[11px] font-mono text-[11px] leading-none font-medium text-muted-foreground tabular-nums\">\n              {active === null ? '' : `${pct(values[active])} ${ofTotal}`}\n            </span>\n          </div>\n        </div>\n\n        <ul\n          aria-label={label}\n          className=\"grid w-full max-w-sm min-w-0 gap-0.5\"\n          onPointerLeave={(e) => {\n            if (e.pointerType !== 'touch') set(focusedRow())\n          }}\n          onBlur={(e) => {\n            if (!e.currentTarget.contains(e.relatedTarget as Node | null)) set(null)\n          }}\n        >\n          {data.map((d, k) => {\n            const on = k === active\n            const p = valueParts(values[k], format)\n            const unit = p.unit && <span className=\"font-normal text-muted-foreground\">{p.unit}</span>\n            return (\n              <li key={d.label} className=\"min-w-0\">\n                <button\n                  ref={(el) => {\n                    rows.current[k] = el\n                  }}\n                  type=\"button\"\n                  tabIndex={k === tabStop ? 0 : -1}\n                  aria-label={`${d.label}: ${formatValue(values[k], format)}, ${share(values[k])} ${percent}`}\n                  onPointerEnter={() => set(k)}\n                  onFocus={() => {\n                    setCursor(k)\n                    set(k)\n                  }}\n                  onClick={(e) => {\n                    // Safari does not focus a button on click; focusing it keeps the row lit like everywhere else.\n                    e.currentTarget.focus()\n                    set(k)\n                    onSelect?.(k)\n                  }}\n                  onKeyDown={(e) => onKey(e, k)}\n                  className={cn(\n                    'relative isolate grid min-h-11 w-full min-w-0 cursor-pointer grid-cols-[12px_minmax(0,1fr)_auto_40px] items-center gap-2.5 rounded-xl px-2.5 text-left text-sm focus-visible:outline-offset-0',\n                    'before:absolute before:inset-0 before:-z-10 before:rounded-xl before:bg-muted before:opacity-0 before:transition-opacity before:duration-150 before:ease-out-quint before:content-[\"\"] hover:before:opacity-100 motion-reduce:before:transition-none',\n                    on && 'before:opacity-100',\n                  )}\n                >\n                  <i aria-hidden className=\"size-3 rounded-[4px]\" style={{ background: d.color ?? seriesColor(k) }} />\n                  <span className=\"truncate\">{d.label}</span>\n                  <span aria-hidden className=\"font-medium whitespace-nowrap tabular-nums\">\n                    {p.sign}\n                    {!p.unitAfter && unit}\n                    {p.whole}\n                    {p.fraction}\n                    {p.unitAfter && <> {unit}</>}\n                    {p.suffix}\n                  </span>\n                  <span aria-hidden className=\"text-right font-mono text-[11.5px] leading-none text-muted-foreground tabular-nums\">\n                    {pct(values[k])}\n                  </span>\n                </button>\n              </li>\n            )\n          })}\n        </ul>\n      </div>\n    </div>\n  )\n}\n\nexport default Donut\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "widgets"
  ],
  "type": "registry:component"
}