{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scatter-plot",
  "type": "registry:ui",
  "title": "Scatter Plot",
  "description": "An SVG scatter plot with size and colour encodings, nearest-point tooltips, lasso or box selection, legend toggles and keyboard travel.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/scatter-plot.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface ScatterDatum {\n  /** Stable key (defaults to the index). */\n  id?: string;\n  x: number;\n  y: number;\n  /** Optional third measure, encoded as the point's area. */\n  size?: number;\n  /** Optional group, encoded as colour (with a legend toggle per category). */\n  category?: string;\n  /** Name shown in the tooltip, the read-out and the data table. */\n  label?: string;\n}\n\nexport type ScatterBrushMode = \"lasso\" | \"rect\" | \"none\";\n\nexport interface ScatterPlotProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  data: ScatterDatum[];\n  /** Accessible name of the chart (also the data table caption). */\n  label: string;\n  xLabel?: string;\n  yLabel?: string;\n  sizeLabel?: string;\n  /** Plot height in px; the width follows the container. */\n  height?: number;\n  /** Largest point radius in px (points without a size use about half of it). */\n  pointSize?: number;\n  /** Fill opacity of the points, 0.1–1. */\n  opacity?: number;\n  /** Drag gesture used to select points. */\n  brushMode?: ScatterBrushMode;\n  /** Wheel and +/− keys zoom around the cursor or the active point. */\n  zoomable?: boolean;\n  showGrid?: boolean;\n  /** Category legend with show/hide toggles (only drawn when there is more than one category). */\n  showLegend?: boolean;\n  /** Colour of the first category; the others use a categorical palette. */\n  accent?: string;\n  formatX?: (value: number) => string;\n  formatY?: (value: number) => string;\n  formatSize?: (value: number) => string;\n  /** Called with the selected points whenever the selection changes. */\n  onSelectionChange?: (selected: ScatterDatum[]) => void;\n}\n\n/* --------------------------------- Scales --------------------------------- */\n\ntype Domain = [number, number];\ninterface View {\n  x: Domain;\n  y: Domain;\n}\n\n/** 1, 2 or 5 × 10^k: the step a person would choose for about `count` intervals. */\nfunction niceStep(span: number, count: number) {\n  const raw = Math.max(1e-12, span) / Math.max(1, count);\n  const pow = 10 ** Math.floor(Math.log10(raw));\n  const f = raw / pow;\n  return (f >= 7.5 ? 10 : f >= 3.5 ? 5 : f >= 1.5 ? 2 : 1) * pow;\n}\n\nfunction niceDomain(min: number, max: number, count: number): Domain {\n  if (min === max) {\n    min -= 1;\n    max += 1;\n  }\n  const pad = (max - min) * 0.04;\n  const step = niceStep(max - min + pad * 2, count);\n  return [Math.floor((min - pad) / step) * step, Math.ceil((max + pad) / step) * step];\n}\n\nfunction ticks([min, max]: Domain, count: number) {\n  const step = niceStep(max - min, count);\n  const out: number[] = [];\n  const decimals = Math.max(0, -Math.floor(Math.log10(step)));\n  for (let v = Math.ceil(min / step - 1e-9) * step; v <= max + step * 1e-9; v += step) {\n    out.push(Number(v.toFixed(decimals)));\n  }\n  return { values: out, step };\n}\n\nconst r2 = (n: number) => Math.round(n * 100) / 100;\n\nfunction defaultFormat(step: number) {\n  const digits = step >= 1 ? 0 : Math.min(4, Math.ceil(-Math.log10(step)));\n  const nf = new Intl.NumberFormat(\"en-US\", { notation: Math.abs(step) >= 1000 ? \"compact\" : \"standard\", maximumFractionDigits: digits });\n  return (v: number) => nf.format(v);\n}\nconst plain = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 2 });\n\n/** Ray-casting point-in-polygon test. */\nfunction inPolygon(x: number, y: number, poly: [number, number][]) {\n  let inside = false;\n  for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {\n    const [xi, yi] = poly[i];\n    const [xj, yj] = poly[j];\n    if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;\n  }\n  return inside;\n}\n\n/* --------------------------------- Palette --------------------------------- */\n\nconst PALETTE_VARS =\n  \"[--sp-2:#3d8bea] [--sp-3:#22b07d] [--sp-4:#e0a21a] [--sp-5:#e87ba4] [--sp-6:#8b7cf0] [--sp-7:#4fb9c9] [--sp-8:#a3a3a3]\";\nconst categoryColor = (i: number) => (i === 0 ? \"var(--sp-accent)\" : `var(--sp-${Math.min(i, 7) + 1})`);\n\nconst MARGIN = { top: 14, right: 16, bottom: 34, left: 48 };\nconst HOVER_RADIUS = 28;\n\n/* -------------------------------- Component -------------------------------- */\n\n/**\n * An SVG scatter plot with x/y position, area (size) and colour (category) encodings and nice\n * axis ticks. Hover, tap or focus shows the nearest point; drag a lasso or rectangle to select a\n * subset (others dim) and clear it again; legend buttons hide or show categories; the wheel\n * zooms around the cursor. The plot is keyboard navigable — arrows jump to the nearest point in\n * that direction — and a visually hidden table carries every value for screen readers.\n */\nexport function ScatterPlot({\n  data,\n  label,\n  xLabel = \"x\",\n  yLabel = \"y\",\n  sizeLabel = \"Size\",\n  height = 380,\n  pointSize = 12,\n  opacity = 0.75,\n  brushMode = \"lasso\",\n  zoomable = false,\n  showGrid = true,\n  showLegend = true,\n  accent = \"#FF7448\",\n  formatX,\n  formatY,\n  formatSize,\n  onSelectionChange,\n  className = \"\",\n  style,\n  ...props\n}: ScatterPlotProps) {\n  const uid = React.useId();\n  const clipId = `${uid.replace(/[^a-zA-Z0-9_-]/g, \"\")}-clip`;\n  const hintId = `${uid}-hint`;\n  const boxRef = React.useRef<HTMLDivElement>(null);\n  const svgRef = React.useRef<SVGSVGElement>(null);\n  const lassoRef = React.useRef<SVGPathElement>(null);\n  const brushRef = React.useRef<{ pts: [number, number][]; id: number; moved: boolean } | null>(null);\n\n  const [width, setWidth] = React.useState(720);\n  const [hidden, setHidden] = React.useState<ReadonlySet<string>>(() => new Set());\n  const [selected, setSelected] = React.useState<ReadonlySet<number>>(() => new Set());\n  const [active, setActive] = React.useState<number | null>(null);\n  const [view, setView] = React.useState<View | null>(null);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  /* ------------------------------- Derived data ------------------------------ */\n\n  const categories = React.useMemo(() => {\n    const list: string[] = [];\n    for (const d of data) {\n      const c = d.category ?? \"Series\";\n      if (!list.includes(c)) list.push(c);\n    }\n    return list;\n  }, [data]);\n\n  const extent = React.useMemo(() => {\n    let x0 = Infinity;\n    let x1 = -Infinity;\n    let y0 = Infinity;\n    let y1 = -Infinity;\n    let s0 = Infinity;\n    let s1 = -Infinity;\n    for (const d of data) {\n      x0 = Math.min(x0, d.x);\n      x1 = Math.max(x1, d.x);\n      y0 = Math.min(y0, d.y);\n      y1 = Math.max(y1, d.y);\n      if (d.size != null) {\n        s0 = Math.min(s0, d.size);\n        s1 = Math.max(s1, d.size);\n      }\n    }\n    if (!data.length) return { x: [0, 1] as Domain, y: [0, 1] as Domain, size: null };\n    return { x: niceDomain(x0, x1, 6), y: niceDomain(y0, y1, 5), size: Number.isFinite(s0) ? ([s0, s1] as Domain) : null };\n  }, [data]);\n\n  const domain = view ?? { x: extent.x, y: extent.y };\n  const W = Math.max(120, width);\n  const H = Math.max(160, height);\n  const iw = W - MARGIN.left - MARGIN.right;\n  const ih = H - MARGIN.top - MARGIN.bottom;\n  const sx = (v: number) => MARGIN.left + ((v - domain.x[0]) / (domain.x[1] - domain.x[0])) * iw;\n  const sy = (v: number) => MARGIN.top + ih - ((v - domain.y[0]) / (domain.y[1] - domain.y[0])) * ih;\n  const xTicks = ticks(domain.x, Math.max(2, Math.round(iw / 110)));\n  const yTicks = ticks(domain.y, Math.max(2, Math.round(ih / 64)));\n  const fx = formatX ?? defaultFormat(xTicks.step);\n  const fy = formatY ?? defaultFormat(yTicks.step);\n  const fs = formatSize ?? ((v: number) => plain.format(v));\n\n  const maxR = Math.max(2, pointSize);\n  const minR = Math.min(maxR, 2.5);\n  /** Area-true size encoding: radius ∝ √size. */\n  const radius = (d: ScatterDatum) => {\n    if (d.size == null || !extent.size) return Math.max(minR, maxR * 0.5);\n    const [a, b] = extent.size;\n    const t = b > a ? (d.size - a) / (b - a) : 1;\n    return minR + (maxR - minR) * Math.sqrt(Math.max(0, t));\n  };\n\n  const points = data.map((d, i) => {\n    const cat = d.category ?? \"Series\";\n    return {\n      d,\n      i,\n      cat,\n      ci: categories.indexOf(cat),\n      px: r2(sx(d.x)),\n      py: r2(sy(d.y)),\n      r: r2(radius(d)),\n      name: d.label ?? `Point ${i + 1}`,\n    };\n  });\n  type Point = (typeof points)[number];\n  const inView = (p: Point) => p.px >= MARGIN.left - 0.5 && p.px <= W - MARGIN.right + 0.5 && p.py >= MARGIN.top - 0.5 && p.py <= H - MARGIN.bottom + 0.5;\n  const visible = points.filter((p) => !hidden.has(p.cat) && inView(p));\n  const hasSelection = selected.size > 0;\n  const activePoint = active != null ? (visible.find((p) => p.i === active) ?? null) : null;\n\n  /* ------------------------------- Formatting ------------------------------- */\n\n  const describe = (p: Point) => {\n    const parts = [`${p.name}`];\n    if (categories.length > 1) parts.push(p.cat);\n    parts.push(`${xLabel} ${fx(p.d.x)}`, `${yLabel} ${fy(p.d.y)}`);\n    if (p.d.size != null) parts.push(`${sizeLabel} ${fs(p.d.size)}`);\n    if (selected.has(p.i)) parts.push(\"selected\");\n    return parts.join(\", \");\n  };\n\n  /* -------------------------------- Selection -------------------------------- */\n\n  const commitSelection = (next: ReadonlySet<number>, message: string) => {\n    setSelected(next);\n    setAnnouncement(message);\n    onSelectionChange?.(data.filter((_, i) => next.has(i)));\n  };\n  const clearSelection = () => commitSelection(new Set(), \"Selection cleared.\");\n\n  const toggleCategory = (cat: string) => {\n    const next = new Set(hidden);\n    if (next.has(cat)) next.delete(cat);\n    else next.add(cat);\n    setHidden(next);\n    setAnnouncement(`${cat} ${next.has(cat) ? \"hidden\" : \"shown\"}.`);\n    if (active != null && points[active]?.cat === cat && next.has(cat)) setActive(null);\n  };\n\n  /* --------------------------------- Pointer --------------------------------- */\n\n  const local = (e: { clientX: number; clientY: number }): [number, number] => {\n    const rect = svgRef.current?.getBoundingClientRect();\n    return rect ? [e.clientX - rect.left, e.clientY - rect.top] : [0, 0];\n  };\n\n  /** Linear nearest-point search in pixel space (fast enough for a few thousand points). */\n  const nearest = (x: number, y: number, within: number) => {\n    let best: Point | null = null;\n    let bestD = within * within;\n    for (const p of visible) {\n      const dx = p.px - x;\n      const dy = p.py - y;\n      const d2 = dx * dx + dy * dy - p.r * p.r * 0.5;\n      if (d2 < bestD) {\n        bestD = d2;\n        best = p;\n      }\n    }\n    return best;\n  };\n\n  const drawBrush = (pts: [number, number][]) => {\n    const path = lassoRef.current;\n    if (!path) return;\n    if (pts.length < 2) {\n      path.setAttribute(\"d\", \"\");\n      return;\n    }\n    if (brushMode === \"rect\") {\n      const [a, b] = [pts[0], pts[pts.length - 1]];\n      path.setAttribute(\"d\", `M${a[0]} ${a[1]}H${b[0]}V${b[1]}H${a[0]}Z`);\n    } else {\n      path.setAttribute(\"d\", `M${pts.map(([x, y]) => `${r2(x)} ${r2(y)}`).join(\"L\")}Z`);\n    }\n  };\n\n  const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {\n    if (e.button !== 0) return;\n    // Touch keeps native scrolling: a tap shows the nearest point, dragging scrolls the page.\n    if (brushMode === \"none\" || e.pointerType === \"touch\") {\n      brushRef.current = { pts: [local(e)], id: e.pointerId, moved: false };\n      return;\n    }\n    e.currentTarget.setPointerCapture(e.pointerId);\n    brushRef.current = { pts: [local(e)], id: e.pointerId, moved: false };\n    // The tooltip would sit on top of the lasso; it returns on the next hover.\n    if (active !== null) setActive(null);\n  };\n\n  const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {\n    const [x, y] = local(e);\n    const brush = brushRef.current;\n    if (brush && brush.id === e.pointerId && brushMode !== \"none\" && e.pointerType !== \"touch\") {\n      const [lx, ly] = brush.pts[brush.pts.length - 1];\n      const [x0, y0] = brush.pts[0];\n      if (!brush.moved && Math.hypot(x - x0, y - y0) > 4) brush.moved = true;\n      if (Math.hypot(x - lx, y - ly) > 2) {\n        if (brushMode === \"rect\") brush.pts = [brush.pts[0], [x, y]];\n        else brush.pts.push([x, y]);\n        drawBrush(brush.pts);\n      }\n      return;\n    }\n    if (e.pointerType !== \"mouse\") return;\n    // Only re-render when the nearest point changes, never on every move.\n    const hit = nearest(x, y, HOVER_RADIUS);\n    const next = hit ? hit.i : null;\n    if (next !== active) setActive(next);\n  };\n\n  const onPointerUp = (e: React.PointerEvent<SVGSVGElement>) => {\n    const brush = brushRef.current;\n    brushRef.current = null;\n    drawBrush([]);\n    if (!brush || brush.id !== e.pointerId) return;\n    const [x, y] = local(e);\n    const moved = brush.moved || Math.hypot(x - brush.pts[0][0], y - brush.pts[0][1]) > 4;\n\n    if (!moved) {\n      // A click or tap: show (and on click, toggle) the nearest point; empty space clears the selection.\n      const hit = nearest(x, y, e.pointerType === \"touch\" ? 36 : HOVER_RADIUS);\n      if (hit) {\n        setActive(hit.i);\n        if (e.pointerType !== \"touch\" && brushMode !== \"none\") toggle(hit);\n      } else if (hasSelection) clearSelection();\n      else setActive(null);\n      return;\n    }\n    if (brushMode === \"none\" || e.pointerType === \"touch\") return;\n\n    const pts = brush.pts;\n    let picked: Point[];\n    if (brushMode === \"rect\") {\n      const [[ax, ay], [bx, by]] = [pts[0], pts[pts.length - 1]];\n      const [x0, x1] = [Math.min(ax, bx), Math.max(ax, bx)];\n      const [y0, y1] = [Math.min(ay, by), Math.max(ay, by)];\n      picked = visible.filter((p) => p.px >= x0 && p.px <= x1 && p.py >= y0 && p.py <= y1);\n    } else {\n      picked = pts.length > 2 ? visible.filter((p) => inPolygon(p.px, p.py, pts)) : [];\n    }\n    const next = new Set(e.shiftKey ? selected : []);\n    for (const p of picked) next.add(p.i);\n    commitSelection(next, next.size ? `${next.size} ${next.size === 1 ? \"point\" : \"points\"} selected.` : \"No points in the selection.\");\n  };\n\n  const onPointerCancel = () => {\n    brushRef.current = null;\n    drawBrush([]);\n  };\n\n  function toggle(p: Point) {\n    const next = new Set(selected);\n    if (next.has(p.i)) next.delete(p.i);\n    else next.add(p.i);\n    commitSelection(next, `${p.name} ${next.has(p.i) ? \"added to\" : \"removed from\"} the selection. ${next.size} selected.`);\n  }\n\n  /* ---------------------------------- Zoom ---------------------------------- */\n\n  /** Zooms by `factor` (< 1 zooms in) around a pixel position. Zooming out past the data resets. */\n  const zoomAt = (px: number, py: number, factor: number) => {\n    const cur = view ?? { x: extent.x, y: extent.y };\n    const fxp = Math.min(1, Math.max(0, (px - MARGIN.left) / iw));\n    const fyp = Math.min(1, Math.max(0, 1 - (py - MARGIN.top) / ih));\n    const cx = cur.x[0] + (cur.x[1] - cur.x[0]) * fxp;\n    const cy = cur.y[0] + (cur.y[1] - cur.y[0]) * fyp;\n    const spanX = (cur.x[1] - cur.x[0]) * factor;\n    const spanY = (cur.y[1] - cur.y[0]) * factor;\n    const fullX = extent.x[1] - extent.x[0];\n    if (spanX >= fullX * 0.999) {\n      setView(null);\n      return;\n    }\n    if (spanX < fullX / 40) return;\n    setView({\n      x: [cx - spanX * fxp, cx + spanX * (1 - fxp)],\n      y: [cy - spanY * fyp, cy + spanY * (1 - fyp)],\n    });\n  };\n\n  const onWheel = React.useEffectEvent((e: WheelEvent) => {\n    e.preventDefault();\n    const [x, y] = local(e);\n    zoomAt(x, y, Math.exp(Math.max(-60, Math.min(60, e.deltaY)) * 0.004));\n  });\n\n  React.useEffect(() => {\n    const svg = svgRef.current;\n    if (!svg || !zoomable) return;\n    // Non-passive so the page doesn't scroll while zooming the plot.\n    const handler = (e: WheelEvent) => onWheel(e);\n    svg.addEventListener(\"wheel\", handler, { passive: false });\n    return () => svg.removeEventListener(\"wheel\", handler);\n  }, [zoomable]);\n\n  React.useEffect(() => {\n    const box = boxRef.current;\n    if (!box) return;\n    const ro = new ResizeObserver((entries) => setWidth(Math.max(1, Math.round(entries[0].contentRect.width))));\n    ro.observe(box);\n    return () => ro.disconnect();\n  }, []);\n\n  /* -------------------------------- Keyboard -------------------------------- */\n\n  /** Nearest visible point in a direction, by distance along it with a penalty for drifting sideways. */\n  const neighbour = (from: Point, dx: number, dy: number) => {\n    let best: Point | null = null;\n    let bestScore = Infinity;\n    for (const p of visible) {\n      if (p === from) continue;\n      const tx = p.px - from.px;\n      const ty = p.py - from.py;\n      const along = tx * dx + ty * dy;\n      if (along <= 0.5) continue;\n      const across = Math.abs(tx * dy - ty * dx);\n      const score = along + across * 2;\n      if (score < bestScore) {\n        bestScore = score;\n        best = p;\n      }\n    }\n    return best;\n  };\n\n  const byX = () => [...visible].sort((a, b) => a.d.x - b.d.x || a.d.y - b.d.y);\n\n  const move = (p: Point | null) => {\n    if (!p) return;\n    setActive(p.i);\n    setAnnouncement(describe(p));\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const dirs: Record<string, [number, number]> = { ArrowRight: [1, 0], ArrowLeft: [-1, 0], ArrowDown: [0, 1], ArrowUp: [0, -1] };\n    const dir = dirs[e.key];\n    if (dir) {\n      e.preventDefault();\n      if (!activePoint) move(byX()[0] ?? null);\n      else move(neighbour(activePoint, dir[0], dir[1]));\n    } else if (e.key === \"Home\" || e.key === \"End\") {\n      e.preventDefault();\n      const sorted = byX();\n      move((e.key === \"Home\" ? sorted[0] : sorted[sorted.length - 1]) ?? null);\n    } else if ((e.key === \" \" || e.key === \"Enter\") && activePoint) {\n      e.preventDefault();\n      toggle(activePoint);\n    } else if (e.key === \"Escape\") {\n      if (hasSelection) clearSelection();\n      else setActive(null);\n    } else if (zoomable && (e.key === \"+\" || e.key === \"=\" || e.key === \"-\" || e.key === \"_\")) {\n      e.preventDefault();\n      const cx = activePoint ? activePoint.px : MARGIN.left + iw / 2;\n      const cy = activePoint ? activePoint.py : MARGIN.top + ih / 2;\n      zoomAt(cx, cy, e.key === \"+\" || e.key === \"=\" ? 0.7 : 1 / 0.7);\n    } else if (zoomable && e.key === \"0\") {\n      setView(null);\n    }\n  };\n\n  /* --------------------------------- Render --------------------------------- */\n\n  const counts = categories.map((c) => data.filter((d) => (d.category ?? \"Series\") === c).length);\n  const tipLeft = activePoint ? Math.min(Math.max(activePoint.px, 90), W - 90) : 0;\n  const tipAbove = activePoint ? activePoint.py > 110 : true;\n\n  return (\n    <div\n      className={`flex w-full min-w-0 flex-col gap-3 text-zinc-950 dark:text-zinc-50 ${PALETTE_VARS} ${className}`}\n      style={{ [\"--sp-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <style>{`@keyframes sp-in{from{opacity:0;transform:scale(.2)}}`}</style>\n\n      <div className=\"flex min-w-0 flex-wrap items-center justify-between gap-x-4 gap-y-2\">\n        {showLegend && categories.length > 1 ? (\n          <div role=\"group\" aria-label=\"Categories\" className=\"flex min-w-0 flex-wrap gap-1.5\">\n            {categories.map((c, i) => {\n              const on = !hidden.has(c);\n              return (\n                <button\n                  key={c}\n                  type=\"button\"\n                  aria-pressed={on}\n                  onClick={() => toggleCategory(c)}\n                  className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[12px] transition-[background-color,color,border-color] outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color:var(--sp-accent)] ${\n                    on\n                      ? \"border-zinc-900/15 bg-white/70 text-zinc-800 hover:bg-white dark:border-white/15 dark:bg-white/[0.06] dark:text-zinc-100 dark:hover:bg-white/10\"\n                      : \"border-dashed border-zinc-900/20 text-zinc-400 hover:text-zinc-600 dark:border-white/15 dark:text-zinc-500 dark:hover:text-zinc-300\"\n                  }`}\n                >\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"size-2 rounded-full transition-opacity\"\n                    style={{ backgroundColor: categoryColor(i), opacity: on ? 1 : 0.35 }}\n                  />\n                  {c}\n                  <span className=\"font-mono text-[10px] tabular-nums opacity-60\">{counts[i]}</span>\n                </button>\n              );\n            })}\n          </div>\n        ) : (\n          <span />\n        )}\n        <div className=\"flex items-center gap-1.5\">\n          {view && (\n            <button\n              type=\"button\"\n              onClick={() => {\n                setView(null);\n                setAnnouncement(\"Zoom reset.\");\n              }}\n              className=\"rounded-md border border-zinc-900/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-600 transition-[background-color,color,border-color] outline-hidden hover:bg-zinc-900/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color:var(--sp-accent)] dark:border-white/10 dark:text-zinc-300 dark:hover:bg-white/5\"\n            >\n              Reset zoom\n            </button>\n          )}\n          {hasSelection && (\n            <button\n              type=\"button\"\n              onClick={clearSelection}\n              className=\"rounded-md border border-zinc-900/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-600 transition-[background-color,color,border-color] outline-hidden hover:bg-zinc-900/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color:var(--sp-accent)] dark:border-white/10 dark:text-zinc-300 dark:hover:bg-white/5\"\n            >\n              Clear selection · {selected.size}\n            </button>\n          )}\n        </div>\n      </div>\n\n      <div\n        ref={boxRef}\n        role=\"group\"\n        aria-roledescription=\"scatter plot\"\n        aria-label={label}\n        aria-describedby={hintId}\n        tabIndex={0}\n        onKeyDown={onKeyDown}\n        onBlur={(e) => {\n          if (!e.currentTarget.contains(e.relatedTarget)) setActive(null);\n        }}\n        className=\"relative w-full rounded-md outline-hidden focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-solid focus-visible:outline-[color:var(--sp-accent)]\"\n        style={{ height: H }}\n      >\n        <svg\n          ref={svgRef}\n          aria-hidden=\"true\"\n          width={W}\n          height={H}\n          className={`block select-none ${brushMode === \"none\" ? \"cursor-default\" : \"cursor-crosshair\"}`}\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={onPointerUp}\n          onPointerCancel={onPointerCancel}\n          onPointerLeave={(e) => {\n            if (e.pointerType === \"mouse\" && !brushRef.current && document.activeElement !== boxRef.current) setActive(null);\n          }}\n        >\n          <defs>\n            <clipPath id={clipId}>\n              <rect x={MARGIN.left} y={MARGIN.top} width={iw} height={ih} />\n            </clipPath>\n          </defs>\n\n          {/* Grid and axes */}\n          <g className=\"text-zinc-900/[0.07] dark:text-white/[0.07]\">\n            {showGrid &&\n              yTicks.values.map((v) => (\n                <line key={`gy${v}`} x1={MARGIN.left} x2={W - MARGIN.right} y1={r2(sy(v))} y2={r2(sy(v))} stroke=\"currentColor\" />\n              ))}\n            {showGrid &&\n              xTicks.values.map((v) => (\n                <line key={`gx${v}`} y1={MARGIN.top} y2={H - MARGIN.bottom} x1={r2(sx(v))} x2={r2(sx(v))} stroke=\"currentColor\" />\n              ))}\n          </g>\n          <line\n            x1={MARGIN.left}\n            x2={W - MARGIN.right}\n            y1={H - MARGIN.bottom}\n            y2={H - MARGIN.bottom}\n            className=\"stroke-zinc-900/25 dark:stroke-white/25\"\n          />\n          <line x1={MARGIN.left} x2={MARGIN.left} y1={MARGIN.top} y2={H - MARGIN.bottom} className=\"stroke-zinc-900/25 dark:stroke-white/25\" />\n          <g className=\"fill-zinc-500 font-mono text-[10px] tabular-nums dark:fill-zinc-400\">\n            {xTicks.values.map((v) => (\n              <text key={`tx${v}`} x={r2(sx(v))} y={H - MARGIN.bottom + 15} textAnchor=\"middle\">\n                {fx(v)}\n              </text>\n            ))}\n            {yTicks.values.map((v) => (\n              <text key={`ty${v}`} x={MARGIN.left - 8} y={r2(sy(v)) + 3} textAnchor=\"end\">\n                {fy(v)}\n              </text>\n            ))}\n          </g>\n          <g className=\"fill-zinc-600 font-mono text-[10px] uppercase tracking-[0.12em] dark:fill-zinc-300\">\n            <text x={W - MARGIN.right} y={H - 4} textAnchor=\"end\">\n              {xLabel} →\n            </text>\n            <text x={MARGIN.left + 6} y={MARGIN.top + 10}>\n              ↑ {yLabel}\n            </text>\n          </g>\n\n          {/* Points */}\n          <g clipPath={`url(#${clipId})`}>\n            {points.map((p) => {\n              if (hidden.has(p.cat)) return null;\n              const isSel = selected.has(p.i);\n              const dim = hasSelection && !isSel;\n              const color = categoryColor(p.ci);\n              return (\n                <circle\n                  key={p.d.id ?? p.i}\n                  cx={p.px}\n                  cy={p.py}\n                  r={p.r}\n                  className=\"transition-[opacity,stroke-width] duration-200 [transform-box:fill-box] [transform-origin:center] [animation:sp-in_520ms_cubic-bezier(0.2,0.8,0.2,1)_both] motion-reduce:[animation:none]\"\n                  style={{\n                    fill: color,\n                    fillOpacity: dim ? 0.12 : Math.min(1, Math.max(0.1, opacity)),\n                    stroke: color,\n                    strokeOpacity: dim ? 0.25 : 1,\n                    strokeWidth: isSel ? 2 : 1,\n                    animationDelay: `${Math.min(600, p.i * 3)}ms`,\n                  }}\n                />\n              );\n            })}\n            {activePoint && (\n              <circle\n                cx={activePoint.px}\n                cy={activePoint.py}\n                r={activePoint.r + 4}\n                fill=\"none\"\n                strokeWidth={1.5}\n                className=\"pointer-events-none stroke-zinc-950 dark:stroke-white\"\n              />\n            )}\n            <path\n              ref={lassoRef}\n              d=\"\"\n              className=\"pointer-events-none fill-[color:var(--sp-accent)]/10 stroke-[color:var(--sp-accent)]\"\n              strokeWidth={1.25}\n              strokeDasharray=\"4 3\"\n            />\n          </g>\n        </svg>\n\n        {activePoint && (\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute z-10 w-max max-w-[220px] -translate-x-1/2 rounded-md border border-zinc-900/10 bg-white/95 px-2.5 py-2 text-left shadow-[0_10px_30px_-12px_rgb(0_0_0/0.35)] dark:border-white/10 dark:bg-zinc-900/95\"\n            style={{\n              left: tipLeft,\n              ...(tipAbove ? { bottom: H - activePoint.py + activePoint.r + 10 } : { top: activePoint.py + activePoint.r + 10 }),\n            }}\n          >\n            {categories.length > 1 && (\n              <span className=\"flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400\">\n                <span className=\"size-1.5 rounded-full\" style={{ backgroundColor: categoryColor(activePoint.ci) }} />\n                {activePoint.cat}\n              </span>\n            )}\n            <span className=\"block text-sm font-semibold tracking-[-0.01em]\">{activePoint.name}</span>\n            <span className=\"mt-1 grid grid-cols-[auto_auto] gap-x-3 font-mono text-[11px] tabular-nums text-zinc-600 dark:text-zinc-300\">\n              <span className=\"text-zinc-400 dark:text-zinc-500\">{xLabel}</span>\n              <span className=\"text-right\">{fx(activePoint.d.x)}</span>\n              <span className=\"text-zinc-400 dark:text-zinc-500\">{yLabel}</span>\n              <span className=\"text-right\">{fy(activePoint.d.y)}</span>\n              {activePoint.d.size != null && (\n                <>\n                  <span className=\"text-zinc-400 dark:text-zinc-500\">{sizeLabel}</span>\n                  <span className=\"text-right\">{fs(activePoint.d.size)}</span>\n                </>\n              )}\n            </span>\n          </div>\n        )}\n      </div>\n\n      <p aria-hidden=\"true\" className=\"font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400\">\n        {visible.length} of {data.length} points{hasSelection ? ` · ${selected.size} selected` : \"\"}\n        {brushMode !== \"none\" ? ` · drag to ${brushMode === \"lasso\" ? \"lasso\" : \"box\"}-select` : \"\"}\n        {zoomable ? \" · scroll to zoom\" : \"\"}\n      </p>\n\n      <p id={hintId} className=\"sr-only\">\n        Arrow keys move to the nearest point in that direction; Home and End jump to the first and last point along the x axis.\n        Space or Enter adds the point to the selection or removes it; Escape clears the selection.\n        {zoomable ? \" Plus and minus zoom around the current point; 0 resets the zoom.\" : \"\"}\n      </p>\n      <p className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </p>\n      <table className=\"sr-only\">\n        <caption>{label}</caption>\n        <thead>\n          <tr>\n            <th scope=\"col\">Point</th>\n            {categories.length > 1 && <th scope=\"col\">Category</th>}\n            <th scope=\"col\">{xLabel}</th>\n            <th scope=\"col\">{yLabel}</th>\n            {extent.size && <th scope=\"col\">{sizeLabel}</th>}\n          </tr>\n        </thead>\n        <tbody>\n          {points.map((p) => (\n            <tr key={p.d.id ?? p.i}>\n              <th scope=\"row\">{p.name}</th>\n              {categories.length > 1 && <td>{p.cat}</td>}\n              <td>{fx(p.d.x)}</td>\n              <td>{fy(p.d.y)}</td>\n              {extent.size && <td>{p.d.size != null ? fs(p.d.size) : \"—\"}</td>}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/scatter-plot"
}
