{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "radar-chart",
  "type": "registry:ui",
  "title": "Radar Chart",
  "description": "A spider chart for up to three series whose polygons spring between datasets.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/radar-chart.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface RadarAxis {\n  /** Key used to look up each series' value. */\n  key: string;\n  label: string;\n}\n\nexport interface RadarSeries {\n  /** Stable identity: colour and springs follow the id. */\n  id: string;\n  label: string;\n  /** Values by axis key; missing keys count as 0. */\n  values: Record<string, number>;\n  /** Series colour; falls back to the categorical palette (accent first). */\n  color?: string;\n}\n\nexport interface RadarSpring {\n  stiffness: number;\n  damping: number;\n  mass: number;\n}\n\nexport interface RadarChartProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  axes: RadarAxis[];\n  /** One to three series. */\n  series: RadarSeries[];\n  /** Accessible name; also the caption of the screen-reader table. */\n  label: string;\n  /** Value at the outer ring; 0 picks a rounded maximum from the data. */\n  max?: number;\n  /** Number of grid rings. */\n  levels?: number;\n  /** Grid ring shape. */\n  gridShape?: \"polygon\" | \"circle\";\n  /** Spring used when values change, series toggle or the chart enters. */\n  spring?: RadarSpring;\n  /** Opacity of the series fill, 0–1. */\n  fillOpacity?: number;\n  /** Draw a dot on every vertex. */\n  showDots?: boolean;\n  /** Value formatter for the tooltip, ring labels and table. */\n  format?: (value: number) => string;\n  /** Show the legend (its buttons toggle series). */\n  legend?: boolean;\n  /** Maximum chart size in px; the chart shrinks with its container. */\n  maxSize?: number;\n  /** Colour of the first series without its own colour. */\n  accent?: string;\n}\n\n/* --------------------------- reduced-motion hook --------------------------- */\n\nconst REDUCED_QUERY = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribeReducedMotion(onChange: () => void) {\n  const mq = window.matchMedia(REDUCED_QUERY);\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(REDUCED_QUERY).matches,\n    () => false,\n  );\n}\n\n/* ---------------------------------- utils --------------------------------- */\n\n// Three categorical slots, validated together for colour-vision deficiency, stepped for dark mode.\nconst PALETTE_VARS = \"[--rc-2:#2a78d6] [--rc-3:#1baf7a] [--rc-rest:#a1a1aa] dark:[--rc-2:#3987e5] dark:[--rc-3:#199e70] dark:[--rc-rest:#71717a]\";\n\nfunction colorFor(s: RadarSeries, index: number) {\n  if (s.color) return s.color;\n  return index === 0 ? \"var(--rc-accent)\" : index < 3 ? `var(--rc-${index + 1})` : \"var(--rc-rest)\";\n}\n\n/** Smallest \"nice\" number (1, 2, 2.5, 5 × 10ⁿ) at or above `v`. */\nfunction niceCeil(v: number) {\n  if (v <= 0) return 1;\n  const mag = Math.pow(10, Math.floor(Math.log10(v)));\n  for (const step of [1, 2, 2.5, 5, 10]) if (step * mag >= v) return step * mag;\n  return 10 * mag;\n}\n\nconst DEFAULT_SPRING: RadarSpring = { stiffness: 170, damping: 20, mass: 1 };\n\ninterface SpringState {\n  x: number;\n  v: number;\n}\n\n/* -------------------------------- component -------------------------------- */\n\n/**\n * A radar (spider) chart for one to three series. Every vertex rides its own spring, so new\n * data, toggled series and the entry all morph the polygons organically. Pointer, tap or\n * keyboard focus on an axis highlights it and shows a tooltip; a screen-reader table mirrors the\n * data. Size follows the container via ResizeObserver.\n */\nexport function RadarChart({\n  axes,\n  series,\n  label,\n  max = 0,\n  levels = 4,\n  gridShape = \"polygon\",\n  spring = DEFAULT_SPRING,\n  fillOpacity = 0.15,\n  showDots = true,\n  format,\n  legend = true,\n  maxSize = 440,\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  ...props\n}: RadarChartProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const boxRef = React.useRef<HTMLDivElement>(null);\n  const springsRef = React.useRef<Map<string, SpringState>>(new Map());\n  const [width, setWidth] = React.useState(360);\n  const [hidden, setHidden] = React.useState<ReadonlySet<string>>(() => new Set());\n  const [active, setActive] = React.useState<number | null>(null);\n  const [focusIndex, setFocusIndex] = React.useState(0);\n  const hintId = React.useId();\n\n  const fmt = React.useMemo(() => {\n    if (format) return format;\n    const nf = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 1 });\n    return (v: number) => nf.format(v);\n  }, [format]);\n\n  // Responsive: follow the container width.\n  React.useEffect(() => {\n    const box = boxRef.current;\n    if (!box) return;\n    const ro = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width)));\n    ro.observe(box);\n    return () => ro.disconnect();\n  }, []);\n\n  const n = axes.length;\n  const size = Math.max(200, Math.min(maxSize, width || maxSize));\n  const pad = Math.max(40, size * 0.17);\n  const r = size / 2 - pad;\n  const c = size / 2;\n  const angleOf = (i: number) => (i / Math.max(1, n)) * Math.PI * 2 - Math.PI / 2;\n  const at = (i: number, radius: number) => ({ x: c + Math.cos(angleOf(i)) * radius, y: c + Math.sin(angleOf(i)) * radius });\n\n  const list = series.map((s, i) => ({ ...s, color: colorFor(s, i) }));\n  const dataMax = Math.max(0, ...series.flatMap((s) => axes.map((a) => s.values[a.key] ?? 0)));\n  const top = max > 0 ? max : niceCeil(dataMax);\n\n  // Spring targets as a plain, serialisable map: \"seriesId|axisKey\" → 0..1 of the radius.\n  const targets: [string, number][] = [];\n  for (const s of list) {\n    for (const a of axes) {\n      const value = hidden.has(s.id) ? 0 : Math.max(0, Math.min(1, (s.values[a.key] ?? 0) / top));\n      targets.push([`${s.id}|${a.key}`, value]);\n    }\n  }\n  const targetKey = JSON.stringify(targets);\n  const axisKeys = axes.map((a) => a.key).join(\"\\u0000\");\n  const seriesIds = list.map((s) => s.id).join(\"\\u0000\");\n  const { stiffness, damping, mass } = spring;\n\n  // Spring loop: integrates every vertex and writes polygon points / dot positions directly.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const goal = new Map<string, number>(JSON.parse(targetKey) as [string, number][]);\n    const springs = springsRef.current;\n    for (const key of goal.keys()) if (!springs.has(key)) springs.set(key, { x: 0, v: 0 });\n    const keys = axisKeys.split(\"\\u0000\");\n    const ids = seriesIds.split(\"\\u0000\");\n    const count = keys.length;\n    const angle = (i: number) => (i / Math.max(1, count)) * Math.PI * 2 - Math.PI / 2;\n\n    const polys = new Map<string, SVGPolygonElement>();\n    root.querySelectorAll<SVGPolygonElement>(\"[data-rc-poly]\").forEach((el) => polys.set(el.dataset.rcPoly ?? \"\", el));\n    const dots = new Map<string, SVGCircleElement>();\n    root.querySelectorAll<SVGCircleElement>(\"[data-rc-dot]\").forEach((el) => dots.set(el.dataset.rcDot ?? \"\", el));\n\n    const draw = () => {\n      for (const id of ids) {\n        const pts: string[] = [];\n        keys.forEach((key, i) => {\n          const k = `${id}|${key}`;\n          const radius = Math.max(0, springs.get(k)?.x ?? 0) * r;\n          const x = c + Math.cos(angle(i)) * radius;\n          const y = c + Math.sin(angle(i)) * radius;\n          pts.push(`${x.toFixed(2)},${y.toFixed(2)}`);\n          const dot = dots.get(k);\n          if (dot) {\n            dot.setAttribute(\"cx\", x.toFixed(2));\n            dot.setAttribute(\"cy\", y.toFixed(2));\n          }\n        });\n        polys.get(id)?.setAttribute(\"points\", pts.join(\" \"));\n      }\n    };\n\n    if (reduceMotion) {\n      for (const [key, value] of goal) springs.set(key, { x: value, v: 0 });\n      draw();\n      return;\n    }\n\n    let frame = 0;\n    let visible = true;\n    let last = 0; // set by wake() before the first frame\n    const k = Math.max(1, stiffness);\n    const d = Math.max(0, damping);\n    const m = Math.max(0.1, mass);\n\n    const tick = (now: number) => {\n      const elapsed = Math.min(0.064, (now - last) / 1000);\n      last = now;\n      let moving = false;\n      // Fixed sub-steps keep stiff springs stable regardless of frame rate.\n      const steps = Math.max(1, Math.ceil(elapsed / (1 / 240)));\n      const h = elapsed / steps;\n      for (const [key, target] of goal) {\n        const s = springs.get(key);\n        if (!s) continue;\n        for (let i = 0; i < steps; i++) {\n          const a = (-k * (s.x - target) - d * s.v) / m;\n          s.v += a * h;\n          s.x += s.v * h;\n        }\n        if (Math.abs(s.x - target) > 1e-4 || Math.abs(s.v) > 1e-4) moving = true;\n        else {\n          s.x = target;\n          s.v = 0;\n        }\n      }\n      draw();\n      frame = moving && visible && !document.hidden ? requestAnimationFrame(tick) : 0;\n    };\n\n    const wake = () => {\n      if (frame || !visible || document.hidden) return;\n      last = performance.now();\n      frame = requestAnimationFrame(tick);\n    };\n\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      wake();\n    });\n    io.observe(root);\n    document.addEventListener(\"visibilitychange\", wake);\n    draw();\n    wake();\n\n    return () => {\n      cancelAnimationFrame(frame);\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", wake);\n    };\n  }, [targetKey, axisKeys, seriesIds, stiffness, damping, mass, reduceMotion, r, c]);\n\n  const shownSeries = list.filter((s) => !hidden.has(s.id));\n  const tabIndexAxis = Math.min(focusIndex, Math.max(0, n - 1));\n\n  const describeAxis = (i: number) => {\n    const axis = axes[i];\n    const parts = shownSeries.map((s) => `${s.label} ${fmt(s.values[axis.key] ?? 0)}`);\n    return `${axis.label}: ${parts.length ? parts.join(\", \") : \"no series shown\"}`;\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent<SVGPathElement>, i: number) => {\n    let next = -1;\n    if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") next = (i + 1) % n;\n    else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") next = (i - 1 + n) % n;\n    else if (event.key === \"Home\") next = 0;\n    else if (event.key === \"End\") next = n - 1;\n    else if (event.key === \"Escape\") setActive(null);\n    if (next < 0) return;\n    event.preventDefault();\n    rootRef.current?.querySelector<SVGPathElement>(`[data-rc-axis=\"${next}\"]`)?.focus();\n  };\n\n  const ring = (fraction: number) => {\n    if (gridShape === \"circle\") return null;\n    return axes.map((_, i) => {\n      const p = at(i, r * fraction);\n      return `${p.x.toFixed(2)},${p.y.toFixed(2)}`;\n    }).join(\" \");\n  };\n\n  // Tooltip sits just outside the active axis label (above it on the top half, below on the\n  // bottom half), so it never covers the polygons or the label it describes.\n  let tooltip: { left: number; top: number; transform: string } | null = null;\n  if (active !== null && axes[active]) {\n    const p = at(active, r + 14);\n    const cos = Math.cos(angleOf(active));\n    const sin = Math.sin(angleOf(active));\n    const tx = cos > 0.3 ? \"0%\" : cos < -0.3 ? \"-100%\" : \"-50%\";\n    const ty = sin > 0.3 ? \"22px\" : sin < -0.3 ? \"calc(-100% - 12px)\" : \"14px\";\n    tooltip = { left: p.x, top: p.y, transform: `translate(${tx}, ${ty})` };\n  }\n\n  const half = Math.PI / Math.max(1, n);\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"group\"\n      aria-label={label}\n      className={`w-full text-zinc-950 dark:text-zinc-50 ${PALETTE_VARS} ${className}`}\n      style={{ [\"--rc-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <div ref={boxRef} className=\"w-full\" style={{ maxWidth: maxSize, marginInline: \"auto\" }}>\n        <div className=\"relative mx-auto\" style={{ width: size, height: size }}>\n          <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className=\"block overflow-visible\" onPointerLeave={(e) => e.pointerType === \"mouse\" && setActive(null)}>\n            {/* Grid rings and spokes (recessive). */}\n            <g aria-hidden=\"true\" fill=\"none\" className=\"stroke-zinc-900/10 dark:stroke-white/10\">\n              {Array.from({ length: Math.max(1, levels) }, (_, l) => {\n                const f = (l + 1) / Math.max(1, levels);\n                return gridShape === \"circle\" ? (\n                  <circle key={l} cx={c} cy={c} r={r * f} />\n                ) : (\n                  <polygon key={l} points={ring(f) ?? \"\"} />\n                );\n              })}\n              {axes.map((a, i) => {\n                const p = at(i, r);\n                return <line key={a.key} x1={c} y1={c} x2={p.x} y2={p.y} />;\n              })}\n            </g>\n            {/* Ring values along the first spoke. */}\n            <g aria-hidden=\"true\" className=\"fill-zinc-400 font-mono text-[9px] tabular-nums dark:fill-zinc-500\">\n              {Array.from({ length: Math.max(1, levels) }, (_, l) => {\n                const f = (l + 1) / Math.max(1, levels);\n                return (\n                  <text key={l} x={c + 4} y={c - r * f - 3}>\n                    {fmt(top * f)}\n                  </text>\n                );\n              })}\n            </g>\n            {/* Active axis highlight. */}\n            {active !== null && axes[active] ? (\n              <line\n                aria-hidden=\"true\"\n                x1={c}\n                y1={c}\n                x2={at(active, r).x}\n                y2={at(active, r).y}\n                className=\"stroke-zinc-900/45 dark:stroke-white/45\"\n                strokeWidth={1.5}\n              />\n            ) : null}\n            {/* Series polygons (points written by the spring loop). */}\n            <g aria-hidden=\"true\">\n              {list.map((s) => (\n                <polygon\n                  key={s.id}\n                  data-rc-poly={s.id}\n                  fill={s.color}\n                  fillOpacity={Math.max(0, Math.min(1, fillOpacity))}\n                  stroke={s.color}\n                  strokeWidth={2}\n                  strokeLinejoin=\"round\"\n                  className={`transition-opacity duration-300 ${hidden.has(s.id) ? \"opacity-0\" : \"opacity-100\"}`}\n                />\n              ))}\n              {showDots\n                ? list.map((s) =>\n                    axes.map((a, i) => (\n                      <circle\n                        key={`${s.id}|${a.key}`}\n                        data-rc-dot={`${s.id}|${a.key}`}\n                        r={active === i ? 5.5 : 4}\n                        fill={s.color}\n                        strokeWidth={1.5}\n                        className={`stroke-white transition-[r,opacity] duration-200 dark:stroke-zinc-900 ${hidden.has(s.id) ? \"opacity-0\" : \"opacity-100\"}`}\n                      />\n                    )),\n                  )\n                : null}\n            </g>\n            {/* Axis labels. */}\n            <g aria-hidden=\"true\" className=\"font-mono text-[10px] uppercase tracking-[0.12em]\">\n              {axes.map((a, i) => {\n                const p = at(i, r + 14);\n                const cos = Math.cos(angleOf(i));\n                const sin = Math.sin(angleOf(i));\n                return (\n                  <text\n                    key={a.key}\n                    x={p.x}\n                    y={p.y}\n                    textAnchor={cos > 0.3 ? \"start\" : cos < -0.3 ? \"end\" : \"middle\"}\n                    dominantBaseline={sin > 0.3 ? \"hanging\" : sin < -0.3 ? \"auto\" : \"middle\"}\n                    className={active === i ? \"fill-zinc-950 dark:fill-white\" : \"fill-zinc-500 dark:fill-zinc-400\"}\n                  >\n                    {a.label}\n                  </text>\n                );\n              })}\n            </g>\n            {/* Axis hit areas: one wedge per axis; a single tab stop with arrow keys between axes. */}\n            <g role=\"list\" aria-label={`${label} axes`} aria-describedby={hintId}>\n              {axes.map((a, i) => {\n                const outer = r + 10;\n                const p1 = at(i, 0);\n                const a1 = angleOf(i) - half;\n                const a2 = angleOf(i) + half;\n                const q1 = { x: c + Math.cos(a1) * outer, y: c + Math.sin(a1) * outer };\n                const q2 = { x: c + Math.cos(a2) * outer, y: c + Math.sin(a2) * outer };\n                return (\n                  <path\n                    key={a.key}\n                    role=\"listitem\"\n                    data-rc-axis={i}\n                    aria-label={describeAxis(i)}\n                    tabIndex={i === tabIndexAxis ? 0 : -1}\n                    d={`M ${p1.x} ${p1.y} L ${q1.x} ${q1.y} A ${outer} ${outer} 0 0 1 ${q2.x} ${q2.y} Z`}\n                    fill=\"transparent\"\n                    className=\"cursor-crosshair outline-none focus-visible:stroke-zinc-950/70 focus-visible:[stroke-width:1.5] focus-visible:[stroke-dasharray:3_4] dark:focus-visible:stroke-white/70\"\n                    onPointerEnter={(e) => e.pointerType === \"mouse\" && setActive(i)}\n                    onPointerDown={() => setActive(i)}\n                    onFocus={() => {\n                      setFocusIndex(i);\n                      setActive(i);\n                    }}\n                    onBlur={() => setActive(null)}\n                    onKeyDown={(e) => onKeyDown(e, i)}\n                  />\n                );\n              })}\n            </g>\n          </svg>\n\n          {tooltip && active !== null ? (\n            <div\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute z-10 min-w-32 rounded-md border border-zinc-900/10 bg-white/95 px-2.5 py-2 text-xs shadow-[0_8px_24px_-12px_rgba(0,0,0,0.35)] dark:border-white/10 dark:bg-zinc-900/95\"\n              style={{\n                left: tooltip.left,\n                top: tooltip.top,\n                transform: tooltip.transform,\n              }}\n            >\n              <div className=\"mb-1 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">{axes[active].label}</div>\n              {shownSeries.length ? (\n                shownSeries.map((s) => (\n                  <div key={s.id} className=\"flex items-center justify-between gap-4\">\n                    <span className=\"flex items-center gap-1.5\">\n                      <span className=\"size-2 rounded-full\" style={{ backgroundColor: s.color }} />\n                      {s.label}\n                    </span>\n                    <span className=\"font-mono tabular-nums\">{fmt(s.values[axes[active].key] ?? 0)}</span>\n                  </div>\n                ))\n              ) : (\n                <div className=\"text-zinc-500\">No series shown</div>\n              )}\n            </div>\n          ) : null}\n        </div>\n      </div>\n\n      {legend && list.length > 1 ? (\n        <ul className=\"mt-4 flex flex-wrap justify-center gap-1.5\" aria-label={`${label} series`}>\n          {list.map((s) => {\n            const off = hidden.has(s.id);\n            return (\n              <li key={s.id}>\n                <button\n                  type=\"button\"\n                  aria-pressed={!off}\n                  onClick={() =>\n                    setHidden((prev) => {\n                      const next = new Set(prev);\n                      if (next.has(s.id)) next.delete(s.id);\n                      else next.add(s.id);\n                      return next;\n                    })\n                  }\n                  className={`inline-flex h-8 items-center gap-2 rounded-full border px-3 text-sm transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--rc-accent)] ${\n                    off\n                      ? \"border-dashed border-zinc-900/20 text-zinc-400 dark:border-white/20 dark:text-zinc-500\"\n                      : \"border-zinc-900/15 hover:border-zinc-900/40 dark:border-white/15 dark:hover:border-white/40\"\n                  }`}\n                >\n                  <span\n                    aria-hidden=\"true\"\n                    className={`size-2.5 rounded-full border ${off ? \"border-current\" : \"border-transparent\"}`}\n                    style={off ? undefined : { backgroundColor: s.color }}\n                  />\n                  <span className={off ? \"line-through\" : \"\"}>{s.label}</span>\n                </button>\n              </li>\n            );\n          })}\n        </ul>\n      ) : null}\n\n      <p id={hintId} className=\"sr-only\">\n        Use the arrow keys to move between axes.\n      </p>\n      <table className=\"sr-only\">\n        <caption>{label}</caption>\n        <thead>\n          <tr>\n            <th scope=\"col\">Axis</th>\n            {list.map((s) => (\n              <th key={s.id} scope=\"col\">\n                {s.label}\n              </th>\n            ))}\n          </tr>\n        </thead>\n        <tbody>\n          {axes.map((a) => (\n            <tr key={a.key}>\n              <th scope=\"row\">{a.label}</th>\n              {list.map((s) => (\n                <td key={s.id}>{fmt(s.values[a.key] ?? 0)}</td>\n              ))}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/radar-chart"
}
