{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-scatter-ascii-bin",
  "title": "Chart Scatter ASCII Bin",
  "description": "Two-dimensional scatter plot rendered as a glyph-density grid, with a pointer- or keyboard-driven brush that live re-bins the exact points under it and reads out the selection.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/chart-scatter-ascii-bin/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// ChartScatterAsciiBin — a scatter plot rendered as a glyph-density grid\n// (no scatter or hexbin exists anywhere in this registry — histogram-live-\n// grain bins one dimension into falling grains, stem-and-leaf-live keeps\n// every raw digit, chart-bar-dither is one categorical series; none of the\n// three bins two continuous dimensions at once). Every point is binned into\n// a coarse cell grid and each cell's glyph comes from the family's shared\n// 10-step ASCII ramp (\" .:-=+*#%@\") scaled to that cell's point count — a\n// static print of the joint distribution.\n//\n// The mechanic: a circular brush follows the pointer (or a focused, arrow-\n// key-steerable hit target for keyboard users) and LIVE re-bins — not the\n// grid cells, the raw points themselves, by exact distance to the brush\n// centre — so the readout (\"N pts selected, mean x/y\") is exact, not a cell\n// count. Every cell whose glyph is currently touched by the brush relinks\n// to var(--ns-accent) ink in place, which is the only colour anywhere in the\n// piece and reserved for the live interaction the same way chart-bar-dither\n// reserves it for keyboard focus. Nothing else in the registry re-bins\n// against a live pointer radius.\n// ---------------------------------------------------------------------------\n\nconst RAMP = \" .:-=+*#%@\";\n\nexport interface ScatterPoint {\n  x: number;\n  y: number;\n}\n\nexport interface ChartScatterAsciiBinProps {\n  /** synthetic sample; x/y in arbitrary plot units, any range */\n  data?: ScatterPoint[];\n  /** heading above the plot */\n  title?: string;\n  /** brush radius in plot px */\n  brushRadius?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst COLS = 42;\nconst ROWS = 20;\nconst CELL = 13;\nconst LEFT_PAD = 34;\nconst TOP_PAD = 20;\nconst BOTTOM_PAD = 30;\nconst RIGHT_PAD = 12;\nconst PLOT_W = COLS * CELL;\nconst PLOT_H = ROWS * CELL;\nconst ENTRANCE_MS = 320;\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 useReducedMotion(): boolean {\n  const ref = useRef(false);\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const sync = () => {\n      ref.current = mq.matches;\n    };\n    sync();\n    mq.addEventListener(\"change\", sync);\n    return () => mq.removeEventListener(\"change\", sync);\n  }, []);\n  return ref.current;\n}\n\n// deterministic two-cluster synthetic sample — clearly fabricated demo data\nfunction defaultSample(): ScatterPoint[] {\n  const pts: ScatterPoint[] = [];\n  let seed = 7;\n  const rand = () => {\n    seed = (seed * 16807) % 2147483647;\n    return (seed - 1) / 2147483646;\n  };\n  const gauss = () => {\n    let u = 0;\n    let v = 0;\n    while (u === 0) u = rand();\n    while (v === 0) v = rand();\n    return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);\n  };\n  for (let i = 0; i < 140; i++) {\n    pts.push({ x: 30 + gauss() * 14, y: 65 + gauss() * 10 });\n  }\n  for (let i = 0; i < 90; i++) {\n    pts.push({ x: 74 + gauss() * 10, y: 30 + gauss() * 12 });\n  }\n  return pts;\n}\n\nexport function ChartScatterAsciiBin({\n  data,\n  title = \"Sample distribution\",\n  brushRadius = 46,\n  className = \"\",\n}: ChartScatterAsciiBinProps) {\n  const points = useMemo(() => data ?? defaultSample(), [data]);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const hitRef = useRef<HTMLDivElement>(null);\n  const tokens = useTokens();\n  const reduced = useReducedMotion();\n  const rafRef = useRef(0);\n  const mountAtRef = useRef(0);\n\n  const [brush, setBrush] = useState<{ x: number; y: number } | null>(null);\n  const brushRef = useRef(brush);\n  brushRef.current = brush;\n\n  const xMin = useMemo(() => Math.min(...points.map((p) => p.x)), [points]);\n  const xMax = useMemo(() => Math.max(...points.map((p) => p.x)), [points]);\n  const yMin = useMemo(() => Math.min(...points.map((p) => p.y)), [points]);\n  const yMax = useMemo(() => Math.max(...points.map((p) => p.y)), [points]);\n  const xSpan = Math.max(1e-6, xMax - xMin);\n  const ySpan = Math.max(1e-6, yMax - yMin);\n\n  const toPx = (p: ScatterPoint) => ({\n    px: ((p.x - xMin) / xSpan) * PLOT_W,\n    // screen y grows downward; plot y should grow upward\n    py: PLOT_H - ((p.y - yMin) / ySpan) * PLOT_H,\n  });\n\n  const plotPoints = useMemo(() => points.map(toPx), [points, xMin, xSpan, yMin, ySpan]);\n\n  const cellCounts = useMemo(() => {\n    const counts = new Uint16Array(COLS * ROWS);\n    for (const { px, py } of plotPoints) {\n      const cx = Math.min(COLS - 1, Math.max(0, Math.floor(px / CELL)));\n      const cy = Math.min(ROWS - 1, Math.max(0, Math.floor(py / CELL)));\n      counts[cy * COLS + cx]++;\n    }\n    return counts;\n  }, [plotPoints]);\n  const maxCount = useMemo(() => Math.max(1, ...cellCounts), [cellCounts]);\n\n  const selection = useMemo(() => {\n    if (!brush) return null;\n    let n = 0;\n    let sx = 0;\n    let sy = 0;\n    for (const { px, py } of plotPoints) {\n      const dx = px - brush.x;\n      const dy = py - brush.y;\n      if (dx * dx + dy * dy <= brushRadius * brushRadius) {\n        n++;\n        sx += px;\n        sy += py;\n      }\n    }\n    if (n === 0) return { n: 0, meanX: 0, meanY: 0 };\n    // back to data units for the readout\n    const mpx = sx / n;\n    const mpy = sy / n;\n    const meanX = xMin + (mpx / PLOT_W) * xSpan;\n    const meanY = yMin + (1 - mpy / PLOT_H) * ySpan;\n    return { n, meanX, meanY };\n  }, [brush, brushRadius, plotPoints, xMin, xSpan, yMin, ySpan]);\n\n  useEffect(() => {\n    mountAtRef.current = performance.now();\n  }, [points]);\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 viewW = LEFT_PAD + PLOT_W + RIGHT_PAD;\n    const viewH = TOP_PAD + PLOT_H + BOTTOM_PAD;\n    const dpr = Math.min(window.devicePixelRatio || 1, 2);\n    canvas.width = viewW * dpr;\n    canvas.height = viewH * dpr;\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n    const draw = () => {\n      const now = performance.now();\n      const t = reduced ? 1 : Math.min(1, (now - mountAtRef.current) / ENTRANCE_MS);\n      ctx.clearRect(0, 0, viewW, viewH);\n\n      ctx.strokeStyle = tokens.border;\n      ctx.lineWidth = 1;\n      ctx.strokeRect(LEFT_PAD + 0.5, TOP_PAD + 0.5, PLOT_W, PLOT_H);\n\n      ctx.font = `${CELL - 3}px \"GeistMono\", ui-monospace, monospace`;\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"middle\";\n\n      const bx = brushRef.current;\n      const r2 = brushRadius * brushRadius;\n\n      for (let row = 0; row < ROWS; row++) {\n        for (let col = 0; col < COLS; col++) {\n          const count = cellCounts[row * COLS + col];\n          if (count === 0) continue;\n          const level = Math.min(RAMP.length - 1, Math.round((count / maxCount) * (RAMP.length - 1)));\n          const ch = RAMP[level];\n          if (ch === \" \") continue;\n          const cx = LEFT_PAD + col * CELL + CELL / 2;\n          const cy = TOP_PAD + row * CELL + CELL / 2;\n          const dx = cx - LEFT_PAD - (bx?.x ?? -9999);\n          const dy = cy - TOP_PAD - (bx?.y ?? -9999);\n          const inBrush = bx !== null && dx * dx + dy * dy <= r2;\n          ctx.fillStyle = inBrush ? tokens.accent : tokens.fg;\n          ctx.globalAlpha = t;\n          ctx.fillText(ch, cx, cy);\n          ctx.globalAlpha = 1;\n        }\n      }\n\n      if (bx) {\n        ctx.strokeStyle = tokens.accent;\n        ctx.lineWidth = 1.5;\n        ctx.beginPath();\n        ctx.arc(LEFT_PAD + bx.x, TOP_PAD + bx.y, brushRadius, 0, Math.PI * 2);\n        ctx.stroke();\n      }\n\n      // axis ticks\n      ctx.fillStyle = tokens.muted;\n      ctx.font = `9px \"GeistMono\", ui-monospace, monospace`;\n      ctx.textAlign = \"center\";\n      ctx.fillText(xMin.toFixed(0), LEFT_PAD, TOP_PAD + PLOT_H + 14);\n      ctx.fillText(xMax.toFixed(0), LEFT_PAD + PLOT_W, TOP_PAD + PLOT_H + 14);\n      ctx.save();\n      ctx.textAlign = \"right\";\n      ctx.fillText(yMax.toFixed(0), LEFT_PAD - 6, TOP_PAD + 4);\n      ctx.fillText(yMin.toFixed(0), LEFT_PAD - 6, TOP_PAD + PLOT_H);\n      ctx.restore();\n\n      if (t < 1 && !reduced) rafRef.current = requestAnimationFrame(draw);\n    };\n\n    draw();\n    return () => cancelAnimationFrame(rafRef.current);\n  }, [cellCounts, maxCount, tokens, reduced, brush, brushRadius, xMin, xMax, yMin, yMax]);\n\n  const setBrushFromEvent = (e: { clientX: number; clientY: number }) => {\n    const el = hitRef.current;\n    if (!el) return;\n    const rect = el.getBoundingClientRect();\n    const x = e.clientX - rect.left;\n    const y = e.clientY - rect.top;\n    setBrush({ x: Math.min(PLOT_W, Math.max(0, x)), y: Math.min(PLOT_H, Math.max(0, y)) });\n  };\n\n  const step = CELL;\n  const keyMove = (dx: number, dy: number) => {\n    setBrush((cur) => {\n      const base = cur ?? { x: PLOT_W / 2, y: PLOT_H / 2 };\n      return {\n        x: Math.min(PLOT_W, Math.max(0, base.x + dx)),\n        y: Math.min(PLOT_H, Math.max(0, base.y + dy)),\n      };\n    });\n  };\n\n  return (\n    <figure className={`ns-csab inline-block ${className}`} aria-label={`${title}, scatter plot`}>\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        <span className=\"font-mono text-[11px] text-ns-muted tabular-nums\" aria-live=\"off\">\n          {selection ? `${selection.n} pts · mean (${selection.meanX.toFixed(1)}, ${selection.meanY.toFixed(1)})` : `${points.length} pts total`}\n        </span>\n      </div>\n      <div className=\"relative\" style={{ width: LEFT_PAD + PLOT_W + RIGHT_PAD, maxWidth: \"100%\" }}>\n        <canvas\n          ref={canvasRef}\n          aria-hidden=\"true\"\n          className=\"ns-csab-canvas\"\n          style={{\n            width: LEFT_PAD + PLOT_W + RIGHT_PAD,\n            height: TOP_PAD + PLOT_H + BOTTOM_PAD,\n            maxWidth: \"100%\",\n            display: \"block\",\n          }}\n        />\n        <div\n          ref={hitRef}\n          role=\"button\"\n          tabIndex={0}\n          aria-label=\"Brush the scatter plot: drag or use arrow keys to select nearby points\"\n          className=\"ns-csab-hit absolute cursor-crosshair rounded-sm outline-none\"\n          style={{ left: LEFT_PAD, top: TOP_PAD, width: PLOT_W, height: PLOT_H }}\n          onPointerMove={(e) => setBrushFromEvent(e)}\n          onPointerDown={(e) => setBrushFromEvent(e)}\n          onPointerLeave={() => setBrush(null)}\n          onFocus={() => setBrush((c) => c ?? { x: PLOT_W / 2, y: PLOT_H / 2 })}\n          onBlur={() => setBrush(null)}\n          onKeyDown={(e) => {\n            const d = e.shiftKey ? step * 3 : step;\n            if (e.key === \"ArrowLeft\") {\n              e.preventDefault();\n              keyMove(-d, 0);\n            } else if (e.key === \"ArrowRight\") {\n              e.preventDefault();\n              keyMove(d, 0);\n            } else if (e.key === \"ArrowUp\") {\n              e.preventDefault();\n              keyMove(0, -d);\n            } else if (e.key === \"ArrowDown\") {\n              e.preventDefault();\n              keyMove(0, d);\n            } else if (e.key === \"Escape\") {\n              setBrush(null);\n            }\n          }}\n        />\n      </div>\n    </figure>\n  );\n}\n\nconst CSS = `\n.ns-csab-hit:focus-visible { outline: 2px solid var(--ns-accent); outline-offset: 2px; }\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/chart-scatter-ascii-bin.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",
      "scatter",
      "data-viz",
      "ascii",
      "canvas",
      "brush"
    ],
    "instruction": "A scatter plot rendered entirely as a glyph-density grid: every raw (x, y) sample is binned into a fixed 42x20 cell grid and each cell's character comes from the shared 10-step ASCII ramp ' .:-=+*#%@', scaled to that cell's point count relative to the densest cell — a static joint-distribution print, redundant with nothing else in the registry (histogram-live-grain tallies one dimension, stem-and-leaf-live keeps every digit, neither bins two continuous axes at once). The real mechanic is the brush: a circular region tracks the pointer over the plot, or an arrow-key-steerable focus target for keyboard users (Shift moves it in larger steps, Escape clears it), and on every move it re-bins the RAW points — not the grid cells — by exact Euclidean distance to the brush centre, so the live readout above the plot ('N pts · mean (x, y)') is an exact statistic over the selection, not a cell-count approximation. Every glyph currently touched by the brush relinks from var(--foreground) to var(--ns-accent) ink in place, and a thin accent ring traces the brush radius — the only colour anywhere in the piece, reserved for the live interaction exactly as chart-bar-dither reserves accent for keyboard focus. Losing focus or pointer-leave clears the brush and every glyph returns to plain ink. Tokens are read via getComputedStyle at mount and re-read through a MutationObserver on the document root's class attribute, so both themes repaint correctly on toggle. On mount, the whole grid fades in over 320ms; prefers-reduced-motion renders it at full opacity immediately and the brush still updates instantly on interaction (no animated lerp, no persistent rAF loop once settled). Zero dependencies."
  },
  "type": "registry:ui"
}