{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "donut-legend",
  "type": "registry:component",
  "title": "Donut Legend",
  "description": "A donut and its legend wired both ways: point at a slice or a row and the slice swells, the rest step back and the centre figure rolls to it; click a row to take it out of the total.",
  "categories": [
    "component",
    "Numbers"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/donut-legend.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { motion, type MotionValue, useReducedMotion, useSpring, useTransform } from \"motion/react\";\nimport { type KeyboardEvent, useEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface DonutSlice {\n  id: string;\n  label: string;\n  value: number;\n  color?: string;\n  hint?: string;\n}\n\nexport interface DonutLegendProps {\n  title?: string;\n  caption?: string;\n  slices?: DonutSlice[];\n  totalLabel?: string;\n  format?: Intl.NumberFormatOptions;\n  locale?: string;\n  className?: string;\n}\n\nconst PALETTE = [\n  \"var(--color-violet-500)\",\n  \"var(--color-rose-600)\",\n  \"var(--color-sky-600)\",\n  \"var(--color-amber-600)\",\n  \"var(--color-emerald-600)\",\n];\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\nconst SIZE = 176;\nconst C = SIZE / 2;\nconst R_OUT = 84;\nconst R_IN = 58;\n\nconst defaultSlices: DonutSlice[] = [\n  { id: \"compute\", label: \"Compute\", value: 21480, hint: \"38 instances\" },\n  { id: \"database\", label: \"Database\", value: 9875, hint: \"4 clusters\" },\n  { id: \"storage\", label: \"Object storage\", value: 7120, hint: \"212 TB\" },\n  { id: \"network\", label: \"Network egress\", value: 5310, hint: \"64 TB out\" },\n  { id: \"other\", label: \"Everything else\", value: 4425, hint: \"Queues, DNS, logs\" },\n];\n\nfunction RollingNumber({ text, instant, className }: { text: string; instant?: boolean; className?: string }) {\n  const reduce = useReducedMotion();\n  let d = 0;\n  let o = 0;\n  const glyphs = [...text]\n    .reverse()\n    .map((ch) => {\n      const isDigit = ch >= \"0\" && ch <= \"9\";\n      return { ch, key: isDigit ? `d${d++}` : `s${o++}${ch}`, digit: isDigit ? Number(ch) : null };\n    })\n    .reverse();\n  return (\n    <span className={cn(\"relative inline-flex whitespace-nowrap tabular-nums leading-none\", className)}>\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden className=\"inline-flex [--row:1.15em]\">\n        {glyphs.map((g) =>\n          g.digit === null ? (\n            <span className=\"inline-block h-(--row) leading-(--row)\" key={g.key}>\n              {g.ch}\n            </span>\n          ) : (\n            <span\n              className=\"relative inline-block h-(--row) overflow-hidden\"\n              key={g.key}\n              style={{ maskImage: \"linear-gradient(transparent, black 18%, black 82%, transparent)\" }}\n            >\n              <span className=\"invisible block leading-(--row)\">0</span>\n              <motion.span\n                animate={{ y: `${-g.digit * 10}%` }}\n                className=\"absolute inset-x-0 top-0 flex flex-col items-center\"\n                initial={false}\n                transition={reduce || instant ? { duration: 0 } : { type: \"spring\", stiffness: 260, damping: 30 }}\n              >\n                {DIGITS.map((n) => (\n                  <span className=\"block h-(--row) leading-(--row)\" key={n}>\n                    {n}\n                  </span>\n                ))}\n              </motion.span>\n            </span>\n          ),\n        )}\n      </span>\n    </span>\n  );\n}\n\nfunction arc(a0: number, a1: number, rOut: number, rIn: number) {\n  const sweep = Math.min(Math.PI * 2 - 0.0001, Math.max(0, a1 - a0));\n  if (sweep <= 0.0005) return \"\";\n  const end = a0 + sweep;\n  const large = sweep > Math.PI ? 1 : 0;\n  const p = (r: number, a: number) => `${(C + r * Math.sin(a)).toFixed(3)} ${(C - r * Math.cos(a)).toFixed(3)}`;\n  return `M${p(rOut, a0)} A${rOut} ${rOut} 0 ${large} 1 ${p(rOut, end)} L${p(rIn, end)} A${rIn} ${rIn} 0 ${large} 0 ${p(rIn, a0)} Z`;\n}\n\nfunction Slice({\n  start,\n  end,\n  color,\n  active,\n  dimmed,\n  onEnter,\n}: {\n  start: number;\n  end: number;\n  color: string;\n  active: boolean;\n  dimmed: boolean;\n  onEnter: () => void;\n}) {\n  const reduce = useReducedMotion();\n  const s = useSpring(0, { stiffness: 140, damping: 22, mass: 0.9 });\n  const e = useSpring(0, { stiffness: 140, damping: 22, mass: 0.9 });\n  const grow = useSpring(0, { stiffness: 420, damping: 30 });\n  useEffect(() => {\n    if (reduce) {\n      s.jump(start);\n      e.jump(end);\n    } else {\n      s.set(start);\n      e.set(end);\n    }\n  }, [start, end, s, e, reduce]);\n  useEffect(() => {\n    if (reduce) grow.jump(active ? 1 : 0);\n    else grow.set(active ? 1 : 0);\n  }, [active, grow, reduce]);\n  const d = useTransform([s, e, grow] as MotionValue<number>[], ([a0, a1, g]: number[]) => {\n    return arc(a0, a1, R_OUT + g * 6, R_IN - g * 2);\n  });\n  return (\n    <motion.path\n      animate={{ opacity: dimmed ? 0.35 : 1 }}\n      className=\"stroke-card\"\n      d={d}\n      strokeLinejoin=\"round\"\n      strokeWidth={end - start > Math.PI * 2 - 0.01 ? 0 : 2}\n      onPointerEnter={onEnter}\n      style={{ fill: color }}\n      transition={{ duration: reduce ? 0 : 0.15 }}\n    />\n  );\n}\n\nexport function DonutLegend({\n  title = \"Cloud spend\",\n  caption = \"October, month to date\",\n  slices = defaultSlices,\n  totalLabel = \"Total\",\n  format = { style: \"currency\", currency: \"USD\", maximumFractionDigits: 0 },\n  locale = \"en-US\",\n  className,\n}: DonutLegendProps) {\n  const reduce = useReducedMotion();\n  const [active, setActive] = useState<string | null>(null);\n  const [hidden, setHidden] = useState<Set<string>>(() => new Set());\n  const rowRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const fmt = useMemo(() => new Intl.NumberFormat(locale, format), [locale, format]);\n\n  const shown = slices.filter((s) => !hidden.has(s.id));\n  const total = shown.reduce((sum, s) => sum + s.value, 0);\n  const angles = useMemo(() => {\n    let acc = 0;\n    return slices.map((s) => {\n      const value = hidden.has(s.id) ? 0 : s.value;\n      const a0 = (acc / (total || 1)) * Math.PI * 2;\n      acc += value;\n      return { a0, a1: (acc / (total || 1)) * Math.PI * 2 };\n    });\n  }, [slices, hidden, total]);\n\n  const current = slices.find((s) => s.id === active && !hidden.has(s.id)) ?? null;\n  const shownKey = current?.id ?? \"total\";\n  const lastKey = useRef(shownKey);\n  const swapped = lastKey.current !== shownKey;\n  useEffect(() => {\n    lastKey.current = shownKey;\n  }, [shownKey]);\n  const share = (v: number) => (total ? (v / total) * 100 : 0);\n\n  function toggle(id: string) {\n    setHidden((prev) => {\n      const next = new Set(prev);\n      if (next.has(id)) next.delete(id);\n      else if (slices.length - next.size > 1) next.add(id);\n      return next;\n    });\n  }\n\n  function onKeyDown(e: KeyboardEvent<HTMLButtonElement>, i: number) {\n    const next = e.key === \"ArrowDown\" ? i + 1 : e.key === \"ArrowUp\" ? i - 1 : e.key === \"Home\" ? 0 : e.key === \"End\" ? slices.length - 1 : null;\n    if (next === null) return;\n    e.preventDefault();\n    rowRefs.current[(next + slices.length) % slices.length]?.focus();\n  }\n\n  return (\n    <section\n      aria-label={title}\n      className={cn(\"@container w-full max-w-[460px] rounded-2xl border bg-card p-5 text-card-foreground shadow-xs/5\", className)}\n    >\n      <header className=\"flex items-baseline justify-between gap-3\">\n        <h3 className=\"font-medium text-sm\">{title}</h3>\n        <span className=\"truncate text-muted-foreground text-xs\">{caption}</span>\n      </header>\n\n      <div className=\"mt-4 flex flex-col items-center gap-5 @min-[24rem]:flex-row @min-[24rem]:items-center\">\n        <div className=\"relative shrink-0\" onPointerLeave={() => setActive(null)} style={{ width: SIZE, height: SIZE }}>\n          <svg aria-hidden className=\"overflow-visible\" height={SIZE} viewBox={`0 0 ${SIZE} ${SIZE}`} width={SIZE}>\n            <circle className=\"fill-none stroke-muted\" cx={C} cy={C} r={(R_OUT + R_IN) / 2} strokeWidth={R_OUT - R_IN} />\n            {slices.map((s, i) => (\n              <Slice\n                active={active === s.id}\n                color={s.color ?? PALETTE[i % PALETTE.length]}\n                dimmed={active !== null && active !== s.id}\n                end={angles[i].a1}\n                key={s.id}\n                onEnter={() => setActive(s.id)}\n                start={angles[i].a0}\n              />\n            ))}\n          </svg>\n          <div className=\"pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center\">\n            <span className=\"h-4 w-28 truncate text-muted-foreground text-xs\">{current?.label ?? totalLabel}</span>\n            <RollingNumber\n              className=\"mt-1 font-semibold text-xl tracking-tight\"\n              instant={swapped}\n              text={fmt.format(current?.value ?? total)}\n            />\n            <span className=\"mt-1 text-muted-foreground text-xs tabular-nums\">\n              {current ? `${share(current.value).toFixed(1)}% of total` : `${shown.length} of ${slices.length} shown`}\n            </span>\n          </div>\n        </div>\n\n        <ul aria-label={`${title} breakdown`} className=\"flex w-full min-w-0 flex-col gap-0.5\">\n          {slices.map((s, i) => {\n            const off = hidden.has(s.id);\n            const on = active === s.id;\n            return (\n              <li key={s.id}>\n                <button\n                  aria-label={`${s.label}: ${fmt.format(s.value)}${off ? \", hidden\" : `, ${share(s.value).toFixed(1)}%`}. Press to ${off ? \"show\" : \"hide\"}.`}\n                  aria-pressed={!off}\n                  className={cn(\n                    \"group/row flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-left outline-none transition-[background-color,opacity,scale] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97]\",\n                    on && \"bg-accent\",\n                    off && \"opacity-45\",\n                    active !== null && !on && !off && \"opacity-60\",\n                  )}\n                  onBlur={() => setActive(null)}\n                  onClick={() => toggle(s.id)}\n                  onFocus={() => setActive(s.id)}\n                  onKeyDown={(e) => onKeyDown(e, i)}\n                  onPointerEnter={() => setActive(s.id)}\n                  onPointerLeave={() => setActive(null)}\n                  ref={(el) => {\n                    rowRefs.current[i] = el;\n                  }}\n                  type=\"button\"\n                >\n                  <motion.span\n                    animate={{ scale: on ? 1.25 : off ? 0.7 : 1 }}\n                    className={cn(\"size-2.5 shrink-0 rounded-full\", off && \"bg-transparent ring-1 ring-current ring-inset\")}\n                    style={off ? { color: s.color ?? PALETTE[i % PALETTE.length] } : { background: s.color ?? PALETTE[i % PALETTE.length] }}\n                    transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 500, damping: 26 }}\n                  />\n                  <span className=\"flex min-w-0 flex-1 flex-col\">\n                    <span className={cn(\"truncate text-sm\", off && \"line-through decoration-muted-foreground/60\")}>{s.label}</span>\n                    {s.hint ? <span className=\"truncate text-muted-foreground text-xs\">{s.hint}</span> : null}\n                  </span>\n                  <span className=\"flex shrink-0 flex-col items-end\">\n                    <span className=\"font-medium text-sm tabular-nums\">{fmt.format(s.value)}</span>\n                    <span className=\"text-muted-foreground text-xs tabular-nums\">{off ? \"hidden\" : `${share(s.value).toFixed(1)}%`}</span>\n                  </span>\n                </button>\n              </li>\n            );\n          })}\n        </ul>\n      </div>\n    </section>\n  );\n}\n"
    }
  ]
}