{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ring-chart",
  "type": "registry:component",
  "title": "Ring Chart",
  "description": "Progress towards several targets, as concentric arcs.",
  "dependencies": [
    "@base-ui/react",
    "tailwind-variants"
  ],
  "registryDependencies": [],
  "cssVars": {
    "light": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    },
    "dark": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    }
  },
  "files": [
    {
      "path": "components/panelui/ring-chart/index.tsx",
      "type": "registry:component",
      "target": "@components/panelui/ring-chart/index.tsx",
      "content": "'use client';\n\n/**\n * RingChart: progress towards several targets, as concentric arcs.\n *\n * ```tsx\n * <RingChart data={goals} size={208}>\n *   <RingChart.Header title=\"Today\" value=\"486 kcal\" legend />\n *   <RingChart.Ring index={0} />\n *   <RingChart.Ring index={1} />\n *   <RingChart.Center />\n * </RingChart>\n * ```\n *\n * ## The same parts as the app\n *\n * The parts and their props are the app library's RingChart, so a chart written\n * for one reads the same in the other. What differs is what the web changes: a\n * colour is named by the theme's role, hovering a ring reads it, a click or a tap\n * holds the reading, and the arrow keys walk the rings.\n *\n * ## Why this is plain SVG\n *\n * Each ring is a value against its own target, with a track behind it. A dash on\n * a circle draws that with a rounded end, an open gauge or countable ticks, and\n * sweeps by moving two numbers on a path that never changes. An arc mark can do\n * none of those, and tweening one between its two ends breaks at half a turn.\n *\n * A part portals into the one `<svg>` the root owns, so a ring can sit inside\n * anything and still be drawn with the others.\n */\nimport {\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type CSSProperties,\n  type ReactNode,\n} from 'react';\nimport { createPortal } from 'react-dom';\nimport { tv } from 'tailwind-variants';\nimport { mergeProps } from '@base-ui/react/merge-props';\nimport { useRender } from '@base-ui/react/use-render';\nimport type { ElementType as RenderElementType } from 'react';\nimport { ringAfterKey, ringFraction, ringPercent, ringRadius, ringSpan, ringStagger, ringTick } from './ring-layout';\n\n/** Draws a part as another element, or composes it with a component: `render={<a href=\"/\" />}`. */\ntype RenderProp<Tag extends RenderElementType> = { render?: useRender.ComponentProps<Tag>['render'] };\n\n/** Written out in full, because the registry finds the tokens a component needs by reading its source. */\nconst COLORS = {\n  primary: 'var(--primary)',\n  info: 'var(--info)',\n  success: 'var(--success)',\n  warning: 'var(--warning)',\n  destructive: 'var(--destructive)',\n  'chart-1': 'var(--chart-1)',\n  'chart-2': 'var(--chart-2)',\n  'chart-3': 'var(--chart-3)',\n  'chart-4': 'var(--chart-4)',\n  'chart-5': 'var(--chart-5)',\n};\n\n/** Which of the theme's roles a ring is drawn in. */\nexport type RingChartColor = keyof typeof COLORS;\n\n/** One ring per datum. Nothing is shared between rings, so nothing has to add up. */\nexport interface RingDatum {\n  /** Name for the legend, the centre readout and screen readers. */\n  label: string;\n  /** Where this ring has got to. */\n  value: number;\n  /** What it is aiming at. The ring is full when `value` reaches it. */\n  maxValue: number;\n  /** Which of the theme's roles to draw it in. Defaults to the chart palette, in order. */\n  color?: RingChartColor;\n}\n\nconst PALETTE = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)'];\n\nconst ringChartVariants = tv({\n  slots: {\n    root: 'relative grid w-full grid-cols-1 text-foreground',\n    plot: 'relative col-start-1 row-start-2 w-full justify-self-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring',\n    svg: 'absolute inset-0 block rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring',\n    ring: 'transition-opacity duration-150 motion-reduce:transition-none',\n    sweep:\n      'ease-out transition-[stroke-dasharray] duration-(--ring-duration) delay-(--ring-delay) motion-reduce:transition-none',\n    tick: 'ease-out transition-opacity duration-150 delay-(--ring-delay) motion-reduce:transition-none',\n    hit: 'cursor-pointer touch-pan-y [@media(pointer:coarse)]:[stroke-width:28px]',\n    center:\n      'pointer-events-none relative col-start-1 row-start-2 flex flex-col items-center justify-center self-center justify-self-center text-center',\n    centerLabel: 'max-w-full truncate text-xs text-muted-foreground',\n    centerValue: 'max-w-full truncate font-semibold tabular-nums tracking-tight',\n    centerCaption: 'max-w-full truncate text-xs text-muted-foreground',\n    legend: 'col-start-1 row-start-3 flex w-full flex-wrap items-center justify-center gap-x-3 gap-y-1.5 pt-3 text-xs',\n    legendItem: 'flex items-center gap-1.5',\n    legendButton:\n      'flex max-w-full cursor-pointer items-center gap-1.5 rounded-sm outline-none transition-opacity focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none',\n    legendSwatch: 'size-2 shrink-0 rounded-full',\n    legendName: 'truncate text-muted-foreground',\n    legendValue: 'font-medium tabular-nums',\n    header: 'col-start-1 row-start-1 flex items-start justify-between gap-3 pb-3',\n    headerText: 'flex min-w-0 flex-1 flex-col gap-0.5',\n    headerMuted: 'text-xs text-muted-foreground',\n    headerValue: 'text-xl font-bold tabular-nums tracking-tight',\n    headerTrailing:\n      'flex max-w-[55%] shrink flex-wrap items-center justify-end gap-x-3 gap-y-1 pt-1 text-xs text-muted-foreground',\n  },\n});\n\nconst DIM = 0.35;\nconst NAVIGATION_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End']);\nconst LEGEND_DIM = 0.4;\n/** Below this width the hole fits the number alone. */\nconst FULL_CENTER_ROOM = 90;\n\nconst COMPACT = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });\nconst compactNumber = (value: number) => COMPACT.format(value);\n\ninterface RingChartContextValue {\n  data: readonly RingDatum[];\n  box: number;\n  strokeWidth: number;\n  ringGap: number;\n  startAngle: number;\n  /** How much of a turn the arcs run. 1 is a closed ring. */\n  span: number;\n  animationDuration: number;\n  colors: readonly string[];\n  svg: SVGSVGElement | null;\n  /** False for the frame a sweep starts from. */\n  revealed: boolean;\n  /** Bumped by `replay()`, to draw each ring afresh from nothing. */\n  revision: number;\n  activeIndex: number;\n  read: (index: number) => void;\n  togglePin: (index: number) => void;\n}\n\nconst RingChartContext = createContext<RingChartContextValue | null>(null);\n\nfunction useChart(component: string): RingChartContextValue {\n  const context = useContext(RingChartContext);\n  if (!context) throw new Error(`${component} must be used within a <RingChart>`);\n  return context;\n}\n\n/** The ring being read, for something rendered inside the chart. */\nexport function useRingChart() {\n  const { data, activeIndex } = useChart('useRingChart');\n  return { activeIndex, activeRing: activeIndex >= 0 ? (data[activeIndex] ?? null) : null };\n}\n\nexport interface RingChartHandle {\n  /** Sweep the rings in again from nothing. */\n  replay: () => void;\n}\n\nexport interface RingChartProps extends Omit<ComponentProps<'div'>, 'ref'> {\n  /** One entry per ring, outermost first. */\n  data: readonly RingDatum[];\n  /** Fixed diameter in pixels. Without it the chart fills its container's width as a square. */\n  size?: number;\n  /** Thickness of each ring. */\n  strokeWidth?: number;\n  /** Gap between one ring and the next. */\n  ringGap?: number;\n  /** Where the arcs begin, in degrees clockwise from twelve o'clock. */\n  startAngle?: number;\n  /**\n   * Where they end, on the same clock. Anything less than a turn from `startAngle`\n   * leaves a gap and reads as a gauge: `startAngle={-90} endAngle={90}` is the half\n   * circle over the top.\n   */\n  endAngle?: number;\n  /** Milliseconds for the arcs to sweep in. */\n  animationDuration?: number;\n  /** The ring being read. Leave unset to let the chart track it. */\n  activeIndex?: number;\n  /** Fires when the reading changes, from a hover, a key or a press, with `-1` when it ends. */\n  onActiveIndexChange?: (index: number) => void;\n  /** Names the chart for screen readers. */\n  label?: string;\n  /** Writes one ring for screen readers, for a value with a unit. */\n  labelForDatum?: (datum: RingDatum, index: number) => string;\n}\n\nconst RingChartRoot = forwardRef<RingChartHandle, RingChartProps & RenderProp<'div'>>(function RingChartRoot(\n  {\n    data,\n    size,\n    strokeWidth = 12,\n    ringGap = 6,\n    startAngle = 0,\n    endAngle = 360,\n    animationDuration = 700,\n    activeIndex: activeIndexProp,\n    onActiveIndexChange,\n    label,\n    labelForDatum,\n    className,\n    children,\n    render,\n    ...props\n  },\n  ref\n) {\n  const plotRef = useRef<HTMLDivElement>(null);\n  const [svg, setSvg] = useState<SVGSVGElement | null>(null);\n  const [measured, setMeasured] = useState(0);\n  const [internalActive, setInternalActive] = useState(-1);\n  const [revealed, setRevealed] = useState(false);\n  const [revision, setRevision] = useState(0);\n\n  // Measured rather than scaled by a viewBox, so a ring keeps the thickness it was given at any size.\n  useEffect(() => {\n    const node = plotRef.current;\n    if (!node) return;\n    const measure = () => setMeasured(node.clientWidth);\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const box = size ?? measured;\n  const span = ringSpan(startAngle, endAngle);\n  const colors = useMemo(\n    () => data.map((ring, index) => (ring.color ? COLORS[ring.color] : PALETTE[index % PALETTE.length]!)),\n    [data]\n  );\n\n  // Two frames, so the browser has painted the empty arcs before they transition away from them.\n  const drawable = box > 0;\n  useEffect(() => {\n    if (!drawable) return;\n    let frame = requestAnimationFrame(() => {\n      frame = requestAnimationFrame(() => setRevealed(true));\n    });\n    return () => cancelAnimationFrame(frame);\n  }, [drawable, revision]);\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      replay: () => {\n        setRevealed(false);\n        setRevision((current) => current + 1);\n      },\n    }),\n    []\n  );\n\n  const wanted = activeIndexProp ?? internalActive;\n  // An index the data no longer has reads nothing.\n  const activeIndex = wanted < data.length ? wanted : -1;\n\n  // A reading is reported when it changes, not per pointer event.\n  const reported = useRef(-1);\n  // Latest in a ref, so an inline callback does not count as a new reading.\n  const onChange = useRef(onActiveIndexChange);\n  useEffect(() => {\n    onChange.current = onActiveIndexChange;\n  });\n  const report = useCallback((index: number) => {\n    if (reported.current === index) return;\n    reported.current = index;\n    setInternalActive(index);\n    onChange.current?.(index);\n  }, []);\n\n  // Held in a ref because the pointer's reports must not move a ring that was pressed.\n  const pinned = useRef(-1);\n  // A controlled value is the reading now, whatever was last reported or pressed.\n  useEffect(() => {\n    if (activeIndexProp === undefined) return;\n    reported.current = activeIndexProp;\n    if (pinned.current !== activeIndexProp) pinned.current = -1;\n  }, [activeIndexProp]);\n  const read = useCallback(\n    (index: number) => {\n      if (pinned.current < 0) report(index);\n    },\n    [report]\n  );\n  const togglePin = useCallback(\n    (index: number) => {\n      pinned.current = pinned.current === index ? -1 : index;\n      report(pinned.current);\n    },\n    [report]\n  );\n  const release = useCallback(() => {\n    pinned.current = -1;\n    report(-1);\n  }, [report]);\n  // A held ring that was removed would otherwise block every other reading.\n  useEffect(() => {\n    if (pinned.current >= data.length || reported.current >= data.length) release();\n  }, [data.length, release]);\n\n  const context = useMemo<RingChartContextValue>(\n    () => ({\n      data,\n      box,\n      strokeWidth,\n      ringGap,\n      startAngle,\n      span,\n      animationDuration,\n      colors,\n      svg,\n      revealed,\n      revision,\n      activeIndex,\n      read,\n      togglePin,\n    }),\n    [data, box, strokeWidth, ringGap, startAngle, span, animationDuration, colors, svg, revealed, revision, activeIndex, read, togglePin]\n  );\n\n  const summary = label ?? `Ring chart, ${data.length} ${data.length === 1 ? 'ring' : 'rings'}`;\n  const spoken = (datum: RingDatum, index: number) =>\n    labelForDatum?.(datum, index) ?? `${datum.label}, ${ringPercent(datum.value, datum.maxValue)} percent`;\n  const active = data[activeIndex];\n  const slots = ringChartVariants();\n\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'ring-chart',\n        className: slots.root({ className }),\n        children: (\n          <>\n            <ul aria-label={summary} className=\"sr-only\">\n              {data.map((datum, index) => (\n                <li key={index}>{spoken(datum, index)}</li>\n              ))}\n            </ul>\n            <div aria-live=\"polite\" className=\"sr-only\">\n              {active ? spoken(active, activeIndex) : ''}\n            </div>\n            <div\n              ref={plotRef}\n              data-slot=\"ring-chart-plot\"\n              role=\"group\"\n              aria-label={summary}\n              tabIndex={data.length ? 0 : undefined}\n              className={slots.plot()}\n              style={size ? { width: size, height: size } : { aspectRatio: 1 }}\n              // A press on a ring stops here, so what arrives is a press on nothing.\n              onClick={() => {\n                if (pinned.current >= 0) release();\n              }}\n              onKeyDown={(event) => {\n                if (event.key === 'Escape') {\n                  release();\n                  return;\n                }\n                if ((event.key === 'Enter' || event.key === ' ') && activeIndex >= 0) {\n                  event.preventDefault();\n                  togglePin(activeIndex);\n                  return;\n                }\n                // The browser's own shortcuts, such as going back.\n                if (!NAVIGATION_KEYS.has(event.key) || event.altKey || event.ctrlKey || event.metaKey) return;\n                event.preventDefault();\n                pinned.current = -1;\n                report(ringAfterKey(event.key, activeIndex, data.length));\n              }}\n              onBlur={() => read(-1)}\n            >\n              {box > 0 ? <svg ref={setSvg} aria-hidden className={slots.svg()} width={box} height={box} /> : null}\n            </div>\n            {children}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n\n  return <RingChartContext.Provider value={context}>{element}</RingChartContext.Provider>;\n});\nRingChartRoot.displayName = 'RingChart';\n\nexport interface RingChartRingProps {\n  /** Which entry in `data` this ring draws. */\n  index: number;\n  /** Which of the theme's roles to draw it in, over the datum's and the palette. */\n  color?: RingChartColor;\n  /** Rounded ends or square ones. Defaults to round, and to square when the ring is segmented. */\n  lineCap?: 'round' | 'butt';\n  /** Opacity of the track behind the arc. */\n  trackOpacity?: number;\n  /** Break the ring into this many ticks, lit one at a time, for a target made of countable things. */\n  segments?: number;\n  /** Gap between one tick and the next, in pixels. */\n  segmentGap?: number;\n}\n\n/**\n * One ring: a track, and the arc showing how far along it the value has got. The\n * pointer's target is a wider transparent stroke along the arc, so the dead part\n * of a gauge stays dead.\n */\nfunction RingChartRing({ index, color, lineCap, trackOpacity = 0.15, segments, segmentGap = 3 }: RingChartRingProps) {\n  const { data, box, strokeWidth, ringGap, startAngle, span, animationDuration, colors, svg, revealed, revision, activeIndex, read, togglePin } =\n    useChart('RingChart.Ring');\n  const datum = data[index];\n  if (!datum || !svg || box <= 0) return null;\n\n  const slots = ringChartVariants();\n  const stroke = color ? COLORS[color] : colors[index];\n  const radius = ringRadius(box, strokeWidth, ringGap, index);\n  const circumference = 2 * Math.PI * radius;\n  const arcLength = circumference * span;\n  const fraction = ringFraction(datum.value, datum.maxValue);\n  const ticks = segments && segments > 0 ? Math.floor(segments) : 0;\n  // A round cap on a dash of nothing still draws a dot, which reads as progress.\n  const cap = lineCap ?? (ticks || fraction === 0 ? 'butt' : 'round');\n  const start = ringStagger(index, data.length);\n  const circle = {\n    cx: box / 2,\n    cy: box / 2,\n    r: radius,\n    fill: 'none',\n    strokeWidth,\n    // A circle's stroke starts at three o'clock, so it is turned back a quarter to start at twelve.\n    transform: `rotate(${startAngle - 90} ${box / 2} ${box / 2})`,\n  };\n\n  const body = ticks ? (\n    (() => {\n      const { slot, dash } = ringTick(arcLength, ticks, segmentGap);\n      const lit = Math.round(ticks * fraction);\n      return Array.from({ length: ticks }, (_, tick) => {\n        const pattern = {\n          strokeDasharray: `${dash} ${circumference}`,\n          strokeDashoffset: -slot * tick,\n          strokeLinecap: cap,\n        };\n        const delay = (start + (tick / ticks) * 0.6) * animationDuration;\n        return (\n          <g key={tick}>\n            <circle {...circle} {...pattern} stroke={stroke} strokeOpacity={trackOpacity} />\n            <circle\n              {...circle}\n              {...pattern}\n              stroke={stroke}\n              className={slots.tick()}\n              opacity={revealed && tick < lit ? 1 : 0}\n              style={{ '--ring-delay': `${delay}ms` } as CSSProperties}\n            />\n          </g>\n        );\n      });\n    })()\n  ) : (\n    <>\n      <circle\n        {...circle}\n        stroke={stroke}\n        strokeOpacity={trackOpacity}\n        strokeLinecap={cap}\n        strokeDasharray={`${arcLength} ${circumference}`}\n      />\n      <circle\n        {...circle}\n        stroke={stroke}\n        strokeLinecap={cap}\n        className={slots.sweep()}\n        style={\n          {\n            strokeDasharray: `${revealed ? arcLength * fraction : 0} ${circumference}`,\n            '--ring-duration': `${animationDuration * 0.6}ms`,\n            '--ring-delay': `${start * animationDuration}ms`,\n          } as CSSProperties\n        }\n      />\n    </>\n  );\n\n  return createPortal(\n    <g\n      key={revision}\n      data-slot=\"ring-chart-ring\"\n      className={slots.ring()}\n      opacity={activeIndex >= 0 && activeIndex !== index ? DIM : 1}\n    >\n      {body}\n      <circle\n        {...circle}\n        stroke=\"transparent\"\n        strokeWidth={strokeWidth + ringGap}\n        strokeDasharray={`${arcLength} ${circumference}`}\n        pointerEvents=\"stroke\"\n        className={slots.hit()}\n        onPointerEnter={(event) => {\n          if (event.pointerType === 'mouse') read(index);\n        }}\n        onPointerLeave={(event) => {\n          if (event.pointerType === 'mouse') read(-1);\n        }}\n        onClick={(event) => {\n          event.stopPropagation();\n          togglePin(index);\n        }}\n      />\n    </g>,\n    svg\n  );\n}\nRingChartRing.displayName = 'RingChart.Ring';\n\nexport interface RingChartCenterProps {\n  /** Heading shown when no ring is read. Defaults to the outermost ring's own name. */\n  defaultLabel?: string;\n  /** Format the number under the label. Defaults to a compact number. */\n  formatValue?: (value: number, ring: RingDatum | null) => string;\n  /** Draw the middle yourself. Given the ring being read, or `null` when there is none. */\n  children?: (ring: RingDatum | null) => ReactNode;\n  /** Added to the readout. */\n  className?: string;\n}\n\n/**\n * The hole's readout: the outermost ring until one is read. Never a total, because\n * the rings measure different things against different targets.\n */\nfunction RingChartCenter({ defaultLabel, formatValue, children, className }: RingChartCenterProps) {\n  const { data, box, strokeWidth, ringGap, activeIndex } = useChart('RingChart.Center');\n  if (box <= 0) return null;\n\n  const slots = ringChartVariants();\n  const selected = activeIndex >= 0;\n  const ring = (selected ? data[activeIndex] : data[0]) ?? null;\n  const format = formatValue ?? ((value: number) => compactNumber(value));\n  const inner = Math.max(ringRadius(box, strokeWidth, ringGap, Math.max(data.length - 1, 0)) - strokeWidth / 2, 0);\n  // A square inside a circle, not across it: the corners of a wider box fall outside the hole.\n  const room = inner * Math.SQRT2;\n  const full = room >= FULL_CENTER_ROOM;\n\n  return (\n    <div data-slot=\"ring-chart-center\" className={slots.center({ className })} style={{ maxWidth: room }}>\n      {children ? (\n        children(selected ? ring : null)\n      ) : ring ? (\n        <>\n          <div className={slots.centerLabel()}>{selected ? ring.label : (defaultLabel ?? ring.label)}</div>\n          <div className={slots.centerValue({ className: full ? 'text-xl' : 'text-lg' })}>{format(ring.value, ring)}</div>\n          {full ? <div className={slots.centerCaption()}>{`of ${format(ring.maxValue, ring)}`}</div> : null}\n        </>\n      ) : null}\n    </div>\n  );\n}\nRingChartCenter.displayName = 'RingChart.Center';\n\nexport interface RingChartLegendProps extends ComponentProps<'div'> {\n  /** Show each ring's percentage of its own target beside its name. */\n  showValue?: boolean;\n}\n\n/**\n * A swatch, a name and a percentage per ring, under the plot. Each entry is a button\n * for the same reading as its ring, and the easier target on a small chart.\n */\nfunction RingChartLegend({ showValue = true, className, render, ...props }: RingChartLegendProps & RenderProp<'div'>) {\n  const { data, colors, activeIndex, read, togglePin } = useChart('RingChart.Legend');\n  const slots = ringChartVariants();\n\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'ring-chart-legend',\n        className: slots.legend({ className }),\n        children: data.map((ring, index) => {\n          const percent = ringPercent(ring.value, ring.maxValue);\n          return (\n            <button\n              key={index}\n              type=\"button\"\n              aria-pressed={activeIndex === index}\n              aria-label={`${ring.label}, ${percent} percent`}\n              className={slots.legendButton()}\n              style={{ opacity: activeIndex >= 0 && activeIndex !== index ? LEGEND_DIM : 1 }}\n              onPointerEnter={(event) => {\n                if (event.pointerType === 'mouse') read(index);\n              }}\n              onPointerLeave={(event) => {\n                if (event.pointerType === 'mouse') read(-1);\n              }}\n              onClick={() => togglePin(index)}\n            >\n              <span className={slots.legendSwatch()} style={{ background: colors[index] }} />\n              <span className={slots.legendName()}>{ring.label}</span>\n              {showValue ? <span className={slots.legendValue()}>{percent}%</span> : null}\n            </button>\n          );\n        }),\n      },\n      props\n    ),\n  });\n\n  return data.length ? element : null;\n}\nRingChartLegend.displayName = 'RingChart.Legend';\n\nexport interface RingChartHeaderProps extends Omit<ComponentProps<'div'>, 'title'> {\n  /** Small line above the value: what the chart is of. */\n  title?: ReactNode;\n  /** The readout. The largest thing on the card, and the first thing read. */\n  value?: ReactNode;\n  /** One muted line under the value: a period, a comparison, a target. */\n  caption?: ReactNode;\n  /** Prettier names for the rings, keyed by their `label`. */\n  labels?: Record<string, string>;\n  /** Draw a swatch and a name per ring along the trailing edge. */\n  legend?: boolean;\n  /** Trailing slot: a control, a badge, a range picker. Wins over `legend`. */\n  children?: ReactNode;\n}\n\n/**\n * The strip above the rings: what the chart is of, what it reads and what the colours\n * mean. The value is not derived here, because there is no total to derive. Take it\n * from `onActiveIndexChange` and pass the formatted string down.\n */\nfunction RingChartHeader({\n  title,\n  value,\n  caption,\n  labels,\n  legend = false,\n  className,\n  children,\n  render,\n  ...props\n}: RingChartHeaderProps & RenderProp<'div'>) {\n  const { data, colors } = useChart('RingChart.Header');\n  const slots = ringChartVariants();\n  const trailing =\n    children ??\n    (legend && data.length\n      ? data.map((ring, index) => (\n          <span key={index} className={slots.legendItem()}>\n            <span className={slots.legendSwatch()} style={{ background: colors[index] }} />\n            {labels?.[ring.label] ?? ring.label}\n          </span>\n        ))\n      : null);\n\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'ring-chart-header',\n        className: slots.header({ className }),\n        children: (\n          <>\n            <div className={slots.headerText()}>\n              {title ? <div className={slots.headerMuted()}>{title}</div> : null}\n              {value || value === 0 ? <div className={slots.headerValue()}>{value}</div> : null}\n              {caption ? <div className={slots.headerMuted()}>{caption}</div> : null}\n            </div>\n            {trailing ? <div className={slots.headerTrailing()}>{trailing}</div> : null}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n}\nRingChartHeader.displayName = 'RingChart.Header';\n\nexport const RingChart = Object.assign(RingChartRoot, {\n  Header: RingChartHeader,\n  Ring: RingChartRing,\n  Center: RingChartCenter,\n  Legend: RingChartLegend,\n});\n\n/* By name as well, for a Server Component, which cannot dot into a client\n   module. */\nexport { RingChartHeader, RingChartRing, RingChartCenter, RingChartLegend };\n"
    },
    {
      "path": "components/panelui/ring-chart/ring-layout.ts",
      "type": "registry:component",
      "target": "@components/panelui/ring-chart/ring-layout.ts",
      "content": "/** How far a ring has got towards its target, 0 to 1. A value past it fills the ring and stops. */\nexport function ringFraction(value: number, maxValue: number): number {\n  if (!(maxValue > 0)) return 0;\n  return Math.max(0, Math.min(1, value / maxValue));\n}\n\n/** The same thing in words. Not clamped, because 120 percent of a goal is worth saying. */\nexport function ringPercent(value: number, maxValue: number): number {\n  return maxValue > 0 ? Math.round((value / maxValue) * 100) : 0;\n}\n\n/** How much of a turn the arcs run, from two angles on the clock. A gauge is less than one. */\nexport function ringSpan(startAngle: number, endAngle: number): number {\n  return Math.min(Math.max(endAngle - startAngle, 0), 360) / 360;\n}\n\n/** Where a ring's centre line sits. Outermost first, each one stepping in by its thickness and the gap. */\nexport function ringRadius(box: number, strokeWidth: number, ringGap: number, index: number): number {\n  return Math.max(box / 2 - strokeWidth / 2 - index * (strokeWidth + ringGap), strokeWidth / 2);\n}\n\n/**\n * One tick of a segmented ring: `slot` is a tick and the gap after it, `dash` the\n * tick alone. The gap comes out of the tick, so N ticks still span the whole arc.\n */\nexport function ringTick(arcLength: number, segments: number, segmentGap: number): { slot: number; dash: number } {\n  const slot = arcLength / segments;\n  return { slot, dash: Math.max(slot - segmentGap, 0.5) };\n}\n\n/**\n * When a ring starts its sweep, as a share of the duration. Staggered outward-in,\n * with the last 60 percent left for the sweep itself, so five rings finish inside\n * the one duration.\n */\nexport function ringStagger(index: number, count: number): number {\n  return (index / Math.max(1, count)) * 0.4;\n}\n\n/** The ring a key moves the reading to, or `current` for any other key. Walks in data order and stops at the ends. */\nexport function ringAfterKey(key: string, current: number, count: number): number {\n  if (count <= 0) return current;\n  switch (key) {\n    case 'ArrowRight':\n    case 'ArrowDown':\n      return Math.min(current + 1, count - 1);\n    case 'ArrowLeft':\n    case 'ArrowUp':\n      return Math.max(current - 1, 0);\n    case 'Home':\n      return 0;\n    case 'End':\n      return count - 1;\n    default:\n      return current;\n  }\n}\n"
    }
  ]
}
