{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "radar-chart",
  "type": "registry:component",
  "title": "Radar Chart",
  "description": "Several measures of one thing, drawn as one shape.",
  "dependencies": [
    "@base-ui/react",
    "@tanstack/charts",
    "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/radar-chart/index.tsx",
      "type": "registry:component",
      "target": "@components/panelui/radar-chart/index.tsx",
      "content": "'use client';\n\n/**\n * RadarChart: several measures of one thing, drawn as one shape.\n *\n * ```tsx\n * <RadarChart data={scores} axisKey=\"skill\" domain={[0, 100]}>\n *   <RadarChart.Grid />\n *   <RadarChart.Axis />\n *   <RadarChart.Series dataKey=\"you\" />\n *   <RadarChart.Tooltip />\n * </RadarChart>\n * ```\n *\n * ## The same parts as the app\n *\n * The parts and their props are the app library's RadarChart. What differs is what the\n * web changes: a colour is named by the theme's role, and `RadarChart.Tooltip` adds a\n * reading by pointer and by keyboard, which a screen you hold does not need.\n *\n * ## Parts describe, the root draws\n *\n * A `Series` registers what it wants and draws nothing itself, because the shapes all\n * have to go into one chart, in order, over one measured box.\n *\n * ## The movement\n *\n * The fractions are tweened here, so a shape grows from the centre and travels between\n * two profiles a vertex at a time, with the stroke at its final width throughout. A\n * change part way through another carries on from what is on screen.\n */\nimport { defineChart } from '@tanstack/charts';\nimport { motion } from '@tanstack/charts/motion';\nimport { polar, radialArea, radialDot, radialLine } from '@tanstack/charts/polar';\nimport { Chart } from '@tanstack/charts/react/core';\nimport { scaleLinear } from '@tanstack/charts/scales/linear';\nimport {\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n  type ComponentProps,\n  type ReactNode,\n} from 'react';\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 {\n  radarAxisAt,\n  radarBetween,\n  radarBoxInside,\n  radarDomain,\n  radarFraction,\n  radarKeyWalk,\n  radarLabelPlacement,\n  radarPoint,\n  radarValue,\n  type RadarChartDatum,\n} from './radar-layout';\n\nexport type { RadarChartDatum };\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/** Whether the chart is showing data or waiting for it. */\nexport type RadarChartStatus = 'loading' | 'ready';\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 part is drawn in. */\nexport type RadarChartColor = keyof typeof COLORS;\n\nconst PALETTE = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)'];\n\nconst THEME = {\n  foreground: 'var(--foreground)',\n  muted: 'var(--muted-foreground)',\n  grid: 'var(--border)',\n  background: 'transparent',\n  palette: PALETTE,\n};\n\nconst radarChartVariants = tv({\n  slots: {\n    root: 'relative grid w-full grid-cols-1 text-foreground',\n    plot: 'relative z-1 col-start-1 row-start-2 max-w-full cursor-default touch-pan-y justify-self-center rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring',\n    surface: 'absolute inset-0',\n    chart: 'absolute! inset-0 text-xs',\n    grid: 'pointer-events-none col-start-1 row-start-2 max-w-full justify-self-center text-border',\n    overlay: 'pointer-events-none relative z-2 col-start-1 row-start-2 max-w-full justify-self-center',\n    axisLabel: 'absolute truncate font-medium whitespace-nowrap text-muted-foreground',\n    tooltip:\n      'absolute flex w-33 flex-col gap-0.5 rounded-lg border border-border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-sm',\n    tooltipTitle: 'truncate font-medium',\n    tooltipRow: 'flex items-center gap-1.5 text-muted-foreground',\n    tooltipName: 'min-w-0 flex-1 truncate',\n    tooltipValue: 'font-medium text-popover-foreground tabular-nums',\n    legend: 'pointer-events-none relative z-2 col-start-1 row-start-2 flex flex-col gap-1 self-end justify-self-start text-xs',\n    legendItem: 'flex items-center gap-1.5',\n    legendSwatch: 'size-2 shrink-0 rounded-full',\n    legendName: 'truncate text-muted-foreground',\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\n/** Room around the rings for the labels, wider than tall because the labels are horizontal. */\nconst LABEL_ROOM = { x: 62, y: 26 };\n/** With no labels asking for it, only enough not to clip the stroke. */\nconst BARE_ROOM = { x: 6, y: 6 };\nconst DEFAULT_SIZE = 180;\n/** How long one profile takes to travel to the next. The growth is `animationDuration`. */\nconst MORPH_DURATION = 420;\n/** The readout's width, matching `w-33`, and its height before and per series. */\nconst TOOLTIP_WIDTH = 132;\nconst TOOLTIP_BASE_HEIGHT = 30;\nconst TOOLTIP_ROW_HEIGHT = 18;\n/** How far along its spoke the readout sits. */\nconst TOOLTIP_AT = 0.5;\n\nconst COMPACT = new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 });\nconst compactNumber = (value: number) => COMPACT.format(value);\n\ninterface SeriesConfig {\n  dataKey: string;\n  name?: string;\n  color?: RadarChartColor;\n  strokeWidth: number;\n  fillOpacity: number;\n  showDots: boolean;\n}\n\ninterface VertexRow {\n  angle: number;\n  radius: number;\n}\n\ntype Fractions = Record<string, readonly (number | null)[]>;\n\ninterface RadarChartContextValue {\n  data: readonly RadarChartDatum[];\n  axisKey: string;\n  width: number;\n  height: number;\n  cx: number;\n  cy: number;\n  radius: number;\n  series: readonly SeriesConfig[];\n  colorOf: (dataKey: string) => string;\n  nameOf: (config: SeriesConfig) => string;\n  fractions: Fractions;\n  loading: boolean;\n  activeIndex: number;\n  registerSeries: (config: SeriesConfig) => void;\n  unregisterSeries: (dataKey: string) => void;\n}\n\nconst RadarChartContext = createContext<RadarChartContextValue | null>(null);\n\nfunction useChart(component: string): RadarChartContextValue {\n  const context = useContext(RadarChartContext);\n  if (!context) throw new Error(`${component} must be used within a <RadarChart>`);\n  return context;\n}\n\nexport interface RadarChartHandle {\n  /** Grow the shapes out of the centre again. */\n  replay: () => void;\n}\n\nexport interface RadarChartProps extends Omit<ComponentProps<'div'>, 'ref'> {\n  /** One row per axis, in the order they go round, with one key per series. */\n  data: readonly RadarChartDatum[];\n  /** The key holding each row's axis label. */\n  axisKey?: string;\n  /** `loading` draws the rings and no shape, and the shapes grow when it turns `ready`. */\n  status?: RadarChartStatus;\n  /** Ring diameter in pixels, with room for the labels added around it. Pass `null` to fill the width at `aspectRatio`. */\n  size?: number | null;\n  /** Width over height of the plot when `size` is `null`. */\n  aspectRatio?: number;\n  /** The values at the centre and at the outer ring. Defaults to zero and just above the largest value. */\n  domain?: readonly [number, number];\n  /** Milliseconds for the shapes to grow. */\n  animationDuration?: number;\n  /** Drop the room for axis labels, for a chart without them. */\n  compact?: boolean;\n  /** Names the chart for screen readers. */\n  label?: string;\n  /** Writes one axis for screen readers, for values with a unit. */\n  labelForDatum?: (datum: RadarChartDatum, index: number) => string;\n}\n\nconst RadarChartRoot = forwardRef<RadarChartHandle, RadarChartProps & RenderProp<'div'>>(function RadarChartRoot(\n  {\n    data,\n    axisKey = 'axis',\n    status = 'ready',\n    size = DEFAULT_SIZE,\n    aspectRatio = 1,\n    domain,\n    animationDuration = 620,\n    compact = false,\n    label,\n    labelForDatum,\n    className,\n    children,\n    render,\n    ...props\n  },\n  ref\n) {\n  const slots = radarChartVariants();\n  const plotRef = useRef<HTMLDivElement>(null);\n  const [box, setBox] = useState({ width: 0, height: 0 });\n  const [series, setSeries] = useState<readonly SeriesConfig[]>([]);\n  const [active, setActive] = useState(-1);\n  const [revision, setRevision] = useState(0);\n\n  // The chart mounts once its box is known. Mounted at a guess, the first measurement is a resize.\n  useEffect(() => {\n    const node = plotRef.current;\n    if (!node) return;\n    const measure = () => setBox({ width: node.clientWidth, height: node.clientHeight });\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const registerSeries = useCallback((config: SeriesConfig) => {\n    setSeries((current) => {\n      const at = current.findIndex((entry) => entry.dataKey === config.dataKey);\n      // In place, so changing a prop does not reorder the shapes or their colours.\n      if (at < 0) return [...current, config];\n      return current.map((entry, index) => (index === at ? config : entry));\n    });\n  }, []);\n  const unregisterSeries = useCallback((dataKey: string) => {\n    setSeries((current) => current.filter((entry) => entry.dataKey !== dataKey));\n  }, []);\n\n  const room = compact ? BARE_ROOM : LABEL_ROOM;\n  const { width, height } = box;\n  const cx = width / 2;\n  const cy = height / 2;\n  const radius = Math.max(Math.min(width / 2 - room.x, height / 2 - room.y), 0);\n  const loading = status === 'loading';\n  const count = data.length;\n\n  const domainMin = domain?.[0];\n  const domainMax = domain?.[1];\n  const seriesKeys = series.map((entry) => entry.dataKey).join('\\0');\n  // By the values, so an inline `domain` or a new prop on a series does not count as a change.\n  const target = useMemo<Fractions>(() => {\n    const keys = seriesKeys ? seriesKeys.split('\\0') : [];\n    const extent = radarDomain(data, keys, domainMin === undefined || domainMax === undefined ? undefined : [domainMin, domainMax]);\n    return Object.fromEntries(\n      keys.map((key) => [\n        key,\n        data.map((row) => {\n          const value = radarValue(row[key]);\n          return value === null ? null : radarFraction(value, extent);\n        }),\n      ])\n    );\n  }, [data, seriesKeys, domainMin, domainMax]);\n\n  // What is on screen, which trails `target` through the growth and a change of data.\n  const [fractions, setFractions] = useState<Fractions>({});\n  const fractionsRef = useRef<Fractions>({});\n  useEffect(() => {\n    const show = (next: Fractions) => {\n      fractionsRef.current = next;\n      setFractions(next);\n    };\n    // Back to loading arms the growth again, so a refetch does not land fully drawn.\n    if (loading) {\n      show({});\n      return;\n    }\n    const from = fractionsRef.current;\n    const keys = Object.keys(target);\n    const moved =\n      keys.length !== Object.keys(from).length ||\n      keys.some((key) => {\n        const before = from[key];\n        const after = target[key]!;\n        return before?.length !== after.length || after.some((value, index) => value !== before[index]);\n      });\n    // An inline `data` array is new on every render without anything having moved.\n    if (!moved) return;\n    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {\n      show(target);\n      return;\n    }\n    const growing = keys.some((key) => !from[key]);\n    const duration = growing ? animationDuration : MORPH_DURATION;\n    const began = performance.now();\n    let frame = 0;\n    const step = (now: number) => {\n      // A frame's timestamp can be earlier than the clock read that started the tween.\n      const elapsed = Math.max(0, Math.min(1, (now - began) / duration));\n      const progress = 1 - (1 - elapsed) ** 3;\n      show(\n        elapsed < 1\n          ? Object.fromEntries(keys.map((key) => [key, radarBetween(from[key], target[key]!, progress)]))\n          : target\n      );\n      if (elapsed < 1) frame = requestAnimationFrame(step);\n    };\n    frame = requestAnimationFrame(step);\n    return () => cancelAnimationFrame(frame);\n  }, [target, loading, animationDuration, revision]);\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      replay: () => {\n        fractionsRef.current = {};\n        setFractions({});\n        setRevision((current) => current + 1);\n      },\n    }),\n    []\n  );\n\n  // One reading. Pressing holds it, so the pointer's reports must not move it. Hence the ref.\n  const pinned = useRef(false);\n  const release = useCallback(() => {\n    pinned.current = false;\n    setActive(-1);\n  }, []);\n  const activeIndex = !loading && count >= 3 && active < count ? active : -1;\n  useEffect(() => {\n    if (loading || count < 3 || active >= count) release();\n  }, [loading, active, count, release]);\n\n  const axisUnder = (clientX: number, clientY: number) => {\n    const rect = plotRef.current?.getBoundingClientRect();\n    return rect ? radarAxisAt(clientX - rect.left - cx, clientY - rect.top - cy, count, radius) : -1;\n  };\n\n  const marks = useMemo(\n    () =>\n      series.flatMap((config, order) => {\n        const color = config.color ? COLORS[config.color] : PALETTE[order % PALETTE.length]!;\n        const values = fractions[config.dataKey] ?? [];\n        const vertices: VertexRow[] = [];\n        values.forEach((fraction, index) => {\n          if (fraction !== null) vertices.push({ angle: (index / count) * Math.PI * 2, radius: fraction });\n        });\n        // Fewer than three readings is not a shape, and two spokes' worth of line would read as one.\n        if (count < 3 || vertices.length < 3) return [];\n        const outline = [...vertices, vertices[0]!];\n        const shape = { angle: (row: VertexRow) => row.angle, radius: (row: VertexRow) => row.radius };\n        return [\n          radialArea(outline, { ...shape, id: `radar-area-${config.dataKey}`, radius1: 0, fill: () => color, fillOpacity: config.fillOpacity }),\n          radialLine(outline, { ...shape, id: `radar-line-${config.dataKey}`, stroke: () => color, strokeWidth: config.strokeWidth }),\n          ...(config.showDots ? [radialDot(vertices, { ...shape, id: `radar-dot-${config.dataKey}`, r: 3, fill: () => color })] : []),\n        ];\n      }),\n    [series, fractions, count]\n  );\n\n  const definition = useMemo(\n    () =>\n      defineChart({\n        theme: THEME,\n        marks: [\n          polar({\n            // Both in the units the rows already hold: radians, and a share of the outer ring.\n            scales: {\n              angle: { scale: scaleLinear([0, Math.PI * 2], [0, Math.PI * 2]) },\n              radius: { scale: scaleLinear([0, 1], [0, 1]), range: [0, radius] },\n            },\n            marks,\n          }),\n        ],\n        scales: { x: null, y: null },\n        margin: 0,\n        guides: false,\n        focusRing: false,\n        keyboard: false,\n        pointer: false,\n        motion: false,\n      }),\n    [marks, radius]\n  );\n\n  const renderer = useMemo(() => motion({ transition: { type: 'tween', duration: 160, easing: 'ease-out' } }), []);\n\n  const colorOf = useCallback(\n    (dataKey: string) => {\n      const order = series.findIndex((entry) => entry.dataKey === dataKey);\n      const config = series[order];\n      return config?.color ? COLORS[config.color] : PALETTE[Math.max(order, 0) % PALETTE.length]!;\n    },\n    [series]\n  );\n  const nameOf = useCallback((config: SeriesConfig) => config.name ?? config.dataKey, []);\n\n  const summary = label ?? `Radar chart, ${count} ${count === 1 ? 'axis' : 'axes'}`;\n  const spoken = (row: RadarChartDatum, index: number) =>\n    labelForDatum?.(row, index) ??\n    [\n      String(row[axisKey] ?? ''),\n      ...series.map((config) => {\n        const value = radarValue(row[config.dataKey]);\n        return `${nameOf(config)} ${value === null ? 'no value' : compactNumber(value)}`;\n      }),\n    ].join(', ');\n\n  const context = useMemo<RadarChartContextValue>(\n    () => ({ data, axisKey, width, height, cx, cy, radius, series, colorOf, nameOf, fractions: target, loading, activeIndex, registerSeries, unregisterSeries }),\n    [data, axisKey, width, height, cx, cy, radius, series, colorOf, nameOf, target, loading, activeIndex, registerSeries, unregisterSeries]\n  );\n\n  const activeRow = activeIndex >= 0 ? data[activeIndex] : undefined;\n\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'radar-chart',\n        className: slots.root({ className }),\n        children: (\n          <>\n            {loading ? null : (\n              <ul aria-label={summary} className=\"sr-only\">\n                {data.map((row, index) => (\n                  <li key={index}>{spoken(row, index)}</li>\n                ))}\n              </ul>\n            )}\n            <div aria-live=\"polite\" className=\"sr-only\">\n              {activeRow ? spoken(activeRow, activeIndex) : ''}\n            </div>\n            <div\n              ref={plotRef}\n              data-slot=\"radar-chart-plot\"\n              role=\"group\"\n              aria-label={summary}\n              tabIndex={0}\n              className={slots.plot()}\n              style={\n                size\n                  ? { width: size + room.x * 2, height: size + room.y * 2 }\n                  : { width: '100%', aspectRatio }\n              }\n              // A finger reads by pressing, and its drags are the page scrolling.\n              onPointerMove={(event) => {\n                if (event.pointerType !== 'touch' && !pinned.current) setActive(axisUnder(event.clientX, event.clientY));\n              }}\n              onPointerLeave={(event) => {\n                if (event.pointerType !== 'touch' && !pinned.current) setActive(-1);\n              }}\n              onBlur={() => {\n                if (!pinned.current) setActive(-1);\n              }}\n              onClick={(event) => {\n                const axis = axisUnder(event.clientX, event.clientY);\n                pinned.current = axis >= 0 && !(pinned.current && axis === activeIndex);\n                setActive(pinned.current ? axis : -1);\n              }}\n              onKeyDown={(event) => {\n                // The browser's own shortcuts, such as going back.\n                if (event.altKey || event.ctrlKey || event.metaKey) return;\n                const next = radarKeyWalk(activeIndex, count, event.key);\n                if (next !== undefined) {\n                  event.preventDefault();\n                  pinned.current = false;\n                  setActive(next);\n                } else if (event.key === 'Escape') {\n                  release();\n                } else if ((event.key === 'Enter' || event.key === ' ') && activeIndex >= 0) {\n                  event.preventDefault();\n                  pinned.current = !pinned.current;\n                }\n              }}\n            >\n              {/* The chart refuses a scale no mark uses, so it waits for a shape. */}\n              {radius > 0 && marks.length ? (\n                // Inert, because the drawing would otherwise take focus away from the plot, which reads the keys.\n                <div inert className={slots.surface()}>\n                  <Chart definition={definition} renderer={renderer} ariaLabel={summary} aspectRatio={width / height} initialWidth={width} className={slots.chart()} />\n                </div>\n              ) : null}\n            </div>\n            {children}\n          </>\n        ),\n      },\n      props\n    ),\n  });\n\n  return <RadarChartContext.Provider value={context}>{element}</RadarChartContext.Provider>;\n});\nRadarChartRoot.displayName = 'RadarChart';\n\nexport interface RadarChartGridProps {\n  /** How many rings, including the outermost. */\n  rings?: number;\n  /** Which of the theme's roles to draw it in. Defaults to the border colour. */\n  color?: RadarChartColor;\n  /** Draw the rings as circles rather than polygons through the spokes. */\n  circular?: boolean;\n  /** Draw a line from the centre to the rim along each axis. */\n  spokes?: boolean;\n}\n\n/**\n * The scale, as rings. Polygons by default, because they are read against the shape on\n * top of them, and a round ring behind a polygon gives each axis a different distance\n * to the edge. Fewer than three axes is not a shape, so it draws nothing.\n */\nfunction RadarChartGrid({ rings = 4, color, circular = false, spokes = true }: RadarChartGridProps) {\n  const { data, width, height, cx, cy, radius } = useChart('RadarChart.Grid');\n  const count = data.length;\n  if (count < 3 || radius <= 0 || rings < 1) return null;\n\n  const polygon = (reach: number) =>\n    Array.from({ length: count }, (_, index) => {\n      const point = radarPoint(cx, cy, reach, index, count);\n      return `${point.x},${point.y}`;\n    }).join(' ');\n\n  return (\n    <svg\n      data-slot=\"radar-chart-grid\"\n      aria-hidden\n      className={radarChartVariants().grid()}\n      width={width}\n      height={height}\n      fill=\"none\"\n      stroke={color ? COLORS[color] : 'currentColor'}\n    >\n      {Array.from({ length: rings }, (_, index) => {\n        const reach = (radius * (index + 1)) / rings;\n        return circular ? <circle key={index} cx={cx} cy={cy} r={reach} /> : <polygon key={index} points={polygon(reach)} />;\n      })}\n      {spokes\n        ? Array.from({ length: count }, (_, index) => {\n            const point = radarPoint(cx, cy, radius, index, count);\n            return <line key={index} x1={cx} y1={cy} x2={point.x} y2={point.y} />;\n          })\n        : null}\n    </svg>\n  );\n}\nRadarChartGrid.displayName = 'RadarChart.Grid';\n\nexport interface RadarChartAxisProps {\n  /** Which of the theme's roles to draw the labels in. Defaults to the muted text colour. */\n  color?: RadarChartColor;\n  /** Label size in pixels. */\n  fontSize?: number;\n  /** Gap between the outer ring and the label, in pixels. */\n  offset?: number;\n  /** Rewrite a label, usually to shorten it. */\n  formatLabel?: (label: string, index: number) => string;\n}\n\n/**\n * The axis names around the rings. Each runs away from the shape by which side of the\n * circle it is on, and a label too long for the room ends in an ellipsis.\n */\nfunction RadarChartAxis({ color, fontSize = 11, offset = 10, formatLabel }: RadarChartAxisProps) {\n  const { data, axisKey, width, height, cx, cy, radius } = useChart('RadarChart.Axis');\n  const count = data.length;\n  if (count < 3 || radius <= 0) return null;\n  const slots = radarChartVariants();\n  const shift = { start: '0', middle: '-50%', end: '-100%' };\n\n  return (\n    <div data-slot=\"radar-chart-axis\" aria-hidden className={slots.overlay()} style={{ width, height }}>\n      {data.map((row, index) => {\n        const raw = String(row[axisKey] ?? '');\n        const point = radarPoint(cx, cy, radius + offset, index, count);\n        const { align, maxWidth } = radarLabelPlacement(point.x, cx, radius, width);\n        return (\n          <span\n            key={index}\n            className={slots.axisLabel()}\n            style={{\n              left: point.x,\n              top: point.y,\n              maxWidth,\n              fontSize,\n              color: color ? COLORS[color] : undefined,\n              transform: `translate(${shift[align]}, ${point.y < cy ? '-100%' : '0'})`,\n            }}\n          >\n            {formatLabel ? formatLabel(raw, index) : raw}\n          </span>\n        );\n      })}\n    </div>\n  );\n}\nRadarChartAxis.displayName = 'RadarChart.Axis';\n\nexport interface RadarChartSeriesProps {\n  /** The key in each row holding this series' value. */\n  dataKey: string;\n  /** What the legend, the readout and screen readers call it. Defaults to `dataKey`. */\n  name?: string;\n  /** Which of the theme's roles to draw it in. Defaults to the chart palette, in the order the series are declared. */\n  color?: RadarChartColor;\n  /** Outline width in pixels. */\n  strokeWidth?: number;\n  /** Opacity of the fill. Pass `0` for an outline only. */\n  fillOpacity?: number;\n  /** Draw a dot at each vertex. */\n  showDots?: boolean;\n}\n\n/**\n * One profile. An axis with no number is left out of the shape, and a series with fewer\n * than three numbers draws nothing, because two points are a line.\n */\nfunction RadarChartSeries({ dataKey, name, color, strokeWidth = 2, fillOpacity = 0.18, showDots = false }: RadarChartSeriesProps) {\n  const { registerSeries, unregisterSeries } = useChart('RadarChart.Series');\n  useEffect(() => {\n    registerSeries({ dataKey, name, color, strokeWidth, fillOpacity, showDots });\n  }, [dataKey, name, color, strokeWidth, fillOpacity, showDots, registerSeries]);\n  useEffect(() => () => unregisterSeries(dataKey), [dataKey, unregisterSeries]);\n  return null;\n}\nRadarChartSeries.displayName = 'RadarChart.Series';\n\nexport interface RadarChartTooltipProps {\n  /** Format a value. Defaults to a compact number. */\n  formatValue?: (value: number, dataKey: string) => string;\n  /** Added to the readout. */\n  className?: string;\n}\n\n/**\n * The reading for the axis nearest the pointer or walked to with the keys: its spoke\n * lit, a dot where each series crosses it, and each series' value in a readout.\n */\nfunction RadarChartTooltip({ formatValue, className }: RadarChartTooltipProps) {\n  const { data, axisKey, width, height, cx, cy, radius, series, colorOf, nameOf, fractions, activeIndex } = useChart('RadarChart.Tooltip');\n  const row = data[activeIndex];\n  if (!row) return null;\n  const slots = radarChartVariants();\n  const count = data.length;\n  const rim = radarPoint(cx, cy, radius, activeIndex, count);\n  const anchor = radarPoint(cx, cy, radius * TOOLTIP_AT, activeIndex, count);\n  const boxHeight = TOOLTIP_BASE_HEIGHT + TOOLTIP_ROW_HEIGHT * series.length;\n\n  return (\n    <div data-slot=\"radar-chart-tooltip\" className={slots.overlay()} style={{ width, height }}>\n      <svg aria-hidden className=\"absolute inset-0\" width={width} height={height}>\n        <line x1={cx} y1={cy} x2={rim.x} y2={rim.y} stroke=\"var(--muted-foreground)\" />\n        {series.map((config) => {\n          const fraction = fractions[config.dataKey]?.[activeIndex];\n          if (fraction === null || fraction === undefined) return null;\n          const point = radarPoint(cx, cy, fraction * radius, activeIndex, count);\n          return <circle key={config.dataKey} cx={point.x} cy={point.y} r={4} fill={colorOf(config.dataKey)} stroke=\"var(--background)\" strokeWidth={1.5} />;\n        })}\n      </svg>\n      <div\n        className={slots.tooltip({ className })}\n        style={{\n          left: radarBoxInside(anchor.x, TOOLTIP_WIDTH, width),\n          top: radarBoxInside(anchor.y, boxHeight, height),\n        }}\n      >\n        <span className={slots.tooltipTitle()}>{String(row[axisKey] ?? '')}</span>\n        {series.map((config) => {\n          const value = radarValue(row[config.dataKey]);\n          return (\n            <span key={config.dataKey} className={slots.tooltipRow()}>\n              <span className={slots.legendSwatch()} style={{ background: colorOf(config.dataKey) }} />\n              <span className={slots.tooltipName()}>{nameOf(config)}</span>\n              <span className={slots.tooltipValue()}>\n                {value === null ? 'n/a' : formatValue ? formatValue(value, config.dataKey) : compactNumber(value)}\n              </span>\n            </span>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\nRadarChartTooltip.displayName = 'RadarChart.Tooltip';\n\nexport interface RadarChartLegendProps extends ComponentProps<'div'> {\n  /** Rewrite a series' name. */\n  formatName?: (dataKey: string) => string;\n}\n\n/** The series, named and coloured, in the bottom-left corner of the plot, which the circle leaves empty. */\nfunction RadarChartLegend({ formatName, className, render, ...props }: RadarChartLegendProps & RenderProp<'div'>) {\n  const { series, colorOf, nameOf } = useChart('RadarChart.Legend');\n  const slots = radarChartVariants();\n  const element = useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'radar-chart-legend',\n        className: slots.legend({ className }),\n        children: series.map((config) => (\n          <div key={config.dataKey} className={slots.legendItem()}>\n            <span className={slots.legendSwatch()} style={{ background: colorOf(config.dataKey) }} />\n            <span className={slots.legendName()}>{formatName ? formatName(config.dataKey) : nameOf(config)}</span>\n          </div>\n        )),\n      },\n      props\n    ),\n  });\n  return series.length ? element : null;\n}\nRadarChartLegend.displayName = 'RadarChart.Legend';\n\nexport interface RadarChartHeaderProps 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 caveat. */\n  caption?: ReactNode;\n  /** Draw a swatch and a name per series 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/** The strip above the rings. The value is not derived here, because its formatting is the caller's to know. */\nfunction RadarChartHeader({ title, value, caption, legend = false, className, children, render, ...props }: RadarChartHeaderProps & RenderProp<'div'>) {\n  const { series, colorOf, nameOf } = useChart('RadarChart.Header');\n  const slots = radarChartVariants();\n  const trailing =\n    children ??\n    (legend && series.length\n      ? series.map((config) => (\n          <span key={config.dataKey} className={slots.legendItem()}>\n            <span className={slots.legendSwatch()} style={{ background: colorOf(config.dataKey) }} />\n            {nameOf(config)}\n          </span>\n        ))\n      : null);\n\n  return useRender({\n    defaultTagName: 'div',\n    render,\n    props: mergeProps(\n      {\n        'data-slot': 'radar-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}\nRadarChartHeader.displayName = 'RadarChart.Header';\n\nexport const RadarChart = Object.assign(RadarChartRoot, {\n  Header: RadarChartHeader,\n  Grid: RadarChartGrid,\n  Axis: RadarChartAxis,\n  Series: RadarChartSeries,\n  Tooltip: RadarChartTooltip,\n  Legend: RadarChartLegend,\n});\n\n/* By name as well, for a Server Component, which cannot dot into a client\n   module. */\nexport { RadarChartHeader, RadarChartGrid, RadarChartAxis, RadarChartSeries, RadarChartTooltip, RadarChartLegend };\n"
    },
    {
      "path": "components/panelui/radar-chart/radar-layout.ts",
      "type": "registry:component",
      "target": "@components/panelui/radar-chart/radar-layout.ts",
      "content": "/** One row is one axis: its label, and one value per series. */\nexport type RadarChartDatum = Record<string, string | number | null | undefined>;\n\n/** A number a series can draw, or null for an axis it has no reading on. */\nexport function radarValue(value: unknown): number | null {\n  return typeof value === 'number' && !Number.isNaN(value) ? value : null;\n}\n\n/**\n * The scale. From zero unless told otherwise, because rings are read as fractions of\n * the whole and a floor above zero makes a small value look like a large one.\n */\nexport function radarDomain(\n  data: readonly RadarChartDatum[],\n  keys: readonly string[],\n  domain?: readonly [number, number]\n): readonly [number, number] {\n  if (domain) return domain;\n  let max = -Infinity;\n  for (const row of data) {\n    for (const key of keys) {\n      const value = radarValue(row[key]);\n      if (value !== null && value > max) max = value;\n    }\n  }\n  if (max === -Infinity) return [0, 1];\n  return [0, max <= 0 ? 1 : max * 1.05];\n}\n\n/** A value's distance from the centre as a share of the radius. A value past the domain draws past the outer ring, so a wrong `domain` shows. */\nexport function radarFraction(value: number, [min, max]: readonly [number, number]): number {\n  if (max <= min) return 0;\n  return (value - min) / (max - min);\n}\n\n/** Fractions part way through a change, `progress` running 0 to 1. A vertex with no earlier place grows from the centre. */\nexport function radarBetween(\n  from: readonly (number | null)[] | undefined,\n  to: readonly (number | null)[],\n  progress: number\n): (number | null)[] {\n  return to.map((value, index) => {\n    if (value === null) return null;\n    const start = from?.[index] ?? 0;\n    return start + (value - start) * progress;\n  });\n}\n\n/** A point on the dial, measured from the top-left of the box. Axis `index` of `count` runs clockwise from twelve. */\nexport function radarPoint(cx: number, cy: number, radius: number, index: number, count: number): { x: number; y: number } {\n  const angle = (index / count) * Math.PI * 2;\n  return { x: cx + Math.sin(angle) * radius, y: cy - Math.cos(angle) * radius };\n}\n\n/** The axis nearest a point measured from the centre, or -1 past `reach` or on a chart too small to have a shape. */\nexport function radarAxisAt(x: number, y: number, count: number, reach: number): number {\n  if (count < 3 || Math.hypot(x, y) > reach) return -1;\n  const turn = Math.atan2(x, -y) / (Math.PI * 2);\n  return ((Math.round(turn * count) % count) + count) % count;\n}\n\nexport type RadarLabelAlign = 'start' | 'middle' | 'end';\n\n/**\n * Which way a label runs from its point. Close to the vertical it is centred. Either\n * side of that it runs away from the shape rather than across it, for as far as the\n * box lets it, so a long label ends in an ellipsis rather than being cut off.\n */\nexport function radarLabelPlacement(x: number, cx: number, radius: number, width: number): { align: RadarLabelAlign; maxWidth: number } {\n  const dx = x - cx;\n  if (Math.abs(dx) < radius * 0.15) return { align: 'middle', maxWidth: width };\n  return dx > 0 ? { align: 'start', maxWidth: Math.max(width - x, 0) } : { align: 'end', maxWidth: Math.max(x, 0) };\n}\n\n/** Where a box goes over a point, pushed back inside the plot. */\nexport function radarBoxInside(point: number, boxSize: number, plot: number): number {\n  return Math.max(0, Math.min(plot - boxSize, point - boxSize / 2));\n}\n\n/** The axis a navigation key moves the reading to, or undefined for any other key. Runs round, because the axes do. */\nexport function radarKeyWalk(current: number, count: number, key: string): number | undefined {\n  if (count < 3) return undefined;\n  switch (key) {\n    case 'ArrowRight':\n    case 'ArrowDown':\n      return (current + 1) % count;\n    case 'ArrowLeft':\n    case 'ArrowUp':\n      return current < 0 ? count - 1 : (current - 1 + count) % count;\n    case 'Home':\n      return 0;\n    case 'End':\n      return count - 1;\n    default:\n      return undefined;\n  }\n}\n"
    }
  ]
}
