{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"chart-donut","type":"registry:component","title":"Chart Donut Chart","description":"Slices sweep in clockwise; the hovered slice lifts and the center swaps from the total to its value and share. variant=\"pie\" for a solid pie.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/chart/donut-chart.tsx","type":"registry:component","target":"@components/motion/chart/donut-chart.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/chart\n\nimport {\n  AnimatePresence,\n  motion,\n  useInView,\n  useReducedMotion,\n} from \"motion/react\";\nimport { useId, useRef, useState } from \"react\";\nimport {\n  ChartLegend,\n  ChartTable,\n  formatFull,\n  seriesColor,\n  stepIndex,\n} from \"@/components/motion/chart/shared\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type DonutSlice = {\n  label: string;\n  value: number;\n  /** Any CSS color. Default: the next `--chart-N` slot, in fixed order. */\n  color?: string;\n};\n\nexport interface DonutChartProps {\n  /**\n   * Parts of one whole. Keep it to five or fewer and fold the long tail into\n   * an \"Other\" slice; close values compare better as bars.\n   */\n  data: DonutSlice[];\n  /** \"donut\" (default) leaves room for the center readout; \"pie\" is solid. */\n  variant?: \"donut\" | \"pie\";\n  /** Diameter in px. Default 220. */\n  size?: number;\n  /** Ring thickness for the donut, in px. Default 32% of the radius. */\n  thickness?: number;\n  /** Center caption under the total. Default \"Total\". */\n  totalLabel?: string;\n  /** Show the legend under the chart. Default true. */\n  legend?: boolean;\n  formatValue?: (value: number) => string;\n  /** Names the chart for assistive tech and the table view. */\n  title: string;\n  className?: string;\n}\n\nconst ACTIVE_GROW = 1.06;\n\n/** Annular sector from angle a0 to a1 (radians, 0 = 12 o'clock, clockwise). */\nfunction sector(c: number, r0: number, r1: number, a0: number, a1: number): string {\n  const pt = (r: number, a: number) =>\n    `${c + r * Math.sin(a)},${c - r * Math.cos(a)}`;\n  const large = a1 - a0 > Math.PI ? 1 : 0;\n  if (a1 - a0 >= Math.PI * 2 - 1e-6) {\n    // A full ring can't be one arc; split it in two halves.\n    const mid = a0 + Math.PI;\n    return `${sector(c, r0, r1, a0, mid)}${sector(c, r0, r1, mid, a1)}`;\n  }\n  const outer = `M${pt(r1, a0)}A${r1},${r1} 0 ${large} 1 ${pt(r1, a1)}`;\n  if (r0 <= 0) return `${outer}L${c},${c}Z`;\n  return `${outer}L${pt(r0, a1)}A${r0},${r0} 0 ${large} 0 ${pt(r0, a0)}Z`;\n}\n\n/**\n * Share of a whole as a donut or pie. Slices sweep in clockwise when scrolled\n * into view; hovering or arrowing to a slice lifts it, dims the rest and\n * swaps the center readout from the total to that slice's value and share.\n */\nexport function DonutChart({\n  data,\n  variant = \"donut\",\n  size = 220,\n  thickness,\n  totalLabel = \"Total\",\n  legend = true,\n  formatValue = formatFull,\n  title,\n  className,\n}: DonutChartProps) {\n  const uid = useId().replace(/:/g, \"\");\n  const reduce = useReducedMotion();\n  const viewRef = useRef<HTMLDivElement>(null);\n  const inView = useInView(viewRef, { once: true, amount: 0.3 });\n  const [active, setActive] = useState<number | null>(null);\n  const shown = reduce || inView;\n\n  // Leave room inside the box for the active slice to grow.\n  const c = size / 2;\n  const r1 = (size / 2) / ACTIVE_GROW - 2;\n  const r0 = variant === \"pie\" ? 0 : r1 - (thickness ?? r1 * 0.32);\n  const total = data.reduce((sum, d) => sum + Math.max(0, d.value), 0);\n  const pct = (v: number) =>\n    total ? `${Math.round((Math.max(0, v) / total) * 100)}%` : \"0%\";\n\n  let cursor = 0;\n  const slices = data.map((d, i) => {\n    const a0 = cursor;\n    cursor += total ? (Math.max(0, d.value) / total) * Math.PI * 2 : 0;\n    return { ...d, color: seriesColor(d, i), path: sector(c, r0, r1, a0, cursor) };\n  });\n\n  const center =\n    active === null\n      ? { key: \"total\", value: formatValue(total), label: totalLabel }\n      : {\n          key: `s${active}`,\n          value: formatValue(data[active].value),\n          label: `${data[active].label} · ${pct(data[active].value)}`,\n        };\n\n  // The reveal mask: one stroke as thick as the whole radius, swept from 12\n  // o'clock with pathLength so every slice uncovers in clockwise order.\n  const maskR = (r1 + r0) / 2;\n  const maskW = r1 - r0 + 8;\n\n  // Donut: the readout sits in the hole. Pie: it sits under the chart.\n  const readout = (\n    <AnimatePresence mode=\"popLayout\" initial={false}>\n      <motion.div\n        key={center.key}\n        initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(4px)\" }}\n        animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n        exit={\n          reduce\n            ? { opacity: 0, transition: { duration: 0.1 } }\n            : {\n                opacity: 0,\n                y: -6,\n                filter: \"blur(4px)\",\n                transition: { duration: 0.12, ease: EASE_OUT },\n              }\n        }\n        transition={{ duration: 0.2, ease: EASE_OUT }}\n        className=\"flex flex-col items-center text-center\"\n      >\n        <span className=\"text-3xl font-bold tracking-tight text-foreground tabular-nums\">\n          {center.value}\n        </span>\n        <span className=\"truncate text-xs text-muted-foreground\">\n          {center.label}\n        </span>\n      </motion.div>\n    </AnimatePresence>\n  );\n\n  return (\n    <div ref={viewRef} className={cn(\"flex flex-col items-center\", className)}>\n      {/* biome-ignore lint/a11y/useSemanticElements: a chart is not a form; group labels the ring */}\n      <div\n        role=\"group\"\n        aria-label={`${title}. Use arrow keys to read each slice.`}\n        // biome-ignore lint/a11y/noNoninteractiveTabindex: focusable chart widget; arrow keys read values\n        tabIndex={0}\n        onKeyDown={(e) => {\n          const next = stepIndex(e.key, active, data.length, true);\n          if (next === undefined) return;\n          e.preventDefault();\n          setActive(next);\n        }}\n        onBlur={() => setActive(null)}\n        className=\"relative rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        style={{ width: size, height: size }}\n      >\n        <svg\n          width={size}\n          height={size}\n          aria-hidden\n          onPointerLeave={() => setActive(null)}\n          className=\"overflow-visible\"\n        >\n          <title>{title}</title>\n          <defs>\n            <mask id={`${uid}-sweep`}>\n              <motion.circle\n                cx={c}\n                cy={c}\n                r={maskR}\n                fill=\"none\"\n                stroke=\"white\"\n                strokeWidth={maskW}\n                transform={`rotate(-90 ${c} ${c})`}\n                initial={false}\n                animate={{ pathLength: shown ? 1 : 0 }}\n                transition={reduce ? { duration: 0 } : { duration: 1, ease: EASE_OUT }}\n              />\n            </mask>\n          </defs>\n          <g mask={`url(#${uid}-sweep)`}>\n            {slices.map((s, i) => (\n              <motion.path\n                key={s.label}\n                d={s.path}\n                fill={s.color}\n                // A surface-colored stroke separates neighbouring slices.\n                stroke=\"var(--card)\"\n                strokeWidth={2}\n                strokeLinejoin=\"round\"\n                initial={false}\n                animate={{\n                  scale: active === i && !reduce ? ACTIVE_GROW : 1,\n                  opacity: active !== null && active !== i ? 0.35 : 1,\n                }}\n                transition={{\n                  scale: { type: \"spring\", stiffness: 420, damping: 28 },\n                  opacity: { duration: 0.15 },\n                }}\n                style={{\n                  transformBox: \"view-box\",\n                  transformOrigin: `${c}px ${c}px`,\n                }}\n                onPointerEnter={() => setActive(i)}\n              />\n            ))}\n          </g>\n        </svg>\n\n        {variant === \"donut\" && (\n          <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center\">\n            {readout}\n          </div>\n        )}\n      </div>\n\n      {variant === \"pie\" && (\n        <div className=\"mt-3 flex min-h-12 items-center justify-center\">{readout}</div>\n      )}\n\n      {legend && (\n        <ChartLegend\n          items={slices.map((s) => ({ label: s.label, color: s.color }))}\n        />\n      )}\n      <ChartTable\n        caption={title}\n        head={[\"Slice\", \"Value\", \"Share\"]}\n        rows={data.map((d) => [d.label, formatValue(d.value), pct(d.value)])}\n      />\n    </div>\n  );\n}\n"},{"path":"components/motion/chart/shared.tsx","type":"registry:component","target":"@components/motion/chart/shared.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ChartSeries = {\n  /** Key of the value in each datum. */\n  key: string;\n  label: string;\n  /** Any CSS color. Default: the next `--chart-N` slot, in fixed order. */\n  color?: string;\n};\n\nexport type ChartDatum = Record<string, string | number>;\n\n/** \"natural\" is a monotone curve: smooth, but never overshoots a data point. */\nexport type ChartCurve = \"natural\" | \"linear\" | \"step\";\n\nexport type TooltipIndicator = \"dot\" | \"line\" | \"dashed\";\n\n// Fixed categorical order, validated for colorblind and normal-vision\n// separation (light and dark steps live in the theme's --chart-* tokens).\n// Fallbacks are the light steps, for projects without the theme layer.\nconst SLOT_FALLBACKS = [\"#2a78d6\", \"#eb6834\", \"#1baf7a\", \"#eda100\", \"#e87ba4\"];\n\n/** Color for slot `index`; color follows the series, never its rank. */\nexport function seriesColor(series: { color?: string }, index: number) {\n  if (series.color) return series.color;\n  const slot = index % SLOT_FALLBACKS.length;\n  return `var(--chart-${slot + 1}, ${SLOT_FALLBACKS[slot]})`;\n}\n\n/**\n * Layout width of the element given the returned ref. ResizeObserver reports\n * the untransformed box, unlike getBoundingClientRect (react-use-measure), so a\n * chart inside a scaled container (thumbnails, zoomed stages) still fills it.\n */\nexport function useChartWidth<T extends Element = HTMLDivElement>() {\n  const [el, setEl] = useState<T | null>(null);\n  const [width, setWidth] = useState(0);\n  useEffect(() => {\n    if (!el) return;\n    const observer = new ResizeObserver(([entry]) =>\n      setWidth(entry.contentRect.width),\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [el]);\n  return [setEl, width] as const;\n}\n\n// A fixed locale keeps server and client output identical for hydration.\nconst FULL = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 2 });\nconst COMPACT = new Intl.NumberFormat(\"en-US\", {\n  notation: \"compact\",\n  maximumFractionDigits: 1,\n});\nexport const formatFull = (n: number) => FULL.format(n);\nexport const formatCompact = (n: number) => COMPACT.format(n);\n\n/** Round tick values covering [min, max], about `count` of them. */\nexport function niceTicks(min: number, max: number, count = 4) {\n  if (min === max) max = min + 1;\n  const raw = (max - min) / count;\n  const mag = 10 ** Math.floor(Math.log10(raw));\n  const step =\n    [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? 10 * mag;\n  const start = Math.floor(min / step) * step;\n  const end = Math.ceil(max / step) * step;\n  const ticks: number[] = [];\n  for (let v = start; v <= end + step / 2; v += step) {\n    ticks.push(Number(v.toFixed(10)));\n  }\n  return ticks;\n}\n\n/** Keep every k-th label so neighbours stay at least `minGap` px apart. */\nexport function labelStride(count: number, width: number, minGap = 56) {\n  return Math.max(1, Math.ceil(count / Math.max(1, Math.floor(width / minGap))));\n}\n\n/** Approximate gutter for axis labels at 12px. */\nexport function gutterFor(labels: string[]) {\n  return Math.max(...labels.map((l) => l.length), 1) * 7 + 12;\n}\n\ntype Point = [number, number];\n\n/** SVG path through `points` (sorted by x) with the given curve. */\nexport function curvePath(points: Point[], curve: ChartCurve) {\n  if (points.length === 0) return \"\";\n  const [x0, y0] = points[0];\n  if (points.length === 1) return `M${x0},${y0}`;\n  if (curve === \"step\") {\n    // Changes value halfway between points, like a stepped series.\n    let d = `M${x0},${y0}`;\n    for (let i = 1; i < points.length; i++) {\n      const [px] = points[i - 1];\n      const [x, y] = points[i];\n      const mid = (px + x) / 2;\n      d += `H${mid}V${y}H${x}`;\n    }\n    return d;\n  }\n  if (curve === \"linear\" || points.length < 3) {\n    return points.map(([x, y], i) => `${i ? \"L\" : \"M\"}${x},${y}`).join(\"\");\n  }\n  // Monotone cubic (Fritsch–Carlson): tangents are clamped so the curve stays\n  // between its neighbours and never invents a peak the data doesn't have.\n  const n = points.length;\n  const slopes: number[] = [];\n  for (let i = 0; i < n - 1; i++) {\n    const dx = points[i + 1][0] - points[i][0];\n    slopes.push(dx ? (points[i + 1][1] - points[i][1]) / dx : 0);\n  }\n  const tangents = points.map((_, i) => {\n    if (i === 0) return slopes[0];\n    if (i === n - 1) return slopes[n - 2];\n    const a = slopes[i - 1];\n    const b = slopes[i];\n    return a * b <= 0 ? 0 : (a + b) / 2;\n  });\n  for (let i = 0; i < n - 1; i++) {\n    const m = slopes[i];\n    if (m === 0) {\n      tangents[i] = 0;\n      tangents[i + 1] = 0;\n      continue;\n    }\n    const a = tangents[i] / m;\n    const b = tangents[i + 1] / m;\n    const s = a * a + b * b;\n    if (s > 9) {\n      const t = 3 / Math.sqrt(s);\n      tangents[i] = t * a * m;\n      tangents[i + 1] = t * b * m;\n    }\n  }\n  let d = `M${x0},${y0}`;\n  for (let i = 0; i < n - 1; i++) {\n    const [xa, ya] = points[i];\n    const [xb, yb] = points[i + 1];\n    const h = (xb - xa) / 3;\n    d += `C${xa + h},${ya + tangents[i] * h} ${xb - h},${yb - tangents[i + 1] * h} ${xb},${yb}`;\n  }\n  return d;\n}\n\n// ── tooltip ────────────────────────────────────────────────────────────────\n\nexport type TooltipRow = { label: string; value: string; color: string };\n\nfunction Indicator({ kind, color }: { kind: TooltipIndicator; color: string }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"shrink-0 rounded-[2px]\",\n        kind === \"dot\" && \"size-2.5\",\n        kind === \"line\" && \"w-1 self-stretch\",\n        kind === \"dashed\" && \"w-0 self-stretch border-[1.5px] border-dashed\",\n      )}\n      style={\n        kind === \"dashed\" ? { borderColor: color } : { background: color }\n      }\n    />\n  );\n}\n\n/**\n * Compact readout that springs between data positions: an optional label,\n * then one row per series with a colored indicator, the name and the value.\n */\nexport function ChartTooltip({\n  open,\n  x,\n  y,\n  width,\n  label,\n  rows,\n  indicator = \"dot\",\n}: {\n  open: boolean;\n  /** Anchor in px, relative to the chart's positioned wrapper. */\n  x: number;\n  y: number;\n  /** Wrapper width, to flip the tooltip before it overflows the edge. */\n  width: number;\n  label?: string;\n  rows: TooltipRow[];\n  indicator?: TooltipIndicator;\n}) {\n  const reduce = useReducedMotion();\n  const flip = x > width / 2;\n  return (\n    <AnimatePresence>\n      {open && (\n        <motion.div\n          aria-hidden\n          initial={{ opacity: 0, x, y, scale: reduce ? 1 : 0.96 }}\n          animate={{ opacity: 1, x, y, scale: 1 }}\n          exit={{ opacity: 0, scale: reduce ? 1 : 0.96, transition: { duration: 0.1 } }}\n          transition={\n            reduce\n              ? { duration: 0 }\n              : {\n                  opacity: { duration: 0.12 },\n                  scale: { duration: 0.15, ease: EASE_OUT },\n                  // Tracks the pointer between data positions; firm enough to\n                  // never trail behind a quick sweep.\n                  default: { type: \"spring\", stiffness: 520, damping: 42 },\n                }\n          }\n          className=\"pointer-events-none absolute top-0 left-0 z-10\"\n        >\n          <div\n            className={cn(\n              \"grid min-w-32 -translate-y-1/2 gap-1.5 rounded-lg border border-border bg-background px-2.5 py-1.5 text-xs shadow-xl\",\n              flip ? \"-translate-x-[calc(100%+12px)]\" : \"translate-x-3\",\n            )}\n          >\n            {label ? (\n              <p className=\"font-medium text-foreground\">{label}</p>\n            ) : null}\n            {rows.map((row) => (\n              <div key={row.label} className=\"flex items-stretch gap-2\">\n                <Indicator kind={indicator} color={row.color} />\n                <div className=\"flex flex-1 items-center justify-between gap-4 leading-none\">\n                  <span className=\"text-muted-foreground\">{row.label}</span>\n                  <span className=\"font-mono font-medium tabular-nums text-foreground\">\n                    {row.value}\n                  </span>\n                </div>\n              </div>\n            ))}\n          </div>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n\n// ── legend & table ─────────────────────────────────────────────────────────\n\nexport function ChartLegend({\n  items,\n  className,\n}: {\n  items: { label: string; color: string }[];\n  className?: string;\n}) {\n  return (\n    <ul\n      className={cn(\n        \"flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3 text-xs text-muted-foreground\",\n        className,\n      )}\n    >\n      {items.map((item) => (\n        <li key={item.label} className=\"flex items-center gap-1.5\">\n          <span\n            aria-hidden\n            className=\"size-2 shrink-0 rounded-[2px]\"\n            style={{ background: item.color }}\n          />\n          {item.label}\n        </li>\n      ))}\n    </ul>\n  );\n}\n\n/** The table view: every value reachable without hovering or seeing color. */\nexport function ChartTable({\n  caption,\n  head,\n  rows,\n}: {\n  caption: string;\n  head: string[];\n  rows: ReactNode[][];\n}) {\n  // The wrapper is what hides it: a <caption> escapes a visually hidden\n  // table's clip and would otherwise paint over the chart.\n  return (\n    <div className=\"sr-only\">\n      <table>\n        <caption>{caption}</caption>\n        <thead>\n          <tr>\n            {head.map((h) => (\n              <th key={h} scope=\"col\">\n                {h}\n              </th>\n            ))}\n          </tr>\n        </thead>\n        <tbody>\n          {rows.map((row, r) => (\n            // biome-ignore lint/suspicious/noArrayIndexKey: rows mirror data order and never reorder.\n            <tr key={r}>\n              {row.map((cell, c) =>\n                c === 0 ? (\n                  // biome-ignore lint/suspicious/noArrayIndexKey: fixed column order.\n                  <th key={c} scope=\"row\">\n                    {cell}\n                  </th>\n                ) : (\n                  // biome-ignore lint/suspicious/noArrayIndexKey: fixed column order.\n                  <td key={c}>{cell}</td>\n                ),\n              )}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n\n/** Shared entrance timing: marks grow once the chart scrolls into view. */\nexport const GROW = { duration: 0.7, ease: EASE_OUT } as const;\n\n/** Arrow-key stepping shared by every chart that reads positions. */\nexport function stepIndex(\n  key: string,\n  current: number | null,\n  count: number,\n  wrap = false,\n) {\n  const forward = key === \"ArrowRight\" || key === \"ArrowDown\";\n  const back = key === \"ArrowLeft\" || key === \"ArrowUp\";\n  if (!forward && !back) return undefined;\n  if (current === null) return forward ? 0 : count - 1;\n  const next = current + (forward ? 1 : -1);\n  if (wrap) return (next + count) % count;\n  return Math.min(count - 1, Math.max(0, next));\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}