{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "radial-chart",
  "type": "registry:component",
  "title": "Radial Chart",
  "description": "A composable polar chart with radial bars, ring bars, dot bars, lines, areas, axes, scrubber and legend",
  "dependencies": [
    "d3-scale",
    "d3-shape",
    "motion"
  ],
  "registryDependencies": [
    "@bklit/chart-animation",
    "@bklit/utils",
    "@bklit/chart-parent-size",
    "@bklit/patterns",
    "@bklit/legend"
  ],
  "files": [
    {
      "path": "src/charts/radial-chart.tsx",
      "content": "\"use client\";\n\nimport {\n  domAnimation,\n  LazyMotion,\n  type MotionValue,\n  type SpringOptions,\n  useReducedMotion,\n  useSpring,\n} from \"motion/react\";\nimport {\n  Children,\n  createContext,\n  isValidElement,\n  type ReactNode,\n  type PointerEvent as ReactPointerEvent,\n  type SVGProps,\n  useCallback,\n  useContext,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useElementSize } from \"./chart-parent-size\";\nimport {\n  cartesianToAngle,\n  createRadialScale,\n  defaultRadialLayout,\n  degreesToRadians,\n  isFullArc,\n  niceRadialDomain,\n  polarToCartesian,\n  type RadialLayout,\n  radialArcBounds,\n  radialArcFraction,\n  radialWedgePath,\n  TAU,\n} from \"./radial-geometry\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface RadialSeries {\n  /** Datum key holding this series' value for a bin */\n  dataKey: string;\n  /** Legend / center label. Default: `dataKey` */\n  label?: string;\n  /**\n   * Fill / stroke color. Default: the palette slot for the series index,\n   * `var(--chart-N)` (see `radialSeriesColor`)\n   */\n  color?: string;\n  /** Base opacity of the series' marks. Default: 1 */\n  opacity?: number;\n  /**\n   * Radial opacity ramp: opacity at the band base, rising to full opacity\n   * outward. Layers decide how to apply it. Default: 1 (no ramp)\n   */\n  rampFrom?: number;\n  /** Tint this series' legend count with its color (e.g. an alert series) */\n  emphasize?: boolean;\n}\n\n/**\n * What the angle encodes.\n * - `clock`: bins of a day (00 at the start angle). Tracks \"now\" and future\n *   bins (ghost data) and labels the axis 00 / 06 / 12 / 18.\n * - `band`: categories — `RadialAxis` labels every bin with `datum[xDataKey]`.\n * - `time`: `datum[xDataKey]` is a Date / timestamp / ISO string per evenly\n *   spaced bin — `RadialAxis` draws time ticks (e.g. months).\n */\nexport type RadialAngleType = \"clock\" | \"band\" | \"time\";\n\n/**\n * How bins and values map onto the disc.\n * - `radial` (default): bins around the angle, value outward along the radius.\n * - `angular`: bins are concentric rings (first bin innermost) and the value\n *   sweeps along the angle — progress-ring / MUI \"horizontal\" radial bars.\n *   Draw it with `RadialRingBar`.\n */\nexport type RadialOrientation = \"radial\" | \"angular\";\n\n/**\n * Chart-level value scale shared by every layer that doesn't pass its own\n * `maxValue`.\n * - number: the value at the band top\n * - `\"auto\"`: the largest single value of any series / `valueKeys` key\n * - `\"stacked\"`: the largest per-bin sum of the series (and any `valueKeys`)\n */\nexport type RadialMaxValue = number | \"auto\" | \"stacked\";\n\nexport interface RadialRingBand {\n  inner: number;\n  outer: number;\n  /** Mid radius of the ring */\n  center: number;\n}\n\nexport interface RadialChartContextValue {\n  data: Record<string, unknown>[];\n  xDataKey: string;\n  series: RadialSeries[];\n  binCount: number;\n  /** Stage diameter in px (twice `radius`; 0 until measured) */\n  size: number;\n  /** Chart radius — every layout fraction is relative to this */\n  radius: number;\n  layout: RadialLayout;\n  /** Center circle radius in px */\n  centerRadius: number;\n  /** Value band base (min value) in px */\n  innerRadius: number;\n  /** Value band top (max value) in px */\n  outerRadius: number;\n  /**\n   * Bins where no series has a value — e.g. the rest of today. Layers can\n   * draw comparison (\"ghost\") data there. All false unless `trackNow`.\n   */\n  futureBins: boolean[];\n  /** Last bin with data (or the `nowIndex` prop) */\n  nowIndex: number;\n  /** True when the chart tracks \"now\" / future bins (clock charts) */\n  trackNow: boolean;\n  /** Per-series sums over every non-future bin */\n  totals: Record<string, number>;\n  hoveredIndex: number | null;\n  setHoveredIndex: (index: number | null) => void;\n  /**\n   * Animated crosshair angle in radians: the mounted `RadialScrubber`'s\n   * spring, else a chart-owned spring with the same config. Hover dots\n   * locked to the crosshair follow it. May run past one turn (it takes the\n   * short way round), so treat it as periodic.\n   */\n  crosshairAngle: MotionValue<number>;\n  /** `RadialScrubber` registers its spring as the crosshair; returns the cleanup */\n  registerCrosshair: (angle: MotionValue<number>) => () => void;\n  /** Legend-hovered series index (dim the others) */\n  hoveredSeries: number | null;\n  setHoveredSeries: (index: number | null) => void;\n  /** False until the first frame after mount — flip to run enter transitions */\n  revealed: boolean;\n  /** Enter animations on (false under prefers-reduced-motion) */\n  animate: boolean;\n  reducedMotion: boolean;\n\n  // Angle ---------------------------------------------------------------------\n  angleType: RadialAngleType;\n  orientation: RadialOrientation;\n  /** Arc start in radians (0 = 12 o'clock, clockwise) */\n  startAngle: number;\n  /** Arc end in radians */\n  endAngle: number;\n  /** True when the arc is the full turn */\n  isFullCircle: boolean;\n  /** Signed angular width of one bin (radial orientation) */\n  binWidth: number;\n  /** Center angle of bin `index` */\n  binAngle: (index: number) => number;\n  /** Start angle of bin `index` (`binStartAngle(binCount)` is the arc end) */\n  binStartAngle: (index: number) => number;\n  /** Bin under an angle, or null in the gap of a partial arc */\n  angleToBin: (angle: number) => number | null;\n  /** Fraction (0–1) along the arc → angle */\n  fractionToAngle: (fraction: number) => number;\n  /**\n   * `time` charts: timestamp (ms) / Date → angle, or null outside the data's\n   * time span. Bin `i` starts at `datum[i][xDataKey]`.\n   */\n  timeToAngle: (time: Date | number) => number | null;\n  /** `time` charts: [first bin start, last bin end] in ms, else null */\n  timeDomain: [number, number] | null;\n\n  // Value ---------------------------------------------------------------------\n  /** Value domain mapped to [innerRadius, outerRadius] (or the arc when angular) */\n  valueDomain: [number, number];\n  /** Shorthand for `valueDomain[1]` */\n  maxValue: number;\n  /**\n   * True when `RadialChart` got `maxValue` — layers without their own\n   * `maxValue` then share `valueScale`. False keeps each layer's own scale.\n   */\n  sharedValueScale: boolean;\n  /** Value → radius (`scaleRadial` over `valueDomain`, clamped) */\n  valueScale: (value: number) => number;\n  /** `angular` orientation: value → angle (linear over `valueDomain`, clamped) */\n  valueToAngle: (value: number) => number;\n  /** `angular` orientation: radii of bin `index`'s ring */\n  ringBand: (index: number) => RadialRingBand;\n}\n\nexport interface RadialChartProps {\n  /** One datum per angular bin, clockwise from the start angle (e.g. 96 × 15 min) */\n  data: Record<string, unknown>[];\n  /** Datum key with the bin label / category / time. Default: \"time\" */\n  xDataKey?: string;\n  /** Series, innermost / first stacked first */\n  series: RadialSeries[];\n  /** Override ring radii (fractions of the chart radius) */\n  layout?: Partial<RadialLayout>;\n  /** What the angle encodes. Default: \"clock\" */\n  angleType?: RadialAngleType;\n  /** Bins along the angle (default) or as concentric rings. Default: \"radial\" */\n  orientation?: RadialOrientation;\n  /** Arc start in degrees, 0 = 12 o'clock, clockwise. Default: 0 */\n  startAngle?: number;\n  /** Arc end in degrees. Default: `startAngle + 360` (full circle) */\n  endAngle?: number;\n  /**\n   * Crop the stage to the arc's bounding box for partial arcs (e.g. a\n   * semicircle is about twice as wide as tall). Default: true\n   */\n  fitArc?: boolean;\n  /**\n   * Track \"now\": bins after the last bin with data are future bins (ghost\n   * data, scrubber rests at now). Default: true for `clock` charts\n   */\n  trackNow?: boolean;\n  /** Bin the resting scrubber points at. Default: last bin with data */\n  nowIndex?: number;\n  /**\n   * Shared value scale for every layer without its own `maxValue` (also\n   * drives `RadialAxis` value rings). Default: unset — each layer scales\n   * itself (`angular` charts default to \"stacked\").\n   */\n  maxValue?: RadialMaxValue;\n  /** Value mapped to the band base. \"auto\" = smallest value. Default: 0 */\n  minValue?: number | \"auto\";\n  /** Extra datum keys (not series) included in the auto domain, e.g. \"yesterday\" */\n  valueKeys?: string[];\n  /** Round an automatic domain to nice tick values. Default: true */\n  niceValues?: boolean;\n  /** `angular` orientation: gap between rings as a fraction of a ring step. Default: 0.3 */\n  ringGap?: number;\n  /** Enter animations. Default: true (skipped for prefers-reduced-motion) */\n  animate?: boolean;\n  /** Controlled hovered bin */\n  hoveredIndex?: number | null;\n  onHoverChange?: (index: number | null) => void;\n  /** Max chart width in px. Default: 640 */\n  maxWidth?: number;\n  className?: string;\n  /**\n   * SVG layers (`RadialAxis`, `RadialDotBar`, `RadialScrubber`, …) are drawn\n   * in a group centred on the chart. `RadialCenter` is drawn as HTML in the\n   * hole and `RadialLegend` below the chart.\n   */\n  children?: ReactNode;\n}\n\n// ---------------------------------------------------------------------------\n// Context\n// ---------------------------------------------------------------------------\n\nconst RadialChartContext = createContext<RadialChartContextValue | null>(null);\n\nexport function useRadialChart(): RadialChartContextValue {\n  const context = useContext(RadialChartContext);\n  if (!context) {\n    throw new Error(\n      \"useRadialChart must be used within a <RadialChart> component.\"\n    );\n  }\n  return context;\n}\n\n/** Finite number at `datum[key]`, else null. */\nexport function radialNumberAt(\n  datum: Record<string, unknown> | undefined | null,\n  key: string\n): number | null {\n  const value = datum?.[key];\n  return typeof value === \"number\" && Number.isFinite(value) ? value : null;\n}\n\n/** Timestamp (ms) of a Date / number / date string, else null. */\nexport function radialTimeOf(value: unknown): number | null {\n  let time = Number.NaN;\n  if (value instanceof Date) {\n    time = value.getTime();\n  } else if (typeof value === \"number\") {\n    time = value;\n  } else if (typeof value === \"string\") {\n    time = Date.parse(value);\n  }\n  return Number.isFinite(time) ? time : null;\n}\n\n// ---------------------------------------------------------------------------\n// Crosshair + hover dots\n// ---------------------------------------------------------------------------\n\n/** Spring of the crosshair (`RadialScrubber` and dots locked to it). */\nexport const RADIAL_CROSSHAIR_SPRING: SpringOptions = {\n  stiffness: 420,\n  damping: 42,\n};\n\n/** Default spring of a hover dot with its own spring (`\"spring\"` motion). */\nexport const RADIAL_DOT_SPRING: SpringOptions = {\n  stiffness: 240,\n  damping: 24,\n};\n\n/**\n * How a hover dot (`RadialLine` marker, `RadialArea` active dot) follows the\n * pointer. It always rides the curve: its radius is the edge's radius at the\n * dot's current angle.\n * - `crosshair`: locked to the crosshair — the same animated angle as\n *   `RadialScrubber` (or the chart's matching spring when there is none)\n * - `spring`: its own spring toward the hovered bin (see the `*Spring` prop)\n * - `none`: snaps to the hovered bin\n */\nexport type RadialDotMotion = \"crosshair\" | \"spring\" | \"none\";\n\n/**\n * Angle that springs toward `angle`, taking the short way round on full\n * circles. While `hidden` it holds still; leaving hidden jumps straight to\n * the target. `snap` (e.g. reduced motion) jumps on every change.\n */\nexport function useRadialAngleSpring({\n  angle,\n  hidden,\n  isFullCircle,\n  snap,\n  spring: springOptions = RADIAL_CROSSHAIR_SPRING,\n}: {\n  angle: number;\n  hidden: boolean;\n  isFullCircle: boolean;\n  snap: boolean;\n  spring?: SpringOptions;\n}): MotionValue<number> {\n  const spring = useSpring(angle, springOptions);\n  const targetRef = useRef(angle);\n  const wasHiddenRef = useRef(hidden);\n\n  // Layout effect: hover dots read the jump before the first paint.\n  useLayoutEffect(() => {\n    const wasHidden = wasHiddenRef.current;\n    wasHiddenRef.current = hidden;\n    if (hidden) {\n      return;\n    }\n    if (wasHidden) {\n      targetRef.current = angle;\n      spring.jump(angle);\n      return;\n    }\n    const previous = targetRef.current;\n    let next = angle;\n    if (isFullCircle) {\n      let delta = (angle - previous) % TAU;\n      if (delta > Math.PI) {\n        delta -= TAU;\n      } else if (delta < -Math.PI) {\n        delta += TAU;\n      }\n      next = previous + delta;\n    }\n    targetRef.current = next;\n    if (snap) {\n      spring.jump(next);\n    } else {\n      spring.set(next);\n    }\n  }, [spring, angle, snap, isFullCircle, hidden]);\n\n  return spring;\n}\n\n/**\n * Animated angle for a hover dot. `crosshair` returns the chart's crosshair;\n * `spring` / `none` run the dot's own spring (`none` and reduced motion snap).\n */\nexport function useRadialDotAngle(\n  motion: RadialDotMotion,\n  spring: SpringOptions = RADIAL_DOT_SPRING\n): MotionValue<number> {\n  const {\n    crosshairAngle,\n    hoveredIndex,\n    binAngle,\n    isFullCircle,\n    reducedMotion,\n  } = useRadialChart();\n  const own = useRadialAngleSpring({\n    angle: binAngle(hoveredIndex ?? 0),\n    hidden: hoveredIndex === null || motion === \"crosshair\",\n    isFullCircle,\n    snap: motion === \"none\" || reducedMotion,\n    spring,\n  });\n  return motion === \"crosshair\" ? crosshairAngle : own;\n}\n\n/** Edge radius along a ray at `angle`, or null where there is no edge (gaps). */\nexport type RadialEdgeLookup = (angle: number) => number | null;\n\nconst BEZIER_STEPS = 10;\n\n/**\n * Flatten whatever a d3 radial generator draws (pass it the context) into\n * segments, and look up the edge radius at any angle by ray intersection —\n * exact for the rendered curve, gaps included.\n */\nexport function createRadialEdgeLookup(\n  draw: (context: CanvasRenderingContext2D) => void\n): RadialEdgeLookup {\n  const segments: number[] = [];\n  let startX = 0;\n  let startY = 0;\n  let x = 0;\n  let y = 0;\n  const push = (nx: number, ny: number) => {\n    segments.push(x, y, nx, ny);\n    x = nx;\n    y = ny;\n  };\n  const context = {\n    moveTo(nx: number, ny: number) {\n      startX = nx;\n      startY = ny;\n      x = nx;\n      y = ny;\n    },\n    lineTo: push,\n    bezierCurveTo(\n      x1: number,\n      y1: number,\n      x2: number,\n      y2: number,\n      x3: number,\n      y3: number\n    ) {\n      const x0 = x;\n      const y0 = y;\n      for (let k = 1; k <= BEZIER_STEPS; k++) {\n        const t = k / BEZIER_STEPS;\n        const u = 1 - t;\n        const a = u * u * u;\n        const b = 3 * u * u * t;\n        const c = 3 * u * t * t;\n        const d = t * t * t;\n        push(\n          a * x0 + b * x1 + c * x2 + d * x3,\n          a * y0 + b * y1 + c * y2 + d * y3\n        );\n      }\n    },\n    quadraticCurveTo(x1: number, y1: number, x2: number, y2: number) {\n      const x0 = x;\n      const y0 = y;\n      for (let k = 1; k <= BEZIER_STEPS; k++) {\n        const t = k / BEZIER_STEPS;\n        const u = 1 - t;\n        push(\n          u * u * x0 + 2 * u * t * x1 + t * t * x2,\n          u * u * y0 + 2 * u * t * y1 + t * t * y2\n        );\n      }\n    },\n    closePath() {\n      if (x !== startX || y !== startY) {\n        push(startX, startY);\n      }\n    },\n  };\n  draw(context as unknown as CanvasRenderingContext2D);\n  const flat = Float64Array.from(segments);\n\n  return (angle) => {\n    const dx = Math.sin(angle);\n    const dy = -Math.cos(angle);\n    let best = -1;\n    for (let i = 0; i < flat.length; i += 4) {\n      const x0 = flat[i] ?? 0;\n      const y0 = flat[i + 1] ?? 0;\n      const ex = (flat[i + 2] ?? 0) - x0;\n      const ey = (flat[i + 3] ?? 0) - y0;\n      const denom = dx * ey - dy * ex;\n      if (Math.abs(denom) < 1e-9) {\n        continue;\n      }\n      // Ray s·d meets segment P0 + t·e\n      const t = (x0 * dy - y0 * dx) / denom;\n      if (t < -1e-6 || t > 1 + 1e-6) {\n        continue;\n      }\n      const s = (x0 * ey - y0 * ex) / denom;\n      if (s > best) {\n        best = s;\n      }\n    }\n    return best > 0 ? best : null;\n  };\n}\n\n/**\n * Hover dot that rides an edge at an animated angle. Position is written to\n * the DOM from the motion value (no React render per frame); the dot hides\n * where the edge has no point (gaps, partial runs).\n */\nexport function RadialHoverDot({\n  angle,\n  lookup,\n  ...props\n}: {\n  angle: MotionValue<number>;\n  lookup: RadialEdgeLookup;\n} & Omit<SVGProps<SVGCircleElement>, \"cx\" | \"cy\" | \"ref\" | \"visibility\">) {\n  const ref = useRef<SVGCircleElement>(null);\n  const place = useCallback(\n    (a: number) => {\n      const r = lookup(a);\n      return r === null ? null : polarToCartesian(a, r);\n    },\n    [lookup]\n  );\n\n  useLayoutEffect(() => {\n    const apply = (a: number) => {\n      const el = ref.current;\n      if (!el) {\n        return;\n      }\n      const p = place(a);\n      if (!p) {\n        el.setAttribute(\"visibility\", \"hidden\");\n        return;\n      }\n      el.removeAttribute(\"visibility\");\n      el.setAttribute(\"cx\", String(p.x));\n      el.setAttribute(\"cy\", String(p.y));\n    };\n    apply(angle.get());\n    return angle.on(\"change\", apply);\n  }, [angle, place]);\n\n  const initial = place(angle.get());\n  return (\n    <circle\n      {...props}\n      cx={initial?.x ?? 0}\n      cy={initial?.y ?? 0}\n      ref={ref}\n      visibility={initial ? undefined : \"hidden\"}\n    />\n  );\n}\n\n/** Children with these display names are drawn as HTML over the hole. */\nconst CENTER_CHILDREN = new Set([\"RadialCenter\"]);\n/** Children with these display names are drawn below the chart. */\nconst BELOW_CHILDREN = new Set([\"RadialLegend\"]);\n\nfunction childDisplayName(child: ReactNode): string {\n  if (!isValidElement(child)) {\n    return \"\";\n  }\n  return (child.type as { displayName?: string }).displayName ?? \"\";\n}\n\n/** Smallest / largest value of any key, and the largest per-bin series stack. */\nfunction scanValues(\n  data: Record<string, unknown>[],\n  series: RadialSeries[],\n  keys: string[]\n) {\n  let lo = Number.POSITIVE_INFINITY;\n  let hi = 0;\n  let stack = 0;\n  for (const d of data) {\n    for (const key of keys) {\n      const v = radialNumberAt(d, key);\n      if (v !== null) {\n        lo = Math.min(lo, v);\n        hi = Math.max(hi, v);\n      }\n    }\n    const sum = series.reduce(\n      (total, s) => total + Math.max(0, radialNumberAt(d, s.dataKey) ?? 0),\n      0\n    );\n    stack = Math.max(stack, sum);\n  }\n  return { lo: Number.isFinite(lo) ? lo : 0, hi, stack };\n}\n\nfunction computeValueDomain({\n  data,\n  series,\n  valueKeys,\n  maxValue,\n  minValue,\n  nice,\n}: {\n  data: Record<string, unknown>[];\n  series: RadialSeries[];\n  valueKeys: string[];\n  maxValue: RadialMaxValue;\n  minValue: number | \"auto\";\n  nice: boolean;\n}): [number, number] {\n  const keys = [...series.map((s) => s.dataKey), ...valueKeys];\n  const { lo, hi, stack } = scanValues(data, series, keys);\n  const autoMin = minValue === \"auto\";\n  const fixedMax = typeof maxValue === \"number\";\n  const min = autoMin ? lo : minValue;\n  let max = Math.max(hi, maxValue === \"stacked\" ? stack : 0);\n  if (fixedMax) {\n    max = maxValue;\n  }\n  if (!nice || (fixedMax && !autoMin)) {\n    return [min, max];\n  }\n  const [niceMin, niceMax] = niceRadialDomain([min, Math.max(max, min)]);\n  return [autoMin ? niceMin : min, fixedMax ? max : niceMax];\n}\n\n// ---------------------------------------------------------------------------\n// Chart shell\n// ---------------------------------------------------------------------------\n\n/**\n * Square, responsive shell for radial charts: measures itself, owns the\n * shared geometry (start angle at the top, clockwise; full circle or a\n * partial arc), the shared value scale, hover-by-angle (or by ring), legend\n * hover and the enter flag, and exposes them through {@link useRadialChart}.\n */\nexport function RadialChart({\n  data,\n  xDataKey = \"time\",\n  series,\n  layout: layoutProp,\n  angleType = \"clock\",\n  orientation = \"radial\",\n  startAngle: startAngleDeg = 0,\n  endAngle: endAngleDeg,\n  fitArc = true,\n  trackNow: trackNowProp,\n  nowIndex: nowIndexProp,\n  maxValue: maxValueProp,\n  minValue = 0,\n  valueKeys,\n  niceValues = true,\n  ringGap = 0.3,\n  animate = true,\n  hoveredIndex: hoveredIndexProp,\n  onHoverChange,\n  maxWidth = 640,\n  className,\n  children,\n}: RadialChartProps) {\n  const stageRef = useRef<HTMLDivElement>(null);\n  const { width } = useElementSize(stageRef, 100);\n  const layout = useMemo(\n    () => ({ ...defaultRadialLayout, ...layoutProp }),\n    [layoutProp]\n  );\n\n  // Arc -----------------------------------------------------------------------\n  const startAngle = degreesToRadians(startAngleDeg);\n  const endAngle = degreesToRadians(endAngleDeg ?? startAngleDeg + 360);\n  const isFullCircle = isFullArc(startAngle, endAngle);\n  const span = endAngle - startAngle;\n\n  const bounds = useMemo(\n    () =>\n      isFullCircle || !fitArc\n        ? { minX: -1, maxX: 1, minY: -1, maxY: 1 }\n        : (() => {\n            const b = radialArcBounds(startAngle, endAngle);\n            // Room around the centre for the hole / center content.\n            const pad = 0.12;\n            return {\n              minX: Math.min(b.minX, -pad),\n              maxX: Math.max(b.maxX, pad),\n              minY: Math.min(b.minY, -pad),\n              maxY: Math.max(b.maxY, pad),\n            };\n          })(),\n    [isFullCircle, fitArc, startAngle, endAngle]\n  );\n  const boundsWidth = bounds.maxX - bounds.minX;\n  const boundsHeight = bounds.maxY - bounds.minY;\n  const stageWidth = Math.max(0, width);\n  const radius = stageWidth / boundsWidth;\n  const stageHeight = radius * boundsHeight;\n  const cx = -bounds.minX * radius;\n  const cy = -bounds.minY * radius;\n  const size = radius * 2;\n\n  const reducedMotion = useReducedMotion() === true;\n  const shouldAnimate = animate && !reducedMotion;\n\n  // Hover ------------------------------------------------------------------\n  const [internalHovered, setInternalHovered] = useState<number | null>(null);\n  const isControlled = hoveredIndexProp !== undefined;\n  const hoveredIndex = isControlled ? hoveredIndexProp : internalHovered;\n  const setHoveredIndex = useCallback(\n    (index: number | null) => {\n      if (!isControlled) {\n        setInternalHovered(index);\n      }\n      onHoverChange?.(index);\n    },\n    [isControlled, onHoverChange]\n  );\n  const [hoveredSeries, setHoveredSeries] = useState<number | null>(null);\n\n  // Data -------------------------------------------------------------------\n  const binCount = data.length;\n  const trackNow = trackNowProp ?? angleType === \"clock\";\n\n  const futureBins = useMemo(\n    () =>\n      data.map(\n        (d) =>\n          trackNow && series.every((s) => radialNumberAt(d, s.dataKey) === null)\n      ),\n    [data, series, trackNow]\n  );\n\n  const nowIndex = useMemo(() => {\n    if (nowIndexProp !== undefined) {\n      return nowIndexProp;\n    }\n    for (let i = binCount - 1; i >= 0; i--) {\n      if (series.some((s) => radialNumberAt(data[i], s.dataKey) !== null)) {\n        return i;\n      }\n    }\n    return binCount - 1;\n  }, [nowIndexProp, data, series, binCount]);\n\n  const totals = useMemo(() => {\n    const result: Record<string, number> = {};\n    for (const s of series) {\n      result[s.dataKey] = 0;\n    }\n    data.forEach((d, i) => {\n      if (futureBins[i]) {\n        return;\n      }\n      for (const s of series) {\n        result[s.dataKey] =\n          (result[s.dataKey] ?? 0) + (radialNumberAt(d, s.dataKey) ?? 0);\n      }\n    });\n    return result;\n  }, [data, series, futureBins]);\n\n  // Value scale ----------------------------------------------------------------\n  const sharedValueScale =\n    maxValueProp !== undefined || orientation === \"angular\";\n  const valueKeysKey = valueKeys?.join(\"\\u0000\") ?? \"\";\n  const valueDomain = useMemo(\n    () =>\n      computeValueDomain({\n        data,\n        series,\n        valueKeys: valueKeys ?? [],\n        maxValue: maxValueProp ?? \"stacked\",\n        minValue,\n        nice: niceValues,\n      }),\n    [data, series, valueKeysKey, maxValueProp, minValue, niceValues]\n  );\n\n  const innerRadius = radius * layout.bandInner;\n  const outerRadius = radius * layout.bandOuter;\n\n  const valueScale = useMemo(() => {\n    const scale = createRadialScale({\n      minValue: valueDomain[0],\n      maxValue: valueDomain[1],\n      innerRadius,\n      outerRadius,\n    });\n    return (value: number) => scale(value) ?? innerRadius;\n  }, [valueDomain, innerRadius, outerRadius]);\n\n  const valueToAngle = useCallback(\n    (value: number) => {\n      const [lo, hi] = valueDomain;\n      const t = hi > lo ? (value - lo) / (hi - lo) : 0;\n      return startAngle + Math.min(1, Math.max(0, t)) * span;\n    },\n    [valueDomain, startAngle, span]\n  );\n\n  const ringBand = useCallback(\n    (index: number): RadialRingBand => {\n      const step = (outerRadius - innerRadius) / Math.max(1, binCount);\n      const gap = step * Math.min(0.95, Math.max(0, ringGap));\n      const inner = innerRadius + index * step + gap / 2;\n      const outer = innerRadius + (index + 1) * step - gap / 2;\n      return { inner, outer, center: (inner + outer) / 2 };\n    },\n    [innerRadius, outerRadius, binCount, ringGap]\n  );\n\n  // Time --------------------------------------------------------------------\n  const timeDomain = useMemo((): [number, number] | null => {\n    if (angleType !== \"time\" || binCount === 0) {\n      return null;\n    }\n    const first = radialTimeOf(data[0]?.[xDataKey]);\n    const last = radialTimeOf(data[binCount - 1]?.[xDataKey]);\n    if (first === null || last === null) {\n      return null;\n    }\n    const step = binCount > 1 ? (last - first) / (binCount - 1) : 1;\n    return [first, first + step * binCount];\n  }, [angleType, binCount, data, xDataKey]);\n\n  const timeToAngle = useCallback(\n    (time: Date | number) => {\n      if (!timeDomain) {\n        return null;\n      }\n      const t = typeof time === \"number\" ? time : time.getTime();\n      const [t0, t1] = timeDomain;\n      if (t < t0 || t > t1 || t1 <= t0) {\n        return null;\n      }\n      return startAngle + ((t - t0) / (t1 - t0)) * span;\n    },\n    [timeDomain, startAngle, span]\n  );\n\n  // Enter flag ---------------------------------------------------------------\n  const [revealed, setRevealed] = useState(false);\n  const hasSize = stageWidth > 0;\n  useEffect(() => {\n    if (!hasSize) {\n      return;\n    }\n    if (!shouldAnimate) {\n      setRevealed(true);\n      return;\n    }\n    let inner = 0;\n    const outer = requestAnimationFrame(() => {\n      inner = requestAnimationFrame(() => setRevealed(true));\n    });\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n    };\n  }, [hasSize, shouldAnimate]);\n\n  // Angle helpers ------------------------------------------------------------\n  const centerRadius = radius * layout.center;\n  const binWidth = binCount > 0 ? span / binCount : 0;\n\n  const binStartAngle = useCallback(\n    (index: number) => startAngle + (index / Math.max(1, binCount)) * span,\n    [startAngle, span, binCount]\n  );\n  const binAngle = useCallback(\n    (index: number) =>\n      startAngle + ((index + 0.5) / Math.max(1, binCount)) * span,\n    [startAngle, span, binCount]\n  );\n  const fractionToAngle = useCallback(\n    (fraction: number) => startAngle + fraction * span,\n    [startAngle, span]\n  );\n  const angleToBin = useCallback(\n    (angle: number) => {\n      const fraction = radialArcFraction(angle, startAngle, endAngle);\n      if (fraction === null || binCount === 0) {\n        return null;\n      }\n      return Math.min(binCount - 1, Math.floor(fraction * binCount));\n    },\n    [startAngle, endAngle, binCount]\n  );\n\n  // Crosshair ------------------------------------------------------------------\n  // Fallback for charts without a RadialScrubber, so locked hover dots move\n  // the same way either way.\n  const fallbackCrosshair = useRadialAngleSpring({\n    angle: binAngle(hoveredIndex ?? 0),\n    hidden: hoveredIndex === null,\n    isFullCircle,\n    snap: reducedMotion,\n  });\n  const [scrubberCrosshair, setScrubberCrosshair] =\n    useState<MotionValue<number> | null>(null);\n  const registerCrosshair = useCallback((angle: MotionValue<number>) => {\n    setScrubberCrosshair(angle);\n    return () =>\n      setScrubberCrosshair((current) => (current === angle ? null : current));\n  }, []);\n  const crosshairAngle = scrubberCrosshair ?? fallbackCrosshair;\n\n  // Pointer ----------------------------------------------------------------\n  const handlePointerMove = useCallback(\n    (event: ReactPointerEvent<SVGSVGElement>) => {\n      if (binCount === 0) {\n        return;\n      }\n      const rect = event.currentTarget.getBoundingClientRect();\n      const dx = event.clientX - rect.left - cx;\n      const dy = event.clientY - rect.top - cy;\n      const distance = Math.hypot(dx, dy);\n      let next: number | null = null;\n      if (orientation === \"angular\") {\n        const step = (outerRadius - innerRadius) / binCount;\n        const ring = Math.floor((distance - innerRadius) / step);\n        next = ring >= 0 && ring < binCount ? ring : null;\n      } else if (distance >= centerRadius * 0.5) {\n        next = angleToBin(cartesianToAngle(dx, dy));\n      }\n      if (next !== hoveredIndex) {\n        setHoveredIndex(next);\n      }\n    },\n    [\n      binCount,\n      cx,\n      cy,\n      orientation,\n      outerRadius,\n      innerRadius,\n      centerRadius,\n      angleToBin,\n      hoveredIndex,\n      setHoveredIndex,\n    ]\n  );\n\n  const handlePointerLeave = useCallback(() => {\n    setHoveredIndex(null);\n  }, [setHoveredIndex]);\n\n  // Context ----------------------------------------------------------------\n  const contextValue = useMemo(\n    (): RadialChartContextValue => ({\n      data,\n      xDataKey,\n      series,\n      binCount,\n      size,\n      radius,\n      layout,\n      centerRadius,\n      innerRadius,\n      outerRadius,\n      futureBins,\n      nowIndex,\n      trackNow,\n      totals,\n      hoveredIndex,\n      setHoveredIndex,\n      crosshairAngle,\n      registerCrosshair,\n      hoveredSeries,\n      setHoveredSeries,\n      revealed,\n      animate: shouldAnimate,\n      reducedMotion,\n      angleType,\n      orientation,\n      startAngle,\n      endAngle,\n      isFullCircle,\n      binWidth,\n      binAngle,\n      binStartAngle,\n      angleToBin,\n      fractionToAngle,\n      timeToAngle,\n      timeDomain,\n      valueDomain,\n      maxValue: valueDomain[1],\n      sharedValueScale,\n      valueScale,\n      valueToAngle,\n      ringBand,\n    }),\n    [\n      data,\n      xDataKey,\n      series,\n      binCount,\n      size,\n      radius,\n      layout,\n      centerRadius,\n      innerRadius,\n      outerRadius,\n      futureBins,\n      nowIndex,\n      trackNow,\n      totals,\n      hoveredIndex,\n      setHoveredIndex,\n      crosshairAngle,\n      registerCrosshair,\n      hoveredSeries,\n      revealed,\n      shouldAnimate,\n      reducedMotion,\n      angleType,\n      orientation,\n      startAngle,\n      endAngle,\n      isFullCircle,\n      binWidth,\n      binAngle,\n      binStartAngle,\n      angleToBin,\n      fractionToAngle,\n      timeToAngle,\n      timeDomain,\n      valueDomain,\n      sharedValueScale,\n      valueScale,\n      valueToAngle,\n      ringBand,\n    ]\n  );\n\n  // Children -------------------------------------------------------------------\n  const svgChildren: ReactNode[] = [];\n  const centerChildren: ReactNode[] = [];\n  const belowChildren: ReactNode[] = [];\n  Children.forEach(children, (child) => {\n    const name = childDisplayName(child);\n    if (CENTER_CHILDREN.has(name)) {\n      centerChildren.push(child);\n    } else if (BELOW_CHILDREN.has(name)) {\n      belowChildren.push(child);\n    } else if (child !== null && child !== undefined && child !== false) {\n      svgChildren.push(child);\n    }\n  });\n\n  const centerSize = Math.max(0, centerRadius * 2 - 16);\n\n  return (\n    <LazyMotion features={domAnimation}>\n      <RadialChartContext.Provider value={contextValue}>\n        <div\n          className={cn(\"mx-auto flex w-full flex-col items-center\", className)}\n          style={{ maxWidth }}\n        >\n          <div\n            className=\"relative w-full\"\n            ref={stageRef}\n            style={{ aspectRatio: `${boundsWidth} / ${boundsHeight}` }}\n          >\n            {hasSize ? (\n              <>\n                <svg\n                  aria-hidden=\"true\"\n                  className=\"absolute inset-0 touch-pan-y select-none\"\n                  height={stageHeight}\n                  onPointerLeave={handlePointerLeave}\n                  onPointerMove={handlePointerMove}\n                  style={{ overflow: \"visible\" }}\n                  width={stageWidth}\n                >\n                  <g transform={`translate(${cx},${cy})`}>\n                    {/* Hit area so the whole disc tracks the pointer */}\n                    {isFullCircle ? (\n                      <circle fill=\"transparent\" r={radius} />\n                    ) : (\n                      <path\n                        d={radialWedgePath(radius, startAngle, endAngle)}\n                        fill=\"transparent\"\n                      />\n                    )}\n                    {svgChildren}\n                  </g>\n                </svg>\n\n                {centerChildren.length > 0 ? (\n                  <div\n                    className=\"pointer-events-none absolute flex -translate-x-1/2 -translate-y-1/2 items-center justify-center\"\n                    style={{\n                      left: cx,\n                      top: cy,\n                      width: centerSize,\n                      height: centerSize,\n                    }}\n                  >\n                    {centerChildren}\n                  </div>\n                ) : null}\n              </>\n            ) : null}\n          </div>\n          {belowChildren}\n        </div>\n      </RadialChartContext.Provider>\n    </LazyMotion>\n  );\n}\n\nRadialChart.displayName = \"RadialChart\";\n\nexport default RadialChart;\n",
      "type": "registry:component",
      "target": "components/charts/radial-chart.tsx"
    },
    {
      "path": "src/charts/radial-geometry.ts",
      "content": "import { scaleLinear, scaleRadial } from \"d3-scale\";\nimport {\n  isFullArc,\n  polarToCartesian,\n  radialArcPath,\n  TAU,\n} from \"./arc-geometry\";\n\n// Shared with the ring chart (dependency-free).\n// biome-ignore lint/performance/noBarrelFile: keeps the radial geometry API stable\nexport {\n  isFullArc,\n  polarToCartesian,\n  radialArcPath,\n  TAU,\n} from \"./arc-geometry\";\n\n/**\n * Radii of the radial chart's rings, as fractions of the chart radius\n * (half the square stage). Shared by every radial layer.\n */\nexport interface RadialLayout {\n  /** Center circle (hole) radius */\n  center: number;\n  /** Tick ring: inner end of every tick */\n  tickInner: number;\n  /** Tick ring: outer end of minor ticks */\n  tickMinorOuter: number;\n  /** Tick ring: outer end of major ticks */\n  tickMajorOuter: number;\n  /** Value band base — value 0 maps here */\n  bandInner: number;\n  /** Value band top — the max value maps here */\n  bandOuter: number;\n  /** Where the scrubber line ends */\n  scrubberOuter: number;\n  /** Axis (hour) labels */\n  axisLabel: number;\n}\n\nexport const defaultRadialLayout: RadialLayout = {\n  center: 0.37,\n  tickInner: 0.38,\n  tickMinorOuter: 0.393,\n  tickMajorOuter: 0.405,\n  bandInner: 0.408,\n  bandOuter: 0.83,\n  scrubberOuter: 0.895,\n  axisLabel: 0.955,\n};\n\n/** Degrees → radians. */\nexport function degreesToRadians(degrees: number): number {\n  return (degrees * Math.PI) / 180;\n}\n\n/** Clockwise angle from 12 o'clock for a point relative to the centre. */\nexport function cartesianToAngle(dx: number, dy: number): number {\n  const angle = Math.atan2(dx, -dy);\n  return angle < 0 ? angle + TAU : angle;\n}\n\n/** Start angle of bin `index` when `binCount` bins fill the full turn. */\nexport function radialBinStartAngle(index: number, binCount: number): number {\n  return (index / Math.max(1, binCount)) * TAU;\n}\n\n/** Center angle of bin `index`. */\nexport function radialBinCenterAngle(index: number, binCount: number): number {\n  return ((index + 0.5) / Math.max(1, binCount)) * TAU;\n}\n\n/** Bin under a clockwise angle (radians). */\nexport function radialAngleToBin(angle: number, binCount: number): number {\n  const normalized = ((angle % TAU) + TAU) % TAU;\n  return Math.min(binCount - 1, Math.floor((normalized / TAU) * binCount));\n}\n\n/**\n * Fraction (0–1) of the arc `start → end` at `angle`, or null when the angle\n * falls in the gap of a partial arc. Works for clockwise (end > start) and\n * counter-clockwise (end < start) arcs.\n */\nexport function radialArcFraction(\n  angle: number,\n  startAngle: number,\n  endAngle: number\n): number | null {\n  const span = endAngle - startAngle;\n  if (span === 0) {\n    return null;\n  }\n  const direction = Math.sign(span);\n  const offset = ((((angle - startAngle) * direction) % TAU) + TAU) % TAU;\n  const length = Math.abs(span);\n  if (length >= TAU - 1e-6) {\n    return offset / TAU;\n  }\n  if (offset <= length) {\n    return offset / length;\n  }\n  return null;\n}\n\n/**\n * Bounding box of an arc of radius 1 (and the centre point), in unit\n * coordinates — used to crop the stage around partial arcs.\n */\nexport function radialArcBounds(startAngle: number, endAngle: number) {\n  const lo = Math.min(startAngle, endAngle);\n  const hi = Math.max(startAngle, endAngle);\n  const points = [\n    { x: 0, y: 0 },\n    polarToCartesian(lo, 1),\n    polarToCartesian(hi, 1),\n  ];\n  // Cardinal directions inside the arc extend the box.\n  const first = Math.ceil(lo / (Math.PI / 2));\n  for (let k = first; k * (Math.PI / 2) <= hi; k++) {\n    points.push(polarToCartesian(k * (Math.PI / 2), 1));\n  }\n  return {\n    minX: Math.min(...points.map((p) => p.x)),\n    maxX: Math.max(...points.map((p) => p.x)),\n    minY: Math.min(...points.map((p) => p.y)),\n    maxY: Math.max(...points.map((p) => p.y)),\n  };\n}\n\n/** Pie-slice path (centre → arc → centre) of radius `r`, for clip paths. */\nexport function radialWedgePath(\n  radius: number,\n  startAngle: number,\n  endAngle: number\n): string {\n  if (isFullArc(startAngle, endAngle)) {\n    return `${radialArcPath(radius, startAngle, endAngle)}`;\n  }\n  return `M0,0L${radialArcPath(radius, startAngle, endAngle).slice(1)}Z`;\n}\n\n/**\n * Value → radius with `d3.scaleRadial`, so the *area* between the band base\n * and the radius grows linearly with the value (the d3 radial bar / area\n * convention). `minValue` (default 0) maps to the band base.\n */\nexport function createRadialScale({\n  minValue = 0,\n  maxValue,\n  innerRadius,\n  outerRadius,\n}: {\n  minValue?: number;\n  maxValue: number;\n  innerRadius: number;\n  outerRadius: number;\n}) {\n  return scaleRadial()\n    .domain([minValue, Math.max(maxValue, minValue + Number.EPSILON)])\n    .range([innerRadius, outerRadius])\n    .clamp(true);\n}\n\n/** Round a value domain outward to nice tick values (d3 `scale.nice`). */\nexport function niceRadialDomain(\n  domain: [number, number],\n  count = 5\n): [number, number] {\n  const [min, max] = scaleLinear().domain(domain).nice(count).domain();\n  return [min ?? domain[0], max ?? domain[1]];\n}\n\n/** Evenly spaced round values across a domain (d3 `scale.ticks`). */\nexport function radialValueTicks(\n  domain: [number, number],\n  count = 5\n): number[] {\n  return scaleLinear().domain(domain).ticks(count);\n}\n",
      "type": "registry:component",
      "target": "components/charts/radial-geometry.ts"
    },
    {
      "path": "src/charts/arc-geometry.ts",
      "content": "/**\n * Dependency-free polar helpers shared by the ring and radial charts.\n * Angles are radians, 0 at 12 o'clock, increasing clockwise (the d3 / visx\n * `arc` convention).\n */\n\n/** Full turn in radians. */\nexport const TAU = Math.PI * 2;\n\n/** Point on a circle where angle 0 is 12 o'clock and angles run clockwise. */\nexport function polarToCartesian(angle: number, radius: number) {\n  return { x: radius * Math.sin(angle), y: -radius * Math.cos(angle) };\n}\n\n/** True when the arc `start → end` (radians) covers the whole turn. */\nexport function isFullArc(startAngle: number, endAngle: number): boolean {\n  return Math.abs(endAngle - startAngle) >= TAU - 1e-6;\n}\n\n/** SVG path of a circle (full arc) or an open arc of radius `r`. */\nexport function radialArcPath(\n  radius: number,\n  startAngle: number,\n  endAngle: number\n): string {\n  if (isFullArc(startAngle, endAngle)) {\n    return `M0,${-radius}A${radius},${radius},0,1,1,0,${radius}A${radius},${radius},0,1,1,0,${-radius}Z`;\n  }\n  const a = polarToCartesian(startAngle, radius);\n  const b = polarToCartesian(endAngle, radius);\n  const large = Math.abs(endAngle - startAngle) > Math.PI ? 1 : 0;\n  const sweep = endAngle > startAngle ? 1 : 0;\n  return `M${a.x},${a.y}A${radius},${radius},0,${large},${sweep},${b.x},${b.y}`;\n}\n\nexport type ArcTextAnchor = \"start\" | \"middle\" | \"end\";\n\n/**\n * Where a label sits just before an arc's start, so it lines up with its\n * ring (one label per concentric ring, stepped back along the start tangent).\n *\n * - Arcs starting near 12 / 6 o'clock: horizontal text beside the start\n *   (in the empty quadrant of a 270° arc).\n * - Arcs starting near 3 / 9 o'clock (semicircles): rings sit side by side,\n *   so the text is rotated upright and runs along the start edge, reading\n *   bottom to top.\n */\nexport function arcStartLabelLayout(\n  startAngle: number,\n  endAngle: number,\n  offset: number\n) {\n  const direction = endAngle >= startAngle ? 1 : -1;\n  // Tangent of the arc at its start, pointing along the sweep.\n  const tx = Math.cos(startAngle) * direction;\n  const ty = Math.sin(startAngle) * direction;\n  const vertical = Math.abs(tx) <= 0.3;\n  let anchor: ArcTextAnchor = \"middle\";\n  if (vertical) {\n    anchor = ty < 0 ? \"end\" : \"start\";\n  } else if (tx > 0.3) {\n    anchor = \"end\";\n  } else if (tx < -0.3) {\n    anchor = \"start\";\n  }\n  return {\n    anchor,\n    vertical,\n    /** Label position for a ring whose band centre is at `radius`. */\n    position(radius: number) {\n      const p = polarToCartesian(startAngle, radius);\n      const x = p.x - tx * offset;\n      const y = p.y - ty * offset;\n      return {\n        x,\n        y,\n        transform: vertical ? `rotate(-90 ${x} ${y})` : undefined,\n      };\n    },\n  };\n}\n\n/**\n * Centre point for upright text of `width` × `height` placed outside a\n * circle of `radius` at `angle`, `gap` px clear of the circle — the box is\n * pushed out along the radius by its own half-extent in that direction.\n */\nexport function outsideArcLabelPoint(\n  angle: number,\n  radius: number,\n  gap: number,\n  width: number,\n  height: number\n) {\n  const extent =\n    Math.abs(Math.sin(angle)) * (width / 2) +\n    Math.abs(Math.cos(angle)) * (height / 2);\n  return polarToCartesian(angle, radius + gap + extent);\n}\n",
      "type": "registry:component",
      "target": "components/charts/arc-geometry.ts"
    },
    {
      "path": "src/charts/radial-series-color.ts",
      "content": "import type { RadialSeries } from \"./radial-chart\";\n\nconst PALETTE_SIZE = 5;\n\n/**\n * Resolved color for a {@link RadialChart} series: its `color`, else the\n * chart palette by position (`--chart-1` … `--chart-5`, cycling). Layers that\n * are not a chart series (`series` undefined) fall back to var(--foreground).\n */\nexport function radialSeriesColor(\n  series: Pick<RadialSeries, \"color\"> | undefined,\n  index: number\n): string {\n  if (!series) {\n    return \"var(--foreground)\";\n  }\n  return (\n    series.color ?? `var(--chart-${(Math.max(0, index) % PALETTE_SIZE) + 1})`\n  );\n}\n",
      "type": "registry:component",
      "target": "components/charts/radial-series-color.ts"
    },
    {
      "path": "src/charts/radial-axis.tsx",
      "content": "\"use client\";\n\nimport { scaleUtc } from \"d3-scale\";\nimport { m, type Transition } from \"motion/react\";\nimport { type ReactNode, useId, useMemo } from \"react\";\nimport {\n  DEFAULT_CHART_ENTER_TRANSITION,\n  GRID_ANGLE_LABEL_FADE_S,\n  GRID_ANGLE_LABEL_STAGGER_S,\n  GRID_RING_SPRING,\n  GRID_RING_STAGGER_S,\n  GRID_SPOKE_SPRING,\n  GRID_SPOKE_STAGGER_S,\n  GRID_VALUE_LABEL_STAGGER_S,\n  gridLabelDelay,\n} from \"./animation\";\nimport { arcStartLabelLayout } from \"./arc-geometry\";\nimport { useRadialChart } from \"./radial-chart\";\nimport {\n  polarToCartesian,\n  radialArcPath,\n  radialValueTicks,\n  TAU,\n} from \"./radial-geometry\";\n\n/**\n * How angle labels sit on the circle.\n * - `horizontal`: upright text centred on its angle (clock / month labels)\n * - `radial`: text runs outward along the radius, flipped on the left half\n *   so it never reads upside down (dense category labels)\n * - `tangential`: text follows the circle, flipped on the bottom half\n */\nexport type RadialLabelOrientation = \"horizontal\" | \"radial\" | \"tangential\";\n\nexport interface RadialAxisProps {\n  /** Center circle with a thin stroke. Default: true */\n  showCircle?: boolean;\n  /** Faint radial-gradient fill inside the center circle. Default: false */\n  centerGradient?: boolean;\n  /** Tick marks just outside the center circle. Default: 24 for clock charts, else 0 */\n  tickCount?: number;\n  /** Every n-th tick is long. Default: 6 (00 / 06 / 12 / 18) */\n  majorTickEvery?: number;\n  /**\n   * Labels spaced evenly along the arc from the start angle, or false to hide.\n   * Default: 00 / 06 / 12 / 18 (clock), every category (band), time ticks\n   * (time), value ticks (angular)\n   */\n  labels?: string[] | false;\n  /** Label color. Default: var(--muted-foreground) */\n  labelColor?: string;\n  /** Circle / tick / grid color. Default: var(--foreground) */\n  stroke?: string;\n  /** Label orientation. Default: \"radial\" for band charts, else \"horizontal\" */\n  labelOrientation?: RadialLabelOrientation;\n  /**\n   * Label radius as a fraction of the chart radius. Default:\n   * `layout.axisLabel` (rotated band labels start just outside the band)\n   */\n  labelRadius?: number;\n  /** Band charts: label every n-th category. Default: 1 */\n  labelEvery?: number;\n  /** Band charts: format `datum[xDataKey]`. Default: String(value) */\n  labelFormat?: (value: unknown, index: number) => string;\n  /** Time charts: tick count hint or explicit tick times. Default: 12 */\n  timeTicks?: number | (Date | number)[];\n  /** Time charts: tick label format. Default: short month (UTC) for spans over 60 days */\n  timeTickFormat?: (date: Date) => string;\n  /**\n   * Time charts: label at the tick, or centred between ticks (month names in\n   * the middle of their month). Default: \"between\"\n   */\n  timeLabelPosition?: \"tick\" | \"between\";\n  /**\n   * Spokes across the value band at every angle tick (time ticks, or value\n   * ticks for angular charts). Default: true for time and angular charts\n   */\n  angleGrid?: boolean;\n  /**\n   * Value grid: tick count hint or explicit values. Radial charts draw a\n   * ring (an arc on partial charts) per value; angular charts use them as\n   * the angle ticks. Default: none for radial charts, 5 for angular charts\n   */\n  valueTicks?: number | number[];\n  /** Value tick label format. Default: en-US number */\n  valueTickFormat?: (value: number) => string;\n  /** Show value tick labels. Default: true */\n  showValueLabels?: boolean;\n  /** Radial charts: angle (degrees) the value labels sit at. Default: the start angle */\n  valueLabelAngle?: number;\n  /** Angular charts: category labels at the start of each ring. Default: true */\n  showRingLabels?: boolean;\n  /** Grid ring / spoke opacity. Default: 0.12 */\n  gridOpacity?: number;\n}\n\nconst DEFAULT_LABELS = [\"00\", \"06\", \"12\", \"18\"];\nconst DAY_MS = 86_400_000;\n\nconst monthFormat = new Intl.DateTimeFormat(\"en-US\", {\n  month: \"short\",\n  timeZone: \"UTC\",\n});\nconst numberFormat = new Intl.NumberFormat(\"en-US\");\n\n/** Font size for radial labels — scales with the chart, 10–18px. */\nexport function radialLabelFontSize(radius: number): number {\n  return Math.min(18, Math.max(10, radius * 0.045));\n}\n\n/*\n * Grid enter — the radar chart standard (see `RadarGrid`): rings scale and\n * fade in from the center one after another, spokes extend outward, then\n * labels fade in. Waits for the chart's `revealed` flag; skipped entirely\n * when `animate` is false (prop or prefers-reduced-motion).\n */\n\ninterface GridEnter {\n  animate: boolean;\n  revealed: boolean;\n}\n\n/** Cap on the total angle-label stagger so dense band labels don't trail. */\nconst ANGLE_LABEL_MAX_STAGGER_S = 0.6;\n\nconst RING_HIDDEN = { scale: 0, opacity: 0 };\nconst RING_SHOWN = { scale: 1, opacity: 1 };\nconst ORIGIN_STYLE = { transformOrigin: \"0px 0px\" };\n\n/** One grid ring (or ring-like layer) growing out of the chart center. */\nfunction GridRingEnter({\n  enter,\n  index,\n  children,\n}: {\n  enter: GridEnter;\n  index: number;\n  children: ReactNode;\n}) {\n  if (!enter.animate) {\n    return <g>{children}</g>;\n  }\n  return (\n    <m.g\n      animate={enter.revealed ? RING_SHOWN : RING_HIDDEN}\n      initial={RING_HIDDEN}\n      style={ORIGIN_STYLE}\n      transition={{\n        ...GRID_RING_SPRING,\n        delay: index * GRID_RING_STAGGER_S,\n      }}\n    >\n      {children}\n    </m.g>\n  );\n}\n\n/** Value / ring labels: a plain fade (RadarGrid level labels). */\nfunction GridLabelFade({\n  enter,\n  delay,\n  children,\n}: {\n  enter: GridEnter;\n  delay: number;\n  children: ReactNode;\n}) {\n  if (!enter.animate) {\n    return <>{children}</>;\n  }\n  const transition: Transition = { ...DEFAULT_CHART_ENTER_TRANSITION, delay };\n  return (\n    <m.g\n      animate={{ opacity: enter.revealed ? 1 : 0 }}\n      initial={{ opacity: 0 }}\n      transition={transition}\n    >\n      {children}\n    </m.g>\n  );\n}\n\ninterface AngleLabel {\n  key: string;\n  angle: number;\n  text: string;\n}\n\nfunction normalizeAngle(angle: number): number {\n  return ((angle % TAU) + TAU) % TAU;\n}\n\n/** SVG transform + anchor for a label at `angle`. */\nfunction labelPlacement(\n  orientation: RadialLabelOrientation,\n  angle: number,\n  r: number\n): { transform: string; anchor: \"start\" | \"middle\" | \"end\" } {\n  const p = polarToCartesian(angle, r);\n  const a = normalizeAngle(angle);\n  const deg = (a * 180) / Math.PI;\n  if (orientation === \"radial\") {\n    const flip = a > Math.PI;\n    return {\n      transform: `translate(${p.x},${p.y}) rotate(${deg - 90 + (flip ? 180 : 0)})`,\n      anchor: flip ? \"end\" : \"start\",\n    };\n  }\n  if (orientation === \"tangential\") {\n    const flip = a > Math.PI / 2 && a < (3 * Math.PI) / 2;\n    return {\n      transform: `translate(${p.x},${p.y}) rotate(${deg + (flip ? 180 : 0)})`,\n      anchor: \"middle\",\n    };\n  }\n  return { transform: `translate(${p.x},${p.y})`, anchor: \"middle\" };\n}\n\ninterface TextProps {\n  className: string;\n  dominantBaseline: \"central\";\n  fill: string;\n  fontSize: number;\n  stroke: string;\n  style: { fill: string };\n}\n\ninterface TimeTick {\n  time: number;\n  angle: number;\n  labelAngle: number | null;\n  text: string;\n}\n\nfunction formatNumber(value: number): string {\n  return numberFormat.format(value);\n}\n\nfunction bandLabel(\n  raw: unknown,\n  index: number,\n  labelFormat: RadialAxisProps[\"labelFormat\"]\n): string {\n  return labelFormat ? labelFormat(raw, index) : String(raw ?? \"\");\n}\n\n/** Labels spread evenly from the start angle (ending on the end angle for partial arcs). */\nfunction evenLabels(\n  texts: string[],\n  isFullCircle: boolean,\n  fractionToAngle: (fraction: number) => number\n): AngleLabel[] {\n  const steps = isFullCircle ? texts.length : Math.max(1, texts.length - 1);\n  return texts.map((text, i) => ({\n    key: `label-${i}-${text}`,\n    angle: fractionToAngle(i / steps),\n    text,\n  }));\n}\n\nfunction computeTimeTicks({\n  timeDomain,\n  timeTicks,\n  timeTickFormat,\n  timeLabelPosition,\n  timeToAngle,\n}: {\n  timeDomain: [number, number];\n  timeTicks: number | (Date | number)[];\n  timeTickFormat?: (date: Date) => string;\n  timeLabelPosition: \"tick\" | \"between\";\n  timeToAngle: (time: number) => number | null;\n}): TimeTick[] {\n  const [t0, t1] = timeDomain;\n  const scale = scaleUtc().domain([t0, t1]);\n  const times =\n    typeof timeTicks === \"number\"\n      ? scale.ticks(timeTicks).map((d) => d.getTime())\n      : timeTicks.map((t) => (typeof t === \"number\" ? t : t.getTime()));\n  const longSpan = t1 - t0 > 60 * DAY_MS;\n  const format =\n    timeTickFormat ??\n    (longSpan ? (d: Date) => monthFormat.format(d) : scale.tickFormat());\n  const result: TimeTick[] = [];\n  times.forEach((time, i) => {\n    const angle = timeToAngle(time);\n    if (angle === null) {\n      return;\n    }\n    let labelAngle: number | null = angle;\n    if (timeLabelPosition === \"between\") {\n      const nextAngle = timeToAngle(Math.min(times[i + 1] ?? t1, t1));\n      labelAngle =\n        nextAngle === null || nextAngle === angle\n          ? null\n          : (angle + nextAngle) / 2;\n    }\n    result.push({ time, angle, labelAngle, text: format(new Date(time)) });\n  });\n  return result;\n}\n\nfunction AxisCenterCircle({\n  r,\n  stroke,\n  gradient,\n}: {\n  r: number;\n  stroke: string;\n  gradient: boolean;\n}) {\n  const gradientId = `radial-axis-center-${useId().replace(/:/g, \"\")}`;\n  return (\n    <>\n      {gradient ? (\n        <defs>\n          <radialGradient id={gradientId}>\n            <stop offset=\"0%\" stopColor={stroke} stopOpacity={0.06} />\n            <stop offset=\"100%\" stopColor={stroke} stopOpacity={0} />\n          </radialGradient>\n        </defs>\n      ) : null}\n      <circle\n        fill={gradient ? `url(#${gradientId})` : \"none\"}\n        r={r}\n        stroke={stroke}\n        strokeOpacity={0.1}\n        strokeWidth={1}\n      />\n    </>\n  );\n}\n\nfunction AxisTickRing({\n  tickCount,\n  majorTickEvery,\n  stroke,\n}: {\n  tickCount: number;\n  majorTickEvery: number;\n  stroke: string;\n}) {\n  const { radius, layout, startAngle, endAngle, isFullCircle } =\n    useRadialChart();\n  const span = endAngle - startAngle;\n  return (\n    <g stroke={stroke} strokeLinecap=\"round\">\n      {Array.from(\n        { length: isFullCircle ? tickCount : tickCount + 1 },\n        (_, i) => {\n          const angle = startAngle + (i / tickCount) * span;\n          const major = majorTickEvery > 0 && i % majorTickEvery === 0;\n          const a = polarToCartesian(angle, radius * layout.tickInner);\n          const b = polarToCartesian(\n            angle,\n            radius * (major ? layout.tickMajorOuter : layout.tickMinorOuter)\n          );\n          return (\n            <line\n              key={`tick-${angle}`}\n              strokeOpacity={major ? 0.4 : 0.22}\n              strokeWidth={major ? 1.25 : 1}\n              x1={a.x}\n              x2={b.x}\n              y1={a.y}\n              y2={b.y}\n            />\n          );\n        }\n      )}\n    </g>\n  );\n}\n\nfunction AxisSpokes({\n  angles,\n  stroke,\n  opacity,\n  enter,\n}: {\n  angles: number[];\n  stroke: string;\n  opacity: number;\n  enter: GridEnter;\n}) {\n  const { innerRadius, outerRadius } = useRadialChart();\n  return (\n    <g stroke={stroke} strokeOpacity={opacity}>\n      {angles.map((angle, i) => {\n        const a = polarToCartesian(angle, innerRadius);\n        const b = polarToCartesian(angle, outerRadius);\n        if (!enter.animate) {\n          return (\n            <line key={`spoke-${angle}`} x1={a.x} x2={b.x} y1={a.y} y2={b.y} />\n          );\n        }\n        // RadarAxis: each spoke extends from its inner end outward.\n        const end = enter.revealed ? b : a;\n        return (\n          <m.line\n            animate={{ x2: end.x, y2: end.y }}\n            initial={{ x2: a.x, y2: a.y }}\n            key={`spoke-${angle}`}\n            transition={{\n              ...GRID_SPOKE_SPRING,\n              delay: i * GRID_SPOKE_STAGGER_S,\n            }}\n            x1={a.x}\n            y1={a.y}\n          />\n        );\n      })}\n    </g>\n  );\n}\n\nfunction AxisValueRings({\n  ticks,\n  format,\n  showLabels,\n  labelAngle,\n  stroke,\n  opacity,\n  textProps,\n  enter,\n  firstRing,\n  labelDelay,\n}: {\n  ticks: number[];\n  format: (value: number) => string;\n  showLabels: boolean;\n  labelAngle: number;\n  stroke: string;\n  opacity: number;\n  textProps: TextProps;\n  enter: GridEnter;\n  /** Enter index of the innermost value ring */\n  firstRing: number;\n  labelDelay: number;\n}) {\n  const { valueScale, startAngle, endAngle } = useRadialChart();\n  // Innermost ring first, whatever order the ticks came in.\n  const order = [...ticks].sort(\n    (a, b) => Math.abs(valueScale(a)) - Math.abs(valueScale(b))\n  );\n  return (\n    <g>\n      <g fill=\"none\" stroke={stroke} strokeOpacity={opacity}>\n        {ticks.map((v) => (\n          <GridRingEnter\n            enter={enter}\n            index={firstRing + order.indexOf(v)}\n            key={`ring-${v}`}\n          >\n            <path d={radialArcPath(valueScale(v), startAngle, endAngle)} />\n          </GridRingEnter>\n        ))}\n      </g>\n      {showLabels\n        ? ticks.map((v) => {\n            const p = polarToCartesian(labelAngle, valueScale(v));\n            return (\n              <GridLabelFade\n                delay={\n                  labelDelay + order.indexOf(v) * GRID_VALUE_LABEL_STAGGER_S\n                }\n                enter={enter}\n                key={`ring-label-${v}`}\n              >\n                <text\n                  {...textProps}\n                  fontSize={textProps.fontSize * 0.85}\n                  paintOrder=\"stroke\"\n                  stroke=\"var(--background)\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth={4}\n                  textAnchor=\"middle\"\n                  x={p.x}\n                  y={p.y}\n                >\n                  {format(v)}\n                </text>\n              </GridLabelFade>\n            );\n          })\n        : null}\n    </g>\n  );\n}\n\n/** Angular charts: category label just before each ring's start. */\nfunction AxisRingLabels({\n  labelFormat,\n  textProps,\n  enter,\n  labelDelay,\n}: {\n  labelFormat: RadialAxisProps[\"labelFormat\"];\n  textProps: TextProps;\n  enter: GridEnter;\n  labelDelay: number;\n}) {\n  const { data, xDataKey, startAngle, endAngle, ringBand } = useRadialChart();\n  // Steps back along the start tangent; rotated upright for 3 / 9 o'clock starts.\n  const layout = arcStartLabelLayout(\n    startAngle,\n    endAngle,\n    textProps.fontSize * 0.6\n  );\n  return (\n    <>\n      {data.map((d, i) => {\n        const { x, y, transform } = layout.position(ringBand(i).center);\n        return (\n          <GridLabelFade\n            delay={labelDelay + i * GRID_VALUE_LABEL_STAGGER_S}\n            enter={enter}\n            key={`ring-label-${i}`}\n          >\n            <text\n              {...textProps}\n              fontSize={textProps.fontSize * 0.8}\n              textAnchor={layout.anchor}\n              transform={transform}\n              x={x}\n              y={y}\n            >\n              {bandLabel(d[xDataKey], i, labelFormat)}\n            </text>\n          </GridLabelFade>\n        );\n      })}\n    </>\n  );\n}\n\n/** Enter index of each ring-like layer, and when the labels start. */\nfunction gridEnterOrder({\n  showCircle,\n  tickRing,\n  valueRings,\n  outerArc,\n}: {\n  showCircle: boolean;\n  tickRing: boolean;\n  valueRings: number;\n  outerArc: boolean;\n}) {\n  const tickRingIndex = Number(showCircle);\n  const firstValueRing = tickRingIndex + Number(tickRing);\n  const outerArcIndex = firstValueRing + valueRings;\n  return {\n    tickRingIndex,\n    firstValueRing,\n    outerArcIndex,\n    labelDelay: gridLabelDelay(outerArcIndex + Number(outerArc)),\n  };\n}\n\n/** Angle labels — fade in while sliding out from the center (RadarLabels). */\nfunction AxisAngleLabels({\n  labels,\n  orientation,\n  labelRadius,\n  fontSize,\n  rotated,\n  textProps,\n  enter,\n  labelDelay,\n}: {\n  labels: AngleLabel[];\n  rotated: boolean;\n  orientation: RadialLabelOrientation;\n  labelRadius: number;\n  fontSize: number;\n  textProps: TextProps;\n  enter: GridEnter;\n  labelDelay: number;\n}) {\n  const stagger = Math.min(\n    GRID_ANGLE_LABEL_STAGGER_S,\n    ANGLE_LABEL_MAX_STAGGER_S / Math.max(1, labels.length)\n  );\n  return (\n    <>\n      {labels.map((label, i) => {\n        const { transform, anchor } = labelPlacement(\n          orientation,\n          label.angle,\n          labelRadius\n        );\n        const text = (\n          <text\n            {...textProps}\n            fontSize={rotated ? fontSize * 0.8 : fontSize}\n            textAnchor={anchor}\n            transform={transform}\n          >\n            {label.text}\n          </text>\n        );\n        if (!enter.animate) {\n          return <g key={label.key}>{text}</g>;\n        }\n        const from = polarToCartesian(label.angle, -labelRadius);\n        const shown = { opacity: 1, x: 0, y: 0 };\n        const hidden = { opacity: 0, x: from.x, y: from.y };\n        return (\n          <m.g\n            animate={enter.revealed ? shown : hidden}\n            initial={hidden}\n            key={label.key}\n            transition={{\n              opacity: {\n                duration: GRID_ANGLE_LABEL_FADE_S,\n                delay: labelDelay + i * stagger,\n              },\n              x: GRID_SPOKE_SPRING,\n              y: GRID_SPOKE_SPRING,\n            }}\n          >\n            {text}\n          </m.g>\n        );\n      })}\n    </>\n  );\n}\n\nfunction useAngleLabels({\n  labels,\n  valueTicks,\n  valueTickFormat,\n  labelEvery,\n  labelFormat,\n  timeTicks,\n}: {\n  labels: string[] | false | undefined;\n  valueTicks: number[];\n  valueTickFormat: (value: number) => string;\n  labelEvery: number;\n  labelFormat: RadialAxisProps[\"labelFormat\"];\n  timeTicks: TimeTick[];\n}): AngleLabel[] {\n  const {\n    data,\n    xDataKey,\n    binCount,\n    angleType,\n    orientation,\n    isFullCircle,\n    binAngle,\n    fractionToAngle,\n    valueToAngle,\n  } = useRadialChart();\n  return useMemo((): AngleLabel[] => {\n    if (labels === false) {\n      return [];\n    }\n    if (labels) {\n      return evenLabels(labels, isFullCircle, fractionToAngle);\n    }\n    if (orientation === \"angular\") {\n      return valueTicks.map((v) => ({\n        key: `value-${v}`,\n        angle: valueToAngle(v),\n        text: valueTickFormat(v),\n      }));\n    }\n    if (angleType === \"band\") {\n      const every = Math.max(1, Math.round(labelEvery));\n      const result: AngleLabel[] = [];\n      for (let i = 0; i < binCount; i += every) {\n        result.push({\n          key: `band-${i}`,\n          angle: binAngle(i),\n          text: bandLabel(data[i]?.[xDataKey], i, labelFormat),\n        });\n      }\n      return result;\n    }\n    if (angleType === \"time\") {\n      return timeTicks.flatMap((t) =>\n        t.labelAngle === null\n          ? []\n          : [{ key: `time-${t.time}`, angle: t.labelAngle, text: t.text }]\n      );\n    }\n    return evenLabels(DEFAULT_LABELS, isFullCircle, fractionToAngle);\n  }, [\n    labels,\n    isFullCircle,\n    fractionToAngle,\n    orientation,\n    valueTicks,\n    valueToAngle,\n    valueTickFormat,\n    angleType,\n    labelEvery,\n    binCount,\n    data,\n    xDataKey,\n    binAngle,\n    labelFormat,\n    timeTicks,\n  ]);\n}\n\nfunction resolveValueTicks(\n  spec: number | number[] | undefined,\n  domain: [number, number]\n): number[] {\n  if (spec === undefined) {\n    return [];\n  }\n  return typeof spec === \"number\" ? radialValueTicks(domain, spec) : spec;\n}\n\n/**\n * Axis chrome for {@link RadialChart}: the center circle, the tick ring,\n * angle labels (clock hours, rotated category labels or time ticks),\n * optional value grid rings with labels, and — for `angular` charts — value\n * ticks along the arc with category labels at each ring's start.\n */\nexport function RadialAxis({\n  showCircle = true,\n  centerGradient = false,\n  tickCount: tickCountProp,\n  majorTickEvery = 6,\n  labels,\n  labelColor = \"var(--muted-foreground)\",\n  stroke = \"var(--foreground)\",\n  labelOrientation: labelOrientationProp,\n  labelRadius: labelRadiusProp,\n  labelEvery = 1,\n  labelFormat,\n  timeTicks = 12,\n  timeTickFormat,\n  timeLabelPosition = \"between\",\n  angleGrid: angleGridProp,\n  valueTicks: valueTicksProp,\n  valueTickFormat = formatNumber,\n  showValueLabels = true,\n  valueLabelAngle,\n  showRingLabels = true,\n  gridOpacity = 0.12,\n}: RadialAxisProps) {\n  const {\n    radius,\n    layout,\n    centerRadius,\n    outerRadius,\n    angleType,\n    orientation,\n    startAngle,\n    endAngle,\n    timeDomain,\n    timeToAngle,\n    valueDomain,\n    valueToAngle,\n    animate,\n    revealed,\n  } = useRadialChart();\n  const enter = useMemo<GridEnter>(\n    () => ({ animate, revealed }),\n    [animate, revealed]\n  );\n  const fontSize = radialLabelFontSize(radius);\n  const angular = orientation === \"angular\";\n  const isClock = angleType === \"clock\" && !angular;\n  const isBand = angleType === \"band\" && !angular;\n\n  const tickCount = tickCountProp ?? (isClock ? 24 : 0);\n  const labelOrientation =\n    labelOrientationProp ?? (isBand ? \"radial\" : \"horizontal\");\n  const rotated = labelOrientation !== \"horizontal\";\n  const defaultLabelRadius = rotated\n    ? layout.bandOuter + 0.03\n    : layout.axisLabel;\n  const labelRadius = radius * (labelRadiusProp ?? defaultLabelRadius);\n  const angleGrid = angleGridProp ?? (angleType === \"time\" || angular);\n\n  const valueTicks = useMemo(\n    () =>\n      resolveValueTicks(\n        valueTicksProp ?? (angular ? 5 : undefined),\n        valueDomain\n      ),\n    [valueTicksProp, angular, valueDomain]\n  );\n\n  const timeTickList = useMemo(\n    () =>\n      !angular && angleType === \"time\" && timeDomain\n        ? computeTimeTicks({\n            timeDomain,\n            timeTicks,\n            timeTickFormat,\n            timeLabelPosition,\n            timeToAngle,\n          })\n        : [],\n    [\n      angular,\n      angleType,\n      timeDomain,\n      timeTicks,\n      timeTickFormat,\n      timeLabelPosition,\n      timeToAngle,\n    ]\n  );\n\n  const angleLabels = useAngleLabels({\n    labels,\n    valueTicks,\n    valueTickFormat,\n    labelEvery,\n    labelFormat,\n    timeTicks: timeTickList,\n  });\n\n  const spokeAngles = useMemo(() => {\n    if (!angleGrid) {\n      return [];\n    }\n    return angular\n      ? valueTicks.map(valueToAngle)\n      : timeTickList.map((t) => t.angle);\n  }, [angleGrid, angular, valueTicks, valueToAngle, timeTickList]);\n\n  // Enter order: center circle, tick ring, value rings (inside out), outer arc.\n  const valueRingCount = angular ? 0 : valueTicks.length;\n  const { tickRingIndex, firstValueRing, outerArcIndex, labelDelay } =\n    gridEnterOrder({\n      showCircle,\n      tickRing: tickCount > 0,\n      valueRings: valueRingCount,\n      outerArc: angular,\n    });\n\n  const textProps: TextProps = {\n    className: \"font-mono\",\n    dominantBaseline: \"central\",\n    fill: labelColor,\n    fontSize,\n    stroke: \"none\",\n    style: { fill: labelColor },\n  };\n\n  return (\n    <g pointerEvents=\"none\">\n      {showCircle ? (\n        <GridRingEnter enter={enter} index={0}>\n          <AxisCenterCircle\n            gradient={centerGradient}\n            r={centerRadius}\n            stroke={stroke}\n          />\n        </GridRingEnter>\n      ) : null}\n\n      {tickCount > 0 ? (\n        <GridRingEnter enter={enter} index={tickRingIndex}>\n          <AxisTickRing\n            majorTickEvery={majorTickEvery}\n            stroke={stroke}\n            tickCount={tickCount}\n          />\n        </GridRingEnter>\n      ) : null}\n\n      {spokeAngles.length > 0 ? (\n        <AxisSpokes\n          angles={spokeAngles}\n          enter={enter}\n          opacity={gridOpacity}\n          stroke={stroke}\n        />\n      ) : null}\n\n      {valueRingCount > 0 ? (\n        <AxisValueRings\n          enter={enter}\n          firstRing={firstValueRing}\n          format={valueTickFormat}\n          labelAngle={\n            valueLabelAngle === undefined\n              ? startAngle\n              : (valueLabelAngle * Math.PI) / 180\n          }\n          labelDelay={labelDelay}\n          opacity={gridOpacity}\n          showLabels={showValueLabels}\n          stroke={stroke}\n          textProps={textProps}\n          ticks={valueTicks}\n        />\n      ) : null}\n\n      {angular ? (\n        <GridRingEnter enter={enter} index={outerArcIndex}>\n          <path\n            d={radialArcPath(outerRadius, startAngle, endAngle)}\n            fill=\"none\"\n            stroke={stroke}\n            strokeOpacity={gridOpacity}\n          />\n        </GridRingEnter>\n      ) : null}\n\n      <AxisAngleLabels\n        enter={enter}\n        fontSize={fontSize}\n        labelDelay={labelDelay}\n        labelRadius={labelRadius}\n        labels={angleLabels}\n        orientation={labelOrientation}\n        rotated={rotated}\n        textProps={textProps}\n      />\n\n      {angular && showRingLabels ? (\n        <AxisRingLabels\n          enter={enter}\n          labelDelay={labelDelay}\n          labelFormat={labelFormat}\n          textProps={textProps}\n        />\n      ) : null}\n    </g>\n  );\n}\n\nRadialAxis.displayName = \"RadialAxis\";\n\nexport default RadialAxis;\n",
      "type": "registry:component",
      "target": "components/charts/radial-axis.tsx"
    },
    {
      "path": "src/charts/radial-bar.tsx",
      "content": "\"use client\";\n\nimport { arc as d3Arc } from \"d3-shape\";\nimport {\n  animate as animateValue,\n  type MotionValue,\n  m,\n  useMotionValue,\n  useTransform,\n} from \"motion/react\";\nimport { memo, useEffect, useId, useMemo } from \"react\";\nimport { radialNumberAt, useRadialChart } from \"./radial-chart\";\nimport { createRadialScale } from \"./radial-geometry\";\nimport { radialSeriesColor } from \"./radial-series-color\";\n\nexport interface RadialBarProps {\n  /**\n   * Datum key of a comparison value (e.g. yesterday's total), drawn as a dim\n   * \"ghost\" bar in bins where no series has a value (the rest of the day).\n   */\n  ghostDataKey?: string;\n  /** Ghost bar color. Default: var(--foreground) */\n  ghostColor?: string;\n  /** Ghost bar opacity. Default: 0.12 */\n  ghostOpacity?: number;\n  /**\n   * Value mapped to the band top. Default: the chart's shared scale when\n   * `RadialChart` has `maxValue`, else the largest stack / ghost value\n   */\n  maxValue?: number;\n  /**\n   * Angular gap between neighbouring bars in radians, held at a constant\n   * width from the band base outward (d3 `padAngle` + `padRadius`).\n   * Default: 18% of one bin's angle\n   */\n  padAngle?: number;\n  /** Corner radius in px (d3 `arc.cornerRadius`). Default: 0 */\n  cornerRadius?: number;\n  /**\n   * Radial gap in px between stacked segments. 0 (default) draws each bin as\n   * one solid column whose outline carries `cornerRadius`; above 0 every\n   * segment is rounded on its own.\n   */\n  stackGap?: number;\n  /** Fraction of the band over which `rampFrom` series reach full opacity. Default: 0.6 */\n  rampExtent?: number;\n  /** Opacity of other bins while a bin is hovered. Default: 0.35 */\n  fadedOpacity?: number;\n  /** Opacity of other series while a legend item is hovered. Default: 0.2 */\n  seriesFadedOpacity?: number;\n}\n\nconst ENTER_SPREAD_S = 0.6;\nconst ENTER_DURATION_S = 0.6;\nconst ENTER_EASE = [0.2, 0.8, 0.2, 1] as const;\n\ntype ArcPath = (radii: {\n  inner: number;\n  outer: number;\n  start: number;\n  end: number;\n  corner: number;\n}) => string;\n\ninterface SegmentSpec {\n  key: string;\n  /** Series index, or -1 for the ghost */\n  seriesIndex: number;\n  fill: string;\n  opacity: number;\n  /** Stack bounds in value space */\n  y0: number;\n  y1: number;\n}\n\ninterface ColumnSpec {\n  index: number;\n  start: number;\n  end: number;\n  segments: SegmentSpec[];\n  delay: number;\n}\n\nconst RadialBarSegment = memo(function RadialBarSegment({\n  segment,\n  column,\n  progress,\n  toRadius,\n  arcPath,\n  cornerRadius,\n  stackGap,\n  isTop,\n  clipPath,\n  dimmed,\n  seriesFadedOpacity,\n}: {\n  segment: SegmentSpec;\n  column: ColumnSpec;\n  progress: MotionValue<number>;\n  toRadius: (value: number) => number;\n  arcPath: ArcPath;\n  cornerRadius: number;\n  stackGap: number;\n  isTop: boolean;\n  clipPath: string | undefined;\n  dimmed: boolean;\n  seriesFadedOpacity: number;\n}) {\n  const d = useTransform(progress, (p) => {\n    const inner = toRadius(segment.y0 * p);\n    const outer = Math.max(\n      inner,\n      toRadius(segment.y1 * p) - (isTop ? 0 : stackGap)\n    );\n    return arcPath({\n      inner,\n      outer,\n      start: column.start,\n      end: column.end,\n      corner: stackGap > 0 ? cornerRadius : 0,\n    });\n  });\n  return (\n    // Opacity lives on a plain group: m.path doesn't re-apply a changed\n    // static `style.opacity`, which would break legend dimming.\n    <g\n      style={{\n        opacity: segment.opacity * (dimmed ? seriesFadedOpacity : 1),\n        transition: \"opacity 0.15s ease-out\",\n      }}\n    >\n      <m.path clipPath={clipPath} d={d} fill={segment.fill} />\n    </g>\n  );\n});\n\nconst RadialBarColumn = memo(function RadialBarColumn({\n  column,\n  toRadius,\n  arcPath,\n  cornerRadius,\n  stackGap,\n  revealed,\n  animate,\n  faded,\n  fadedOpacity,\n  hoveredSeries,\n  seriesFadedOpacity,\n}: {\n  column: ColumnSpec;\n  toRadius: (value: number) => number;\n  arcPath: ArcPath;\n  cornerRadius: number;\n  stackGap: number;\n  revealed: boolean;\n  animate: boolean;\n  faded: boolean;\n  fadedOpacity: number;\n  hoveredSeries: number | null;\n  seriesFadedOpacity: number;\n}) {\n  const progress = useMotionValue(animate ? 0 : 1);\n  useEffect(() => {\n    if (!animate) {\n      progress.jump(1);\n      return;\n    }\n    if (!revealed) {\n      return;\n    }\n    const controls = animateValue(progress, 1, {\n      duration: ENTER_DURATION_S,\n      delay: column.delay,\n      ease: ENTER_EASE,\n    });\n    return () => controls.stop();\n  }, [animate, revealed, progress, column.delay]);\n\n  const top = column.segments.at(-1)?.y1 ?? 0;\n  const clipId = `radial-bar-clip-${useId().replace(/:/g, \"\")}`;\n  const useClip = cornerRadius > 0 && stackGap <= 0;\n  const outline = useTransform(progress, (p) =>\n    arcPath({\n      inner: toRadius(0),\n      outer: toRadius(top * p),\n      start: column.start,\n      end: column.end,\n      corner: cornerRadius,\n    })\n  );\n\n  if (column.segments.length === 0) {\n    return null;\n  }\n\n  return (\n    <g\n      style={{\n        opacity: faded ? fadedOpacity : 1,\n        transition: \"opacity 0.15s ease-out\",\n      }}\n    >\n      {useClip ? (\n        <clipPath id={clipId}>\n          <m.path d={outline} />\n        </clipPath>\n      ) : null}\n      {column.segments.map((segment, i) => (\n        <RadialBarSegment\n          arcPath={arcPath}\n          clipPath={useClip ? `url(#${clipId})` : undefined}\n          column={column}\n          cornerRadius={cornerRadius}\n          dimmed={\n            hoveredSeries !== null && hoveredSeries !== segment.seriesIndex\n          }\n          isTop={i === column.segments.length - 1}\n          key={segment.key}\n          progress={progress}\n          segment={segment}\n          seriesFadedOpacity={seriesFadedOpacity}\n          stackGap={stackGap}\n          toRadius={toRadius}\n        />\n      ))}\n    </g>\n  );\n});\n\n/**\n * Radial stacked bars for {@link RadialChart} (after d3's radial stacked bar\n * chart): every bin is an arc column with one segment per series, innermost\n * series first, on a `scaleRadial` so segment *area* tracks the value. Bars\n * grow outward on enter, staggered clockwise from 12 o'clock.\n */\nexport function RadialBar({\n  ghostDataKey,\n  ghostColor = \"var(--foreground)\",\n  ghostOpacity = 0.12,\n  maxValue: maxValueProp,\n  padAngle: padAngleProp,\n  cornerRadius = 0,\n  stackGap = 0,\n  rampExtent = 0.6,\n  fadedOpacity = 0.35,\n  seriesFadedOpacity = 0.2,\n}: RadialBarProps) {\n  const {\n    data,\n    series,\n    binCount,\n    innerRadius,\n    outerRadius,\n    futureBins,\n    hoveredIndex,\n    hoveredSeries,\n    revealed,\n    animate,\n    binStartAngle,\n    binWidth,\n    sharedValueScale,\n    valueScale,\n  } = useRadialChart();\n  const gradientPrefix = `radial-bar-ramp-${useId().replace(/:/g, \"\")}`;\n\n  const maxValue = useMemo(() => {\n    if (maxValueProp !== undefined) {\n      return maxValueProp;\n    }\n    let max = 0;\n    data.forEach((d, i) => {\n      const value = futureBins[i]\n        ? ((ghostDataKey ? radialNumberAt(d, ghostDataKey) : 0) ?? 0)\n        : series.reduce(\n            (sum, s) => sum + Math.max(0, radialNumberAt(d, s.dataKey) ?? 0),\n            0\n          );\n      max = Math.max(max, value);\n    });\n    return max;\n  }, [maxValueProp, data, series, futureBins, ghostDataKey]);\n\n  const toRadius = useMemo(() => {\n    if (maxValueProp === undefined && sharedValueScale) {\n      return (value: number) => valueScale(Math.max(0, value));\n    }\n    const scale = createRadialScale({ maxValue, innerRadius, outerRadius });\n    return (value: number) => scale(Math.max(0, value)) ?? innerRadius;\n  }, [\n    maxValueProp,\n    sharedValueScale,\n    valueScale,\n    maxValue,\n    innerRadius,\n    outerRadius,\n  ]);\n\n  const padAngle = padAngleProp ?? Math.abs(binWidth) * 0.18;\n\n  const arcPath = useMemo((): ArcPath => {\n    const generator = d3Arc<{\n      inner: number;\n      outer: number;\n      start: number;\n      end: number;\n      corner: number;\n    }>()\n      .innerRadius((a) => a.inner)\n      .outerRadius((a) => a.outer)\n      .startAngle((a) => a.start)\n      .endAngle((a) => a.end)\n      .cornerRadius((a) => a.corner)\n      .padAngle(padAngle)\n      .padRadius(innerRadius);\n    return (radii) => generator(radii) ?? \"\";\n  }, [padAngle, innerRadius]);\n\n  const rampFills = useMemo(\n    () =>\n      series.map((s, i) =>\n        (s.rampFrom ?? 1) < 1 ? `url(#${gradientPrefix}-${i})` : null\n      ),\n    [series, gradientPrefix]\n  );\n\n  const columns = useMemo(\n    (): ColumnSpec[] =>\n      data.map((d, index) => {\n        const start = binStartAngle(index);\n        const delay = (index / Math.max(1, binCount)) * ENTER_SPREAD_S;\n        const column = { index, start, end: start + binWidth, delay };\n        if (futureBins[index]) {\n          const ghost = ghostDataKey\n            ? (radialNumberAt(d, ghostDataKey) ?? 0)\n            : 0;\n          return {\n            ...column,\n            segments:\n              ghost > 0\n                ? [\n                    {\n                      key: \"ghost\",\n                      seriesIndex: -1,\n                      fill: ghostColor,\n                      opacity: ghostOpacity,\n                      y0: 0,\n                      y1: ghost,\n                    },\n                  ]\n                : [],\n          };\n        }\n        const segments: SegmentSpec[] = [];\n        let y0 = 0;\n        series.forEach((s, seriesIndex) => {\n          const value = Math.max(0, radialNumberAt(d, s.dataKey) ?? 0);\n          if (value <= 0) {\n            return;\n          }\n          segments.push({\n            key: s.dataKey,\n            seriesIndex,\n            fill: rampFills[seriesIndex] ?? radialSeriesColor(s, seriesIndex),\n            opacity: s.opacity ?? 1,\n            y0,\n            y1: y0 + value,\n          });\n          y0 += value;\n        });\n        return { ...column, segments };\n      }),\n    [\n      data,\n      binCount,\n      binStartAngle,\n      binWidth,\n      futureBins,\n      ghostDataKey,\n      ghostColor,\n      ghostOpacity,\n      series,\n      rampFills,\n    ]\n  );\n\n  if (binCount === 0 || outerRadius <= innerRadius) {\n    return null;\n  }\n\n  const rampStart = innerRadius / outerRadius;\n  const rampEnd =\n    (innerRadius + (outerRadius - innerRadius) * rampExtent) / outerRadius;\n\n  return (\n    <g pointerEvents=\"none\">\n      <defs>\n        {series.map((s, i) =>\n          rampFills[i] ? (\n            <radialGradient\n              cx={0}\n              cy={0}\n              gradientUnits=\"userSpaceOnUse\"\n              id={`${gradientPrefix}-${i}`}\n              key={s.dataKey}\n              r={outerRadius}\n            >\n              <stop\n                offset={rampStart}\n                stopColor={radialSeriesColor(s, i)}\n                stopOpacity={s.rampFrom ?? 1}\n              />\n              <stop\n                offset={rampEnd}\n                stopColor={radialSeriesColor(s, i)}\n                stopOpacity={1}\n              />\n            </radialGradient>\n          ) : null\n        )}\n      </defs>\n      {columns.map((column) => (\n        <RadialBarColumn\n          animate={animate}\n          arcPath={arcPath}\n          column={column}\n          cornerRadius={cornerRadius}\n          faded={hoveredIndex !== null && hoveredIndex !== column.index}\n          fadedOpacity={fadedOpacity}\n          hoveredSeries={hoveredSeries}\n          key={column.index}\n          revealed={revealed}\n          seriesFadedOpacity={seriesFadedOpacity}\n          stackGap={stackGap}\n          toRadius={toRadius}\n        />\n      ))}\n    </g>\n  );\n}\n\nRadialBar.displayName = \"RadialBar\";\n\nexport default RadialBar;\n",
      "type": "registry:component",
      "target": "components/charts/radial-bar.tsx"
    },
    {
      "path": "src/charts/radial-ring-bar.tsx",
      "content": "\"use client\";\n\nimport { arc as d3Arc } from \"d3-shape\";\nimport {\n  animate as animateValue,\n  type MotionValue,\n  m,\n  useMotionValue,\n  useTransform,\n} from \"motion/react\";\nimport { memo, useEffect, useMemo } from \"react\";\nimport { DEFAULT_CHART_ENTER_TRANSITION } from \"./animation\";\nimport { radialNumberAt, useRadialChart } from \"./radial-chart\";\nimport { radialSeriesColor } from \"./radial-series-color\";\nimport { useEnterComplete } from \"./use-enter-complete\";\n\nexport interface RadialRingBarProps {\n  /**\n   * Value mapped to the arc end. Default: the chart's value domain\n   * (`RadialChart maxValue`, else the largest per-ring stack)\n   */\n  maxValue?: number;\n  /** Corner radius in px, or \"round\" for fully rounded ends. Default: 0 */\n  cornerRadius?: number | \"round\";\n  /**\n   * Gap between stacked segments in px — constant across the ring's\n   * thickness (parallel edges), whatever the ring's radius. Default: 0\n   */\n  segmentGap?: number;\n  /** Faint full-arc track behind every ring. Default: true */\n  track?: boolean;\n  /** Track color. Default: var(--foreground) */\n  trackColor?: string;\n  /** Track opacity. Default: 0.06 */\n  trackOpacity?: number;\n  /** Opacity of other rings while a ring is hovered. Default: 0.35 */\n  fadedOpacity?: number;\n  /** Opacity of other series while a legend item is hovered. Default: 0.2 */\n  seriesFadedOpacity?: number;\n}\n\n/*\n * Enter timing — matches RingChart's `Ring`: each ring's track scales out of\n * the center (staggered 80ms), then its value sweeps along the arc from the\n * start angle (0.6s in, staggered 100ms), both on the default chart enter\n * tween.\n */\nconst EXPAND_STAGGER_S = 0.08;\nconst SWEEP_DELAY_S = 0.6;\nconst SWEEP_STAGGER_S = 0.1;\n\ninterface RingSegment {\n  key: string;\n  seriesIndex: number;\n  fill: string;\n  opacity: number;\n  y0: number;\n  y1: number;\n  /** Extend the start / end by half a pad so the outer edges stay flush */\n  padStart: boolean;\n  padEnd: boolean;\n}\n\ninterface ArcDatum {\n  inner: number;\n  outer: number;\n  start: number;\n  end: number;\n  pad: number;\n}\n\n/** 0 → 1 enter progress on the chart enter tween, once the chart is revealed. */\nfunction useEnterProgress(\n  animate: boolean,\n  revealed: boolean,\n  delay: number\n): MotionValue<number> {\n  const progress = useMotionValue(animate ? 0 : 1);\n  useEffect(() => {\n    if (!animate) {\n      progress.jump(1);\n      return;\n    }\n    if (!revealed) {\n      return;\n    }\n    const controls = animateValue(progress, 1, {\n      ...DEFAULT_CHART_ENTER_TRANSITION,\n      delay,\n    });\n    return () => controls.stop();\n  }, [animate, revealed, progress, delay]);\n  return progress;\n}\n\nfunction segmentArc(\n  segment: RingSegment,\n  p: number,\n  inner: number,\n  outer: number,\n  pad: number,\n  direction: number,\n  toAngle: (value: number) => number\n): ArcDatum {\n  const half = (pad / 2) * direction;\n  return {\n    inner,\n    outer,\n    start: toAngle(segment.y0 * p) - (segment.padStart ? half : 0),\n    end: toAngle(segment.y1 * p) + (segment.padEnd ? half : 0),\n    pad,\n  };\n}\n\nconst RingSegmentPath = memo(function RingSegmentPath({\n  segment,\n  progress,\n  static: isStatic,\n  arcPath,\n  inner,\n  outer,\n  pad,\n  direction,\n  toAngle,\n  dimmed,\n  seriesFadedOpacity,\n}: {\n  segment: RingSegment;\n  progress: MotionValue<number>;\n  static: boolean;\n  arcPath: (d: ArcDatum) => string;\n  inner: number;\n  outer: number;\n  pad: number;\n  direction: number;\n  toAngle: (value: number) => number;\n  dimmed: boolean;\n  seriesFadedOpacity: number;\n}) {\n  const d = useTransform(progress, (p) =>\n    arcPath(segmentArc(segment, p, inner, outer, pad, direction, toAngle))\n  );\n  return (\n    <g\n      style={{\n        opacity: segment.opacity * (dimmed ? seriesFadedOpacity : 1),\n        transition: \"opacity 0.15s ease-out\",\n      }}\n    >\n      {isStatic ? (\n        <path\n          d={arcPath(\n            segmentArc(segment, 1, inner, outer, pad, direction, toAngle)\n          )}\n          fill={segment.fill}\n        />\n      ) : (\n        <m.path d={d} fill={segment.fill} />\n      )}\n    </g>\n  );\n});\n\nconst RingRow = memo(function RingRow({\n  index,\n  segments,\n  inner,\n  outer,\n  pad,\n  direction,\n  arcPath,\n  toAngle,\n  trackPath,\n  trackColor,\n  trackOpacity,\n  revealed,\n  animate,\n  faded,\n  fadedOpacity,\n  hoveredSeries,\n  seriesFadedOpacity,\n}: {\n  index: number;\n  segments: RingSegment[];\n  inner: number;\n  outer: number;\n  pad: number;\n  direction: number;\n  arcPath: (d: ArcDatum) => string;\n  toAngle: (value: number) => number;\n  trackPath: string | null;\n  trackColor: string;\n  trackOpacity: number;\n  revealed: boolean;\n  animate: boolean;\n  faded: boolean;\n  fadedOpacity: number;\n  hoveredSeries: number | null;\n  seriesFadedOpacity: number;\n}) {\n  const expand = useEnterProgress(animate, revealed, index * EXPAND_STAGGER_S);\n  const sweep = useEnterProgress(\n    animate,\n    revealed,\n    SWEEP_DELAY_S + index * SWEEP_STAGGER_S\n  );\n  const expandDone = useEnterComplete(expand);\n  const sweepDone = useEnterComplete(sweep);\n\n  const content = (\n    <>\n      {trackPath ? (\n        <path d={trackPath} fill={trackColor} fillOpacity={trackOpacity} />\n      ) : null}\n      {segments.map((segment) => (\n        <RingSegmentPath\n          arcPath={arcPath}\n          dimmed={\n            hoveredSeries !== null && hoveredSeries !== segment.seriesIndex\n          }\n          direction={direction}\n          inner={inner}\n          key={segment.key}\n          outer={outer}\n          pad={pad}\n          progress={sweep}\n          segment={segment}\n          seriesFadedOpacity={seriesFadedOpacity}\n          static={!animate || sweepDone}\n          toAngle={toAngle}\n        />\n      ))}\n    </>\n  );\n\n  return (\n    <g\n      style={{\n        opacity: faded ? fadedOpacity : 1,\n        transition: \"opacity 0.15s ease-out\",\n      }}\n    >\n      {!animate || expandDone ? (\n        content\n      ) : (\n        <m.g style={{ scale: expand, transformOrigin: \"0px 0px\" }}>\n          {content}\n        </m.g>\n      )}\n    </g>\n  );\n});\n\n/**\n * Concentric ring bars for an `orientation=\"angular\"` {@link RadialChart}\n * (MUI-style \"horizontal\" radial bars / progress rings): every bin is a\n * ring, first bin innermost, and each series' value sweeps along the arc\n * from the start angle, stacked end to end. Works on full circles and\n * partial arcs (e.g. `startAngle={-90} endAngle={90}`). Enters like\n * `RingChart`: tracks grow out of the center, then values sweep in. For\n * single-value progress rings with hover glow, `RingChart` is the simpler\n * choice.\n */\nexport function RadialRingBar({\n  maxValue: maxValueProp,\n  cornerRadius = 0,\n  segmentGap = 0,\n  track = true,\n  trackColor = \"var(--foreground)\",\n  trackOpacity = 0.06,\n  fadedOpacity = 0.35,\n  seriesFadedOpacity = 0.2,\n}: RadialRingBarProps) {\n  const {\n    data,\n    series,\n    binCount,\n    startAngle,\n    endAngle,\n    isFullCircle,\n    valueDomain,\n    valueToAngle,\n    ringBand,\n    hoveredIndex,\n    hoveredSeries,\n    revealed,\n    animate,\n  } = useRadialChart();\n\n  const direction = endAngle >= startAngle ? 1 : -1;\n\n  const toAngle = useMemo(() => {\n    if (maxValueProp === undefined) {\n      return valueToAngle;\n    }\n    const lo = valueDomain[0];\n    const span = endAngle - startAngle;\n    return (value: number) => {\n      const t = maxValueProp > lo ? (value - lo) / (maxValueProp - lo) : 0;\n      return startAngle + Math.min(1, Math.max(0, t)) * span;\n    };\n  }, [maxValueProp, valueToAngle, valueDomain, startAngle, endAngle]);\n\n  const arcPath = useMemo(() => {\n    // d3 pads by a constant linear width (padRadius × padAngle) on both\n    // edges, so segment gaps stay parallel and rounded corners stay intact.\n    const generator = d3Arc<ArcDatum>()\n      .innerRadius((d) => d.inner)\n      .outerRadius((d) => d.outer)\n      .startAngle((d) => d.start)\n      .endAngle((d) => d.end)\n      .padAngle((d) => d.pad)\n      .cornerRadius((d) =>\n        cornerRadius === \"round\" ? (d.outer - d.inner) / 2 : cornerRadius\n      );\n    return (d: ArcDatum) =>\n      Math.abs(d.end - d.start) <= d.pad + 1e-4 ? \"\" : (generator(d) ?? \"\");\n  }, [cornerRadius]);\n\n  const rings = useMemo(\n    () =>\n      data.map((d, index) => {\n        const band = ringBand(index);\n        const segments: RingSegment[] = [];\n        let y0 = 0;\n        series.forEach((s, seriesIndex) => {\n          const value = Math.max(0, radialNumberAt(d, s.dataKey) ?? 0);\n          if (value <= 0) {\n            return;\n          }\n          segments.push({\n            key: s.dataKey,\n            seriesIndex,\n            fill: radialSeriesColor(s, seriesIndex),\n            opacity: s.opacity ?? 1,\n            y0,\n            y1: y0 + value,\n            padStart: false,\n            padEnd: false,\n          });\n          y0 += value;\n        });\n        // Gaps only between segments: the first segment keeps its start on\n        // the start angle and the last its true end (full circles keep a\n        // gap at the seam too).\n        const first = segments[0];\n        const last = segments.at(-1);\n        if (first && last && !isFullCircle) {\n          first.padStart = true;\n          last.padEnd = true;\n        }\n        // d3's default pad radius: √(inner² + outer²)\n        const padRadius = Math.sqrt(band.inner ** 2 + band.outer ** 2);\n        const pad =\n          segmentGap > 0 && segments.length > 0 && padRadius > 0\n            ? segmentGap / padRadius\n            : 0;\n        return { index, band, segments, pad };\n      }),\n    [data, series, ringBand, segmentGap, isFullCircle]\n  );\n\n  if (binCount === 0) {\n    return null;\n  }\n\n  return (\n    <g pointerEvents=\"none\">\n      {rings.map((ring) => (\n        <RingRow\n          animate={animate}\n          arcPath={arcPath}\n          direction={direction}\n          faded={hoveredIndex !== null && hoveredIndex !== ring.index}\n          fadedOpacity={fadedOpacity}\n          hoveredSeries={hoveredSeries}\n          index={ring.index}\n          inner={ring.band.inner}\n          key={ring.index}\n          outer={ring.band.outer}\n          pad={ring.pad}\n          revealed={revealed}\n          segments={ring.segments}\n          seriesFadedOpacity={seriesFadedOpacity}\n          toAngle={toAngle}\n          trackColor={trackColor}\n          trackOpacity={trackOpacity}\n          trackPath={\n            track\n              ? arcPath({\n                  inner: ring.band.inner,\n                  outer: ring.band.outer,\n                  start: startAngle,\n                  end: endAngle,\n                  pad: 0,\n                })\n              : null\n          }\n        />\n      ))}\n    </g>\n  );\n}\n\nRadialRingBar.displayName = \"RadialRingBar\";\n\nexport default RadialRingBar;\n",
      "type": "registry:component",
      "target": "components/charts/radial-ring-bar.tsx"
    },
    {
      "path": "src/charts/radial-dot-bar.tsx",
      "content": "\"use client\";\n\nimport { memo, type ReactNode, useId, useMemo } from \"react\";\nimport {\n  type PatternPresetId,\n  patternPresetTileSize,\n  renderPatternPreset,\n} from \"./pattern-preset\";\nimport { radialLabelFontSize } from \"./radial-axis\";\nimport {\n  type RadialSeries,\n  radialNumberAt,\n  useRadialChart,\n} from \"./radial-chart\";\nimport {\n  computeRadialDotGrid,\n  createRadialRowScale,\n  type RadialDotLayout,\n  type RadialDotSlot,\n  radialDotRowRadius,\n  stackRadialRows,\n} from \"./radial-dot-bar-layout\";\nimport { polarToCartesian } from \"./radial-geometry\";\nimport { radialSeriesColor } from \"./radial-series-color\";\n\n/**\n * Pattern fill for a series' (or the ghost layer's) dots, built from the\n * shared pattern presets (`renderPatternPreset`).\n */\nexport interface RadialDotPattern {\n  /** Pattern preset. Default: \"diagonal-up\" */\n  preset?: PatternPresetId;\n  /**\n   * Custom fill instead of a preset — e.g. `url(#my-pattern)` for a pattern\n   * you render yourself. Ignores `preset`, `color`, `scale` and `units`.\n   */\n  fill?: string;\n  /** Stripe / dot color. Default: the series color */\n  color?: string;\n  /** Pattern tile scale (1 = preset size). Default: 1 */\n  scale?: number;\n  /** Stripe stroke width in px. Default: preset width × scale */\n  strokeWidth?: number;\n  /** Tile background behind the stripes. Default: none (transparent) */\n  background?: string;\n  /** Ring around each dot so patterned dots read as circles. Default: `color`; \"none\" hides it */\n  stroke?: string;\n  /** Ring width in px. Default: 1 */\n  ringWidth?: number;\n  /**\n   * - `dot`: the tile is fitted to each dot so every dot shows the same,\n   *   centred stripes.\n   * - `chart`: one continuous texture across the chart, seen through the dots.\n   * Default: \"dot\"\n   */\n  units?: \"dot\" | \"chart\";\n}\n\nexport interface RadialDotBarAnnotation {\n  /** Bin index the label points at */\n  index: number;\n  label: string;\n  /**\n   * Text color. Default: the first `emphasize` series' color, else the\n   * color of the series at the bin's tip\n   */\n  color?: string;\n}\n\nexport interface RadialDotBarProps {\n  /**\n   * Datum key of a comparison value (e.g. yesterday's total), drawn as dim\n   * \"ghost\" dots in bins where no series has a value (the rest of the day).\n   */\n  ghostDataKey?: string;\n  /** Ghost dot color. Default: var(--foreground) */\n  ghostColor?: string;\n  /** Ghost dot opacity. Default: 0.22 */\n  ghostOpacity?: number;\n  /** Value mapped to the outermost row. Default: largest stack / ghost value */\n  maxValue?: number;\n  /** Concentric rows across the value band. Default: 14 */\n  rows?: number;\n  /** Dot placement on each row. Default: \"grid\" (even density) */\n  layout?: RadialDotLayout;\n  /** Dot diameter as a fraction of the row spacing. Default: 0.44 */\n  dotSize?: number;\n  /** Ghost dot diameter as a fraction of the row spacing. Default: 0.26 */\n  ghostDotSize?: number;\n  /** Fraction of the rows over which `rampFrom` series reach full opacity. Default: 0.5 */\n  rampExtent?: number;\n  /** Callout labels outside a bin's tip */\n  annotations?: RadialDotBarAnnotation[];\n  /** Opacity of other series while a legend item is hovered. Default: 0.2 */\n  fadedOpacity?: number;\n  /**\n   * Pattern fills keyed by series `dataKey` (use `\"ghost\"` for the ghost\n   * layer). Pass a preset id (e.g. `\"diagonal-up\"`) or a {@link RadialDotPattern}.\n   * Series without an entry stay solid.\n   */\n  patterns?: Record<string, PatternPresetId | RadialDotPattern>;\n}\n\nconst ENTER_SPREAD_S = 0.75;\nconst ENTER_ROW_STEP_S = 0.03;\nconst ENTER_DURATION_S = 0.36;\n\ninterface DotSpec {\n  key: string;\n  x: number;\n  y: number;\n  r: number;\n  opacity: number;\n  delay: number;\n}\n\ninterface DotGroup {\n  key: string;\n  /** Series index, or -1 for the ghost layer */\n  seriesIndex: number;\n  fill: string;\n  stroke?: string;\n  strokeWidth?: number;\n  dots: DotSpec[];\n}\n\ninterface ResolvedDotFill {\n  fill: string;\n  stroke?: string;\n  strokeWidth?: number;\n  /** Ring inset so the outer edge of the ring matches the dot size */\n  inset: number;\n  defs: ReactNode;\n}\n\n/** Largest tile count ≥ 1 that keeps the tile near the preset size. */\nfunction fitTileCount(diameter: number, tile: number): number {\n  return Math.max(1, Math.round(diameter / Math.max(0.5, tile)));\n}\n\nfunction resolveDotFill({\n  pattern,\n  color,\n  id,\n  diameter,\n}: {\n  pattern: PatternPresetId | RadialDotPattern | undefined;\n  color: string;\n  id: string;\n  diameter: number;\n}): ResolvedDotFill {\n  if (pattern === undefined || pattern === \"none\") {\n    return { fill: color, inset: 0, defs: null };\n  }\n  const spec: RadialDotPattern =\n    typeof pattern === \"string\" ? { preset: pattern } : pattern;\n  const patternColor = spec.color ?? color;\n  const stroke = spec.stroke ?? patternColor;\n  const ringWidth = stroke === \"none\" ? 0 : (spec.ringWidth ?? 1);\n  const ring = {\n    stroke: ringWidth > 0 ? stroke : undefined,\n    strokeWidth: ringWidth > 0 ? ringWidth : undefined,\n    inset: ringWidth / 2,\n  };\n  if (spec.fill) {\n    return { fill: spec.fill, defs: null, ...ring };\n  }\n  const preset = spec.preset ?? \"diagonal-up\";\n  if (preset === \"none\") {\n    return { fill: patternColor, defs: null, ...ring };\n  }\n  const baseScale = spec.scale ?? 1;\n  const options = {\n    color: patternColor,\n    strokeWidth: spec.strokeWidth,\n    tileBackground: spec.background,\n  };\n  if ((spec.units ?? \"dot\") === \"chart\" || diameter <= 0) {\n    return {\n      fill: `url(#${id})`,\n      defs: renderPatternPreset(preset, id, { ...options, scale: baseScale }),\n      ...ring,\n    };\n  }\n  // Fit a whole number of tiles across the dot, then re-anchor the tile to\n  // each dot's bounding box so every dot shows identical, centred stripes.\n  const tile = patternPresetTileSize(preset, baseScale).width;\n  const count = fitTileCount(diameter, tile);\n  const scale = (baseScale * diameter) / (count * tile);\n  const baseId = `${id}-base`;\n  return {\n    fill: `url(#${id})`,\n    defs: (\n      <>\n        {renderPatternPreset(preset, baseId, { ...options, scale })}\n        <defs>\n          <pattern\n            height={1 / count}\n            href={`#${baseId}`}\n            id={id}\n            patternUnits=\"objectBoundingBox\"\n            width={1 / count}\n            x={0}\n            y={0}\n          />\n        </defs>\n      </>\n    ),\n    ...ring,\n  };\n}\n\nconst DotGroups = memo(function DotGroups({\n  groups,\n  revealed,\n  animate,\n  hoveredSeries,\n  fadedOpacity,\n}: {\n  groups: DotGroup[];\n  revealed: boolean;\n  animate: boolean;\n  hoveredSeries: number | null;\n  fadedOpacity: number;\n}) {\n  return (\n    <g pointerEvents=\"none\">\n      {groups.map((group) => {\n        const dimmed =\n          hoveredSeries !== null && hoveredSeries !== group.seriesIndex;\n        return (\n          <g\n            fill={group.fill}\n            key={group.key}\n            stroke={group.stroke}\n            strokeWidth={group.strokeWidth}\n            style={{\n              opacity: dimmed ? fadedOpacity : 1,\n              transition: \"opacity 0.15s ease-out\",\n            }}\n          >\n            {group.dots.map((dot) => (\n              <circle\n                cx={dot.x}\n                cy={dot.y}\n                key={dot.key}\n                r={dot.r}\n                style={{\n                  opacity: revealed ? dot.opacity : 0,\n                  transform: revealed ? undefined : \"scale(0)\",\n                  transformBox: \"fill-box\",\n                  transformOrigin: \"center\",\n                  transition: animate\n                    ? `opacity ${ENTER_DURATION_S}s ease-out ${dot.delay}s, transform ${ENTER_DURATION_S}s cubic-bezier(0.2, 0.8, 0.2, 1) ${dot.delay}s`\n                    : undefined,\n                }}\n              />\n            ))}\n          </g>\n        );\n      })}\n    </g>\n  );\n});\n\n/**\n * Default annotation color: the first `emphasize` series, else the topmost\n * series with dots in the annotated bin.\n */\nfunction annotationSeriesColor(\n  series: RadialSeries[],\n  ends: number[] | undefined\n): string {\n  let index = series.findIndex((s) => s.emphasize);\n  if (index < 0 && ends) {\n    for (let i = ends.length - 1; i >= 0; i--) {\n      if ((ends[i] ?? 0) > (i > 0 ? (ends[i - 1] ?? 0) : 0)) {\n        index = i;\n        break;\n      }\n    }\n  }\n  return radialSeriesColor(series[index], Math.max(0, index));\n}\n\n/**\n * Radial dot bars for {@link RadialChart}: every bin is a stacked column of\n * dots on concentric rows (innermost series first), cascading outward on\n * enter like `BarSquares`. A radial scale keeps the dot count proportional to\n * the value with the default `grid` layout.\n */\nexport function RadialDotBar({\n  ghostDataKey,\n  ghostColor = \"var(--foreground)\",\n  ghostOpacity = 0.22,\n  maxValue: maxValueProp,\n  rows = 14,\n  layout = \"grid\",\n  dotSize = 0.44,\n  ghostDotSize = 0.26,\n  rampExtent = 0.5,\n  annotations,\n  fadedOpacity = 0.2,\n  patterns,\n}: RadialDotBarProps) {\n  const patternIdBase = `radial-dot-pattern-${useId().replace(/:/g, \"\")}`;\n  const {\n    data,\n    series,\n    binCount,\n    radius,\n    layout: ringLayout,\n    innerRadius,\n    outerRadius,\n    futureBins,\n    hoveredSeries,\n    revealed,\n    animate,\n    binAngle,\n  } = useRadialChart();\n\n  const maxValue = useMemo(() => {\n    if (maxValueProp !== undefined) {\n      return maxValueProp;\n    }\n    let max = 0;\n    data.forEach((d, i) => {\n      const value = futureBins[i]\n        ? ((ghostDataKey ? radialNumberAt(d, ghostDataKey) : 0) ?? 0)\n        : series.reduce(\n            (sum, s) => sum + Math.max(0, radialNumberAt(d, s.dataKey) ?? 0),\n            0\n          );\n      max = Math.max(max, value);\n    });\n    return max;\n  }, [maxValueProp, data, series, futureBins, ghostDataKey]);\n\n  const spacing = rows > 0 ? (outerRadius - innerRadius) / rows : 0;\n\n  const grid = useMemo(\n    () =>\n      computeRadialDotGrid({\n        binCount,\n        baseRadius: innerRadius,\n        spacing,\n        rows,\n        layout,\n      }),\n    [binCount, innerRadius, spacing, rows, layout]\n  );\n\n  const toRows = useMemo(\n    () =>\n      createRadialRowScale({\n        maxValue,\n        baseRadius: innerRadius,\n        spacing,\n        rows,\n      }),\n    [maxValue, innerRadius, spacing, rows]\n  );\n\n  /** Exclusive end row of each series per bin (future bins: the ghost). */\n  const stackEnds = useMemo(\n    () =>\n      data.map((d, i) => {\n        if (futureBins[i]) {\n          const ghost = ghostDataKey\n            ? (radialNumberAt(d, ghostDataKey) ?? 0)\n            : 0;\n          return stackRadialRows([ghost], toRows, rows);\n        }\n        return stackRadialRows(\n          series.map((s) => radialNumberAt(d, s.dataKey) ?? 0),\n          toRows,\n          rows\n        );\n      }),\n    [data, futureBins, ghostDataKey, series, toRows, rows]\n  );\n\n  const fills = useMemo(() => {\n    const dotDiameter = spacing * dotSize;\n    const seriesFills = series.map((s, seriesIndex) =>\n      resolveDotFill({\n        pattern: patterns?.[s.dataKey],\n        color: radialSeriesColor(s, seriesIndex),\n        id: `${patternIdBase}-${seriesIndex}`,\n        diameter: dotDiameter,\n      })\n    );\n    const ghostFill = resolveDotFill({\n      pattern: patterns?.ghost,\n      color: ghostColor,\n      id: `${patternIdBase}-ghost`,\n      diameter: spacing * ghostDotSize,\n    });\n    return { series: seriesFills, ghost: ghostFill };\n  }, [\n    spacing,\n    dotSize,\n    ghostDotSize,\n    series,\n    patterns,\n    ghostColor,\n    patternIdBase,\n  ]);\n\n  const groups = useMemo((): DotGroup[] => {\n    if (spacing <= 0) {\n      return [];\n    }\n    const seriesGroups: DotGroup[] = series.map((s, seriesIndex) => {\n      const f = fills.series[seriesIndex];\n      return {\n        key: `series-${s.dataKey}`,\n        seriesIndex,\n        fill: f?.fill ?? radialSeriesColor(s, seriesIndex),\n        stroke: f?.stroke,\n        strokeWidth: f?.strokeWidth,\n        dots: [],\n      };\n    });\n    const ghostGroup: DotGroup = {\n      key: \"ghost\",\n      seriesIndex: -1,\n      fill: fills.ghost.fill,\n      stroke: fills.ghost.stroke,\n      strokeWidth: fills.ghost.strokeWidth,\n      dots: [],\n    };\n    const seriesR = series.map((_, i) =>\n      Math.max(0, (spacing * dotSize) / 2 - (fills.series[i]?.inset ?? 0))\n    );\n    const ghostR = Math.max(\n      0,\n      (spacing * ghostDotSize) / 2 - fills.ghost.inset\n    );\n    const rampRows = Math.max(1, rows * rampExtent);\n\n    const seriesDot = (slot: RadialDotSlot, ends: number[]) => {\n      const seriesIndex = ends.findIndex((end) => slot.row < end);\n      const s = series[seriesIndex];\n      const group = seriesGroups[seriesIndex];\n      if (!(s && group)) {\n        return null;\n      }\n      const rampFrom = s.rampFrom ?? 1;\n      const ramp = rampFrom + (1 - rampFrom) * Math.min(1, slot.row / rampRows);\n      return {\n        group,\n        r: seriesR[seriesIndex] ?? 0,\n        opacity: (s.opacity ?? 1) * ramp,\n      };\n    };\n\n    grid.forEach((slots, bin) => {\n      const ends = stackEnds[bin] ?? [];\n      const binDelay = (bin / Math.max(1, binCount)) * ENTER_SPREAD_S;\n      for (const slot of slots) {\n        let target: { group: DotGroup; r: number; opacity: number } | null =\n          null;\n        if (futureBins[bin]) {\n          target =\n            (ends[0] ?? 0) > slot.row\n              ? { group: ghostGroup, r: ghostR, opacity: ghostOpacity }\n              : null;\n        } else {\n          target = seriesDot(slot, ends);\n        }\n        target?.group.dots.push({\n          key: `${bin}-${slot.row}-${slot.angle.toFixed(4)}`,\n          x: slot.x,\n          y: slot.y,\n          r: target.r,\n          opacity: target.opacity,\n          delay: binDelay + slot.row * ENTER_ROW_STEP_S,\n        });\n      }\n    });\n\n    return [ghostGroup, ...seriesGroups];\n  }, [\n    spacing,\n    series,\n    ghostColor,\n    dotSize,\n    ghostDotSize,\n    rows,\n    rampExtent,\n    grid,\n    stackEnds,\n    binCount,\n    futureBins,\n    ghostOpacity,\n    fills,\n  ]);\n\n  const fontSize = radialLabelFontSize(radius);\n\n  return (\n    <g>\n      {fills.ghost.defs}\n      {fills.series.map((f, i) => (\n        <g key={series[i]?.dataKey ?? i}>{f.defs}</g>\n      ))}\n      <DotGroups\n        animate={animate}\n        fadedOpacity={fadedOpacity}\n        groups={groups}\n        hoveredSeries={hoveredSeries}\n        revealed={revealed}\n      />\n      {annotations?.map((annotation) => {\n        const ends = stackEnds[annotation.index];\n        const annotationColor =\n          annotation.color ?? annotationSeriesColor(series, ends);\n        const tipRow = Math.max(0, (ends?.at(-1) ?? 1) - 1);\n        const tipRadius = radialDotRowRadius(innerRadius, spacing, tipRow);\n        const angle = binAngle(annotation.index);\n        const p = polarToCartesian(\n          angle,\n          Math.max(\n            tipRadius + spacing * 2.5,\n            radius * ringLayout.axisLabel * 1.02\n          )\n        );\n        const sin = Math.sin(angle);\n        let anchor: \"start\" | \"middle\" | \"end\" = \"middle\";\n        if (sin > 0.25) {\n          anchor = \"start\";\n        } else if (sin < -0.25) {\n          anchor = \"end\";\n        }\n        return (\n          <text\n            className=\"font-mono\"\n            dominantBaseline=\"central\"\n            fill={annotationColor}\n            fontSize={fontSize}\n            key={`annotation-${annotation.index}-${annotation.label}`}\n            pointerEvents=\"none\"\n            style={{\n              opacity: revealed ? 1 : 0,\n              transition: animate\n                ? `opacity 0.4s ease-out ${ENTER_SPREAD_S}s`\n                : undefined,\n            }}\n            textAnchor={anchor}\n            x={p.x}\n            y={p.y}\n          >\n            {annotation.label}\n          </text>\n        );\n      })}\n    </g>\n  );\n}\n\nRadialDotBar.displayName = \"RadialDotBar\";\n\nexport default RadialDotBar;\n",
      "type": "registry:component",
      "target": "components/charts/radial-dot-bar.tsx"
    },
    {
      "path": "src/charts/radial-dot-bar-layout.ts",
      "content": "import { createRadialScale, polarToCartesian, TAU } from \"./radial-geometry\";\n\n/**\n * How dots are placed on each concentric row.\n * - `grid`: constant arc spacing, so outer rows hold more dots and the dot\n *   density stays even (area ∝ value with a radial scale).\n * - `columns`: one dot per bin on every row — strict radial columns.\n */\nexport type RadialDotLayout = \"grid\" | \"columns\";\n\nexport interface RadialDotSlot {\n  /** Row index, 0 = innermost */\n  row: number;\n  /** Bin (time slot) the dot belongs to */\n  bin: number;\n  /** Angle in radians, 0 = 12 o'clock, clockwise */\n  angle: number;\n  radius: number;\n  x: number;\n  y: number;\n}\n\nexport interface RadialDotGridInput {\n  binCount: number;\n  /** Radius of the band's base (row 0 sits half a step outside it) */\n  baseRadius: number;\n  /** Centre-to-centre spacing between rows (and between dots on a row) */\n  spacing: number;\n  rows: number;\n  layout?: RadialDotLayout;\n}\n\n/** Radius of row `row` (dot centre). */\nexport function radialDotRowRadius(\n  baseRadius: number,\n  spacing: number,\n  row: number\n): number {\n  return baseRadius + spacing * (row + 0.5);\n}\n\n/**\n * Every dot slot of the polar grid, grouped by bin and sorted by row\n * (innermost first). Bins with no slot on a row simply skip that row.\n */\nexport function computeRadialDotGrid({\n  binCount,\n  baseRadius,\n  spacing,\n  rows,\n  layout = \"grid\",\n}: RadialDotGridInput): RadialDotSlot[][] {\n  const byBin: RadialDotSlot[][] = Array.from({ length: binCount }, () => []);\n  if (binCount <= 0 || spacing <= 0 || rows <= 0) {\n    return byBin;\n  }\n\n  for (let row = 0; row < rows; row++) {\n    const radius = radialDotRowRadius(baseRadius, spacing, row);\n    const count =\n      layout === \"columns\"\n        ? binCount\n        : Math.max(1, Math.round((TAU * radius) / spacing));\n\n    for (let j = 0; j < count; j++) {\n      const angle = ((j + 0.5) / count) * TAU;\n      const bin = Math.min(binCount - 1, Math.floor((angle / TAU) * binCount));\n      const { x, y } = polarToCartesian(angle, radius);\n      byBin[bin]?.push({ row, bin, angle, radius, x, y });\n    }\n  }\n\n  return byBin;\n}\n\n/**\n * Value → row count, using a radial scale so the dot area (and with the `grid`\n * layout, the dot count) grows linearly with the value.\n */\nexport function createRadialRowScale({\n  maxValue,\n  baseRadius,\n  spacing,\n  rows,\n}: {\n  maxValue: number;\n  baseRadius: number;\n  spacing: number;\n  rows: number;\n}): (value: number) => number {\n  const radial = createRadialScale({\n    maxValue,\n    innerRadius: baseRadius,\n    outerRadius: baseRadius + rows * spacing,\n  });\n  return (value: number) => {\n    if (!(value > 0)) {\n      return 0;\n    }\n    return Math.max(0, Math.round((radial(value) - baseRadius) / spacing));\n  };\n}\n\n/**\n * Stacked row boundaries for one bin: `ends[i]` is the exclusive end row of\n * series `i`. Positive values always get at least one row.\n */\nexport function stackRadialRows(\n  values: number[],\n  toRows: (value: number) => number,\n  maxRows: number\n): number[] {\n  const ends: number[] = [];\n  let cumulative = 0;\n  let previousEnd = 0;\n  for (const value of values) {\n    const v = value > 0 ? value : 0;\n    cumulative += v;\n    let end = toRows(cumulative);\n    if (v > 0) {\n      end = Math.max(end, previousEnd + 1);\n    }\n    end = Math.min(maxRows, Math.max(previousEnd, end));\n    ends.push(end);\n    previousEnd = end;\n  }\n  return ends;\n}\n",
      "type": "registry:component",
      "target": "components/charts/radial-dot-bar-layout.ts"
    },
    {
      "path": "src/charts/radial-line.tsx",
      "content": "\"use client\";\n\nimport {\n  type CurveFactory,\n  curveCatmullRom,\n  curveCatmullRomClosed,\n  lineRadial,\n} from \"d3-shape\";\nimport { m, type SpringOptions } from \"motion/react\";\nimport { memo, useId, useMemo } from \"react\";\nimport {\n  createRadialEdgeLookup,\n  type RadialDotMotion,\n  RadialHoverDot,\n  radialNumberAt,\n  useRadialChart,\n  useRadialDotAngle,\n} from \"./radial-chart\";\nimport { createRadialScale, polarToCartesian } from \"./radial-geometry\";\nimport { radialSeriesColor } from \"./radial-series-color\";\n\nexport interface RadialLineProps {\n  /**\n   * Datum key plotted by this line. When it matches a `RadialChart` series the\n   * line inherits its color / opacity and dims with the legend; any other key\n   * (e.g. `\"yesterday\"`) is drawn as a comparison line.\n   */\n  dataKey: string;\n  /** Stroke color. Default: the series color (else its palette slot), else var(--foreground) */\n  stroke?: string;\n  /** Stroke width in px. Default: 2 */\n  strokeWidth?: number;\n  /** Stroke opacity. Default: the series opacity, else 1 */\n  strokeOpacity?: number;\n  /**\n   * Dash pattern — `true` for `strokeWidth` dashes with 3× gaps,\n   * or any SVG `stroke-dasharray`. Default: solid\n   */\n  dash?: boolean | string;\n  /**\n   * d3 curve. Default: `curveCatmullRomClosed` for a closed loop, else\n   * `curveCatmullRom` (smooth without overshooting the data)\n   */\n  curve?: CurveFactory;\n  /** Join the last bin back to the first. Default: true when every bin has a value on a full circle */\n  closed?: boolean;\n  /**\n   * Value mapped to the band top. Default: the chart's shared scale when\n   * `RadialChart` has `maxValue`, else the largest value across every series\n   * and this line\n   */\n  maxValue?: number;\n  /** Point marker on the hovered bin, riding the line. Default: true */\n  showMarkers?: boolean;\n  /**\n   * How the hover marker follows the pointer: locked to the crosshair (the\n   * `RadialScrubber` spring), its own spring (`markerSpring`) or snapping to\n   * the bin. Reduced motion always snaps. Default: \"crosshair\"\n   */\n  markerMotion?: RadialDotMotion;\n  /** `markerMotion=\"spring\"`: the marker's spring. Default: `{ stiffness: 240, damping: 24 }` */\n  markerSpring?: SpringOptions;\n  /** Marker at the line's last value (\"now\"). Default: true for open lines */\n  showNowMarker?: boolean;\n  /** Soft pulse around the \"now\" marker (off under reduced motion). Default: true */\n  pulse?: boolean;\n  /** Marker radius in px. Default: scales with the chart */\n  markerRadius?: number;\n  /** Opacity while another series is hovered in the legend. Default: 0.2 */\n  fadedOpacity?: number;\n  /** Delay before the clockwise draw starts, in seconds. Default: 0 */\n  enterDelay?: number;\n}\n\nconst DRAW_DURATION_S = 1.2;\nconst DRAW_EASE = \"cubic-bezier(0.65, 0, 0.35, 1)\";\n\ninterface Point {\n  x: number;\n  y: number;\n}\n\nconst NowMarker = memo(function NowMarker({\n  point,\n  color,\n  r,\n  pulse,\n  visible,\n  animate,\n  delay,\n}: {\n  point: Point;\n  color: string;\n  r: number;\n  pulse: boolean;\n  visible: boolean;\n  animate: boolean;\n  delay: number;\n}) {\n  return (\n    <g\n      style={{\n        opacity: visible ? 1 : 0,\n        transition: animate ? `opacity 0.3s ease-out ${delay}s` : undefined,\n      }}\n      transform={`translate(${point.x},${point.y})`}\n    >\n      {pulse ? (\n        <m.circle\n          animate={{ scale: [1, 2.6], opacity: [0.35, 0] }}\n          fill={color}\n          r={r}\n          style={{ transformBox: \"fill-box\", transformOrigin: \"center\" }}\n          transition={{\n            duration: 2,\n            ease: \"easeOut\",\n            repeat: Number.POSITIVE_INFINITY,\n            repeatDelay: 0.4,\n          }}\n        />\n      ) : null}\n      <circle fill={color} fillOpacity={0.16} r={r * 2} />\n      <circle\n        fill={color}\n        r={r}\n        stroke=\"var(--background)\"\n        strokeWidth={Math.max(1.5, r * 0.5)}\n      />\n    </g>\n  );\n});\n\nfunction isClosedLoop(isFullCircle: boolean, values: (number | null)[]) {\n  return isFullCircle && values.every((v) => v !== null);\n}\n\nfunction ownRadialScale(\n  maxValue: number,\n  innerRadius: number,\n  outerRadius: number\n): (value: number) => number {\n  const scale = createRadialScale({ maxValue, innerRadius, outerRadius });\n  return (value) => scale(value) ?? innerRadius;\n}\n\n/** Edge lookup for the hover marker — the same curve as the drawn line. */\nfunction lineEdgeLookup(\n  values: (number | null)[],\n  binAngle: (index: number) => number,\n  scale: (value: number) => number,\n  curve: CurveFactory\n) {\n  const indices = values.map((_, i) => i);\n  return createRadialEdgeLookup((context) =>\n    lineRadial<number>()\n      .angle((i) => binAngle(i))\n      .radius((i) => scale(values[i] ?? 0))\n      .defined((i) => values[i] !== null)\n      .curve(curve)\n      .context(context)(indices)\n  );\n}\n\nfunction resolveDashArray(\n  dash: boolean | string | undefined,\n  strokeWidth: number\n): string | undefined {\n  if (dash === true) {\n    return `${strokeWidth} ${strokeWidth * 3}`;\n  }\n  return typeof dash === \"string\" ? dash : undefined;\n}\n\n/**\n * Smooth radial line for {@link RadialChart}: one value per bin plotted\n * around the 24h clock (00 at the top, clockwise) with `d3.lineRadial`.\n * Draws itself clockwise on enter, ends in a \"now\" marker and marks the\n * hovered bin with a dot that rides the line, locked to the crosshair by\n * default (`markerMotion`). Dash it and give it a non-series `dataKey` for a comparison line\n * (e.g. yesterday).\n */\nexport function RadialLine({\n  dataKey,\n  stroke: strokeProp,\n  strokeWidth = 2,\n  strokeOpacity: strokeOpacityProp,\n  dash,\n  curve: curveProp,\n  closed: closedProp,\n  maxValue: maxValueProp,\n  showMarkers = true,\n  markerMotion = \"crosshair\",\n  markerSpring,\n  showNowMarker: showNowMarkerProp,\n  pulse = true,\n  markerRadius: markerRadiusProp,\n  fadedOpacity = 0.2,\n  enterDelay = 0,\n}: RadialLineProps) {\n  const {\n    data,\n    series,\n    binCount,\n    radius,\n    innerRadius,\n    outerRadius,\n    hoveredIndex,\n    hoveredSeries,\n    revealed,\n    animate,\n    reducedMotion,\n    binAngle,\n    isFullCircle,\n    sharedValueScale,\n    valueScale,\n  } = useRadialChart();\n  const maskId = `radial-line-mask-${useId().replace(/:/g, \"\")}`;\n\n  const seriesIndex = series.findIndex((s) => s.dataKey === dataKey);\n  const seriesConfig = seriesIndex >= 0 ? series[seriesIndex] : undefined;\n  const stroke = strokeProp ?? radialSeriesColor(seriesConfig, seriesIndex);\n  const strokeOpacity = strokeOpacityProp ?? seriesConfig?.opacity ?? 1;\n\n  const values = useMemo(\n    () => data.map((d) => radialNumberAt(d, dataKey)),\n    [data, dataKey]\n  );\n\n  const maxValue = useMemo(() => {\n    if (maxValueProp !== undefined) {\n      return maxValueProp;\n    }\n    let max = 0;\n    for (const d of data) {\n      for (const key of [dataKey, ...series.map((s) => s.dataKey)]) {\n        max = Math.max(max, radialNumberAt(d, key) ?? 0);\n      }\n    }\n    return max;\n  }, [maxValueProp, data, dataKey, series]);\n\n  const scale = useMemo(\n    () =>\n      maxValueProp === undefined && sharedValueScale\n        ? valueScale\n        : ownRadialScale(maxValue, innerRadius, outerRadius),\n    [\n      maxValueProp,\n      sharedValueScale,\n      valueScale,\n      maxValue,\n      innerRadius,\n      outerRadius,\n    ]\n  );\n\n  const closed = closedProp ?? isClosedLoop(isFullCircle, values);\n  const curve = curveProp ?? (closed ? curveCatmullRomClosed : curveCatmullRom);\n\n  const pathD = useMemo(() => {\n    if (binCount === 0 || outerRadius <= 0) {\n      return null;\n    }\n    const indices = values.map((_, i) => i);\n    const generator = lineRadial<number>()\n      .angle((i) => binAngle(i))\n      .radius((i) => scale(values[i] ?? 0))\n      .defined((i) => values[i] !== null)\n      .curve(curve);\n    return generator(indices);\n  }, [binCount, outerRadius, values, binAngle, scale, curve]);\n\n  const edge = useMemo(\n    () =>\n      showMarkers && pathD\n        ? lineEdgeLookup(values, binAngle, scale, curve)\n        : null,\n    [showMarkers, pathD, values, binAngle, scale, curve]\n  );\n  const markerAngle = useRadialDotAngle(markerMotion, markerSpring);\n\n  const pointAt = (index: number | null): Point | null => {\n    if (index === null) {\n      return null;\n    }\n    const value = values[index];\n    if (value === null || value === undefined) {\n      return null;\n    }\n    return polarToCartesian(binAngle(index), scale(value));\n  };\n\n  const lastIndex = useMemo(() => {\n    for (let i = values.length - 1; i >= 0; i--) {\n      if (values[i] !== null) {\n        return i;\n      }\n    }\n    return null;\n  }, [values]);\n\n  const showNowMarker = showNowMarkerProp ?? !closed;\n  const markerRadius = markerRadiusProp ?? Math.max(3.5, radius * 0.016);\n  const nowPoint = showNowMarker ? pointAt(lastIndex) : null;\n  // Only while the hovered bin has a value on this line.\n  const markerLookup = pointAt(hoveredIndex) ? edge : null;\n\n  const dashArray = resolveDashArray(dash, strokeWidth);\n\n  const dimmed = hoveredSeries !== null && hoveredSeries !== seriesIndex;\n  const drawEnd = enterDelay + DRAW_DURATION_S;\n\n  if (!pathD) {\n    return null;\n  }\n\n  return (\n    <g\n      pointerEvents=\"none\"\n      style={{\n        opacity: dimmed ? fadedOpacity : 1,\n        transition: \"opacity 0.15s ease-out\",\n      }}\n    >\n      {animate ? (\n        <defs>\n          <mask\n            height={radius * 2}\n            id={maskId}\n            maskUnits=\"userSpaceOnUse\"\n            width={radius * 2}\n            x={-radius}\n            y={-radius}\n          >\n            <path\n              d={pathD}\n              fill=\"none\"\n              pathLength={1}\n              stroke=\"white\"\n              strokeDasharray=\"1 1\"\n              strokeLinecap=\"butt\"\n              strokeWidth={strokeWidth + 6}\n              style={{\n                strokeDashoffset: revealed ? 0 : 1,\n                transition: `stroke-dashoffset ${DRAW_DURATION_S}s ${DRAW_EASE} ${enterDelay}s`,\n              }}\n            />\n          </mask>\n        </defs>\n      ) : null}\n\n      <path\n        d={pathD}\n        fill=\"none\"\n        mask={animate ? `url(#${maskId})` : undefined}\n        stroke={stroke}\n        strokeDasharray={dashArray}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeOpacity={strokeOpacity}\n        strokeWidth={strokeWidth}\n      />\n\n      {nowPoint ? (\n        <NowMarker\n          animate={animate}\n          color={stroke}\n          delay={drawEnd}\n          point={nowPoint}\n          pulse={pulse && !reducedMotion}\n          r={markerRadius}\n          visible={revealed}\n        />\n      ) : null}\n\n      {markerLookup ? (\n        <RadialHoverDot\n          angle={markerAngle}\n          fill={stroke}\n          fillOpacity={Math.max(strokeOpacity, 0.5)}\n          lookup={markerLookup}\n          r={markerRadius}\n          stroke=\"var(--background)\"\n          strokeWidth={Math.max(1.5, markerRadius * 0.5)}\n        />\n      ) : null}\n    </g>\n  );\n}\n\nRadialLine.displayName = \"RadialLine\";\n\nexport default RadialLine;\n",
      "type": "registry:component",
      "target": "components/charts/radial-line.tsx"
    },
    {
      "path": "src/charts/radial-area.tsx",
      "content": "\"use client\";\n\nimport {\n  areaRadial,\n  type CurveFactory,\n  curveBasis,\n  curveBasisClosed,\n  curveCatmullRom,\n  curveCatmullRomClosed,\n  curveLinear,\n  curveLinearClosed,\n  lineRadial,\n} from \"d3-shape\";\nimport {\n  animate as animateValue,\n  type MotionValue,\n  type SpringOptions,\n  useMotionValue,\n} from \"motion/react\";\nimport { memo, useEffect, useId, useMemo, useRef } from \"react\";\nimport {\n  createRadialEdgeLookup,\n  type RadialDotMotion,\n  type RadialEdgeLookup,\n  RadialHoverDot,\n  radialNumberAt,\n  useRadialChart,\n  useRadialDotAngle,\n} from \"./radial-chart\";\nimport { createRadialScale, radialWedgePath } from \"./radial-geometry\";\nimport { radialSeriesColor } from \"./radial-series-color\";\n\nexport type RadialAreaCurve = \"catmullRom\" | \"basis\" | \"linear\";\n\nexport type RadialAreaStroke = \"outer\" | \"both\" | \"none\";\n\nexport interface RadialAreaProps {\n  /**\n   * Draw a single area for this datum key (it need not be a chart series;\n   * a matching series supplies color, opacity and legend dimming). Omit to\n   * draw every chart series.\n   */\n  dataKey?: string;\n  /** Inner edge of a single area — e.g. a min/max band. Default: the band base (0) */\n  baseDataKey?: string;\n  /** Series mode: stack series outward (true) or overlay them from the base. Default: true */\n  stacked?: boolean;\n  /** Fill / stroke color for a single area. Default: the series color (else its palette slot), else var(--foreground) */\n  color?: string;\n  /** Draw the fill. Default: true (false gives an outline-only line) */\n  fill?: boolean;\n  /** Fill opacity at the outer edge. Default: 0.32 */\n  fillOpacity?: number;\n  /**\n   * Radial gradient: fraction of `fillOpacity` at the band base, rising to\n   * the full value at the outer radius. Default: the series' `rampFrom`, else 0.25\n   */\n  rampFrom?: number;\n  /** Which edges get a stroke. Default: \"outer\" */\n  stroke?: RadialAreaStroke;\n  /** Stroke width in px. Default: 1.5 */\n  strokeWidth?: number;\n  /** Stroke opacity. Default: 1 */\n  strokeOpacity?: number;\n  /** e.g. \"4 4\" for a dashed line */\n  strokeDasharray?: string;\n  /** Curve through the bin centers (closed when every bin has a value). Default: \"catmullRom\" */\n  curve?: RadialAreaCurve;\n  /**\n   * Datum key of a comparison value (e.g. yesterday's total), drawn as a dim\n   * \"ghost\" area with a dashed edge in bins where no series has a value.\n   */\n  ghostDataKey?: string;\n  /** Ghost color. Default: var(--foreground) */\n  ghostColor?: string;\n  /** Ghost fill opacity (its stroke is twice this). Default: 0.1 */\n  ghostOpacity?: number;\n  /**\n   * Value mapped to the outer radius. Default: the chart's shared scale when\n   * `RadialChart` has `maxValue`, else the largest drawn value\n   */\n  maxValue?: number;\n  /** Dot on the outer edge of the hovered bin, riding the edge. Default: true */\n  activeDot?: boolean;\n  /**\n   * How the active dot follows the pointer: locked to the crosshair (the\n   * `RadialScrubber` spring), its own spring (`activeDotSpring`) or snapping\n   * to the bin. Reduced motion always snaps. Default: \"crosshair\"\n   */\n  activeDotMotion?: RadialDotMotion;\n  /** `activeDotMotion=\"spring\"`: the dot's spring. Default: `{ stiffness: 240, damping: 24 }` */\n  activeDotSpring?: SpringOptions;\n  /** Opacity of other series while a legend item is hovered. Default: 0.2 */\n  fadedOpacity?: number;\n}\n\nconst CURVES: Record<\n  RadialAreaCurve,\n  { open: CurveFactory; closed: CurveFactory }\n> = {\n  catmullRom: { open: curveCatmullRom, closed: curveCatmullRomClosed },\n  basis: { open: curveBasis, closed: curveBasisClosed },\n  linear: { open: curveLinear, closed: curveLinearClosed },\n};\n\n/** Clockwise sweep — matches `RadialDotBar`'s cascade. */\nconst ENTER_SPREAD_S = 0.75;\n/** Rise time of each bin from the band base. */\nconst ENTER_DURATION_S = 0.5;\nconst ENTER_TOTAL_S = ENTER_SPREAD_S + ENTER_DURATION_S;\n\ninterface AreaPoint {\n  angle: number;\n  /** Bin index (drives the enter delay) */\n  index: number;\n  inner: number;\n  outer: number;\n}\n\ninterface AreaLayer {\n  key: string;\n  /** Series index, -1 when not a series, -2 for the ghost */\n  seriesIndex: number;\n  color: string;\n  fill: boolean;\n  fillOpacity: number;\n  rampFrom: number;\n  stroke: RadialAreaStroke;\n  strokeOpacity: number;\n  strokeDasharray?: string;\n  /** Contiguous runs of points; `closed` runs wrap the full turn */\n  runs: { points: AreaPoint[]; closed: boolean }[];\n  /** Runs the edge strokes follow (stacked series skip zero-height bins) */\n  strokeRuns: { points: AreaPoint[]; closed: boolean }[];\n  /** Stroked point per bin (null where there is no edge) for the active dot */\n  byBin: (AreaPoint | null)[];\n}\n\ninterface RawLayer {\n  key: string;\n  seriesIndex: number;\n  values: ({ lo: number; hi: number } | null)[];\n}\n\nfunction easeOutCubic(t: number): number {\n  return 1 - (1 - t) ** 3;\n}\n\n/** Split per-bin points into runs; extend open runs to their bin edges. */\nfunction toRuns(\n  points: (AreaPoint | null)[],\n  binStartAngle: (index: number) => number,\n  isFullCircle: boolean\n): AreaLayer[\"runs\"] {\n  if (isFullCircle && points.length > 0 && points.every((p) => p !== null)) {\n    return [{ points: points as AreaPoint[], closed: true }];\n  }\n  const runs: AreaLayer[\"runs\"] = [];\n  let current: AreaPoint[] = [];\n  const flush = () => {\n    const first = current[0];\n    const last = current.at(-1);\n    if (first && last) {\n      runs.push({\n        closed: false,\n        points: [\n          { ...first, angle: binStartAngle(first.index) },\n          ...current,\n          { ...last, angle: binStartAngle(last.index + 1) },\n        ],\n      });\n    }\n    current = [];\n  };\n  for (const p of points) {\n    if (p) {\n      current.push(p);\n    } else {\n      flush();\n    }\n  }\n  flush();\n  return runs;\n}\n\n/** Points at enter progress `p` (0–1): each bin rises from the base after a clockwise delay. */\nfunction grow(\n  points: AreaPoint[],\n  p: number,\n  base: number,\n  binCount: number\n): AreaPoint[] {\n  if (p >= 1) {\n    return points;\n  }\n  const t = p * ENTER_TOTAL_S;\n  return points.map((point) => {\n    const delay = (point.index / Math.max(1, binCount)) * ENTER_SPREAD_S;\n    const e = easeOutCubic(\n      Math.min(1, Math.max(0, (t - delay) / ENTER_DURATION_S))\n    );\n    return {\n      ...point,\n      inner: base + (point.inner - base) * e,\n      outer: base + (point.outer - base) * e,\n    };\n  });\n}\n\n/** Enter clip: a wedge sweeping from the start angle to the end angle. */\nfunction wedgePath(\n  p: number,\n  r: number,\n  startAngle: number,\n  endAngle: number\n): string {\n  const t = Math.min(1, (p * ENTER_TOTAL_S) / ENTER_SPREAD_S);\n  if (t >= 1 - 1e-4) {\n    // Whole disc once the sweep is done (also covers partial arcs).\n    return `M0,${-r}A${r},${r},0,1,1,0,${r}A${r},${r},0,1,1,0,${-r}Z`;\n  }\n  return radialWedgePath(\n    r,\n    startAngle,\n    startAngle + (endAngle - startAngle) * t\n  );\n}\n\n/** Keep an attribute in sync with the enter progress without re-rendering. */\nfunction useProgressAttribute<T extends SVGElement>(\n  progress: MotionValue<number>,\n  attribute: string,\n  compute: (p: number) => string\n) {\n  const ref = useRef<T>(null);\n  useEffect(() => {\n    const apply = (p: number) =>\n      ref.current?.setAttribute(attribute, compute(p));\n    apply(progress.get());\n    return progress.on(\"change\", apply);\n  }, [progress, attribute, compute]);\n  return ref;\n}\n\nconst AreaLayerPaths = memo(function AreaLayerPaths({\n  layer,\n  progress,\n  curve,\n  base,\n  binCount,\n  gradientId,\n}: {\n  layer: AreaLayer;\n  progress: MotionValue<number>;\n  curve: RadialAreaCurve;\n  base: number;\n  binCount: number;\n  gradientId: string;\n}) {\n  const paths = useMemo(() => {\n    const factories = CURVES[curve];\n    const build = (kind: \"area\" | \"outer\" | \"inner\", p: number): string =>\n      (kind === \"area\" ? layer.runs : layer.strokeRuns)\n        .map(({ points, closed }) => {\n          const factory = closed ? factories.closed : factories.open;\n          const grown = grow(points, p, base, binCount);\n          if (kind === \"area\") {\n            return (\n              areaRadial<AreaPoint>()\n                .angle((d) => d.angle)\n                .innerRadius((d) => d.inner)\n                .outerRadius((d) => d.outer)\n                .curve(factory)(grown) ?? \"\"\n            );\n          }\n          return (\n            lineRadial<AreaPoint>()\n              .angle((d) => d.angle)\n              .radius((d) => (kind === \"outer\" ? d.outer : d.inner))\n              .curve(factory)(grown) ?? \"\"\n          );\n        })\n        .join(\"\");\n    return {\n      area: (p: number) => build(\"area\", p),\n      outer: (p: number) => build(\"outer\", p),\n      inner: (p: number) => build(\"inner\", p),\n    };\n  }, [layer, curve, base, binCount]);\n\n  const areaRef = useProgressAttribute<SVGPathElement>(\n    progress,\n    \"d\",\n    paths.area\n  );\n  const outerRef = useProgressAttribute<SVGPathElement>(\n    progress,\n    \"d\",\n    paths.outer\n  );\n  const innerRef = useProgressAttribute<SVGPathElement>(\n    progress,\n    \"d\",\n    paths.inner\n  );\n  const p = progress.get();\n\n  return (\n    <>\n      {layer.fill ? (\n        <path\n          d={paths.area(p)}\n          fill={`url(#${gradientId})`}\n          fillRule=\"evenodd\"\n          ref={areaRef}\n        />\n      ) : null}\n      {layer.stroke === \"none\" ? null : (\n        <g\n          fill=\"none\"\n          stroke={layer.color}\n          strokeDasharray={layer.strokeDasharray}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeOpacity={layer.strokeOpacity}\n        >\n          <path d={paths.outer(p)} ref={outerRef} />\n          {layer.stroke === \"both\" ? (\n            <path d={paths.inner(p)} ref={innerRef} />\n          ) : null}\n        </g>\n      )}\n    </>\n  );\n});\n\nfunction EnterClip({\n  id,\n  progress,\n  radius,\n  startAngle,\n  endAngle,\n}: {\n  id: string;\n  progress: MotionValue<number>;\n  radius: number;\n  startAngle: number;\n  endAngle: number;\n}) {\n  const r = radius * 1.2;\n  const compute = useMemo(\n    () => (p: number) => wedgePath(p, r, startAngle, endAngle),\n    [r, startAngle, endAngle]\n  );\n  const ref = useProgressAttribute<SVGPathElement>(progress, \"d\", compute);\n  return (\n    <clipPath id={id}>\n      <path d={compute(progress.get())} ref={ref} />\n    </clipPath>\n  );\n}\n\n/**\n * Radial areas for {@link RadialChart} (d3 `areaRadial`): every chart series\n * stacked or overlaid from the band base, or a single `dataKey` area —\n * optionally a band between `baseDataKey` and `dataKey`. A smooth curve runs\n * through the bin centers and closes on itself when every bin has a value.\n * On enter the areas sweep in clockwise, each bin rising from the base.\n * The active dot rides each outer edge, locked to the crosshair by default\n * (`activeDotMotion`).\n */\nexport function RadialArea({\n  dataKey,\n  baseDataKey,\n  stacked = true,\n  color,\n  fill = true,\n  fillOpacity = 0.32,\n  rampFrom,\n  stroke = \"outer\",\n  strokeWidth = 1.5,\n  strokeOpacity = 1,\n  strokeDasharray,\n  curve = \"catmullRom\",\n  ghostDataKey,\n  ghostColor = \"var(--foreground)\",\n  ghostOpacity = 0.1,\n  maxValue: maxValueProp,\n  activeDot = true,\n  activeDotMotion = \"crosshair\",\n  activeDotSpring,\n  fadedOpacity = 0.2,\n}: RadialAreaProps) {\n  const {\n    data,\n    series,\n    binCount,\n    radius,\n    innerRadius,\n    outerRadius,\n    futureBins,\n    hoveredIndex,\n    hoveredSeries,\n    revealed,\n    animate,\n    binAngle,\n    binStartAngle,\n    isFullCircle,\n    startAngle,\n    endAngle,\n    sharedValueScale,\n    valueScale,\n  } = useRadialChart();\n\n  const uid = useId().replace(/:/g, \"\");\n  const clipId = `radial-area-clip-${uid}`;\n\n  // Raw values per layer (before scaling) ------------------------------------\n  const raw = useMemo(() => {\n    const layers: RawLayer[] = [];\n    if (dataKey) {\n      layers.push({\n        key: `area-${dataKey}`,\n        seriesIndex: series.findIndex((s) => s.dataKey === dataKey),\n        values: data.map((d) => {\n          const hi = radialNumberAt(d, dataKey);\n          if (hi === null) {\n            return null;\n          }\n          const lo = baseDataKey ? (radialNumberAt(d, baseDataKey) ?? 0) : 0;\n          return { lo: Math.max(0, lo), hi: Math.max(0, hi) };\n        }),\n      });\n    } else {\n      const cumulative = data.map(() => 0);\n      series.forEach((s, seriesIndex) => {\n        layers.push({\n          key: `series-${s.dataKey}`,\n          seriesIndex,\n          values: data.map((d, i) => {\n            const v = radialNumberAt(d, s.dataKey);\n            if (v === null) {\n              return null;\n            }\n            const lo = stacked ? (cumulative[i] ?? 0) : 0;\n            const hi = lo + Math.max(0, v);\n            if (stacked) {\n              cumulative[i] = hi;\n            }\n            return { lo, hi };\n          }),\n        });\n      });\n    }\n    const ghost = ghostDataKey\n      ? data.map((d, i) =>\n          futureBins[i]\n            ? (() => {\n                const v = radialNumberAt(d, ghostDataKey);\n                return v === null ? null : { lo: 0, hi: Math.max(0, v) };\n              })()\n            : null\n        )\n      : null;\n    return { layers, ghost };\n  }, [dataKey, baseDataKey, stacked, series, data, ghostDataKey, futureBins]);\n\n  const maxValue = useMemo(() => {\n    if (maxValueProp !== undefined) {\n      return maxValueProp;\n    }\n    let max = 0;\n    for (const layer of raw.layers) {\n      for (const v of layer.values) {\n        max = Math.max(max, v?.hi ?? 0);\n      }\n    }\n    for (const v of raw.ghost ?? []) {\n      max = Math.max(max, v?.hi ?? 0);\n    }\n    return max;\n  }, [maxValueProp, raw]);\n\n  const layers = useMemo((): AreaLayer[] => {\n    if (outerRadius <= innerRadius) {\n      return [];\n    }\n    const own = createRadialScale({ maxValue, innerRadius, outerRadius });\n    const scale =\n      maxValueProp === undefined && sharedValueScale\n        ? valueScale\n        : (value: number) => own(value) ?? innerRadius;\n    const toPoints = (values: ({ lo: number; hi: number } | null)[]) =>\n      values.map((v, index): AreaPoint | null =>\n        v\n          ? {\n              index,\n              angle: binAngle(index),\n              inner: scale(v.lo),\n              outer: scale(v.hi),\n            }\n          : null\n      );\n\n    const result: AreaLayer[] = [];\n    if (raw.ghost) {\n      const byBin = toPoints(raw.ghost);\n      result.push({\n        key: \"ghost\",\n        seriesIndex: -2,\n        color: ghostColor,\n        fill: true,\n        fillOpacity: ghostOpacity,\n        rampFrom: 0.4,\n        stroke: \"outer\",\n        strokeOpacity: Math.min(1, ghostOpacity * 2.5),\n        strokeDasharray: \"3 4\",\n        runs: toRuns(byBin, binStartAngle, isFullCircle),\n        strokeRuns: toRuns(byBin, binStartAngle, isFullCircle),\n        byBin,\n      });\n    }\n    for (const layer of raw.layers) {\n      const s = series[layer.seriesIndex];\n      const points = toPoints(layer.values);\n      // A zero-height stacked series would trace the stack top it sits on.\n      const byBin =\n        dataKey || !stacked\n          ? points\n          : points.map((p) => (p && p.outer > p.inner ? p : null));\n      result.push({\n        key: layer.key,\n        seriesIndex: layer.seriesIndex,\n        color:\n          (dataKey ? color : undefined) ??\n          radialSeriesColor(s, layer.seriesIndex),\n        fill,\n        fillOpacity: fillOpacity * (s?.opacity ?? 1),\n        rampFrom: rampFrom ?? s?.rampFrom ?? 0.25,\n        stroke,\n        strokeOpacity: strokeOpacity * (s?.opacity ?? 1),\n        strokeDasharray,\n        runs: toRuns(points, binStartAngle, isFullCircle),\n        strokeRuns: toRuns(byBin, binStartAngle, isFullCircle),\n        byBin,\n      });\n    }\n    return result;\n  }, [\n    raw,\n    series,\n    maxValue,\n    maxValueProp,\n    sharedValueScale,\n    valueScale,\n    innerRadius,\n    outerRadius,\n    binAngle,\n    binStartAngle,\n    isFullCircle,\n    dataKey,\n    stacked,\n    color,\n    fill,\n    fillOpacity,\n    rampFrom,\n    stroke,\n    strokeOpacity,\n    strokeDasharray,\n    ghostColor,\n    ghostOpacity,\n  ]);\n\n  // Enter progress -----------------------------------------------------------\n  const progress = useMotionValue(animate ? 0 : 1);\n  useEffect(() => {\n    if (!animate) {\n      progress.jump(1);\n      return;\n    }\n    if (!revealed) {\n      return;\n    }\n    const controls = animateValue(progress, 1, {\n      duration: ENTER_TOTAL_S,\n      ease: \"linear\",\n    });\n    return () => controls.stop();\n  }, [animate, revealed, progress]);\n\n  // Outer edge per layer, for the active dot to ride.\n  const edges = useMemo(() => {\n    const result = new Map<string, RadialEdgeLookup>();\n    if (!activeDot) {\n      return result;\n    }\n    const factories = CURVES[curve];\n    for (const layer of layers) {\n      if (layer.stroke === \"none\") {\n        continue;\n      }\n      result.set(\n        layer.key,\n        createRadialEdgeLookup((context) => {\n          for (const { points, closed } of layer.strokeRuns) {\n            lineRadial<AreaPoint>()\n              .angle((d) => d.angle)\n              .radius((d) => d.outer)\n              .curve(closed ? factories.closed : factories.open)\n              .context(context)(points);\n          }\n        })\n      );\n    }\n    return result;\n  }, [activeDot, layers, curve]);\n  const dotAngle = useRadialDotAngle(activeDotMotion, activeDotSpring);\n\n  const dotR = Math.max(2.5, radius * 0.009);\n\n  return (\n    <g pointerEvents=\"none\">\n      <defs>\n        <EnterClip\n          endAngle={endAngle}\n          id={clipId}\n          progress={progress}\n          radius={radius}\n          startAngle={startAngle}\n        />\n        {layers.map((layer) => (\n          <radialGradient\n            cx={0}\n            cy={0}\n            gradientUnits=\"userSpaceOnUse\"\n            id={`radial-area-fill-${uid}-${layer.key}`}\n            key={layer.key}\n            r={outerRadius}\n          >\n            <stop\n              offset={innerRadius / outerRadius}\n              stopColor={layer.color}\n              stopOpacity={layer.fillOpacity * layer.rampFrom}\n            />\n            <stop\n              offset={1}\n              stopColor={layer.color}\n              stopOpacity={layer.fillOpacity}\n            />\n          </radialGradient>\n        ))}\n      </defs>\n      <g clipPath={`url(#${clipId})`} strokeWidth={strokeWidth}>\n        {layers.map((layer) => {\n          const dimmed =\n            hoveredSeries !== null && hoveredSeries !== layer.seriesIndex;\n          return (\n            <g\n              key={layer.key}\n              style={{\n                opacity: dimmed ? fadedOpacity : 1,\n                transition: \"opacity 0.15s ease-out\",\n              }}\n            >\n              <AreaLayerPaths\n                base={innerRadius}\n                binCount={binCount}\n                curve={curve}\n                gradientId={`radial-area-fill-${uid}-${layer.key}`}\n                layer={layer}\n                progress={progress}\n              />\n            </g>\n          );\n        })}\n      </g>\n      {activeDot && hoveredIndex !== null\n        ? layers.map((layer) => {\n            const lookup = edges.get(layer.key);\n            if (!(lookup && layer.byBin[hoveredIndex])) {\n              return null;\n            }\n            const dimmed =\n              hoveredSeries !== null && hoveredSeries !== layer.seriesIndex;\n            return (\n              <RadialHoverDot\n                angle={dotAngle}\n                fill={layer.color}\n                key={`dot-${layer.key}`}\n                lookup={lookup}\n                opacity={dimmed ? fadedOpacity : 1}\n                r={dotR}\n                stroke=\"var(--background)\"\n                strokeWidth={1.5}\n              />\n            );\n          })\n        : null}\n    </g>\n  );\n}\n\nRadialArea.displayName = \"RadialArea\";\n\nexport default RadialArea;\n",
      "type": "registry:component",
      "target": "components/charts/radial-area.tsx"
    },
    {
      "path": "src/charts/radial-scrubber.tsx",
      "content": "\"use client\";\n\nimport { m, type SpringOptions, useTransform } from \"motion/react\";\nimport { useId, useLayoutEffect } from \"react\";\nimport {\n  RADIAL_CROSSHAIR_SPRING,\n  useRadialAngleSpring,\n  useRadialChart,\n} from \"./radial-chart\";\n\n/**\n * Scrubber line style.\n * - `solid`: a plain line\n * - `dashed`: a dashed line (see `dashArray`)\n * - `gradient`: fades in along its length, transparent at the center circle\n *   and full strength at the outer end\n */\nexport type RadialScrubberVariant = \"solid\" | \"dashed\" | \"gradient\";\n\nexport interface RadialScrubberProps {\n  /** Line style. Default: \"solid\" */\n  variant?: RadialScrubberVariant;\n  /** `dashed` variant: SVG stroke-dasharray. Default: \"4 4\" */\n  dashArray?: string;\n  /** End dot. Default: true for solid and dashed, false for gradient */\n  showDot?: boolean;\n  /** Line + dot color. Default: var(--foreground) */\n  color?: string;\n  /** Line opacity. Default: 0.7 */\n  lineOpacity?: number;\n  /** Line width. Default: 1.5 */\n  strokeWidth?: number;\n  /** End dot radius in px. Default: scales with the chart */\n  dotRadius?: number;\n  /**\n   * Where the scrubber rests when nothing is hovered: at \"now\" (the trailing\n   * edge of `nowIndex`) or hidden. Default: \"now\" when the chart tracks now\n   * (clock charts), else \"hidden\"\n   */\n  rest?: \"now\" | \"hidden\";\n  /**\n   * Spring toward the hovered bin. Hover dots locked to the crosshair\n   * (`RadialLine` / `RadialArea` default) follow it too.\n   * Default: `{ stiffness: 420, damping: 42 }`\n   */\n  spring?: SpringOptions;\n}\n\n/**\n * Line from the center circle outward ending in a solid dot (solid / dashed\n * lines) or fading in toward the outer end (gradient). At rest it marks\n * \"now\" (the trailing edge of `nowIndex`) on clock charts and is hidden\n * otherwise; while hovering it springs to the hovered bin, taking the short\n * way round across the start angle on full circles. Its spring is the\n * chart's crosshair: `RadialLine` / `RadialArea` hover dots ride along it.\n */\nexport function RadialScrubber({\n  variant = \"solid\",\n  dashArray = \"4 4\",\n  showDot: showDotProp,\n  color = \"var(--foreground)\",\n  lineOpacity = 0.7,\n  strokeWidth = 1.5,\n  dotRadius,\n  rest: restProp,\n  spring: springOptions = RADIAL_CROSSHAIR_SPRING,\n}: RadialScrubberProps) {\n  const {\n    binCount,\n    nowIndex,\n    trackNow,\n    hoveredIndex,\n    binAngle,\n    binStartAngle,\n    isFullCircle,\n    orientation,\n    centerRadius,\n    radius,\n    layout,\n    reducedMotion,\n    registerCrosshair,\n  } = useRadialChart();\n\n  const gradientId = `radial-scrubber-${useId().replace(/:/g, \"\")}`;\n  const rest = restProp ?? (trackNow ? \"now\" : \"hidden\");\n  const hidden = hoveredIndex === null && rest === \"hidden\";\n  const angle =\n    hoveredIndex === null\n      ? binStartAngle(nowIndex + 1)\n      : binAngle(hoveredIndex);\n\n  const spring = useRadialAngleSpring({\n    angle,\n    hidden,\n    isFullCircle,\n    snap: reducedMotion,\n    spring: springOptions,\n  });\n\n  // Hover dots locked to the crosshair follow this spring.\n  const drawn = binCount > 0 && orientation !== \"angular\";\n  useLayoutEffect(\n    () => (drawn ? registerCrosshair(spring) : undefined),\n    [drawn, registerCrosshair, spring]\n  );\n\n  const inner = centerRadius + 2;\n  const outer = radius * layout.scrubberOuter;\n  const x1 = useTransform(spring, (a) => inner * Math.sin(a));\n  const y1 = useTransform(spring, (a) => -inner * Math.cos(a));\n  const x2 = useTransform(spring, (a) => outer * Math.sin(a));\n  const y2 = useTransform(spring, (a) => -outer * Math.cos(a));\n\n  if (binCount === 0 || orientation === \"angular\") {\n    return null;\n  }\n\n  const gradient = variant === \"gradient\";\n  const dashed = variant === \"dashed\";\n  const showDot = showDotProp ?? !gradient;\n\n  return (\n    <g\n      pointerEvents=\"none\"\n      style={{\n        opacity: hidden ? 0 : 1,\n        transition: \"opacity 0.15s ease-out\",\n      }}\n    >\n      {gradient ? (\n        <defs>\n          {/* User space, following the line, so it rotates with the angle */}\n          <m.linearGradient\n            gradientUnits=\"userSpaceOnUse\"\n            id={gradientId}\n            x1={x1}\n            x2={x2}\n            y1={y1}\n            y2={y2}\n          >\n            <stop offset=\"0%\" stopColor={color} stopOpacity={0} />\n            <stop offset=\"100%\" stopColor={color} stopOpacity={1} />\n          </m.linearGradient>\n        </defs>\n      ) : null}\n      <m.line\n        stroke={gradient ? `url(#${gradientId})` : color}\n        strokeDasharray={dashed ? dashArray : undefined}\n        strokeLinecap={dashed ? \"butt\" : \"round\"}\n        strokeOpacity={lineOpacity}\n        strokeWidth={strokeWidth}\n        x1={x1}\n        x2={x2}\n        y1={y1}\n        y2={y2}\n      />\n      {showDot ? (\n        <m.circle\n          cx={x2}\n          cy={y2}\n          fill={color}\n          r={dotRadius ?? Math.max(2.5, radius * 0.011)}\n        />\n      ) : null}\n    </g>\n  );\n}\n\nRadialScrubber.displayName = \"RadialScrubber\";\n\nexport default RadialScrubber;\n",
      "type": "registry:component",
      "target": "components/charts/radial-scrubber.tsx"
    },
    {
      "path": "src/charts/radial-center.tsx",
      "content": "\"use client\";\n\nimport { type ReactNode, useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  chartCenterContainerClassName,\n  chartCenterLabelClassName,\n  chartCenterValueClassName,\n} from \"./chart-center-typography\";\nimport { ChartStatFlow, type ChartStatFlowFormat } from \"./chart-stat-flow\";\nimport { radialNumberAt, useRadialChart } from \"./radial-chart\";\n\nexport interface RadialCenterState {\n  /** Hovered bin, or null at rest */\n  index: number | null;\n  /** Hovered datum, or null at rest */\n  datum: Record<string, unknown> | null;\n  /** Hovered bin's x label (e.g. \"09:15\"), or null at rest */\n  label: string | null;\n  /** True when the hovered bin has no series values (future / ghost bin) */\n  isFuture: boolean;\n  /** Per-series totals over every non-future bin */\n  totals: Record<string, number>;\n  /** Per-series values of the hovered bin (0 when missing / at rest) */\n  values: Record<string, number>;\n}\n\nexport interface RadialCenterProps {\n  /** Label under the value at rest. Default: \"Total\" */\n  defaultLabel?: string;\n  formatOptions?: ChartStatFlowFormat;\n  prefix?: string;\n  suffix?: string;\n  className?: string;\n  valueClassName?: string;\n  labelClassName?: string;\n  /** Full control over the center content (at rest and while hovering) */\n  children?: (state: RadialCenterState) => ReactNode;\n}\n\n/**\n * Center content of {@link RadialChart}, drawn as HTML over the SVG hole\n * (same approach as `PieCenter`). Shows the sum of every series' total at\n * rest and the hovered bin's sum while scrubbing; pass `children` for custom\n * content.\n */\nexport function RadialCenter({\n  defaultLabel = \"Total\",\n  formatOptions,\n  prefix,\n  suffix,\n  className,\n  valueClassName = chartCenterValueClassName,\n  labelClassName = chartCenterLabelClassName,\n  children,\n}: RadialCenterProps) {\n  const { data, series, xDataKey, hoveredIndex, futureBins, totals } =\n    useRadialChart();\n\n  const state = useMemo((): RadialCenterState => {\n    const datum = hoveredIndex === null ? null : (data[hoveredIndex] ?? null);\n    const values: Record<string, number> = {};\n    for (const s of series) {\n      values[s.dataKey] = radialNumberAt(datum, s.dataKey) ?? 0;\n    }\n    const rawLabel = datum?.[xDataKey];\n    return {\n      index: hoveredIndex,\n      datum,\n      label: rawLabel === undefined ? null : String(rawLabel),\n      isFuture:\n        hoveredIndex === null ? false : Boolean(futureBins[hoveredIndex]),\n      totals,\n      values,\n    };\n  }, [data, series, xDataKey, hoveredIndex, futureBins, totals]);\n\n  const containerClassName = cn(\n    chartCenterContainerClassName,\n    \"flex flex-col items-center justify-center text-center\",\n    className\n  );\n\n  if (children) {\n    return <div className={containerClassName}>{children(state)}</div>;\n  }\n\n  const source = state.datum ? state.values : state.totals;\n  const value = Object.values(source).reduce((sum, v) => sum + v, 0);\n\n  return (\n    <div className={containerClassName}>\n      <ChartStatFlow\n        formatOptions={formatOptions}\n        label={state.label ?? defaultLabel}\n        labelClassName={labelClassName}\n        prefix={prefix}\n        suffix={suffix}\n        value={value}\n        valueClassName={valueClassName}\n      />\n    </div>\n  );\n}\n\nRadialCenter.displayName = \"RadialCenter\";\n\nexport default RadialCenter;\n",
      "type": "registry:component",
      "target": "components/charts/radial-center.tsx"
    },
    {
      "path": "src/charts/radial-legend.tsx",
      "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { intFmt } from \"./chart-formatters\";\nimport { Legend } from \"./legend/legend\";\nimport { useLegendItem } from \"./legend/legend-context\";\nimport { LegendItem } from \"./legend/legend-item\";\nimport { LegendLabel } from \"./legend/legend-label\";\nimport { LegendMarker } from \"./legend/legend-marker\";\nimport { useRadialChart } from \"./radial-chart\";\nimport { radialSeriesColor } from \"./radial-series-color\";\n\nfunction RadialLegendValue({\n  formatValue,\n}: {\n  formatValue: (value: number) => string;\n}) {\n  const { series } = useRadialChart();\n  const { item, index } = useLegendItem();\n  const emphasize = series[index]?.emphasize;\n  return (\n    <span\n      className=\"font-semibold text-foreground tabular-nums\"\n      style={emphasize ? { color: item.color } : undefined}\n    >\n      {formatValue(item.value)}\n    </span>\n  );\n}\n\nexport interface RadialLegendProps {\n  className?: string;\n  /** Format the per-series totals. Default: en-US integer */\n  formatValue?: (value: number) => string;\n  /**\n   * Show each series' total after its label. Turn off when totals are\n   * meaningless (e.g. temperatures). Default: true\n   */\n  showValues?: boolean;\n}\n\n/**\n * Inline legend for {@link RadialChart}: dot, muted label and the series\n * total (tinted for `emphasize` series). Hovering an item sets\n * `hoveredSeries` so layers can dim the other series.\n */\nexport function RadialLegend({\n  className,\n  formatValue = intFmt,\n  showValues = true,\n}: RadialLegendProps) {\n  const { series, totals, hoveredSeries, setHoveredSeries } = useRadialChart();\n  const items = useMemo(\n    () =>\n      series.map((s, i) => ({\n        label: s.label ?? s.dataKey,\n        value: totals[s.dataKey] ?? 0,\n        color: radialSeriesColor(s, i),\n      })),\n    [series, totals]\n  );\n\n  return (\n    <Legend\n      className={cn(\n        \"mt-2 flex-row flex-wrap items-center justify-center gap-x-3 gap-y-1\",\n        className\n      )}\n      hoveredIndex={hoveredSeries}\n      items={items}\n      onHoverChange={setHoveredSeries}\n    >\n      <LegendItem className=\"flex items-center gap-2\">\n        <LegendMarker className=\"size-2\" />\n        <LegendLabel className=\"text-muted-foreground text-sm\" />\n        {showValues ? <RadialLegendValue formatValue={formatValue} /> : null}\n      </LegendItem>\n    </Legend>\n  );\n}\n\nRadialLegend.displayName = \"RadialLegend\";\n\nexport default RadialLegend;\n",
      "type": "registry:component",
      "target": "components/charts/radial-legend.tsx"
    },
    {
      "path": "src/charts/chart-stat-flow.tsx",
      "content": "\"use client\";\n\nimport NumberFlow from \"@number-flow/react\";\nimport { type ReactNode, useEffect, useMemo, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/** Subset of `Intl.NumberFormatOptions` supported by NumberFlow */\nexport interface ChartStatFlowFormat {\n  notation?: \"standard\" | \"compact\";\n  compactDisplay?: \"short\" | \"long\";\n  minimumFractionDigits?: number;\n  maximumFractionDigits?: number;\n  minimumIntegerDigits?: number;\n  minimumSignificantDigits?: number;\n  maximumSignificantDigits?: number;\n  style?: \"decimal\" | \"percent\" | \"currency\";\n  currency?: string;\n  currencyDisplay?: \"symbol\" | \"narrowSymbol\" | \"code\" | \"name\";\n  unit?: string;\n  unitDisplay?: \"short\" | \"long\" | \"narrow\";\n}\n\nexport const defaultChartStatFlowFormat: ChartStatFlowFormat = {\n  notation: \"standard\",\n  maximumFractionDigits: 0,\n};\n\nfunction formatStatValue(\n  value: number,\n  formatOptions: ChartStatFlowFormat,\n  prefix?: string,\n  suffix?: string\n): string {\n  const formatted = new Intl.NumberFormat(undefined, formatOptions).format(\n    value\n  );\n  return `${prefix ?? \"\"}${formatted}${suffix ?? \"\"}`;\n}\n\nexport function useNumberFlowElementReady(): boolean {\n  const [ready, setReady] = useState(\n    () =>\n      typeof customElements !== \"undefined\" &&\n      Boolean(customElements.get(\"number-flow-react\"))\n  );\n\n  useEffect(() => {\n    if (ready) {\n      return;\n    }\n    let cancelled = false;\n    customElements.whenDefined(\"number-flow-react\").then(() => {\n      if (!cancelled) {\n        setReady(true);\n      }\n    });\n    return () => {\n      cancelled = true;\n    };\n  }, [ready]);\n\n  return ready;\n}\n\nexport interface ChartStatFlowProps {\n  value: number;\n  label: string;\n  formatOptions?: ChartStatFlowFormat;\n  prefix?: string;\n  suffix?: string;\n  valueClassName?: string;\n  labelClassName?: string;\n  icon?: ReactNode;\n}\n\n/**\n * Shared value + label stack using NumberFlow (same layout as pie / ring centers).\n * Parent should provide flex alignment and sizing when needed.\n */\nexport function ChartStatFlow({\n  value,\n  label,\n  formatOptions = defaultChartStatFlowFormat,\n  prefix,\n  suffix,\n  valueClassName = \"text-2xl font-bold\",\n  labelClassName = \"text-xs\",\n  icon,\n}: ChartStatFlowProps) {\n  const numberFlowReady = useNumberFlowElementReady();\n  const staticValue = useMemo(\n    () => formatStatValue(value, formatOptions, prefix, suffix),\n    [value, formatOptions, prefix, suffix]\n  );\n\n  return (\n    <>\n      {icon ? (\n        <div className=\"mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-muted/50\">\n          {icon}\n        </div>\n      ) : null}\n      <span className={cn(\"text-foreground tabular-nums\", valueClassName)}>\n        {numberFlowReady ? (\n          <NumberFlow\n            format={formatOptions}\n            isolate\n            prefix={prefix}\n            suffix={suffix}\n            value={value}\n            willChange\n          />\n        ) : (\n          staticValue\n        )}\n      </span>\n      <span className={cn(\"mt-0.5 text-chart-label\", labelClassName)}>\n        {label}\n      </span>\n    </>\n  );\n}\n\nChartStatFlow.displayName = \"ChartStatFlow\";\n",
      "type": "registry:component",
      "target": "components/charts/chart-stat-flow.tsx"
    },
    {
      "path": "src/charts/chart-center-typography.ts",
      "content": "/**\n * Fluid typography for pie / ring / gauge center labels.\n *\n * Uses CSS container query units (`cqw`) so values scale with the center\n * hole — not the viewport — which keeps stat text readable on small charts.\n */\nexport const chartCenterContainerClassName =\n  \"@container/chart-center size-full min-w-0\";\n\n/** Primary stat — ~22% of center width, clamped between text-sm and text-3xl. */\nexport const chartCenterValueClassName =\n  \"font-bold tabular-nums leading-none text-[clamp(0.75rem,22cqw,1.875rem)]\";\n\n/** Supporting label — ~9% of center width, clamped between 10px and text-xs. */\nexport const chartCenterLabelClassName =\n  \"max-w-full truncate leading-tight text-[clamp(0.625rem,9cqw,0.75rem)]\";\n",
      "type": "registry:component",
      "target": "components/charts/chart-center-typography.ts"
    },
    {
      "path": "src/charts/pattern-preset.tsx",
      "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { PatternTile } from \"./pattern-tile\";\n\n/**\n * Pattern presets — minimal repeating cells re-authored from Nucleo's free SVG\n * patterns (https://nucleoapp.com/svg-patterns/). Every cell is painted with\n * `currentColor`, so `color` drives it. Base names use 6px spacing; `-sparse`\n * and `-dense` variants double or halve it.\n */\ninterface PatternPresetDef {\n  label: string;\n  family: PatternPresetFamily;\n  /** Cell size in user units (the pattern repeats every `size × scale` px). */\n  size: number;\n  /** Filled artwork. */\n  fill?: string;\n  /** 1px-stroked artwork (stroke width follows `strokeWidth`). */\n  stroke?: string;\n}\n\nexport type PatternPresetFamily =\n  | \"lines\"\n  | \"grids\"\n  | \"dashed\"\n  | \"dots\"\n  | \"circles\"\n  | \"tiles\";\n\nconst PATTERN_PRESET_DEFS = {\n  vertical: {\n    label: \"Vertical\",\n    family: \"lines\",\n    size: 6,\n    stroke: \"M5.5 0V6M-0.5 0V6\",\n  },\n  \"vertical-dense\": {\n    label: \"Vertical dense\",\n    family: \"lines\",\n    size: 3,\n    stroke: \"M2.5 0V3M-0.5 0V3\",\n  },\n  horizontal: {\n    label: \"Horizontal\",\n    family: \"lines\",\n    size: 6,\n    stroke: \"M0 5.5H6M0 -0.5H6\",\n  },\n  \"horizontal-dense\": {\n    label: \"Horizontal dense\",\n    family: \"lines\",\n    size: 3,\n    stroke: \"M0 2.5H3M0 -0.5H3\",\n  },\n  \"diagonal-up\": {\n    label: \"Diagonal up\",\n    family: \"lines\",\n    size: 6,\n    stroke: \"M-2 1L1 -2M-1 6L6 -1M4 7L7 4\",\n  },\n  \"diagonal-up-sparse\": {\n    label: \"Diagonal up sparse\",\n    family: \"lines\",\n    size: 12,\n    stroke: \"M-2 1L1 -2M-1 12L12 -1M10 13L13 10\",\n  },\n  \"diagonal-down\": {\n    label: \"Diagonal down\",\n    family: \"lines\",\n    size: 6,\n    stroke: \"M-1 -1L7 7M-2 4L2 8M4 -2L8 2\",\n  },\n  \"diagonal-down-sparse\": {\n    label: \"Diagonal down sparse\",\n    family: \"lines\",\n    size: 12,\n    stroke: \"M-1 -1L13 13M-2 10L2 14M10 -2L14 2\",\n  },\n  grid: {\n    label: \"Grid\",\n    family: \"grids\",\n    size: 6,\n    stroke: \"M5.5 0V6M-0.5 0V6M0 5.5H6M0 -0.5H6\",\n  },\n  \"vertical-dashed\": {\n    label: \"Vertical dashed\",\n    family: \"dashed\",\n    size: 6,\n    fill: \"M6 1V4H5V1H6Z\",\n  },\n  \"horizontal-dashed\": {\n    label: \"Horizontal dashed\",\n    family: \"dashed\",\n    size: 6,\n    fill: \"M4 6L1 6L1 5L4 5L4 6Z\",\n  },\n  \"horizontal-dashed-dense\": {\n    label: \"Horizontal dashed dense\",\n    family: \"dashed\",\n    size: 6,\n    fill: \"M4 6L1 6L1 5L4 5L4 6ZM4 3L1 3L1 2L4 2L4 3Z\",\n  },\n  \"grid-dashed\": {\n    label: \"Grid dashed\",\n    family: \"dashed\",\n    size: 6,\n    fill: \"M6 1V4H5V1H6ZM4 6L1 6L1 5L4 5L4 6Z\",\n  },\n  \"grid-dotted\": {\n    label: \"Grid dotted\",\n    family: \"dashed\",\n    size: 6,\n    fill: \"M1 6V5H2V6H1ZM3 6V5H4V6H3ZM5 6V5H6V6H5ZM6 4H5V3H6V4ZM6 2H5V1H6V2Z\",\n  },\n  dots: { label: \"Dots\", family: \"dots\", size: 6, fill: \"M6 5H5V6H6V5Z\" },\n  \"dots-sparse\": {\n    label: \"Dots sparse\",\n    family: \"dots\",\n    size: 12,\n    fill: \"M12 11H11V12H12V11ZM6 5H5V6H6V5Z\",\n  },\n  \"diagonal-dots-up\": {\n    label: \"Diagonal dots up\",\n    family: \"dots\",\n    size: 6,\n    fill: \"M5 5h1v1h-1zM3 1h1v1h-1zM1 3h1v1h-1z\",\n  },\n  \"diagonal-dots-up-sparse\": {\n    label: \"Diagonal dots up sparse\",\n    family: \"dots\",\n    size: 12,\n    fill: \"M5 5h1v1h-1zM11 11h1v1h-1zM3 7h1v1h-1zM1 9h1v1h-1zM7 3h1v1h-1zM9 1h1v1h-1z\",\n  },\n  \"diagonal-dots-down\": {\n    label: \"Diagonal dots down\",\n    family: \"dots\",\n    size: 6,\n    fill: \"M5 5h1v1h-1zM3 3h1v1h-1zM1 1h1v1h-1z\",\n  },\n  \"diagonal-dots-down-sparse\": {\n    label: \"Diagonal dots down sparse\",\n    family: \"dots\",\n    size: 12,\n    fill: \"M11 11h1v1h-1zM9 9h1v1h-1zM7 7h1v1h-1zM5 5h1v1h-1zM3 3h1v1h-1zM1 1h1v1h-1z\",\n  },\n  \"circles-dense\": {\n    label: \"Circles dense\",\n    family: \"circles\",\n    size: 6,\n    fill: \"M1 2.5a1.5 1.5 0 1 0 3 0a1.5 1.5 0 1 0-3 0z\",\n  },\n  circles: {\n    label: \"Circles\",\n    family: \"circles\",\n    size: 12,\n    fill: \"M4 5.5a1.5 1.5 0 1 0 3 0a1.5 1.5 0 1 0-3 0z\",\n  },\n  \"circles-sparse\": {\n    label: \"Circles sparse\",\n    family: \"circles\",\n    size: 24,\n    fill: \"M4 5.5a1.5 1.5 0 1 0 3 0a1.5 1.5 0 1 0-3 0zM16 17.5a1.5 1.5 0 1 0 3 0a1.5 1.5 0 1 0-3 0z\",\n  },\n  blocks: { label: \"Blocks\", family: \"tiles\", size: 6, fill: \"M1 1h3v3h-3z\" },\n  \"checkerboard-xs\": {\n    label: \"Checkerboard XS\",\n    family: \"tiles\",\n    size: 6,\n    fill: \"M0 0h3v3h-3zM3 3h3v3h-3z\",\n  },\n  \"checkerboard-sm\": {\n    label: \"Checkerboard SM\",\n    family: \"tiles\",\n    size: 8,\n    fill: \"M0 0h4v4h-4zM4 4h4v4h-4z\",\n  },\n  checkerboard: {\n    label: \"Checkerboard\",\n    family: \"tiles\",\n    size: 12,\n    fill: \"M0 0h6v6h-6zM6 6h6v6h-6z\",\n  },\n  \"checkerboard-lg\": {\n    label: \"Checkerboard LG\",\n    family: \"tiles\",\n    size: 24,\n    fill: \"M12 0H0V12H12V0ZM24 12H12V24H24V12Z\",\n  },\n  \"checkerboard-inset\": {\n    label: \"Checkerboard inset\",\n    family: \"tiles\",\n    size: 12,\n    fill: \"M0 0h5v5h-5zM6 6h5v5h-5z\",\n  },\n} as const satisfies Record<string, PatternPresetDef>;\n\nexport const PATTERN_PRESET_IDS = [\n  \"none\",\n  \"vertical\",\n  \"vertical-dense\",\n  \"horizontal\",\n  \"horizontal-dense\",\n  \"diagonal-up\",\n  \"diagonal-up-sparse\",\n  \"diagonal-down\",\n  \"diagonal-down-sparse\",\n  \"grid\",\n  \"vertical-dashed\",\n  \"horizontal-dashed\",\n  \"horizontal-dashed-dense\",\n  \"grid-dashed\",\n  \"grid-dotted\",\n  \"dots\",\n  \"dots-sparse\",\n  \"diagonal-dots-up\",\n  \"diagonal-dots-up-sparse\",\n  \"diagonal-dots-down\",\n  \"diagonal-dots-down-sparse\",\n  \"circles-dense\",\n  \"circles\",\n  \"circles-sparse\",\n  \"blocks\",\n  \"checkerboard-xs\",\n  \"checkerboard-sm\",\n  \"checkerboard\",\n  \"checkerboard-lg\",\n  \"checkerboard-inset\",\n] as const;\n\n/** A current preset id (what `PATTERN_PRESET_IDS` lists). */\nexport type PatternPresetKey = (typeof PATTERN_PRESET_IDS)[number];\n\n/** Ids from the pre-Nucleo preset set, still accepted and mapped forward. */\nexport type LegacyPatternPresetId = \"diagonal\" | \"cross\" | \"accent\";\n\n/** Any accepted preset id — current or legacy. */\nexport type PatternPresetId = PatternPresetKey | LegacyPatternPresetId;\n\nconst LEGACY_PATTERN_PRESETS: Record<LegacyPatternPresetId, PatternPresetKey> =\n  {\n    diagonal: \"diagonal-up\",\n    cross: \"grid\",\n    accent: \"diagonal-up\",\n  };\n\n/** Map any preset id (current or legacy) to a current one; unknown → `none`. */\nexport function resolvePatternPresetId(\n  preset: string | null | undefined\n): PatternPresetKey {\n  if (!preset) {\n    return \"none\";\n  }\n  if (preset in LEGACY_PATTERN_PRESETS) {\n    return LEGACY_PATTERN_PRESETS[preset as LegacyPatternPresetId];\n  }\n  return (PATTERN_PRESET_IDS as readonly string[]).includes(preset)\n    ? (preset as PatternPresetKey)\n    : \"none\";\n}\n\nexport interface PatternPresetMeta {\n  id: Exclude<PatternPresetKey, \"none\">;\n  label: string;\n  family: PatternPresetFamily;\n  size: number;\n}\n\n/** Every preset except `none`, in display order. */\nexport const PATTERN_PRESETS: readonly PatternPresetMeta[] = (\n  Object.entries(PATTERN_PRESET_DEFS) as [\n    Exclude<PatternPresetKey, \"none\">,\n    PatternPresetDef,\n  ][]\n).map(([id, def]) => ({\n  id,\n  label: def.label,\n  family: def.family,\n  size: def.size,\n}));\n\nexport interface PatternPresetOptions {\n  /** Artwork colour (`currentColor`). Default: `var(--chart-1)` */\n  color?: string;\n  /** Tile size multiplier. Default: 1 */\n  scale?: number;\n  /** Stroke width for line presets, in cell units. Default: 1 */\n  strokeWidth?: number;\n  /** Artwork opacity. Default: 1 */\n  opacity?: number;\n  /** Solid fill painted under the artwork. */\n  tileBackground?: string;\n  /** @deprecated No effect — only applied to the pre-Nucleo circle presets. */\n  radius?: number;\n  /** @deprecated No effect — only applied to the pre-Nucleo circle presets. */\n  complement?: boolean;\n  /** @deprecated No effect — only applied to the pre-Nucleo circle presets. */\n  fill?: string;\n  /** @deprecated No effect — only applied to the pre-Nucleo dot preset. */\n  dotFill?: boolean;\n}\n\n/** Presets drawn as circles. */\nexport function isCirclePattern(preset: PatternPresetId): boolean {\n  const id = resolvePatternPresetId(preset);\n  return id !== \"none\" && PATTERN_PRESET_DEFS[id].family === \"circles\";\n}\n\n/** @deprecated Use `isCirclePattern`. */\nexport function isCirclesPattern(preset: PatternPresetId): boolean {\n  return isCirclePattern(preset);\n}\n\n/** Rendered tile size for a preset at `scale`. */\nexport function patternPresetTileSize(\n  preset: PatternPresetId,\n  scale = 1\n): { width: number; height: number; strokeWidth: number } {\n  const id = resolvePatternPresetId(preset);\n  const def: PatternPresetDef | undefined =\n    id === \"none\" ? undefined : PATTERN_PRESET_DEFS[id];\n  const size = (def?.size ?? 6) * scale;\n  return {\n    width: size,\n    height: size,\n    strokeWidth: def?.stroke ? scale : 0,\n  };\n}\n\n/** Renders the `<defs><pattern/></defs>` node for a preset. */\nexport function renderPatternPreset(\n  preset: PatternPresetId,\n  id: string,\n  options: PatternPresetOptions = {}\n): ReactNode {\n  const resolved = resolvePatternPresetId(preset);\n  if (resolved === \"none\") {\n    return null;\n  }\n  const def: PatternPresetDef = PATTERN_PRESET_DEFS[resolved];\n  const color =\n    options.color ?? (preset === \"accent\" ? \"#e879f9\" : \"var(--chart-1)\");\n\n  return (\n    <PatternTile\n      background={options.tileBackground}\n      color={color}\n      id={id}\n      opacity={options.opacity}\n      scale={options.scale}\n      size={def.size}\n    >\n      {def.fill ? <path d={def.fill} fill=\"currentColor\" /> : null}\n      {def.stroke ? (\n        <path\n          d={def.stroke}\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={options.strokeWidth ?? 1}\n        />\n      ) : null}\n    </PatternTile>\n  );\n}\n\nexport interface PatternPresetProps extends PatternPresetOptions {\n  /** Unique id — reference it as `fill=\"url(#id)\"`. */\n  id: string;\n  /** Preset to draw. Default: `\"diagonal-up\"` */\n  preset?: PatternPresetId;\n}\n\n/**\n * Pattern definition for any preset. Drop it into a chart's children and\n * reference it with `fill=\"url(#id)\"`.\n *\n * @example <PatternPreset id=\"stripes\" preset=\"diagonal-up\" color=\"var(--chart-2)\" />\n */\nexport function PatternPreset({\n  id,\n  preset = \"diagonal-up\",\n  ...options\n}: PatternPresetProps) {\n  return <>{renderPatternPreset(preset, id, options)}</>;\n}\n\nPatternPreset.displayName = \"PatternPreset\";\n",
      "type": "registry:component",
      "target": "components/charts/pattern-preset.tsx"
    },
    {
      "path": "src/charts/pattern-tile.tsx",
      "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\n\nexport interface PatternTileProps {\n  /** Unique id — reference it as `fill=\"url(#id)\"`. */\n  id: string;\n  /** Tile artwork. Paint with `currentColor` so `color` drives it. */\n  children: ReactNode;\n  /** Native tile size of the artwork's viewBox (square). Default: 48 */\n  size?: number;\n  /** Multiplier on the rendered tile size. Default: 1 */\n  scale?: number;\n  /** Colour applied as `currentColor`. Default: `var(--chart-1)` */\n  color?: string;\n  /** Opacity of the artwork (not the background). Default: 1 */\n  opacity?: number;\n  /** Optional solid fill painted under the artwork. */\n  background?: string;\n}\n\n/**\n * Square SVG pattern built from tile artwork drawn in a `size × size` viewBox.\n * Hoisted into chart `<defs>` like the other `Pattern*` components.\n */\nexport function PatternTile({\n  id,\n  children,\n  size = 48,\n  scale = 1,\n  color = \"var(--chart-1)\",\n  opacity = 1,\n  background,\n}: PatternTileProps) {\n  const tile = size * scale;\n  return (\n    <defs>\n      <pattern\n        height={tile}\n        id={id}\n        patternUnits=\"userSpaceOnUse\"\n        viewBox={`0 0 ${size} ${size}`}\n        width={tile}\n      >\n        {background ? (\n          <rect fill={background} height={size} width={size} />\n        ) : null}\n        <g opacity={opacity} style={{ color }}>\n          {children}\n        </g>\n      </pattern>\n    </defs>\n  );\n}\n\nPatternTile.displayName = \"PatternTile\";\n",
      "type": "registry:component",
      "target": "components/charts/pattern-tile.tsx"
    },
    {
      "path": "src/charts/visx-pattern.tsx",
      "content": "\"use client\";\n\nimport type { ComponentProps } from \"react\";\nimport {\n  PatternCircles as VisxPatternCircles,\n  PatternHexagons as VisxPatternHexagons,\n  PatternLines as VisxPatternLines,\n  PatternWaves as VisxPatternWaves,\n} from \"./patterns/index\";\n\n/** @deprecated Use `PatternPreset` (Nucleo-based presets) instead. */\nexport function PatternLines(props: ComponentProps<typeof VisxPatternLines>) {\n  return <VisxPatternLines {...props} />;\n}\nPatternLines.displayName = \"PatternLines\";\n\n/** @deprecated Use `PatternPreset` (Nucleo-based presets) instead. */\nexport function PatternCircles(\n  props: ComponentProps<typeof VisxPatternCircles>\n) {\n  return <VisxPatternCircles {...props} />;\n}\nPatternCircles.displayName = \"PatternCircles\";\n\n/** @deprecated Use `PatternPreset` (Nucleo-based presets) instead. */\nexport function PatternWaves(props: ComponentProps<typeof VisxPatternWaves>) {\n  return <VisxPatternWaves {...props} />;\n}\nPatternWaves.displayName = \"PatternWaves\";\n\n/** @deprecated Use `PatternPreset` (Nucleo-based presets) instead. */\nexport function PatternHexagons(\n  props: ComponentProps<typeof VisxPatternHexagons>\n) {\n  return <VisxPatternHexagons {...props} />;\n}\nPatternHexagons.displayName = \"PatternHexagons\";\n",
      "type": "registry:component",
      "target": "components/charts/visx-pattern.tsx"
    }
  ]
}