{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sunburst-chart",
  "type": "registry:component",
  "title": "Sunburst Chart",
  "description": "A composable hierarchical sunburst chart with drill-down zoom and animated segments",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "@bklit/chart-animation",
    "@bklit/utils"
  ],
  "files": [
    {
      "path": "src/charts/sunburst-chart.tsx",
      "content": "\"use client\";\n\nimport type { Transition } from \"motion/react\";\nimport { animate, motion } from \"motion/react\";\nimport {\n  Children,\n  isValidElement,\n  memo,\n  type ReactElement,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  type ArcDatum,\n  buildArcs,\n  buildHoverGrowTargets,\n  buildSunburstEnterTiming,\n  defaultSunburstGrowPadding,\n  maxHoverSegmentThickness,\n} from \"./sunburst\";\nimport {\n  defaultSunburstColors,\n  opacityForRelativeDepth,\n  type SunburstContextValue,\n  SunburstProvider,\n} from \"./sunburst-context\";\nimport type { SunburstNode } from \"./sunburst-data\";\n\nexport type { ArcDatum, Focus } from \"./sunburst\";\nexport type { SunburstNode } from \"./sunburst-data\";\n\nconst DEFAULT_HOVER_POP = 8;\n\nfunction componentDisplayName(child: ReactElement): string {\n  return (\n    (child.type as { displayName?: string }).displayName ||\n    (child.type as { name?: string }).name ||\n    \"\"\n  );\n}\n\nfunction isDefsComponent(child: ReactElement): boolean {\n  const name = componentDisplayName(child);\n  return (\n    name.includes(\"Gradient\") ||\n    name.includes(\"Pattern\") ||\n    name === \"LinearGradient\" ||\n    name === \"RadialGradient\"\n  );\n}\n\nfunction isOutsideSvgComponent(name: string): boolean {\n  return name === \"SunburstBreadcrumb\" || name === \"SunburstHint\";\n}\n\nfunction isSunburstSegment(\n  child: ReactNode\n): child is ReactElement<{ index: number }> {\n  return (\n    isValidElement(child) && componentDisplayName(child) === \"SunburstSegment\"\n  );\n}\n\n/** Inner rings last so parent segments win hit testing at ring boundaries. */\nfunction sortSunburstSegments(\n  segments: ReactElement<{ index: number }>[],\n  arcs: ArcDatum[]\n): ReactElement<{ index: number }>[] {\n  return [...segments].sort((a, b) => {\n    const arcA = arcs[a.props.index];\n    const arcB = arcs[b.props.index];\n    const depthA = arcA?.depth ?? 0;\n    const depthB = arcB?.depth ?? 0;\n    if (depthA !== depthB) {\n      return depthB - depthA;\n    }\n    return (b.props.index ?? 0) - (a.props.index ?? 0);\n  });\n}\n\nexport interface SunburstChartProps {\n  data: SunburstNode;\n  size?: number;\n  /** Bump to replay the initialization animation. */\n  playKey?: number;\n  className?: string;\n  /** Controlled focus node id for drill-down. */\n  focusId?: string;\n  /** Called when focus changes via segment click or breadcrumb. */\n  onFocusChange?: (focusId: string) => void;\n  /** Controlled hover — arc index in the arcs array. */\n  hoveredIndex?: number | null;\n  onHoverChange?: (index: number | null) => void;\n  hoverPop?: number;\n  /** Inset reserved for hover growth; defaults from layout depth and hoverPop. */\n  padding?: number;\n  enterTransition?: Transition;\n  enterStaggerScale?: number;\n  children: ReactNode;\n}\n\nconst SunburstChartCore = memo(function SunburstChartCore({\n  data,\n  size = 520,\n  playKey = 0,\n  className,\n  focusId: focusIdProp,\n  onFocusChange,\n  hoveredIndex: hoveredIndexProp,\n  onHoverChange,\n  hoverPop = DEFAULT_HOVER_POP,\n  padding: paddingProp,\n  enterTransition,\n  enterStaggerScale = 1,\n  children,\n}: SunburstChartProps) {\n  const fullRadius = size / 2;\n  const containerRef = useRef<HTMLDivElement>(null);\n  const { arcs, maxDepth, focusById, rootId } = useMemo(\n    () => buildArcs(data),\n    [data]\n  );\n\n  const growPadding = useMemo(\n    () => paddingProp ?? defaultSunburstGrowPadding(maxDepth, size, hoverPop),\n    [paddingProp, maxDepth, size, hoverPop]\n  );\n  const radius = Math.max(8, fullRadius - growPadding);\n\n  const [skipEnterAnimation, setSkipEnterAnimation] = useState(false);\n  const [internalHoveredArc, setInternalHoveredArc] = useState<ArcDatum | null>(\n    null\n  );\n  const [internalHoveredIndex, setInternalHoveredIndex] = useState<\n    number | null\n  >(null);\n  const growRef = useRef<Map<string, number>>(new Map());\n  const [growTick, setGrowTick] = useState(0);\n\n  const [internalFocusId, setInternalFocusId] = useState(rootId);\n  const [prevFocusId, setPrevFocusId] = useState(rootId);\n  const [zoomT, setZoomT] = useState(1);\n\n  const isFocusControlled = focusIdProp !== undefined;\n  const focusId = isFocusControlled ? focusIdProp : internalFocusId;\n\n  const isHoverControlled = hoveredIndexProp !== undefined;\n  const hoveredArcIndex = isHoverControlled\n    ? hoveredIndexProp\n    : internalHoveredIndex;\n  const hoveredArc = useMemo(() => {\n    if (hoveredArcIndex != null) {\n      return arcs[hoveredArcIndex] ?? null;\n    }\n    return internalHoveredArc;\n  }, [arcs, hoveredArcIndex, internalHoveredArc]);\n\n  const setHoveredArcIndex = useCallback(\n    (index: number | null) => {\n      if (isHoverControlled) {\n        onHoverChange?.(index);\n      } else {\n        setInternalHoveredIndex(index);\n        setInternalHoveredArc(index == null ? null : (arcs[index] ?? null));\n      }\n    },\n    [arcs, isHoverControlled, onHoverChange]\n  );\n\n  const setHoveredArc = useCallback(\n    (arc: ArcDatum | null) => {\n      setHoveredArcIndex(arc ? arc.arcIndex : null);\n    },\n    [setHoveredArcIndex]\n  );\n\n  const setFocusId = useCallback(\n    (nextId: string) => {\n      if (isFocusControlled) {\n        onFocusChange?.(nextId);\n      } else {\n        setInternalFocusId(nextId);\n      }\n    },\n    [isFocusControlled, onFocusChange]\n  );\n\n  const rootFocus = focusById.get(rootId);\n  const focus = focusById.get(focusId) ?? rootFocus;\n  const prevFocus = focusById.get(prevFocusId) ?? focus;\n\n  useEffect(() => {\n    if (!isFocusControlled) {\n      setInternalFocusId(rootId);\n    }\n    setPrevFocusId(rootId);\n    setZoomT(1);\n  }, [rootId, isFocusControlled]);\n\n  const enterTiming = useMemo(\n    () => buildSunburstEnterTiming(arcs, enterStaggerScale),\n    [arcs, enterStaggerScale]\n  );\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: replay when data or playKey changes\n  useEffect(() => {\n    setSkipEnterAnimation(\n      typeof window !== \"undefined\" &&\n        window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches === true\n    );\n  }, [playKey, arcs]);\n\n  const prefersReduced = () =>\n    typeof window !== \"undefined\" &&\n    window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches;\n\n  const growControls = useRef<ReturnType<typeof animate> | null>(null);\n  const zoomControls = useRef<ReturnType<typeof animate> | null>(null);\n  const zoomGen = useRef(0);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: prefersReduced reads matchMedia at call time\n  const zoomTo = useCallback(\n    (nextId: string) => {\n      if (nextId === focusId) {\n        return;\n      }\n      setHoveredArc(null);\n      growControls.current?.stop();\n      growRef.current = new Map();\n      setGrowTick((n) => n + 1);\n      setPrevFocusId(focusId);\n      setFocusId(nextId);\n      zoomControls.current?.stop();\n      const gen = ++zoomGen.current;\n      if (prefersReduced()) {\n        setZoomT(1);\n        return;\n      }\n      setZoomT(0);\n      zoomControls.current = animate(0, 1, {\n        duration: 0.75,\n        ease: [0.22, 1, 0.36, 1],\n        onUpdate: (value) => {\n          if (zoomGen.current === gen) {\n            setZoomT(value);\n          }\n        },\n        onComplete: () => {\n          if (zoomGen.current === gen) {\n            setZoomT(1);\n            setPrevFocusId(nextId);\n          }\n        },\n      });\n    },\n    [focusId, setFocusId, setHoveredArc]\n  );\n\n  const isDescendant = useCallback(\n    (d: ArcDatum, ancestorId: string) =>\n      d.id === ancestorId || d.id.startsWith(`${ancestorId} / `),\n    []\n  );\n\n  const isOnHoverPath = useCallback(\n    (d: ArcDatum, hoveredId: string) =>\n      d.id === hoveredId || hoveredId.startsWith(`${d.id} / `),\n    []\n  );\n\n  const isRelated = useCallback(\n    (d: ArcDatum) => {\n      if (!hoveredArc) {\n        return true;\n      }\n      return (\n        isDescendant(d, hoveredArc.id) || hoveredArc.id.startsWith(`${d.id} / `)\n      );\n    },\n    [hoveredArc, isDescendant]\n  );\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: grow targets from visible hover path\n  useEffect(() => {\n    const targets =\n      hoveredArc && focus\n        ? buildHoverGrowTargets(\n            arcs,\n            hoveredArc,\n            focus,\n            maxDepth,\n            radius,\n            hoverPop,\n            isOnHoverPath\n          )\n        : new Map<string, number>();\n    const starts = new Map(growRef.current);\n    const ids = new Set<string>([...starts.keys(), ...targets.keys()]);\n\n    growControls.current?.stop();\n\n    if (prefersReduced()) {\n      growRef.current = targets;\n      setGrowTick((n) => n + 1);\n      return;\n    }\n\n    growControls.current = animate(0, 1, {\n      duration: 0.42,\n      ease: [0.22, 1, 0.36, 1],\n      onUpdate: (p) => {\n        const next = new Map<string, number>();\n        for (const id of ids) {\n          const start = starts.get(id) ?? 0;\n          const target = targets.get(id) ?? 0;\n          const val = start + (target - start) * p;\n          if (val > 0.01) {\n            next.set(id, val);\n          }\n        }\n        growRef.current = next;\n        setGrowTick((n) => n + 1);\n      },\n    });\n    return () => growControls.current?.stop();\n  }, [hoveredArc, arcs, hoverPop, isOnHoverPath, focus, maxDepth, radius]);\n\n  const getColor = useCallback((categoryIndex: number, nodeColor?: string) => {\n    if (nodeColor) {\n      return nodeColor;\n    }\n    return defaultSunburstColors[\n      categoryIndex % defaultSunburstColors.length\n    ] as string;\n  }, []);\n\n  const getFill = useCallback(\n    (arcIndex: number, fillOverride?: string, colorOverride?: string) => {\n      if (fillOverride) {\n        return fillOverride;\n      }\n      const arc = arcs[arcIndex];\n      if (!arc) {\n        return defaultSunburstColors[0] as string;\n      }\n      return (\n        colorOverride ?? arc.fill ?? arc.color ?? getColor(arc.categoryIndex)\n      );\n    },\n    [arcs, getColor]\n  );\n\n  const getFillOpacity = useCallback(\n    (relativeDepth: number, override?: number) =>\n      override ?? opacityForRelativeDepth(relativeDepth),\n    []\n  );\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: growTick forces re-read from growRef\n  const growAmountForArc = useCallback(\n    (arcId: string) => growRef.current.get(arcId) ?? 0,\n    [growTick]\n  );\n\n  const maxExpandedThickness = useMemo(\n    () => maxHoverSegmentThickness(maxDepth, radius, hoverPop),\n    [maxDepth, radius, hoverPop]\n  );\n\n  if (!(focus && prevFocus)) {\n    return null;\n  }\n\n  const childArray = Children.toArray(children);\n  const defsChildren = childArray.filter(\n    (child): child is ReactElement =>\n      isValidElement(child) && isDefsComponent(child)\n  );\n\n  const outsideChildren: ReactNode[] = [];\n  const svgChildren: ReactNode[] = [];\n\n  for (const child of childArray) {\n    if (!isValidElement(child)) {\n      svgChildren.push(child);\n      continue;\n    }\n    if (isDefsComponent(child)) {\n      continue;\n    }\n    const name = componentDisplayName(child);\n    if (isOutsideSvgComponent(name)) {\n      outsideChildren.push(child);\n    } else {\n      svgChildren.push(child);\n    }\n  }\n\n  const segmentChildren = svgChildren.filter(isSunburstSegment);\n  const otherSvgChildren = svgChildren.filter(\n    (child) => !isSunburstSegment(child)\n  );\n  const orderedSegments = sortSunburstSegments(segmentChildren, arcs);\n  const orderedSvgChildren = [...orderedSegments, ...otherSvgChildren];\n\n  const providerValue: SunburstContextValue = {\n    data,\n    arcs,\n    focusById,\n    rootId,\n    maxDepth,\n    radius,\n    size,\n    focus,\n    prevFocus,\n    focusId,\n    zoomTo,\n    zoomT,\n    enterTiming,\n    skipEnterAnimation,\n    growAmountForArc,\n    getColor,\n    getFill,\n    getFillOpacity,\n    isRelated,\n    isDescendant,\n    enterTransition,\n    enterStaggerScale,\n    playKey,\n    hoverPop,\n    maxExpandedThickness,\n    containerRef,\n    hoveredArcIndex,\n    setHoveredArcIndex,\n    hoveredArc,\n    setHoveredArc,\n  };\n\n  return (\n    <SunburstProvider value={providerValue}>\n      <div\n        className={className}\n        ref={containerRef}\n        style={{ maxWidth: \"100%\", width: size }}\n      >\n        {outsideChildren.filter(\n          (child) =>\n            isValidElement(child) &&\n            componentDisplayName(child) === \"SunburstBreadcrumb\"\n        )}\n        <div\n          className=\"mx-auto w-full\"\n          style={{ aspectRatio: \"1 / 1\", maxWidth: size }}\n        >\n          <motion.svg\n            animate={{ opacity: 1 }}\n            aria-label={`Sunburst chart of ${data.name}`}\n            initial={{ opacity: 0 }}\n            onPointerLeave={() => setHoveredArc(null)}\n            role=\"img\"\n            style={{ display: \"block\", overflow: \"visible\" }}\n            transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}\n            viewBox={`${-fullRadius} ${-fullRadius} ${size} ${size}`}\n            width=\"100%\"\n          >\n            {defsChildren.length > 0 ? <defs>{defsChildren}</defs> : null}\n            {orderedSvgChildren}\n          </motion.svg>\n        </div>\n        {outsideChildren.filter(\n          (child) =>\n            isValidElement(child) &&\n            componentDisplayName(child) === \"SunburstHint\"\n        )}\n      </div>\n    </SunburstProvider>\n  );\n});\n\nexport function SunburstChart(props: SunburstChartProps) {\n  return <SunburstChartCore {...props} />;\n}\n\nSunburstChart.displayName = \"SunburstChart\";\n",
      "type": "registry:component",
      "target": "components/charts/sunburst-chart.tsx"
    },
    {
      "path": "src/charts/sunburst-context.tsx",
      "content": "\"use client\";\n\nimport type { Transition } from \"motion/react\";\nimport {\n  createContext,\n  type ReactNode,\n  type RefObject,\n  useContext,\n  useMemo,\n} from \"react\";\nimport type { ArcDatum, Focus, SunburstEnterTiming } from \"./sunburst\";\nimport type { SunburstNode } from \"./sunburst-data\";\n\nexport const sunburstCssVars = {\n  background: \"var(--chart-background)\",\n  foreground: \"var(--chart-foreground)\",\n  foregroundMuted: \"var(--chart-foreground-muted)\",\n  label: \"var(--chart-label)\",\n  ring: \"var(--chart-background)\",\n  slice1: \"var(--chart-1)\",\n  slice2: \"var(--chart-2)\",\n  slice3: \"var(--chart-3)\",\n  slice4: \"var(--chart-4)\",\n  slice5: \"var(--chart-5)\",\n};\n\nexport const defaultSunburstColors = [\n  sunburstCssVars.slice1,\n  sunburstCssVars.slice2,\n  sunburstCssVars.slice3,\n  sunburstCssVars.slice4,\n  sunburstCssVars.slice5,\n];\n\nconst OPACITY_STEP = 0.15;\nconst OPACITY_FLOOR = 0.45;\n\n/** Relative depth within the current focus view (1 = innermost visible ring). */\nexport function opacityForRelativeDepth(relativeDepth: number): number {\n  if (relativeDepth <= 1) {\n    return 1;\n  }\n  return Math.max(OPACITY_FLOOR, 1 - (relativeDepth - 1) * OPACITY_STEP);\n}\n\nexport interface SunburstHoverContextValue {\n  hoveredArcIndex: number | null;\n  setHoveredArcIndex: (index: number | null) => void;\n  hoveredArc: ArcDatum | null;\n  setHoveredArc: (arc: ArcDatum | null) => void;\n}\n\nexport interface SunburstStableContextValue {\n  data: SunburstNode;\n  arcs: ArcDatum[];\n  focusById: Map<string, Focus>;\n  rootId: string;\n  maxDepth: number;\n  radius: number;\n  size: number;\n\n  focus: Focus;\n  prevFocus: Focus;\n  focusId: string;\n  zoomTo: (nextId: string) => void;\n\n  zoomT: number;\n  enterTiming: SunburstEnterTiming;\n  skipEnterAnimation: boolean;\n  growAmountForArc: (arcId: string) => number;\n\n  getColor: (categoryIndex: number, nodeColor?: string) => string;\n  getFill: (\n    arcIndex: number,\n    fillOverride?: string,\n    colorOverride?: string\n  ) => string;\n  getFillOpacity: (relativeDepth: number, override?: number) => number;\n\n  isRelated: (arc: ArcDatum) => boolean;\n  isDescendant: (arc: ArcDatum, ancestorId: string) => boolean;\n\n  enterTransition?: Transition;\n  enterStaggerScale: number;\n  playKey: number;\n  hoverPop: number;\n  maxExpandedThickness: number;\n\n  containerRef: RefObject<HTMLDivElement | null>;\n}\n\nexport type SunburstContextValue = SunburstStableContextValue &\n  SunburstHoverContextValue;\n\nconst SunburstStableContext = createContext<SunburstStableContextValue | null>(\n  null\n);\nconst SunburstHoverContext = createContext<SunburstHoverContextValue | null>(\n  null\n);\n\nexport function SunburstProvider({\n  children,\n  value,\n}: {\n  children: ReactNode;\n  value: SunburstContextValue;\n}) {\n  const stable = useMemo<SunburstStableContextValue>(\n    () => ({\n      data: value.data,\n      arcs: value.arcs,\n      focusById: value.focusById,\n      rootId: value.rootId,\n      maxDepth: value.maxDepth,\n      radius: value.radius,\n      size: value.size,\n      focus: value.focus,\n      prevFocus: value.prevFocus,\n      focusId: value.focusId,\n      zoomTo: value.zoomTo,\n      zoomT: value.zoomT,\n      enterTiming: value.enterTiming,\n      skipEnterAnimation: value.skipEnterAnimation,\n      growAmountForArc: value.growAmountForArc,\n      getColor: value.getColor,\n      getFill: value.getFill,\n      getFillOpacity: value.getFillOpacity,\n      isRelated: value.isRelated,\n      isDescendant: value.isDescendant,\n      enterTransition: value.enterTransition,\n      enterStaggerScale: value.enterStaggerScale,\n      playKey: value.playKey,\n      hoverPop: value.hoverPop,\n      maxExpandedThickness: value.maxExpandedThickness,\n      containerRef: value.containerRef,\n    }),\n    [\n      value.data,\n      value.arcs,\n      value.focusById,\n      value.rootId,\n      value.maxDepth,\n      value.radius,\n      value.size,\n      value.focus,\n      value.prevFocus,\n      value.focusId,\n      value.zoomTo,\n      value.zoomT,\n      value.enterTiming,\n      value.skipEnterAnimation,\n      value.growAmountForArc,\n      value.getColor,\n      value.getFill,\n      value.getFillOpacity,\n      value.isRelated,\n      value.isDescendant,\n      value.enterTransition,\n      value.enterStaggerScale,\n      value.playKey,\n      value.hoverPop,\n      value.maxExpandedThickness,\n      value.containerRef,\n    ]\n  );\n\n  const hover = useMemo<SunburstHoverContextValue>(\n    () => ({\n      hoveredArcIndex: value.hoveredArcIndex,\n      setHoveredArcIndex: value.setHoveredArcIndex,\n      hoveredArc: value.hoveredArc,\n      setHoveredArc: value.setHoveredArc,\n    }),\n    [\n      value.hoveredArcIndex,\n      value.setHoveredArcIndex,\n      value.hoveredArc,\n      value.setHoveredArc,\n    ]\n  );\n\n  return (\n    <SunburstStableContext.Provider value={stable}>\n      <SunburstHoverContext.Provider value={hover}>\n        {children}\n      </SunburstHoverContext.Provider>\n    </SunburstStableContext.Provider>\n  );\n}\n\nexport function useSunburstStable() {\n  const ctx = useContext(SunburstStableContext);\n  if (!ctx) {\n    throw new Error(\"useSunburstStable must be used within SunburstChart\");\n  }\n  return ctx;\n}\n\nexport function useSunburstHover() {\n  const ctx = useContext(SunburstHoverContext);\n  if (!ctx) {\n    throw new Error(\"useSunburstHover must be used within SunburstChart\");\n  }\n  return ctx;\n}\n",
      "type": "registry:component",
      "target": "components/charts/sunburst-context.tsx"
    },
    {
      "path": "src/charts/sunburst-segment.tsx",
      "content": "\"use client\";\n\nimport { motion, useTransform } from \"motion/react\";\nimport { memo, useMemo } from \"react\";\nimport { applyHoverGrow, arcPath, transitionGeometry } from \"./sunburst\";\nimport {\n  sunburstCssVars,\n  useSunburstHover,\n  useSunburstStable,\n} from \"./sunburst-context\";\nimport { useEnterComplete } from \"./use-enter-complete\";\nimport { useMountProgress } from \"./use-mount-progress\";\n\nconst HOVER_DIM_TRANSITION = { duration: 0.16, ease: \"easeOut\" as const };\n\nexport interface SunburstSegmentProps {\n  index: number;\n  /** Optional color override */\n  color?: string;\n  /** Optional fill override (patterns/gradients) */\n  fill?: string;\n  /** Optional fill opacity override */\n  fillOpacity?: number;\n}\n\nexport const SunburstSegment = memo(function SunburstSegment({\n  index,\n  color: colorProp,\n  fill: fillProp,\n  fillOpacity: fillOpacityProp,\n}: SunburstSegmentProps) {\n  const {\n    arcs,\n    focus,\n    prevFocus,\n    maxDepth,\n    radius,\n    zoomT,\n    enterTiming,\n    enterTransition,\n    playKey,\n    skipEnterAnimation,\n    growAmountForArc,\n    getFill,\n    getFillOpacity,\n    isRelated,\n    maxExpandedThickness,\n    zoomTo,\n  } = useSunburstStable();\n  const { setHoveredArc, setHoveredArcIndex } = useSunburstHover();\n\n  const arc = arcs[index];\n  const segmentDelay =\n    (arc ? enterTiming.segmentDelays.get(arc.id)?.delay : undefined) ?? 0;\n  const replayId = arc?.id ?? `missing-${index}`;\n\n  const base = useMemo(() => {\n    if (!arc) {\n      return null;\n    }\n    return transitionGeometry(arc, prevFocus, focus, maxDepth, radius, zoomT);\n  }, [arc, prevFocus, focus, maxDepth, radius, zoomT]);\n\n  const visualGeometry = useMemo(() => {\n    if (!(arc && base)) {\n      return null;\n    }\n    return applyHoverGrow(base, arc.id, growAmountForArc, maxExpandedThickness);\n  }, [arc, base, growAmountForArc, maxExpandedThickness]);\n\n  const enterProgress = useMountProgress(\n    enterTransition,\n    segmentDelay,\n    `${playKey}-enter-${replayId}`\n  );\n  const enterComplete = useEnterComplete(enterProgress);\n  const enterScale = useTransform(enterProgress, [0, 1], [0, 1]);\n  const animatedHitPath = useTransform(enterProgress, (value) =>\n    base ? (arcPath(base, value, 1) ?? \"\") : \"\"\n  );\n  const animatedVisualPath = useTransform(enterProgress, (value) =>\n    visualGeometry ? (arcPath(visualGeometry, value, 1) ?? \"\") : \"\"\n  );\n\n  if (!arc) {\n    return null;\n  }\n\n  if (!base) {\n    return null;\n  }\n\n  const showStatic = skipEnterAnimation || enterComplete;\n\n  const fullHitPath = arcPath(base, 1, 1);\n  const fullVisualPath = visualGeometry ? arcPath(visualGeometry, 1, 1) : null;\n  if (!(fullHitPath && fullVisualPath)) {\n    return null;\n  }\n\n  const relativeDepth = arc.depth - focus.depth;\n  const segmentFill = getFill(index, fillProp, colorProp);\n  const fillOpacity = fillOpacityProp ?? getFillOpacity(relativeDepth);\n  const related = isRelated(arc);\n  const layerOpacity = related ? 1 : 0.25;\n\n  const groupStyle = {\n    cursor: arc.hasChildren ? (\"pointer\" as const) : (\"default\" as const),\n    transformOrigin: \"0px 0px\",\n  };\n\n  const hitHandlers = {\n    onClick: () => arc.hasChildren && zoomTo(arc.id),\n    onPointerEnter: () => {\n      setHoveredArc(arc);\n      setHoveredArcIndex(index);\n    },\n  };\n\n  const visualPathProps = {\n    fill: segmentFill,\n    fillOpacity,\n    pointerEvents: \"none\" as const,\n    stroke: sunburstCssVars.ring,\n    strokeLinejoin: \"round\" as const,\n    strokeWidth: 1,\n  };\n\n  if (showStatic) {\n    return (\n      <motion.g\n        animate={{ opacity: layerOpacity }}\n        initial={false}\n        onClick={hitHandlers.onClick}\n        onPointerEnter={hitHandlers.onPointerEnter}\n        style={groupStyle}\n        transition={{ opacity: HOVER_DIM_TRANSITION }}\n      >\n        <path d={fullHitPath} fill=\"transparent\" />\n        <path d={fullVisualPath} {...visualPathProps} />\n      </motion.g>\n    );\n  }\n\n  return (\n    <motion.g\n      animate={{ opacity: layerOpacity }}\n      initial={false}\n      onClick={hitHandlers.onClick}\n      onPointerEnter={hitHandlers.onPointerEnter}\n      style={{\n        ...groupStyle,\n        scale: enterScale,\n      }}\n      transition={{ opacity: HOVER_DIM_TRANSITION }}\n    >\n      <motion.path d={animatedHitPath} fill=\"transparent\" />\n      <motion.path d={animatedVisualPath} {...visualPathProps} />\n    </motion.g>\n  );\n});\n\nSunburstSegment.displayName = \"SunburstSegment\";\n",
      "type": "registry:component",
      "target": "components/charts/sunburst-segment.tsx"
    },
    {
      "path": "src/charts/sunburst-center.tsx",
      "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { ringOptions } from \"./sunburst\";\nimport { sunburstCssVars, useSunburstStable } from \"./sunburst-context\";\n\nexport interface SunburstCenterProps {\n  className?: string;\n}\n\nexport const SunburstCenter = memo(function SunburstCenter({\n  className,\n}: SunburstCenterProps) {\n  const { focus, prevFocus, maxDepth, radius, zoomT, zoomTo, getColor } =\n    useSunburstStable();\n\n  const { centerR } = ringOptions(focus.depth, maxDepth, radius);\n  const liveCenterR =\n    centerR * zoomT +\n    ringOptions(prevFocus.depth, maxDepth, radius).centerR * (1 - zoomT);\n\n  if (liveCenterR <= 1) {\n    return null;\n  }\n\n  const centerColor =\n    focus.depth === 0\n      ? sunburstCssVars.background\n      : getColor(focus.categoryIndex);\n\n  return (\n    /* biome-ignore lint/a11y/noStaticElementInteractions: Center zoom-out control */\n    <circle\n      className={className}\n      cx={0}\n      cy={0}\n      fill={centerColor}\n      onClick={() => focus.parentId && zoomTo(focus.parentId)}\n      r={Math.max(liveCenterR - 2, 0)}\n      stroke={sunburstCssVars.ring}\n      strokeWidth={1}\n      style={{ cursor: focus.parentId ? \"pointer\" : \"default\" }}\n    />\n  );\n});\n\nSunburstCenter.displayName = \"SunburstCenter\";\n",
      "type": "registry:component",
      "target": "components/charts/sunburst-center.tsx"
    },
    {
      "path": "src/charts/sunburst-breadcrumb.tsx",
      "content": "\"use client\";\n\nimport { memo, type ReactNode, useMemo } from \"react\";\nimport type { Focus } from \"./sunburst\";\nimport { useSunburstStable } from \"./sunburst-context\";\n\nexport interface SunburstBreadcrumbItem {\n  id: string;\n  label: string;\n  isCurrent: boolean;\n}\n\nexport function useSunburstBreadcrumbItems() {\n  const { data, focus, focusById, rootId, zoomTo } = useSunburstStable();\n\n  const items = useMemo((): SunburstBreadcrumbItem[] => {\n    const crumbs: Focus[] = [];\n    let cur: Focus | undefined = focus;\n    while (cur) {\n      crumbs.unshift(cur);\n      cur = cur.parentId ? focusById.get(cur.parentId) : undefined;\n    }\n\n    return crumbs.map((c, index) => ({\n      id: c.id,\n      label: c.id === rootId ? data.name : c.name,\n      isCurrent: index === crumbs.length - 1,\n    }));\n  }, [data.name, focus, focusById, rootId]);\n\n  return { items, zoomTo };\n}\n\nexport interface SunburstBreadcrumbProps {\n  className?: string;\n  children: ReactNode;\n}\n\nexport const SunburstBreadcrumb = memo(function SunburstBreadcrumb({\n  className,\n  children,\n}: SunburstBreadcrumbProps) {\n  return (\n    <nav aria-label=\"Drill-down path\" className={className ?? \"mb-4\"}>\n      {children}\n    </nav>\n  );\n});\n\nSunburstBreadcrumb.displayName = \"SunburstBreadcrumb\";\n",
      "type": "registry:component",
      "target": "components/charts/sunburst-breadcrumb.tsx"
    },
    {
      "path": "src/charts/sunburst-hint.tsx",
      "content": "\"use client\";\n\nimport { memo, type ReactNode } from \"react\";\nimport type { ArcDatum, Focus } from \"./sunburst\";\nimport { useSunburstHover, useSunburstStable } from \"./sunburst-context\";\n\nexport interface SunburstHintContext {\n  hintText: string;\n  hoveredArc: ArcDatum | null;\n  focus: Focus;\n}\n\nexport interface SunburstHintProps {\n  className?: string;\n  children?: ReactNode | ((context: SunburstHintContext) => ReactNode);\n}\n\nexport const SunburstHint = memo(function SunburstHint({\n  className,\n  children,\n}: SunburstHintProps) {\n  const { focus } = useSunburstStable();\n  const { hoveredArc } = useSunburstHover();\n\n  const hintText = (() => {\n    if (hoveredArc) {\n      return hoveredArc.trail.join(\"  ›  \");\n    }\n    if (focus.depth === 0) {\n      return \"Click a segment to zoom in · hover to inspect\";\n    }\n    return \"Click the center to zoom out\";\n  })();\n\n  const context: SunburstHintContext = { hintText, hoveredArc, focus };\n\n  let content: ReactNode;\n  if (typeof children === \"function\") {\n    content = children(context);\n  } else if (children == null) {\n    content = hintText;\n  } else {\n    content = children;\n  }\n\n  return (\n    <div\n      aria-live=\"polite\"\n      className={\n        className ?? \"mt-3 min-h-5 text-center text-muted-foreground text-sm\"\n      }\n      style={{ minHeight: 20 }}\n    >\n      {content}\n    </div>\n  );\n});\n\nSunburstHint.displayName = \"SunburstHint\";\n",
      "type": "registry:component",
      "target": "components/charts/sunburst-hint.tsx"
    },
    {
      "path": "src/charts/sunburst-labels.tsx",
      "content": "\"use client\";\n\nimport { motion, useTransform } from \"motion/react\";\nimport { memo } from \"react\";\nimport { DEFAULT_CHART_ENTER_TRANSITION } from \"./animation\";\nimport {\n  applyHoverGrow,\n  geomCentroidAngle,\n  geomCentroidRadius,\n  transitionGeometry,\n} from \"./sunburst\";\nimport { sunburstCssVars, useSunburstStable } from \"./sunburst-context\";\nimport { useEnterComplete } from \"./use-enter-complete\";\nimport { useMountProgress } from \"./use-mount-progress\";\n\nexport interface SunburstLabelsProps {\n  fontSize?: number;\n  fill?: string;\n  stroke?: string;\n  strokeWidth?: number;\n  className?: string;\n}\n\nexport const SunburstLabels = memo(function SunburstLabels({\n  fontSize = 11,\n  fill = sunburstCssVars.label,\n  stroke = sunburstCssVars.background,\n  strokeWidth = 2.5,\n  className,\n}: SunburstLabelsProps) {\n  const {\n    arcs,\n    focus,\n    prevFocus,\n    maxDepth,\n    radius,\n    zoomT,\n    enterTiming,\n    enterTransition,\n    playKey,\n    skipEnterAnimation,\n    growAmountForArc,\n    isRelated,\n    maxExpandedThickness,\n  } = useSunburstStable();\n\n  const enterDuration =\n    typeof enterTransition?.duration === \"number\"\n      ? enterTransition.duration\n      : (DEFAULT_CHART_ENTER_TRANSITION.duration as number);\n  const labelsDelay = enterTiming.maxDelay + enterDuration * 0.85;\n\n  const labelsProgress = useMountProgress(\n    enterTransition,\n    labelsDelay,\n    `${playKey}-labels`\n  );\n  const labelsComplete = useEnterComplete(labelsProgress);\n  const labelOpacity = useTransform(labelsProgress, [0, 1], [0, 1]);\n  const showLabels = skipEnterAnimation || labelsComplete;\n\n  return (\n    <g className={className}>\n      {arcs.map((arc) => {\n        const base = transitionGeometry(\n          arc,\n          prevFocus,\n          focus,\n          maxDepth,\n          radius,\n          zoomT\n        );\n        if (!base) {\n          return null;\n        }\n        const g = applyHoverGrow(\n          base,\n          arc.id,\n          growAmountForArc,\n          maxExpandedThickness\n        );\n        const angleSpan = g.a1 - g.a0;\n        const r = geomCentroidRadius(g);\n        if (angleSpan * r < 26 || g.outerR - g.innerR < 16) {\n          return null;\n        }\n        if (!isRelated(arc)) {\n          return null;\n        }\n\n        const mid = geomCentroidAngle(g);\n        const x = Math.sin(mid) * r;\n        const y = -Math.cos(mid) * r;\n        let deg = (mid * 180) / Math.PI - 90;\n        if (deg > 90) {\n          deg -= 180;\n        }\n        if (deg < -90) {\n          deg += 180;\n        }\n\n        const labelStyle: React.CSSProperties = {\n          fill,\n          fontFamily: \"inherit\",\n          fontSize,\n          fontWeight: 600,\n        };\n        if (strokeWidth > 0) {\n          labelStyle.paintOrder = \"stroke\";\n          labelStyle.stroke = stroke;\n          labelStyle.strokeLinejoin = \"round\";\n          labelStyle.strokeWidth = strokeWidth;\n        }\n\n        if (showLabels) {\n          return (\n            <text\n              dominantBaseline=\"middle\"\n              key={`label-${arc.id}`}\n              pointerEvents=\"none\"\n              style={{ ...labelStyle, opacity: 1 }}\n              textAnchor=\"middle\"\n              transform={`rotate(${deg} ${x} ${y})`}\n              x={x}\n              y={y}\n            >\n              {arc.name}\n            </text>\n          );\n        }\n\n        return (\n          <motion.text\n            dominantBaseline=\"middle\"\n            key={`label-${arc.id}`}\n            pointerEvents=\"none\"\n            style={{ ...labelStyle, opacity: labelOpacity }}\n            textAnchor=\"middle\"\n            transform={`rotate(${deg} ${x} ${y})`}\n            x={x}\n            y={y}\n          >\n            {arc.name}\n          </motion.text>\n        );\n      })}\n    </g>\n  );\n});\n\nSunburstLabels.displayName = \"SunburstLabels\";\n",
      "type": "registry:component",
      "target": "components/charts/sunburst-labels.tsx"
    },
    {
      "path": "src/charts/sunburst.ts",
      "content": "import type { SunburstNode } from \"./sunburst-data\";\n\nexport interface ArcDatum {\n  id: string;\n  name: string;\n  depth: number;\n  value: number;\n  categoryIndex: number;\n  hasChildren: boolean;\n  trail: string[];\n  parentId: string | null;\n  a0: number;\n  a1: number;\n  /** Stable index for Studio layer wiring. */\n  arcIndex: number;\n  /** Optional color override from data node. */\n  color?: string;\n  /** Optional fill override from data node (patterns). */\n  fill?: string;\n}\n\nexport interface Focus {\n  id: string;\n  name: string;\n  depth: number;\n  parentId: string | null;\n  categoryIndex: number;\n  a0: number;\n  a1: number;\n}\n\nexport interface ArcGeometry {\n  a0: number;\n  a1: number;\n  innerR: number;\n  outerR: number;\n}\n\nconst TOP = -Math.PI / 2;\nconst TWO_PI = 2 * Math.PI;\nconst ID_SEP = \" / \";\n/** Drill-down navigation hub is smaller than one ring to leave room for hover grow. */\nconst DRILL_CENTER_SCALE = 0.65;\n/** Extra hub shrink per focus level beyond the first drill. */\nconst DRILL_CENTER_DEPTH_SHRINK = 0.08;\n/** Max total radial hover grow as a fraction of the current ring width. */\nconst HOVER_GROW_RING_BUDGET = 0.28;\n/** Per-segment hover grow cap as a fraction of ring width. */\nconst HOVER_GROW_SEGMENT_CAP = 0.1;\n\nfunction nodeId(parentId: string | null, name: string): string {\n  return parentId ? `${parentId}${ID_SEP}${name}` : name;\n}\n\nexport function sumValues(node: SunburstNode): number {\n  if (node.children?.length) {\n    return node.children.reduce((sum, child) => sum + sumValues(child), 0);\n  }\n  return node.value ?? 0;\n}\n\ninterface BuildContext {\n  arcs: ArcDatum[];\n  focusById: Map<string, Focus>;\n  maxDepth: number;\n  rootId: string;\n  arcIndex: number;\n}\n\nfunction layoutNode(\n  node: SunburstNode,\n  id: string,\n  depth: number,\n  a0: number,\n  a1: number,\n  parentId: string | null,\n  categoryIndex: number,\n  trail: string[],\n  ctx: BuildContext\n) {\n  const value = sumValues(node);\n  const hasChildren = Boolean(node.children?.length);\n\n  if (depth > 0) {\n    ctx.arcs.push({\n      id,\n      name: node.name,\n      depth,\n      value,\n      categoryIndex,\n      hasChildren,\n      trail: [...trail, node.name],\n      parentId,\n      a0,\n      a1,\n      arcIndex: ctx.arcIndex,\n      color: node.color,\n      fill: node.fill,\n    });\n    ctx.arcIndex += 1;\n  }\n\n  ctx.focusById.set(id, {\n    id,\n    name: node.name,\n    depth,\n    parentId,\n    categoryIndex,\n    a0,\n    a1,\n  });\n  ctx.maxDepth = Math.max(ctx.maxDepth, depth);\n\n  if (!(hasChildren && node.children?.length)) {\n    return;\n  }\n\n  const span = a1 - a0;\n  let cursor = a0;\n  for (const [index, child] of node.children.entries()) {\n    const childValue = sumValues(child);\n    const childSpan = value > 0 ? (childValue / value) * span : 0;\n    const childId = nodeId(id, child.name);\n    const childCategory = depth === 0 ? index : categoryIndex;\n    layoutNode(\n      child,\n      childId,\n      depth + 1,\n      cursor,\n      cursor + childSpan,\n      id,\n      childCategory,\n      depth === 0 ? [node.name] : trail,\n      ctx\n    );\n    cursor += childSpan;\n  }\n}\n\nfunction toRadians(normalized: number): number {\n  return TOP + normalized * TWO_PI;\n}\n\nexport function buildArcs(data: SunburstNode) {\n  const rootId = data.name;\n  const ctx: BuildContext = {\n    arcs: [],\n    focusById: new Map(),\n    maxDepth: 0,\n    rootId,\n    arcIndex: 0,\n  };\n\n  layoutNode(data, rootId, 0, 0, 1, null, 0, [], ctx);\n\n  for (const arc of ctx.arcs) {\n    arc.a0 = toRadians(arc.a0);\n    arc.a1 = toRadians(arc.a1);\n  }\n  for (const focus of ctx.focusById.values()) {\n    focus.a0 = toRadians(focus.a0);\n    focus.a1 = toRadians(focus.a1);\n  }\n\n  return {\n    arcs: ctx.arcs,\n    maxDepth: ctx.maxDepth,\n    total: sumValues(data),\n    focusById: ctx.focusById,\n    rootId,\n  };\n}\n\nexport function ringOptions(\n  focusDepth: number,\n  maxDepth: number,\n  radius: number\n) {\n  const oneLevelCenterR = radius / maxDepth;\n  if (focusDepth === 0) {\n    // Root view — segments fill from the center, no navigation hub gap.\n    return { centerR: 0, ringWidth: oneLevelCenterR };\n  }\n  // Hub shrinks on drill-down; shrinks further when focus moves deeper.\n  const depthPastFirstDrill = Math.max(0, focusDepth - 1);\n  const centerScale = Math.max(\n    0.45,\n    DRILL_CENTER_SCALE - depthPastFirstDrill * DRILL_CENTER_DEPTH_SHRINK\n  );\n  const centerR = oneLevelCenterR * centerScale;\n  const visibleRings = Math.max(1, maxDepth - focusDepth);\n  const ringWidth = (radius - centerR) / visibleRings;\n  return { centerR, ringWidth };\n}\n\nexport function geometryFor(\n  arc: ArcDatum,\n  focus: Focus,\n  maxDepth: number,\n  radius: number\n): ArcGeometry | null {\n  if (arc.depth <= focus.depth) {\n    return null;\n  }\n  if (arc.id !== focus.id && !arc.id.startsWith(`${focus.id}${ID_SEP}`)) {\n    return null;\n  }\n\n  const { centerR, ringWidth } = ringOptions(focus.depth, maxDepth, radius);\n  const relativeDepth = arc.depth - focus.depth;\n  const focusSpan = focus.a1 - focus.a0;\n  const mapAngle = (angle: number) => {\n    if (focusSpan <= 1e-9) {\n      return TOP;\n    }\n    return TOP + ((angle - focus.a0) / focusSpan) * TWO_PI;\n  };\n\n  return {\n    a0: mapAngle(arc.a0),\n    a1: mapAngle(arc.a1),\n    innerR: centerR + (relativeDepth - 1) * ringWidth,\n    outerR: centerR + relativeDepth * ringWidth,\n  };\n}\n\nexport function lerpGeometry(\n  from: ArcGeometry,\n  to: ArcGeometry,\n  progress: number\n): ArcGeometry {\n  const t = Math.min(1, Math.max(0, progress));\n  const fromMid = (from.a0 + from.a1) / 2;\n  const toMid = (to.a0 + to.a1) / 2;\n  const fromHalf = (from.a1 - from.a0) / 2;\n  const toHalf = (to.a1 - to.a0) / 2;\n  const mid = lerpAngle(fromMid, toMid, t);\n  const half = fromHalf + (toHalf - fromHalf) * t;\n\n  return {\n    a0: mid - half,\n    a1: mid + half,\n    innerR: from.innerR + (to.innerR - from.innerR) * t,\n    outerR: from.outerR + (to.outerR - from.outerR) * t,\n  };\n}\n\nfunction lerpAngle(from: number, to: number, progress: number): number {\n  let delta = to - from;\n  while (delta > Math.PI) {\n    delta -= TWO_PI;\n  }\n  while (delta < -Math.PI) {\n    delta += TWO_PI;\n  }\n  return from + delta * progress;\n}\n\nfunction pointGeometry(source: ArcGeometry): ArcGeometry {\n  const mid = (source.a0 + source.a1) / 2;\n  const radius = (source.innerR + source.outerR) / 2;\n  const pin = Math.max(0, Math.min(radius * 0.12, source.innerR));\n  return { a0: mid, a1: mid, innerR: pin, outerR: pin };\n}\n\n/** Zoom morph — lerps matching arcs; entering/exiting arcs collapse to a point. */\nexport function transitionGeometry(\n  arc: ArcDatum,\n  fromFocus: Focus,\n  toFocus: Focus,\n  maxDepth: number,\n  radius: number,\n  progress: number\n): ArcGeometry | null {\n  const from = geometryFor(arc, fromFocus, maxDepth, radius);\n  const to = geometryFor(arc, toFocus, maxDepth, radius);\n\n  if (!(from || to)) {\n    return null;\n  }\n  if (from && to) {\n    return lerpGeometry(from, to, progress);\n  }\n  if (from) {\n    return lerpGeometry(from, pointGeometry(from), progress);\n  }\n  if (to) {\n    return lerpGeometry(pointGeometry(to), to, progress);\n  }\n  return null;\n}\n\n/** Normalized clockwise angle from 12 o'clock (0 → 1). */\nexport function clockwiseFraction(angle: number): number {\n  let normalized = angle - TOP;\n  if (normalized < 0) {\n    normalized += TWO_PI;\n  }\n  return normalized / TWO_PI;\n}\n\n/** Ring-chart-style enter delay (seconds) — scale from center. */\nexport interface SunburstSegmentEnterDelays {\n  delay: number;\n}\n\nexport interface SunburstEnterTiming {\n  segmentDelays: Map<string, SunburstSegmentEnterDelays>;\n  maxDelay: number;\n}\n\n/** Matches ring chart expand — each ring/segment grows from the chart center. */\nexport function buildSunburstEnterTiming(\n  arcs: ArcDatum[],\n  staggerScale = 1\n): SunburstEnterTiming {\n  const scale = Math.max(0.25, staggerScale);\n  const byDepth = new Map<number, ArcDatum[]>();\n\n  for (const arc of arcs) {\n    const list = byDepth.get(arc.depth) ?? [];\n    list.push(arc);\n    byDepth.set(arc.depth, list);\n  }\n\n  const segmentDelays = new Map<string, SunburstSegmentEnterDelays>();\n  let maxDelay = 0;\n\n  for (const [, ringArcs] of byDepth) {\n    const sorted = [...ringArcs].sort(\n      (a, b) => clockwiseFraction(a.a0) - clockwiseFraction(b.a0)\n    );\n    const ringIndex = (sorted[0]?.depth ?? 1) - 1;\n\n    for (const [index, arc] of sorted.entries()) {\n      const delay = (ringIndex * 0.12 + index * 0.08) * scale;\n      segmentDelays.set(arc.id, { delay });\n      maxDelay = Math.max(maxDelay, delay);\n    }\n  }\n\n  return { segmentDelays, maxDelay };\n}\n\n/** @deprecated Use buildSunburstEnterTiming */\nexport interface SunburstRevealSchedule {\n  ringStarts: Map<number, number>;\n  ringDuration: number;\n  segmentsCompleteAt: number;\n  labelsStart: number;\n  labelDuration: number;\n}\n\n/** @deprecated Use buildSunburstEnterTiming */\nexport function buildRevealSchedule(\n  arcs: ArcDatum[],\n  staggerScale = 1\n): SunburstRevealSchedule {\n  const timing = buildSunburstEnterTiming(arcs, staggerScale);\n  const ringStarts = new Map<number, number>();\n  for (const arc of arcs) {\n    const delays = timing.segmentDelays.get(arc.id);\n    if (delays) {\n      ringStarts.set(arc.depth, delays.delay);\n    }\n  }\n  return {\n    ringStarts,\n    ringDuration: 0.6,\n    segmentsCompleteAt: timing.maxDelay,\n    labelsStart: timing.maxDelay + 0.12,\n    labelDuration: 0.2,\n  };\n}\n\n/** @deprecated Ring sweep reveal — use expand + sweep enter instead. */\nexport function segmentRevealFromRingSweep(\n  ringProgress: number,\n  a0: number,\n  a1: number\n): { angular: number; radial: number } {\n  const radial = Math.min(1, Math.max(0, ringProgress));\n  const startF = clockwiseFraction(a0);\n  const endF = clockwiseFraction(a1);\n\n  if (ringProgress <= startF) {\n    return { angular: 0, radial };\n  }\n  if (ringProgress >= endF) {\n    return { angular: 1, radial };\n  }\n  const angular = (ringProgress - startF) / Math.max(endF - startF, 1e-9);\n  return { angular, radial };\n}\n\n/** @deprecated Use buildSunburstEnterTiming */\nexport function buildRevealDelays(arcs: ArcDatum[]): Map<string, number> {\n  const timing = buildSunburstEnterTiming(arcs);\n  const map = new Map<string, number>();\n  for (const arc of arcs) {\n    map.set(arc.id, timing.segmentDelays.get(arc.id)?.delay ?? 0);\n  }\n  return map;\n}\n\nexport function arcPath(\n  geometry: ArcGeometry,\n  progress: number,\n  radialProgress = progress\n): string | null {\n  if (progress <= 0 && radialProgress <= 0) {\n    return null;\n  }\n\n  const p = Math.min(1, Math.max(0, progress));\n  const radialP = Math.min(1, Math.max(0, radialProgress));\n  const { a0, a1, innerR, outerR } = geometry;\n\n  if (p >= 1 && radialP >= 1) {\n    return arcPathFromGeometry(geometry);\n  }\n\n  const span = a1 - a0;\n\n  // Clockwise sweep from the segment's leading edge (matches pie chart enter).\n  const currentA0 = a0;\n  const currentA1 = a0 + span * p;\n  const currentInner = innerR < 1 ? 0 : innerR;\n  const currentOuter =\n    innerR < 1 ? outerR * radialP : innerR + (outerR - innerR) * radialP;\n\n  return arcPathFromRadii(currentA0, currentA1, currentInner, currentOuter);\n}\n\nfunction arcPathFromGeometry(geometry: ArcGeometry): string | null {\n  const { a0, a1, innerR, outerR } = geometry;\n  return arcPathFromRadii(a0, a1, innerR, outerR);\n}\n\nfunction arcPathFromRadii(\n  currentA0: number,\n  currentA1: number,\n  currentInner: number,\n  currentOuter: number\n): string | null {\n  if (currentOuter - currentInner < 0.5 || currentA1 - currentA0 < 0.001) {\n    return null;\n  }\n\n  const largeArc = currentA1 - currentA0 > Math.PI ? 1 : 0;\n  const outerX0 = Math.sin(currentA0) * currentOuter;\n  const outerY0 = -Math.cos(currentA0) * currentOuter;\n  const outerX1 = Math.sin(currentA1) * currentOuter;\n  const outerY1 = -Math.cos(currentA1) * currentOuter;\n\n  if (currentInner < 1) {\n    return `M 0 0 L ${outerX0} ${outerY0} A ${currentOuter} ${currentOuter} 0 ${largeArc} 1 ${outerX1} ${outerY1} Z`;\n  }\n\n  const innerX1 = Math.sin(currentA1) * currentInner;\n  const innerY1 = -Math.cos(currentA1) * currentInner;\n  const innerX0 = Math.sin(currentA0) * currentInner;\n  const innerY0 = -Math.cos(currentA0) * currentInner;\n\n  return `M ${outerX0} ${outerY0} A ${currentOuter} ${currentOuter} 0 ${largeArc} 1 ${outerX1} ${outerY1} L ${innerX1} ${innerY1} A ${currentInner} ${currentInner} 0 ${largeArc} 0 ${innerX0} ${innerY0} Z`;\n}\n\nexport function centroidAngle(arc: ArcDatum): number {\n  return (arc.a0 + arc.a1) / 2;\n}\n\nexport function geomCentroidAngle(geometry: ArcGeometry): number {\n  return (geometry.a0 + geometry.a1) / 2;\n}\n\nexport function geomCentroidRadius(geometry: ArcGeometry): number {\n  return (geometry.innerR + geometry.outerR) / 2;\n}\n\n/** Visible path length from focus to hovered arc (in ring count). */\nexport function visibleHoverPathLength(\n  hoveredDepth: number,\n  focusDepth: number\n): number {\n  return Math.max(1, hoveredDepth - focusDepth);\n}\n\n/** Scale hover grow so deep paths and wide drill-down rings stay within budget. */\nexport function hoverGrowForPathSegment(\n  hoverPop: number,\n  ringWidth: number,\n  pathLength: number\n): number {\n  const maxTotalGrow = ringWidth * HOVER_GROW_RING_BUDGET;\n  const budgetPerSegment = maxTotalGrow / pathLength;\n  const perSegmentCap = ringWidth * HOVER_GROW_SEGMENT_CAP;\n  return Math.min(hoverPop, perSegmentCap, budgetPerSegment);\n}\n\n/**\n * Max radial thickness for a hovered segment — matches one expanded ring at the\n * first drill level (e.g. Enterprise under Product).\n */\nexport function maxHoverSegmentThickness(\n  maxDepth: number,\n  radius: number,\n  hoverPop: number,\n  referenceFocusDepth = 1\n): number {\n  const { ringWidth } = ringOptions(referenceFocusDepth, maxDepth, radius);\n  const grow = hoverGrowForPathSegment(hoverPop, ringWidth, 1);\n  return ringWidth + grow;\n}\n\nexport function buildHoverGrowTargets(\n  arcs: ArcDatum[],\n  hoveredArc: ArcDatum,\n  focus: Focus,\n  maxDepth: number,\n  radius: number,\n  hoverPop: number,\n  isOnHoverPath: (arc: ArcDatum, hoveredId: string) => boolean\n): Map<string, number> {\n  const targets = new Map<string, number>();\n  const { ringWidth } = ringOptions(focus.depth, maxDepth, radius);\n  const pathLength = visibleHoverPathLength(hoveredArc.depth, focus.depth);\n  const segmentGrow = hoverGrowForPathSegment(hoverPop, ringWidth, pathLength);\n  const maxExpandedThickness = maxHoverSegmentThickness(\n    maxDepth,\n    radius,\n    hoverPop\n  );\n\n  for (const d of arcs) {\n    if (!isOnHoverPath(d, hoveredArc.id) || d.depth <= focus.depth) {\n      continue;\n    }\n    const base = geometryFor(d, focus, maxDepth, radius);\n    const baseThickness = base\n      ? base.outerR - base.innerR\n      : maxExpandedThickness;\n    const allowedGrow =\n      baseThickness >= maxExpandedThickness\n        ? 0\n        : Math.min(segmentGrow, maxExpandedThickness - baseThickness);\n    targets.set(d.id, allowedGrow);\n  }\n\n  return targets;\n}\n\n/** Inset reserved around the drawable chart so hover growth stays inside the view box. */\nexport function defaultSunburstGrowPadding(\n  maxDepth: number,\n  size: number,\n  hoverPop: number\n): number {\n  const fullRadius = size / 2;\n  const rootRingWidth = fullRadius / Math.max(1, maxDepth);\n  const pathLength = Math.max(1, maxDepth - 1);\n  const segmentGrow = hoverGrowForPathSegment(\n    hoverPop,\n    rootRingWidth,\n    pathLength\n  );\n  return Math.ceil(segmentGrow * pathLength + segmentGrow);\n}\n\n/** Sum of hover grow from ancestors on the path to this arc (excludes self). */\nexport function ancestorGrowOffset(\n  arcId: string,\n  growAmountForArc: (id: string) => number\n): number {\n  const parts = arcId.split(ID_SEP);\n  let push = 0;\n  for (let i = 1; i < parts.length; i++) {\n    push += growAmountForArc(parts.slice(0, i).join(ID_SEP));\n  }\n  return push;\n}\n\n/** Shift descendants outward and expand the hovered segment on its outer edge. */\nexport function applyHoverGrow(\n  base: ArcGeometry,\n  arcId: string,\n  growAmountForArc: (id: string) => number,\n  maxExpandedThickness: number\n): ArcGeometry {\n  const push = ancestorGrowOffset(arcId, growAmountForArc);\n  const ownGrow = growAmountForArc(arcId);\n  if (push <= 0 && ownGrow <= 0) {\n    return base;\n  }\n\n  const baseThickness = base.outerR - base.innerR;\n  if (baseThickness >= maxExpandedThickness) {\n    if (push <= 0) {\n      return base;\n    }\n    return {\n      ...base,\n      innerR: base.innerR + push,\n      outerR: base.outerR + push,\n    };\n  }\n\n  const innerR = base.innerR + push;\n  let outerR = base.outerR + push + ownGrow;\n  const thickness = outerR - innerR;\n  if (thickness > maxExpandedThickness) {\n    outerR = innerR + maxExpandedThickness;\n  }\n\n  return {\n    ...base,\n    innerR,\n    outerR,\n  };\n}\n\nexport function localProgress(\n  progress: number,\n  delay: number,\n  duration: number\n): number {\n  if (progress <= delay) {\n    return 0;\n  }\n  if (duration <= 0) {\n    return 1;\n  }\n  return Math.min(1, (progress - delay) / duration);\n}\n",
      "type": "registry:component",
      "target": "components/charts/sunburst.ts"
    },
    {
      "path": "src/charts/sunburst-data.ts",
      "content": "export interface SunburstNode {\n  name: string;\n  value?: number;\n  color?: string;\n  /** Optional fill override for patterns/gradients (e.g., \"url(#patternId)\") */\n  fill?: string;\n  children?: SunburstNode[];\n}\n",
      "type": "registry:component",
      "target": "components/charts/sunburst-data.ts"
    }
  ]
}