{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-radar-dither",
  "title": "Chart Radar Dither",
  "description": "Canvas radar/spider chart in ordered-dither ink density, swept by a continuously rotating radar line that locally resolves each axis wedge to sharper ink as it passes.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/chart-radar-dither/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// ChartRadarDither — the dithered-chart family's radar/spider chart, the\n// fourth CANVAS member. The polygon's interior is tiled into one triangular\n// wedge per axis (center to each pair of adjacent vertices), each wedge\n// filled with the family's shared ordered-dither ramp (4x4 Bayer matrix, 17\n// ink levels) at a density tracking that axis pair's own value — the usual\n// redundant channel alongside the polygon's own radius. The mechanic unique\n// to this family member: a literal radar sweep line rotates continuously\n// around the center, and as it crosses a wedge, that wedge's ink eases down\n// to a fine resolution and then decays back to coarse over a trailing\n// half-second echo — a scanning \"resolve\" that visits every axis in turn.\n// Hovering or focusing an axis (a real button at its vertex) independently\n// pins its two adjacent wedges to fine resolution regardless of where the\n// sweep currently is. Resolution is the only thing either mechanic changes;\n// colour never encodes data — pure var(--foreground) ink on\n// var(--background) paper, var(--ns-accent) reserved for the sweep line and\n// keyboard focus only, matching the rest of the family.\n// ---------------------------------------------------------------------------\n\nexport interface ChartRadarDitherDatum {\n  label: string;\n  value: number;\n  /** axis max; defaults to 100 */\n  max?: number;\n}\n\nexport interface ChartRadarDitherProps {\n  /** the plotted axes/values */\n  data?: ChartRadarDitherDatum[];\n  /** heading above the chart */\n  title?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];\nconst LEVELS = 16;\nconst CELL_COARSE = 6;\nconst CELL_FINE = 2;\n\nconst SIZE = 280;\nconst R_MAX = 98;\nconst SWEEP_SPEED = (Math.PI * 2) / 6000; // one revolution / 6s\nconst DECAY_MS = 650;\nconst ENTRANCE_MS = 460;\n\nfunction levelFor(norm: number): number {\n  return Math.round(Math.min(1, Math.max(0, norm)) * LEVELS);\n}\n\nfunction easeOutExpo(t: number): number {\n  return t >= 1 ? 1 : 1 - Math.pow(2, -10 * t);\n}\n\nfunction normalizeAngle(a: number): number {\n  const twoPi = Math.PI * 2;\n  return ((a % twoPi) + twoPi) % twoPi;\n}\n\nfunction inArc(angle: number, from: number, to: number): boolean {\n  const a = normalizeAngle(angle);\n  const f = normalizeAngle(from);\n  const t = normalizeAngle(to);\n  if (f <= t) return a >= f && a <= t;\n  return a >= f || a <= t;\n}\n\ninterface Tokens {\n  fg: string;\n  bg: string;\n  muted: string;\n  border: string;\n  accent: string;\n}\n\nfunction readTokens(): Tokens {\n  const cs = getComputedStyle(document.documentElement);\n  const get = (name: string, fallback: string) => cs.getPropertyValue(name).trim() || fallback;\n  return {\n    fg: get(\"--foreground\", \"#171717\"),\n    bg: get(\"--background\", \"#ffffff\"),\n    muted: get(\"--ns-muted\", \"#4d4d4d\"),\n    border: get(\"--border\", \"#ebebeb\"),\n    accent: get(\"--ns-accent\", \"#006bff\"),\n  };\n}\n\nfunction useTokens(): Tokens {\n  const [tokens, setTokens] = useState<Tokens>(() =>\n    typeof document === \"undefined\"\n      ? { fg: \"#171717\", bg: \"#ffffff\", muted: \"#4d4d4d\", border: \"#ebebeb\", accent: \"#006bff\" }\n      : readTokens()\n  );\n  useEffect(() => {\n    const sync = () => setTokens(readTokens());\n    sync();\n    const mo = new MutationObserver(sync);\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"style\"] });\n    return () => mo.disconnect();\n  }, []);\n  return tokens;\n}\n\nfunction polar(cx: number, cy: number, r: number, angle: number) {\n  return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) };\n}\n\nexport function ChartRadarDither({ data = [], title = \"Chart\", className = \"\" }: ChartRadarDitherProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const tokens = useTokens();\n  const [activeIndex, setActiveIndex] = useState(0);\n  const [hoverIndex, setHoverIndex] = useState<number | null>(null);\n  const [showTable, setShowTable] = useState(false);\n  // useId, not Math.random(): the id is serialized into element ids on the\n  // server and must match what the client renders, or React discards the\n  // subtree as a hydration mismatch\n  const uid = `crd-${useId().replace(/:/g, \"\")}`;\n\n  const reducedRef = useRef(false);\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const sync = () => {\n      reducedRef.current = mq.matches;\n    };\n    sync();\n    mq.addEventListener(\"change\", sync);\n    return () => mq.removeEventListener(\"change\", sync);\n  }, []);\n\n  const n = data.length;\n  const cx = SIZE / 2;\n  const cy = SIZE / 2;\n\n  const axes = useMemo(\n    () =>\n      data.map((d, i) => {\n        const max = d.max ?? 100;\n        const frac = max > 0 ? Math.min(1, Math.max(0, d.value / max)) : 0;\n        const angle = -Math.PI / 2 + (i / Math.max(1, n)) * Math.PI * 2;\n        const r = R_MAX * frac;\n        const vertex = polar(cx, cy, r, angle);\n        const labelPos = polar(cx, cy, R_MAX + 22, angle);\n        return { ...d, index: i, max, frac, angle, r, vertex, labelPos, level: levelFor(frac) };\n      }),\n    [data, n, cx, cy]\n  );\n\n  const focusAxis = (i: number) => {\n    if (i < 0 || i >= n) return;\n    setActiveIndex(i);\n    document.getElementById(`${uid}-axis-${i}`)?.focus();\n  };\n\n  const mountAtRef = useRef(0);\n  const rafRef = useRef(0);\n  // per-wedge \"last time the sweep crossed it\" for the decaying echo\n  const lastFreshRef = useRef<number[]>([]);\n\n  useEffect(() => {\n    mountAtRef.current = performance.now();\n    lastFreshRef.current = axes.map(() => -Infinity);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [n]);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    canvas.width = SIZE * dpr;\n    canvas.height = SIZE * dpr;\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n    const draw = () => {\n      ctx.clearRect(0, 0, SIZE, SIZE);\n      if (n < 3) return;\n      const now = performance.now();\n      const t = reducedRef.current ? 1 : Math.min(1, Math.max(0, (now - mountAtRef.current) / ENTRANCE_MS));\n      let stillAnimating = t < 1;\n      const grow = easeOutExpo(t);\n\n      // background rings + spokes\n      ctx.strokeStyle = tokens.border;\n      ctx.lineWidth = 1;\n      [0.25, 0.5, 0.75, 1].forEach((f) => {\n        ctx.beginPath();\n        axes.forEach((a, i) => {\n          const p = polar(cx, cy, R_MAX * f, a.angle);\n          if (i === 0) ctx.moveTo(p.x, p.y);\n          else ctx.lineTo(p.x, p.y);\n        });\n        ctx.closePath();\n        ctx.stroke();\n      });\n      axes.forEach((a) => {\n        const p = polar(cx, cy, R_MAX, a.angle);\n        ctx.beginPath();\n        ctx.moveTo(cx, cy);\n        ctx.lineTo(p.x, p.y);\n        ctx.stroke();\n      });\n\n      // the sweep angle, and per-wedge freshness (fine <- sweep OR hover)\n      const sweepAngle = reducedRef.current ? -Math.PI / 2 : normalizeAngle((now * SWEEP_SPEED) % (Math.PI * 2));\n\n      axes.forEach((a, i) => {\n        const b = axes[(i + 1) % n];\n        const a0 = a.angle;\n        const a1 = b.angle > a0 ? b.angle : b.angle + Math.PI * 2;\n\n        const sweptNow = !reducedRef.current && inArc(sweepAngle, a0, a1);\n        if (sweptNow) lastFreshRef.current[i] = now;\n        const sinceFresh = now - (lastFreshRef.current[i] ?? -Infinity);\n        const sweepFresh = reducedRef.current ? 0 : Math.max(0, 1 - sinceFresh / DECAY_MS);\n        const hoverFresh = hoverIndex === i || hoverIndex === (i + 1) % n ? 1 : 0;\n        const freshness = Math.max(sweepFresh, hoverFresh);\n        if (freshness > 0.01 && freshness < 1) stillAnimating = true;\n        if (sweptNow) stillAnimating = true;\n\n        const cell = CELL_COARSE - (CELL_COARSE - CELL_FINE) * freshness;\n        const vA = { x: cx + (a.vertex.x - cx) * grow, y: cy + (a.vertex.y - cy) * grow };\n        const vB = { x: cx + (b.vertex.x - cx) * grow, y: cy + (b.vertex.y - cy) * grow };\n\n        ctx.save();\n        ctx.beginPath();\n        ctx.moveTo(cx, cy);\n        ctx.lineTo(vA.x, vA.y);\n        ctx.lineTo(vB.x, vB.y);\n        ctx.closePath();\n        ctx.clip();\n\n        ctx.fillStyle = tokens.fg;\n        const level = Math.round(((a.level + b.level) / 2));\n        const minX = Math.min(cx, vA.x, vB.x);\n        const maxX = Math.max(cx, vA.x, vB.x);\n        const minY = Math.min(cy, vA.y, vB.y);\n        const maxY = Math.max(cy, vA.y, vB.y);\n        const startCol = Math.floor(minX / cell);\n        const endCol = Math.ceil(maxX / cell);\n        const startRow = Math.floor(minY / cell);\n        const endRow = Math.ceil(maxY / cell);\n        for (let gy = startRow; gy < endRow; gy++) {\n          for (let gx = startCol; gx < endCol; gx++) {\n            const idx = (((gy % 4) + 4) % 4) * 4 + (((gx % 4) + 4) % 4);\n            if (BAYER[idx] < level) ctx.fillRect(gx * cell, gy * cell, cell, cell);\n          }\n        }\n        ctx.restore();\n      });\n\n      // polygon outline\n      ctx.strokeStyle = tokens.fg;\n      ctx.lineWidth = 1.5;\n      ctx.beginPath();\n      axes.forEach((a, i) => {\n        const v = { x: cx + (a.vertex.x - cx) * grow, y: cy + (a.vertex.y - cy) * grow };\n        if (i === 0) ctx.moveTo(v.x, v.y);\n        else ctx.lineTo(v.x, v.y);\n      });\n      ctx.closePath();\n      ctx.stroke();\n\n      // vertex dots + labels\n      ctx.font = `9.5px \"GeistMono\", ui-monospace, monospace`;\n      ctx.textAlign = \"center\";\n      axes.forEach((a, i) => {\n        const isActive = hoverIndex === i;\n        const v = { x: cx + (a.vertex.x - cx) * grow, y: cy + (a.vertex.y - cy) * grow };\n        ctx.beginPath();\n        ctx.fillStyle = isActive ? tokens.accent : tokens.fg;\n        ctx.arc(v.x, v.y, isActive ? 3 : 2, 0, Math.PI * 2);\n        ctx.fill();\n        ctx.fillStyle = tokens.muted;\n        ctx.fillText(a.label.toUpperCase(), a.labelPos.x, a.labelPos.y);\n      });\n\n      // the sweep line itself\n      if (!reducedRef.current) {\n        const tip = polar(cx, cy, R_MAX, sweepAngle);\n        ctx.strokeStyle = tokens.accent;\n        ctx.globalAlpha = 0.55;\n        ctx.lineWidth = 1.25;\n        ctx.beginPath();\n        ctx.moveTo(cx, cy);\n        ctx.lineTo(tip.x, tip.y);\n        ctx.stroke();\n        ctx.globalAlpha = 1;\n      }\n\n      if (stillAnimating) {\n        rafRef.current = requestAnimationFrame(draw);\n      }\n    };\n\n    draw();\n    return () => cancelAnimationFrame(rafRef.current);\n  }, [axes, tokens, n, hoverIndex, cx, cy]);\n\n  const shown = hoverIndex ?? null;\n  const shownAxis = shown !== null ? axes[shown] : null;\n\n  return (\n    <figure className={`ns-crd inline-block ${className}`} aria-label={`${title}, radar chart`}>\n      <style>{CSS}</style>\n      <div className=\"flex items-center justify-between gap-3 pb-2\">\n        <span className=\"font-mono text-xs tracking-widest text-ns-muted\">{title.toUpperCase()}</span>\n        <div className=\"flex items-center gap-3\">\n          <span className=\"font-mono text-[11px] text-foreground\" aria-live=\"polite\">\n            {shownAxis ? (\n              <>\n                <strong>{shownAxis.value.toLocaleString()}</strong>{\" \"}\n                <span className=\"text-ns-muted\">/ {shownAxis.max}</span>\n              </>\n            ) : (\n              \" \"\n            )}\n          </span>\n          <button\n            type=\"button\"\n            onClick={() => setShowTable((s) => !s)}\n            className=\"ns-crd-toggle rounded-sm border border-border px-2 py-1 font-mono text-[10px] tracking-widest text-ns-muted transition-colors duration-150 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n            aria-pressed={showTable}\n          >\n            {showTable ? \"VIEW CHART\" : \"VIEW TABLE\"}\n          </button>\n        </div>\n      </div>\n\n      {showTable ? (\n        <table className=\"ns-crd-table w-full border-collapse font-mono text-xs\">\n          <caption className=\"sr-only\">{title}</caption>\n          <thead>\n            <tr>\n              <th scope=\"col\" className=\"border-b border-border px-2 py-1.5 text-left text-ns-muted\">\n                Axis\n              </th>\n              <th scope=\"col\" className=\"border-b border-border px-2 py-1.5 text-right text-ns-muted tabular-nums\">\n                Value\n              </th>\n            </tr>\n          </thead>\n          <tbody>\n            {data.map((d) => (\n              <tr key={d.label}>\n                <td className=\"border-b border-border px-2 py-1.5 text-foreground\">{d.label}</td>\n                <td className=\"border-b border-border px-2 py-1.5 text-right text-foreground tabular-nums\">\n                  {d.value.toLocaleString()} / {d.max ?? 100}\n                </td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      ) : (\n        <div className=\"relative\" style={{ width: SIZE, height: SIZE, maxWidth: \"100%\" }}>\n          <canvas\n            ref={canvasRef}\n            aria-hidden=\"true\"\n            className=\"ns-crd-canvas\"\n            style={{ width: SIZE, height: SIZE, maxWidth: \"100%\", display: \"block\" }}\n          />\n\n          <div role=\"listbox\" aria-label={`${title} axes`} className=\"contents\">\n            {axes.map((a) => (\n              <button\n                key={a.label}\n                type=\"button\"\n                role=\"option\"\n                id={`${uid}-axis-${a.index}`}\n                aria-selected={hoverIndex === a.index}\n                tabIndex={activeIndex === a.index ? 0 : -1}\n                aria-label={`${a.label}: ${a.value.toLocaleString()} of ${a.max}`}\n                className=\"ns-crd-hit absolute cursor-pointer rounded-full border-0 bg-transparent p-0 outline-none\"\n                style={{ left: a.vertex.x - 14, top: a.vertex.y - 14, width: 28, height: 28 }}\n                onPointerEnter={() => setHoverIndex(a.index)}\n                onPointerLeave={() => setHoverIndex((c) => (c === a.index ? null : c))}\n                onFocus={() => {\n                  setActiveIndex(a.index);\n                  setHoverIndex(a.index);\n                }}\n                onBlur={() => setHoverIndex((c) => (c === a.index ? null : c))}\n                onKeyDown={(e) => {\n                  if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") {\n                    e.preventDefault();\n                    focusAxis((a.index - 1 + n) % n);\n                  } else if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") {\n                    e.preventDefault();\n                    focusAxis((a.index + 1) % n);\n                  }\n                }}\n              />\n            ))}\n          </div>\n        </div>\n      )}\n    </figure>\n  );\n}\n\nconst CSS = `\n.ns-crd-hit { touch-action: manipulation; }\n.ns-crd-hit:focus-visible { outline: 2px solid var(--ns-accent); outline-offset: 2px; }\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/chart-radar-dither.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "chart",
      "radar",
      "spider",
      "data-viz",
      "dither",
      "canvas",
      "ink"
    ],
    "instruction": "The dithered-chart family's radar/spider chart, its fourth CANVAS member. The polygon's interior is tiled into one triangular wedge per axis (center to each pair of adjacent vertices), each filled with the family's shared ordered-dither ramp (4x4 Bayer matrix, 17 ink levels) at a density averaging that wedge's two adjacent axis fractions — the usual redundant channel alongside the polygon's own radius, which remains the chart's only true magnitude encoding. The mechanic unique to this family member: a literal radar sweep line rotates continuously around the center (one revolution per 6s) and, as it crosses a wedge, that wedge's ink cell eases from a coarse 6px down to a fine 2px, then decays back to coarse over a trailing 650ms echo once the sweep has moved on — every axis is visited and briefly sharpened in turn, entirely ambient. Hovering or focusing an axis (a real, circular <button> at its vertex, role=option inside a role=listbox with roving tabindex — ArrowLeft/Right or ArrowUp/Down cycle axes) independently pins that axis's two adjacent wedges to fine resolution regardless of where the sweep currently is, and raises a live value readout above the chart. Resolution is the only channel either mechanic touches; colour never encodes data — pure var(--foreground) ink on var(--background) paper, var(--ns-accent) reserved for the sweep line itself, the hovered vertex, and keyboard focus, matching the rest of the family. Tokens are read via getComputedStyle on the document root at mount and re-read through a MutationObserver on its class attribute. On mount the polygon scales in from the center over 460ms; prefers-reduced-motion freezes the sweep line at a fixed bearing with no rotation and no persistent rAF loop, while hover/focus still snaps the touched wedges to fine resolution instantly, so the interaction stays available without motion. A VIEW TABLE toggle swaps the chart for a real HTML table listing each axis's value against its own max. Requires at least 3 axes. Zero dependencies."
  },
  "type": "registry:ui"
}