{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-radial",
  "title": "Chart Radial",
  "description": "Donut, pie or gauge from your rows with sweep-in slices, legend toggling with re-flow, hover explode and a live center total.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://trytweenly.vercel.app/r/chart-kit.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/chart-radial/chart-radial.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useRef, useState } from \"react\"\nimport { animate, AnimatePresence, motion, useMotionValue, useTransform, type MotionValue } from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\nimport {\n  ChartCard,\n  ChartPattern,\n  ChartTooltip,\n  chartTheme,\n  createFormatter,\n  polar,\n  textureFill,\n  toNumber,\n  useChartReveal,\n  type ChartBaseProps,\n  type ChartDatum,\n  type ChartTexture,\n  type ChartTheme,\n} from \"@/registry/new-york/lib/chart-kit\"\n\nexport interface ChartRadialProps extends ChartBaseProps {\n  /** Rows to plot, one slice per row. */\n  data: ChartDatum[]\n  /** Key holding each slice's name, e.g. \"browser\". */\n  nameKey: string\n  /** Key holding each slice's value, e.g. \"visitors\". */\n  valueKey: string\n  /** Optional key holding a CSS color per row. */\n  colorKey?: string\n  /** Fill styles cycled per slice. Default: [\"solid\", \"hatch\", \"solid\", \"muted\", \"hatch\", \"muted\"] */\n  textures?: ChartTexture[]\n  /** Hole size as a fraction of the radius; 0 draws a pie. Default: 0.62 */\n  innerRadius?: number\n  /** Gap between slices in degrees. Default: 1.5 */\n  padAngle?: number\n  /** Rounding of slice corners in px. Default: 4 */\n  cornerRadius?: number\n  /** Angle where the first slice starts, in degrees (0 = 12 o'clock). Default: 0 */\n  startAngle?: number\n  /** Angle where the last slice ends; use -90 / 90 for a gauge. Default: 360 */\n  endAngle?: number\n  /** Text drawn on each slice; hidden on slices too small to fit it. Default: \"percent\" */\n  showLabels?: \"none\" | \"percent\" | \"value\" | \"name\"\n  /** Content of the hole: the total, the active slice, or nothing. Default: \"total\" */\n  center?: \"total\" | \"active\" | \"none\"\n  /** Caption under the total in the hole. Default: \"Total\" */\n  centerLabel?: string\n  /** Index (after sorting) of a slice pushed outward. Default: undefined */\n  highlight?: number\n  /** What hovering a slice does. Default: \"explode\" */\n  hoverEffect?: \"explode\" | \"dim\" | \"none\"\n  /** Show the legend with values and shares; click a row to toggle its slice. Default: true */\n  showLegend?: boolean\n  /** Show a tooltip next to the pointer. Default: false */\n  showTooltip?: boolean\n  /** Chart diameter in px. Default: 220 */\n  size?: number\n  /** Slice order. Default: \"none\" */\n  sortBy?: \"none\" | \"value\"\n}\n\nconst DEFAULT_TEXTURES: ChartTexture[] = [\"solid\", \"hatch\", \"solid\", \"muted\", \"hatch\", \"muted\"]\nconst EXPLODE = 7\nconst REFLOW = { duration: 0.6, ease: [0.32, 0.72, 0, 1] as const }\n\ninterface Slice {\n  id: string\n  name: string\n  value: number\n  share: number\n  color: string\n  texture: ChartTexture\n  a0: number\n  a1: number\n  hidden: boolean\n  pattern: string\n}\n\n/** Annular sector with rounded corners (quadratic corner approximation). */\nfunction slicePath(cx: number, cy: number, r0: number, r1: number, a0: number, a1: number, cr: number) {\n  const sweep = a1 - a0\n  if (sweep <= 0.05) return \"\"\n  if (sweep >= 359.95) {\n    const ring = (r: number, dir: 0 | 1) =>\n      `M${cx},${cy - r}A${r},${r} 0 1 ${dir} ${cx},${cy + r}A${r},${r} 0 1 ${dir} ${cx},${cy - r}Z`\n    return ring(r1, 1) + (r0 > 0 ? ring(r0, 0) : \"\")\n  }\n  const rad = (sweep * Math.PI) / 180\n  const deg = 180 / Math.PI\n  const band = (r1 - r0) / 2\n  const cOut = Math.max(0, Math.min(cr, band, (rad * r1) / 2.2))\n  const cIn = r0 > 0 ? Math.max(0, Math.min(cr, band, (rad * r0) / 2.2)) : 0\n  const dOut = (cOut / r1) * deg\n  const dIn = r0 > 0 ? (cIn / r0) * deg : 0\n  const p = (r: number, a: number) => polar(cx, cy, r, a).join(\",\")\n  let d = `M${p(r1 - cOut, a0)}Q${p(r1, a0)} ${p(r1, a0 + dOut)}`\n  d += `A${r1},${r1} 0 ${sweep - 2 * dOut > 180 ? 1 : 0} 1 ${p(r1, a1 - dOut)}`\n  d += `Q${p(r1, a1)} ${p(r1 - cOut, a1)}`\n  if (r0 > 0) {\n    d += `L${p(r0 + cIn, a1)}Q${p(r0, a1)} ${p(r0, a1 - dIn)}`\n    d += `A${r0},${r0} 0 ${sweep - 2 * dIn > 180 ? 1 : 0} 0 ${p(r0, a0 + dIn)}`\n    d += `Q${p(r0, a0)} ${p(r0 + cIn, a0)}`\n  } else {\n    d += `L${cx},${cy}`\n  }\n  return `${d}Z`\n}\n\nconst pctText = (share: number) => {\n  const v = share * 100\n  return `${v > 0 && v < 1 ? v.toFixed(1) : Math.round(v)}%`\n}\n\nconst clamp01 = (v: number) => Math.min(1, Math.max(0, v))\n\nfunction PieGlyph() {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8} strokeLinejoin=\"round\">\n      <circle cx=\"8\" cy=\"8\" r=\"5.5\" />\n      <path d=\"M8 2.5V8h5.5\" />\n    </svg>\n  )\n}\n\nexport function ChartRadial({\n  data,\n  nameKey,\n  valueKey,\n  colorKey,\n  textures = DEFAULT_TEXTURES,\n  innerRadius = 0.62,\n  padAngle = 1.5,\n  cornerRadius = 4,\n  startAngle = 0,\n  endAngle = 360,\n  showLabels = \"percent\",\n  center = \"total\",\n  centerLabel = \"Total\",\n  highlight,\n  hoverEffect = \"explode\",\n  showLegend = true,\n  showTooltip = false,\n  size = 220,\n  sortBy = \"none\",\n  title,\n  description,\n  titleSize,\n  surface = \"dark\",\n  accent,\n  palette,\n  icon,\n  radius,\n  bare,\n  animate: animateProp = true,\n  duration = 1.2,\n  delay = 0,\n  once = true,\n  valueFormat = \"number\",\n  currency,\n  decimals,\n  className,\n}: ChartRadialProps) {\n  const theme = chartTheme(surface, accent)\n  const fmt = createFormatter(valueFormat, { currency, decimals })\n  const uid = useId().replace(/[^a-zA-Z0-9_-]/g, \"\")\n  const { ref, shown, still } = useChartReveal(once, animateProp)\n  const boxRef = useRef<HTMLDivElement>(null)\n  const [hidden, setHidden] = useState<Set<string>>(() => new Set())\n  const [active, setActive] = useState<string | null>(null)\n  const [tip, setTip] = useState({ x: 0, y: 0, w: 1 })\n\n  const span = endAngle > startAngle ? Math.min(360, endAngle - startAngle) : 360\n  const full = span >= 359.9\n  const R = size / 2 - EXPLODE - 1\n  const r0 = R * Math.min(0.95, Math.max(0, innerRadius))\n  const cx = 0\n  const cy = 0\n\n  const derived = [\n    theme.accent,\n    theme.accent,\n    `color-mix(in oklab, ${theme.accent} 72%, #000)`,\n    theme.muted,\n    `color-mix(in oklab, ${theme.accent} 72%, #000)`,\n    theme.muted,\n  ]\n  const rows = data.map((row, i) => ({\n    name: String(row[nameKey] ?? `Item ${i + 1}`),\n    value: Math.max(0, toNumber(row[valueKey])),\n    color:\n      (colorKey && typeof row[colorKey] === \"string\" && (row[colorKey] as string)) ||\n      palette?.[i % palette.length] ||\n      derived[i % derived.length],\n    texture: textures.length ? textures[i % textures.length] : \"solid\",\n  }))\n  if (sortBy === \"value\") rows.sort((a, b) => b.value - a.value)\n\n  const seen = new Map<string, number>()\n  const ids = rows.map((r) => {\n    const n = seen.get(r.name) ?? 0\n    seen.set(r.name, n + 1)\n    return n ? `${r.name}-${n}` : r.name\n  })\n  const total = rows.reduce((sum, r, i) => sum + (hidden.has(ids[i]) ? 0 : r.value), 0)\n  const slices: Slice[] = []\n  let cursor = startAngle\n  for (let i = 0; i < rows.length; i++) {\n    const off = hidden.has(ids[i])\n    const share = off || !total ? 0 : rows[i].value / total\n    const a0 = cursor\n    cursor += share * span\n    slices.push({ ...rows[i], id: ids[i], share, a0, a1: cursor, hidden: off, pattern: `${uid}-s${i}` })\n  }\n  const visibleCount = slices.filter((s) => !s.hidden && s.value > 0).length\n  const padHalf = visibleCount > 1 ? Math.max(0, padAngle) / 2 : 0\n\n  const activeSlice = slices.find((s) => s.id === active && !s.hidden)\n  const featured =\n    activeSlice ??\n    (highlight !== undefined && slices[highlight] && !slices[highlight].hidden\n      ? slices[highlight]\n      : slices.reduce<Slice | undefined>((best, s) => (!best || s.share > best.share ? s : best), undefined))\n\n  const progress = useMotionValue(still ? 1 : 0)\n  const totalMv = useMotionValue(still ? total : 0)\n\n  useEffect(() => {\n    if (still) {\n      progress.set(1)\n      return\n    }\n    if (!shown) {\n      progress.set(0)\n      return\n    }\n    const c = animate(progress, 1, { duration, delay, ease: [0.65, 0, 0.35, 1] })\n    return () => c.stop()\n  }, [shown, still, duration, delay, progress])\n\n  useEffect(() => {\n    if (still) {\n      totalMv.set(total)\n      return\n    }\n    if (!shown) {\n      totalMv.set(0)\n      return\n    }\n    const intro = progress.get() < 1\n    const c = animate(totalMv, total, intro ? { duration, delay, ease: [0.16, 1, 0.3, 1] } : REFLOW)\n    return () => c.stop()\n  }, [shown, still, total, duration, delay, totalMv, progress])\n\n  const totalText = useTransform(totalMv, (v) => fmt(Number.isInteger(total) ? Math.round(v) : v))\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 if (visibleCount > 1) next.add(id)\n      return next\n    })\n\n  const track = (e: React.PointerEvent) => {\n    const box = boxRef.current?.getBoundingClientRect()\n    if (box) setTip({ x: e.clientX - box.left, y: e.clientY - box.top, w: box.width })\n  }\n\n  // Bounds of the drawn arc range, so gauges don't waste space\n  const m = EXPLODE + 1\n  let minX = -(R + m)\n  let maxX = R + m\n  let minY = -(R + m)\n  let maxY = R + m\n  if (!full) {\n    const pts: [number, number][] = [[cx, cy + 6]]\n    for (let a = startAngle; a <= startAngle + span + 0.001; a += Math.min(2, span)) {\n      pts.push(polar(cx, cy, R + m, a), polar(cx, cy, Math.max(r0, 1), a))\n    }\n    minX = Math.min(...pts.map((p) => p[0]))\n    maxX = Math.max(...pts.map((p) => p[0]))\n    minY = Math.min(...pts.map((p) => p[1]))\n    maxY = Math.max(...pts.map((p) => p[1]))\n  }\n  const vw = maxX - minX\n  const vh = maxY - minY\n\n  const holeOk = r0 >= 34 && center !== \"none\"\n  const big = Math.min(34, Math.max(14, r0 * 0.36))\n  const small = Math.min(12.5, Math.max(10, r0 * 0.14))\n  const bigY = full ? cy + big * 0.3 - small * 0.55 : cy - small - 8\n  const smallY = full ? bigY + small + 6 : cy - 2\n  const centerKey =\n    center === \"active\" ? (featured?.id ?? \"none\") : activeSlice ? activeSlice.id : \"__total\"\n\n  const tipSlice = showTooltip ? activeSlice : undefined\n\n  return (\n    <ChartCard\n      theme={theme}\n      title={title}\n      description={description}\n      titleSize={titleSize}\n      icon={icon === undefined ? <PieGlyph /> : icon}\n      radius={radius}\n      bare={bare}\n      className={className}\n    >\n      <div ref={ref} className=\"flex w-full flex-col items-center gap-6\">\n        <div ref={boxRef} className=\"relative flex w-full justify-center\">\n          <svg\n            viewBox={`${minX} ${minY} ${vw} ${vh}`}\n            width={vw}\n            className=\"h-auto max-w-full overflow-visible\"\n            role=\"img\"\n            aria-label={title ?? \"Radial chart\"}\n            onPointerLeave={() => setActive(null)}\n          >\n            <defs>\n              {slices.map((s) => (\n                <ChartPattern key={s.pattern} id={s.pattern} color={s.color} texture={s.texture} theme={theme} />\n              ))}\n            </defs>\n            {!full && (\n              <path\n                d={slicePath(cx, cy, r0, R, startAngle, startAngle + span, cornerRadius)}\n                fill={theme.track}\n                opacity={0.6}\n              />\n            )}\n            {slices.map((s, i) => {\n              const exploded =\n                (i === highlight && !s.hidden) || (hoverEffect === \"explode\" && active === s.id && !s.hidden)\n              const dimmed = hoverEffect === \"dim\" && active !== null && active !== s.id\n              return (\n                <SliceShape\n                  key={`${s.id}-${i}`}\n                  slice={s}\n                  theme={theme}\n                  progress={progress}\n                  origin={startAngle}\n                  span={span}\n                  r0={r0}\n                  r1={R}\n                  padHalf={padHalf}\n                  cornerRadius={cornerRadius}\n                  exploded={exploded}\n                  dimmed={dimmed}\n                  label={\n                    showLabels === \"none\"\n                      ? \"\"\n                      : showLabels === \"value\"\n                        ? fmt(s.value)\n                        : showLabels === \"name\"\n                          ? s.name\n                          : pctText(s.share)\n                  }\n                  still={still}\n                  onEnter={() => setActive(s.id)}\n                  onMove={track}\n                />\n              )\n            })}\n            {holeOk && (\n              <AnimatePresence initial={false}>\n                <motion.g\n                  key={centerKey}\n                  initial={{ opacity: 0, y: 4 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  exit={{ opacity: 0, y: -4 }}\n                  transition={{ duration: 0.18 }}\n                  className=\"pointer-events-none\"\n                >\n                  {center === \"total\" && !activeSlice ? (\n                    <>\n                      <motion.text\n                        x={cx}\n                        y={bigY}\n                        textAnchor=\"middle\"\n                        fill={theme.fg}\n                        fontSize={big}\n                        fontWeight={700}\n                        letterSpacing=\"-0.03em\"\n                        style={{ fontVariantNumeric: \"tabular-nums\" }}\n                      >\n                        {totalText}\n                      </motion.text>\n                      <text x={cx} y={smallY} textAnchor=\"middle\" fill={theme.muted} fontSize={small} fontWeight={500}>\n                        {centerLabel}\n                      </text>\n                    </>\n                  ) : (\n                    (() => {\n                      const s = center === \"total\" ? activeSlice : featured\n                      if (!s) return null\n                      return (\n                        <>\n                          <text\n                            x={cx}\n                            y={bigY}\n                            textAnchor=\"middle\"\n                            fill={theme.fg}\n                            fontSize={big}\n                            fontWeight={700}\n                            letterSpacing=\"-0.03em\"\n                            style={{ fontVariantNumeric: \"tabular-nums\" }}\n                          >\n                            {center === \"total\" ? fmt(s.value) : pctText(s.share)}\n                          </text>\n                          <text x={cx} y={smallY} textAnchor=\"middle\" fill={theme.muted} fontSize={small} fontWeight={500}>\n                            {s.name.length > 16 ? `${s.name.slice(0, 15)}…` : s.name}\n                          </text>\n                        </>\n                      )\n                    })()\n                  )}\n                </motion.g>\n              </AnimatePresence>\n            )}\n          </svg>\n          <ChartTooltip\n            open={!!tipSlice}\n            x={tip.x}\n            y={tip.y}\n            width={tip.w}\n            theme={theme}\n            rows={\n              tipSlice\n                ? [\n                    {\n                      label: tipSlice.name,\n                      value: `${fmt(tipSlice.value)} · ${pctText(tipSlice.share)}`,\n                      color: tipSlice.color,\n                      texture: tipSlice.texture,\n                    },\n                  ]\n                : []\n            }\n          />\n        </div>\n\n        {showLegend && (\n          <motion.ul\n            className=\"w-full divide-y text-[13px]\"\n            style={{ borderColor: theme.border }}\n            initial={still ? false : { opacity: 0, y: 6 }}\n            animate={shown ? { opacity: 1, y: 0 } : { opacity: 0, y: 6 }}\n            transition={{ duration: 0.5, delay: still ? 0 : delay + duration * 0.6, ease: [0.16, 1, 0.3, 1] }}\n            onPointerLeave={() => setActive(null)}\n          >\n            {slices.map((s, i) => (\n              <li key={`${s.id}-${i}`} style={{ borderColor: theme.border }}>\n                <button\n                  type=\"button\"\n                  onClick={() => toggle(s.id)}\n                  onPointerEnter={() => setActive(s.id)}\n                  aria-pressed={!s.hidden}\n                  className={cn(\n                    \"flex w-full items-center gap-2.5 py-2 text-left transition-opacity\",\n                    s.hidden && \"opacity-35\",\n                    active !== null && active !== s.id && !s.hidden && \"opacity-60\"\n                  )}\n                >\n                  <svg width=\"10\" height=\"10\" className=\"shrink-0 overflow-visible\">\n                    <rect width=\"10\" height=\"10\" rx=\"3\" fill={textureFill(s.pattern, s.texture, s.color)} />\n                    {s.texture !== \"solid\" && (\n                      <rect\n                        width=\"10\"\n                        height=\"10\"\n                        rx=\"3\"\n                        fill=\"none\"\n                        stroke={s.texture === \"muted\" ? theme.muted : s.color}\n                        strokeOpacity=\"0.6\"\n                      />\n                    )}\n                  </svg>\n                  <span className={cn(\"truncate\", s.hidden && \"line-through\")} style={{ color: theme.muted }}>\n                    {s.name}\n                  </span>\n                  <span className=\"ml-auto font-medium tabular-nums\">{fmt(s.value)}</span>\n                  <span className=\"w-11 text-right tabular-nums\" style={{ color: theme.muted }}>\n                    {s.hidden ? \"–\" : pctText(s.share)}\n                  </span>\n                </button>\n              </li>\n            ))}\n          </motion.ul>\n        )}\n      </div>\n    </ChartCard>\n  )\n}\n\nfunction SliceShape({\n  slice,\n  theme,\n  progress,\n  origin,\n  span,\n  r0,\n  r1,\n  padHalf,\n  cornerRadius,\n  exploded,\n  dimmed,\n  label,\n  still,\n  onEnter,\n  onMove,\n}: {\n  slice: Slice\n  theme: ChartTheme\n  progress: MotionValue<number>\n  origin: number\n  span: number\n  r0: number\n  r1: number\n  padHalf: number\n  cornerRadius: number\n  exploded: boolean\n  dimmed: boolean\n  label: string\n  still: boolean\n  onEnter: () => void\n  onMove: (e: React.PointerEvent) => void\n}) {\n  const start = useMotionValue(slice.a0)\n  const end = useMotionValue(slice.a1)\n\n  useEffect(() => {\n    if (still) {\n      start.set(slice.a0)\n      end.set(slice.a1)\n      return\n    }\n    const a = animate(start, slice.a0, REFLOW)\n    const b = animate(end, slice.a1, REFLOW)\n    return () => {\n      a.stop()\n      b.stop()\n    }\n  }, [slice.a0, slice.a1, still, start, end])\n\n  const d = useTransform([start, end, progress], ([s, e, p]: number[]) => {\n    const to = Math.min(e - padHalf, origin + p * span)\n    return slicePath(0, 0, r0, r1, s + padHalf, to, cornerRadius)\n  })\n\n  const rl = r0 > 0 ? (r0 + r1) / 2 : r1 * 0.64\n  const labelArc = label.length * 6.4 + 10\n  const minSpan = (labelArc / rl) * (180 / Math.PI)\n  const fits = r1 - r0 >= 16 && !!label\n  const lx = useTransform([start, end], ([s, e]: number[]) => polar(0, 0, rl, (s + e) / 2)[0])\n  const ly = useTransform([start, end], ([s, e]: number[]) => polar(0, 0, rl, (s + e) / 2)[1])\n  const labelOpacity = useTransform([start, end, progress], ([s, e, p]: number[]) => {\n    const w = e - s\n    const sweep = origin + p * span\n    const reveal = clamp01((sweep - (e - w * 0.3)) / (w * 0.3 + 0.001))\n    return reveal * clamp01((w - minSpan) / 6)\n  })\n\n  const mid = (slice.a0 + slice.a1) / 2\n  const [ox, oy] = exploded ? polar(0, 0, EXPLODE, mid) : [0, 0]\n  const fill = textureFill(slice.pattern, slice.texture, slice.color)\n\n  return (\n    <motion.g\n      initial={false}\n      animate={{ x: ox, y: oy, opacity: dimmed ? 0.3 : 1 }}\n      transition={{ type: \"spring\", stiffness: 420, damping: 30 }}\n    >\n      <motion.path\n        d={d}\n        fill={fill}\n        fillRule=\"evenodd\"\n        stroke={slice.texture === \"solid\" ? \"none\" : slice.texture === \"muted\" ? theme.muted : slice.color}\n        strokeOpacity={slice.texture === \"solid\" ? 0 : 0.35}\n        strokeWidth={1}\n        className=\"cursor-pointer outline-none\"\n        onPointerEnter={onEnter}\n        onPointerMove={onMove}\n        aria-label={`${slice.name}: ${slice.value}`}\n      />\n      {fits && (\n        <motion.text\n          x={lx}\n          y={ly}\n          textAnchor=\"middle\"\n          dominantBaseline=\"central\"\n          fontSize={11.5}\n          fontWeight={700}\n          fill={slice.texture === \"solid\" ? \"#fff\" : theme.fg}\n          style={{ opacity: labelOpacity, fontVariantNumeric: \"tabular-nums\" }}\n          className=\"pointer-events-none select-none\"\n        >\n          {label}\n        </motion.text>\n      )}\n    </motion.g>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/chart-radial",
  "type": "registry:component"
}