{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "force-graph",
  "type": "registry:ui",
  "title": "Force Graph",
  "description": "A force-directed network graph in SVG: inline simulation that settles and stops, draggable nodes, zoom and pan, keyboard navigation.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/force-graph.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface ForceGraphNode {\n  id: string;\n  label: string;\n  /** Nodes in the same group share a colour (the first group uses the accent). */\n  group?: string;\n}\n\nexport interface ForceGraphLink {\n  source: string;\n  target: string;\n  /** Optional weight; heavier links are drawn thicker. */\n  value?: number;\n}\n\nexport interface ForceGraphData {\n  nodes: ForceGraphNode[];\n  links: ForceGraphLink[];\n}\n\nexport type ForceGraphLabels = \"auto\" | \"all\" | \"none\";\n\nexport interface ForceGraphProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  data: ForceGraphData;\n  /** Height of the graph in px; the width follows the container. */\n  height?: number;\n  /** Resting length of the link springs, in px. */\n  linkDistance?: number;\n  /** Strength of the repulsion between nodes. */\n  charge?: number;\n  /** \"auto\" labels hubs and the highlighted neighbourhood; \"all\" or \"none\" are literal. */\n  labels?: ForceGraphLabels;\n  /** Colour of the first group and the highlight. */\n  accent?: string;\n  /** Accessible name of the graph. */\n  label?: string;\n}\n\n/* ----------------------------- Reduced motion ------------------------------ */\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* ---------------------------------- Graph ---------------------------------- */\n\nconst PALETTE = [\"var(--fg-accent)\", \"#3d8bea\", \"#22b07d\", \"#f0b429\", \"#e87ba4\", \"#8b7cf0\", \"#4fb9c9\", \"#a3a3a3\"];\nconst MAX_NODES = 150;\n\ninterface GNode {\n  id: string;\n  label: string;\n  group: string;\n  color: string;\n  degree: number;\n  radius: number;\n  hub: boolean;\n  neighbours: number[];\n}\n\ninterface GLink {\n  source: number;\n  target: number;\n  value: number;\n}\n\ninterface Graph {\n  nodes: GNode[];\n  links: GLink[];\n}\n\n/** Validates the data (unique ids, known endpoints, no self or duplicate links) and derives degree, size and colour. */\nfunction buildGraph(data: ForceGraphData): Graph {\n  const index = new Map<string, number>();\n  const groups = new Map<string, number>();\n  const nodes: GNode[] = [];\n  for (const n of data.nodes) {\n    if (index.has(n.id) || nodes.length >= MAX_NODES) continue;\n    const group = n.group ?? \"\";\n    if (!groups.has(group)) groups.set(group, groups.size);\n    index.set(n.id, nodes.length);\n    nodes.push({ id: n.id, label: n.label, group, color: PALETTE[groups.get(group)! % PALETTE.length], degree: 0, radius: 4, hub: false, neighbours: [] });\n  }\n  const seen = new Set<string>();\n  const links: GLink[] = [];\n  for (const l of data.links) {\n    const s = index.get(l.source);\n    const t = index.get(l.target);\n    if (s === undefined || t === undefined || s === t) continue;\n    const key = s < t ? `${s}-${t}` : `${t}-${s}`;\n    if (seen.has(key)) continue;\n    seen.add(key);\n    links.push({ source: s, target: t, value: l.value ?? 1 });\n    nodes[s].neighbours.push(t);\n    nodes[t].neighbours.push(s);\n  }\n  const degrees = nodes.map((n) => (n.degree = n.neighbours.length)).sort((a, b) => b - a);\n  // Hubs: roughly the best-connected eighth, and never fewer than three links.\n  const hubAt = Math.max(3, degrees[Math.floor(nodes.length * 0.12)] ?? 3);\n  for (const n of nodes) {\n    n.radius = Math.min(13, 4 + Math.sqrt(n.degree) * 1.7);\n    n.hub = n.degree >= hubAt;\n  }\n  return { nodes, links };\n}\n\n/* -------------------------------- Simulation -------------------------------- */\n\ninterface Body {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  /** Pinned position while dragged, else null. */\n  fx: number | null;\n  fy: number | null;\n}\n\ninterface Sim {\n  graph: Graph;\n  bodies: Body[];\n  alpha: number;\n  alphaTarget: number;\n  distance: number;\n  charge: number;\n}\n\nconst ALPHA_MIN = 0.001;\nconst ALPHA_DECAY = 1 - Math.pow(ALPHA_MIN, 1 / 300); // settles in ~300 steps (~5s)\nconst VELOCITY_KEEP = 0.6;\nconst PRESETTLE_ALPHA = 0.12;\nconst STEP_MS = 1000 / 60;\n\n/**\n * One simulation step (the d3-force recipe, inline): link springs, O(n²) many-body repulsion\n * (fine up to 150 nodes), a gentle pull to the centre, collision, then velocity integration.\n */\nfunction tick(sim: Sim) {\n  const { bodies, graph, alpha } = sim;\n  const n = bodies.length;\n\n  // Link springs, stiffer for nodes with few links (as in d3: 1 / min degree).\n  for (const l of graph.links) {\n    const s = bodies[l.source];\n    const t = bodies[l.target];\n    const ds = graph.nodes[l.source].degree;\n    const dt = graph.nodes[l.target].degree;\n    let dx = t.x + t.vx - s.x - s.vx || 1e-6;\n    let dy = t.y + t.vy - s.y - s.vy || 1e-6;\n    const len = Math.sqrt(dx * dx + dy * dy);\n    const k = ((len - sim.distance) / len) * alpha * (1 / Math.min(ds, dt));\n    dx *= k;\n    dy *= k;\n    const bias = ds / (ds + dt);\n    t.vx -= dx * bias;\n    t.vy -= dy * bias;\n    s.vx += dx * (1 - bias);\n    s.vy += dy * (1 - bias);\n  }\n\n  // Many-body repulsion and collision, pairwise.\n  for (let i = 0; i < n; i++) {\n    const a = bodies[i];\n    const ra = graph.nodes[i].radius;\n    for (let j = i + 1; j < n; j++) {\n      const b = bodies[j];\n      let dx = b.x - a.x;\n      let dy = b.y - a.y;\n      if (dx === 0 && dy === 0) {\n        // Deterministic nudge for coincident nodes.\n        dx = ((i * 7 + j * 13) % 11) / 1000 - 0.005 || 1e-3;\n        dy = ((i * 5 + j * 3) % 7) / 1000 - 0.003 || 1e-3;\n      }\n      const d2 = Math.max(1, dx * dx + dy * dy);\n      if (d2 < 640_000) {\n        const f = (sim.charge * alpha) / d2;\n        a.vx -= dx * f;\n        a.vy -= dy * f;\n        b.vx += dx * f;\n        b.vy += dy * f;\n      }\n      const reach = ra + graph.nodes[j].radius + 3;\n      if (d2 < reach * reach) {\n        const d = Math.sqrt(d2);\n        const push = ((reach - d) / d) * 0.35;\n        a.vx -= dx * push;\n        a.vy -= dy * push;\n        b.vx += dx * push;\n        b.vy += dy * push;\n      }\n    }\n  }\n\n  for (const body of bodies) {\n    // Weak pull toward the centre keeps disconnected pieces in view.\n    body.vx -= body.x * 0.035 * alpha;\n    body.vy -= body.y * 0.035 * alpha;\n    if (body.fx !== null && body.fy !== null) {\n      body.x = body.fx;\n      body.y = body.fy;\n      body.vx = 0;\n      body.vy = 0;\n    } else {\n      body.vx *= VELOCITY_KEEP;\n      body.vy *= VELOCITY_KEEP;\n      body.x += body.vx;\n      body.y += body.vy;\n    }\n  }\n  sim.alpha += (sim.alphaTarget - sim.alpha) * ALPHA_DECAY;\n}\n\ntype Point = [number, number];\n\nconst r2 = (v: number) => Math.round(v * 100) / 100;\n\n/**\n * Deterministic start: nodes on a phyllotaxis spiral (no randomness), then simulated until\n * mostly settled. Server and client compute the same layout, so the SSR markup hydrates as-is\n * and the client only animates the last stretch of settling.\n */\nfunction presimulate(graph: Graph, distance: number, charge: number) {\n  const bodies: Body[] = graph.nodes.map((_, i) => {\n    const r = 10 * Math.sqrt(0.5 + i);\n    const a = i * Math.PI * (3 - Math.sqrt(5));\n    return { x: r * Math.cos(a), y: r * Math.sin(a), vx: 0, vy: 0, fx: null, fy: null };\n  });\n  const sim: Sim = { graph, bodies, alpha: 1, alphaTarget: 0, distance, charge };\n  while (sim.alpha > PRESETTLE_ALPHA) tick(sim);\n  return { graph, positions: bodies.map((b): Point => [r2(b.x), r2(b.y)]), alpha: sim.alpha };\n}\n\n/* ---------------------------------- View ----------------------------------- */\n\ninterface View {\n  k: number;\n  /** Pan offset of the graph's origin from the centre of the box, px. */\n  px: number;\n  py: number;\n}\n\nconst MIN_K = 0.25;\nconst MAX_K = 4;\n\n/** Zoom and offset that fit every node in the box with some padding (never zooming in past 1.4×). */\nfunction fitView(points: Point[], w: number, h: number): View {\n  if (!points.length) return { k: 1, px: 0, py: 0 };\n  let x0 = Infinity;\n  let y0 = Infinity;\n  let x1 = -Infinity;\n  let y1 = -Infinity;\n  for (const [x, y] of points) {\n    x0 = Math.min(x0, x);\n    y0 = Math.min(y0, y);\n    x1 = Math.max(x1, x);\n    y1 = Math.max(y1, y);\n  }\n  const pad = Math.min(56, Math.min(w, h) * 0.12);\n  const k = Math.min(1.4, Math.max(MIN_K, Math.min((w - pad * 2) / Math.max(1, x1 - x0), (h - pad * 2) / Math.max(1, y1 - y0))));\n  return { k: Math.round(k * 1000) / 1000, px: r2(-((x0 + x1) / 2) * k), py: r2(-((y0 + y1) / 2) * k) };\n}\n\nconst viewTransform = (v: View, w: number, h: number) => `translate(${r2(w / 2 + v.px)},${r2(h / 2 + v.py)}) scale(${Math.round(v.k * 1000) / 1000})`;\n\n/* -------------------------------- Component -------------------------------- */\n\n/**\n * A force-directed network graph in SVG with an inline simulation (springs, O(n²) repulsion,\n * centring, collision) that runs on fixed 60Hz steps and stops once settled. Drag nodes (they\n * pin while held), pan the background, zoom with the buttons, keys or Ctrl/⌘ + wheel or pinch.\n * Hover or focus highlights a node's neighbours. Every node is a tab stop and arrow keys move\n * to the nearest node in that direction, preferring connected ones. Up to 150 nodes.\n */\nexport function ForceGraph({\n  data,\n  height = 440,\n  linkDistance = 48,\n  charge = 120,\n  labels = \"auto\",\n  accent = \"#FF7448\",\n  label = \"Network graph\",\n  className = \"\",\n  style,\n  ...props\n}: ForceGraphProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const hintId = `${uid}-hint`;\n  const boxRef = React.useRef<HTMLDivElement>(null);\n  const svgRef = React.useRef<SVGSVGElement>(null);\n  const viewportRef = React.useRef<SVGGElement>(null);\n  const simRef = React.useRef<Sim | null>(null);\n  const viewRef = React.useRef<View>({ k: 1, px: 0, py: 0 });\n  const controlsRef = React.useRef<{ reheat: (alpha: number) => void } | null>(null);\n  const movedRef = React.useRef(false); // the user has panned or zoomed\n\n  const H = Math.max(160, height);\n  const [width, setWidth] = React.useState(720);\n  const W = Math.max(160, width);\n\n  const [seed, setSeed] = React.useState(() => presimulate(buildGraph(data), linkDistance, charge));\n  const [positions, setPositions] = React.useState<Point[]>(seed.positions);\n  const [view, setView] = React.useState<View>(() => fitView(seed.positions, 720, H));\n  const [prevData, setPrevData] = React.useState(data);\n  if (data !== prevData) {\n    // New data: a fresh deterministic layout, fitted to the box.\n    const next = presimulate(buildGraph(data), linkDistance, charge);\n    setPrevData(data);\n    setSeed(next);\n    setPositions(next.positions);\n    setView(fitView(next.positions, W, H));\n  }\n  const graph = seed.graph;\n\n  const [hoverId, setHoverId] = React.useState<number | null>(null);\n  const [focusId, setFocusId] = React.useState<number | null>(null);\n  const active = hoverId ?? focusId;\n  const lit = active === null ? null : new Set([active, ...graph.nodes[active].neighbours]);\n\n  React.useEffect(() => {\n    viewRef.current = view;\n  }, [view]);\n\n  /* ------------------------- simulation + gestures ------------------------- */\n\n  const tuning = React.useEffectEvent(() => ({ distance: linkDistance, charge, reduce: reduceMotion }));\n  const commitView = React.useEffectEvent((v: View) => setView({ ...v }));\n  const commitPositions = React.useEffectEvent((sim: Sim) => setPositions(sim.bodies.map((b): Point => [r2(b.x), r2(b.y)])));\n  const tapNode = React.useEffectEvent((i: number) => setHoverId((h) => (h === i ? null : i)));\n  const clearTap = React.useEffectEvent(() => setHoverId(null));\n\n  React.useEffect(() => {\n    const svg = svgRef.current;\n    const viewport = viewportRef.current;\n    if (!svg || !viewport) return;\n    const { distance, charge: strength } = tuning();\n    const sim: Sim = {\n      graph: seed.graph,\n      bodies: seed.positions.map(([x, y]) => ({ x, y, vx: 0, vy: 0, fx: null, fy: null })),\n      alpha: seed.alpha,\n      alphaTarget: 0,\n      distance,\n      charge: strength,\n    };\n    simRef.current = sim;\n\n    const nodeEls = new Map<number, SVGGElement>();\n    svg.querySelectorAll<SVGGElement>(\"[data-fg-node]\").forEach((el) => nodeEls.set(Number(el.dataset.fgNode), el));\n    const linkEls = new Map<number, SVGLineElement>();\n    svg.querySelectorAll<SVGLineElement>(\"[data-fg-link]\").forEach((el) => linkEls.set(Number(el.dataset.fgLink), el));\n\n    /** Writes positions straight to the DOM; React state is only updated once things settle. */\n    const paint = () => {\n      sim.bodies.forEach((b, i) => nodeEls.get(i)?.setAttribute(\"transform\", `translate(${r2(b.x)},${r2(b.y)})`));\n      sim.graph.links.forEach((l, i) => {\n        const el = linkEls.get(i);\n        if (!el) return;\n        const s = sim.bodies[l.source];\n        const t = sim.bodies[l.target];\n        el.setAttribute(\"x1\", String(r2(s.x)));\n        el.setAttribute(\"y1\", String(r2(s.y)));\n        el.setAttribute(\"x2\", String(r2(t.x)));\n        el.setAttribute(\"y2\", String(r2(t.y)));\n      });\n    };\n\n    let raf = 0;\n    let last = 0;\n    let pending = 0;\n    let running = false;\n    let inView = true;\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = Math.min(64, last ? now - last : STEP_MS);\n      last = now;\n      // Fixed 60Hz steps: the same settling on 60, 120 or 144Hz displays. A short frame may run\n      // no step at all; alpha only changes inside a step, so such a frame can never count as settled.\n      pending = Math.min(pending + dt, STEP_MS * 4);\n      let stepped = false;\n      while (pending >= STEP_MS) {\n        tick(sim);\n        pending -= STEP_MS;\n        stepped = true;\n      }\n      if (stepped) paint();\n      if (stepped && sim.alpha < ALPHA_MIN && sim.alphaTarget === 0) {\n        running = false;\n        last = 0;\n        pending = 0;\n        commitPositions(sim);\n        return;\n      }\n      schedule();\n    };\n    const schedule = () => {\n      if (running && !raf && inView && !document.hidden) raf = requestAnimationFrame(frame);\n    };\n    const stop = () => {\n      cancelAnimationFrame(raf);\n      raf = 0;\n      last = 0;\n    };\n\n    /** Restarts the simulation; with reduced motion it runs to rest synchronously, unseen. */\n    const reheat = (to: number) => {\n      sim.alpha = Math.max(sim.alpha, to);\n      if (tuning().reduce) {\n        while (sim.alpha >= ALPHA_MIN) tick(sim);\n        paint();\n        commitPositions(sim);\n        return;\n      }\n      running = true;\n      schedule();\n    };\n    controlsRef.current = {\n      reheat: (to) => {\n        const t = tuning();\n        sim.distance = t.distance;\n        sim.charge = t.charge;\n        reheat(to);\n      },\n    };\n    // Finish the last stretch of settling (instantly with reduced motion).\n    reheat(0);\n\n    /* -------------------------------- gestures ------------------------------- */\n\n    const pointers = new Map<number, { x: number; y: number }>();\n    let drag: { index: number; id: number; startX: number; startY: number; moved: boolean } | null = null;\n    let pan: { id: number; x: number; y: number } | null = null;\n    let pinch: { dist: number; k: number } | null = null;\n    let commitTimer = 0;\n\n    const applyView = () => viewport.setAttribute(\"transform\", viewTransform(viewRef.current, svg.clientWidth, svg.clientHeight));\n    const local = (e: PointerEvent | WheelEvent) => {\n      const rect = svg.getBoundingClientRect();\n      return { x: e.clientX - rect.left, y: e.clientY - rect.top };\n    };\n    const toSim = (x: number, y: number) => {\n      const v = viewRef.current;\n      return { x: (x - svg.clientWidth / 2 - v.px) / v.k, y: (y - svg.clientHeight / 2 - v.py) / v.k };\n    };\n    /** Zooms by `factor` keeping the point (x, y) of the box fixed. */\n    const zoomAt = (factor: number, x: number, y: number) => {\n      const v = viewRef.current;\n      const k = Math.min(MAX_K, Math.max(MIN_K, v.k * factor));\n      const cx = x - svg.clientWidth / 2;\n      const cy = y - svg.clientHeight / 2;\n      viewRef.current = { k, px: cx - ((cx - v.px) * k) / v.k, py: cy - ((cy - v.py) * k) / v.k };\n      movedRef.current = true;\n      applyView();\n    };\n\n    const onPointerDown = (e: PointerEvent) => {\n      if (e.button !== 0) return;\n      const p = local(e);\n      pointers.set(e.pointerId, p);\n      svg.setPointerCapture(e.pointerId);\n      if (pointers.size === 2) {\n        // Second finger: switch to pinch zoom.\n        const [a, b] = [...pointers.values()];\n        pinch = { dist: Math.hypot(a.x - b.x, a.y - b.y) || 1, k: viewRef.current.k };\n        if (drag) sim.bodies[drag.index].fx = sim.bodies[drag.index].fy = null;\n        drag = null;\n        pan = null;\n        return;\n      }\n      const nodeEl = (e.target as Element).closest<SVGGElement>(\"[data-fg-node]\");\n      if (nodeEl) {\n        const index = Number(nodeEl.dataset.fgNode);\n        drag = { index, id: e.pointerId, startX: p.x, startY: p.y, moved: false };\n        sim.bodies[index].fx = sim.bodies[index].x;\n        sim.bodies[index].fy = sim.bodies[index].y;\n      } else {\n        pan = { id: e.pointerId, x: p.x, y: p.y };\n        if (e.pointerType !== \"mouse\") clearTap(); // touching the background clears a tapped highlight\n      }\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      if (!pointers.has(e.pointerId)) return;\n      const p = local(e);\n      pointers.set(e.pointerId, p);\n      if (pinch && pointers.size >= 2) {\n        const [a, b] = [...pointers.values()];\n        const dist = Math.hypot(a.x - b.x, a.y - b.y) || 1;\n        zoomAt((pinch.k * (dist / pinch.dist)) / viewRef.current.k, (a.x + b.x) / 2, (a.y + b.y) / 2);\n        return;\n      }\n      if (drag && drag.id === e.pointerId) {\n        if (!drag.moved && Math.hypot(p.x - drag.startX, p.y - drag.startY) < 4) return;\n        drag.moved = true;\n        const at = toSim(p.x, p.y);\n        const body = sim.bodies[drag.index];\n        // The dragged node is pinned to the pointer; the rest of the graph reacts to it.\n        body.fx = at.x;\n        body.fy = at.y;\n        if (tuning().reduce) {\n          body.x = at.x;\n          body.y = at.y;\n          paint();\n        } else {\n          sim.alphaTarget = 0.3;\n          reheat(0.3);\n        }\n      } else if (pan && pan.id === e.pointerId) {\n        const v = viewRef.current;\n        viewRef.current = { ...v, px: v.px + p.x - pan.x, py: v.py + p.y - pan.y };\n        pan.x = p.x;\n        pan.y = p.y;\n        movedRef.current = true;\n        applyView();\n      }\n    };\n\n    const onPointerUp = (e: PointerEvent) => {\n      if (!pointers.has(e.pointerId)) return;\n      pointers.delete(e.pointerId);\n      if (pinch) {\n        if (pointers.size < 2) {\n          pinch = null;\n          commitView(viewRef.current);\n        }\n        return;\n      }\n      if (drag && drag.id === e.pointerId) {\n        const body = sim.bodies[drag.index];\n        body.fx = null;\n        body.fy = null;\n        sim.alphaTarget = 0;\n        if (!drag.moved && e.pointerType !== \"mouse\") tapNode(drag.index); // touch: tap shows the neighbourhood\n        else if (drag.moved) clearTap(); // pointer capture hides the leave event, so drop the hover here\n        if (drag.moved && tuning().reduce) commitPositions(sim);\n        drag = null;\n      } else if (pan && pan.id === e.pointerId) {\n        pan = null;\n        commitView(viewRef.current);\n      }\n    };\n\n    // Ctrl/⌘ + wheel (and trackpad pinch, which arrives as Ctrl + wheel) zooms; a plain wheel\n    // keeps scrolling the page.\n    const onWheel = (e: WheelEvent) => {\n      if (!e.ctrlKey && !e.metaKey) return;\n      e.preventDefault();\n      const p = local(e);\n      zoomAt(Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.002)), p.x, p.y);\n      window.clearTimeout(commitTimer);\n      commitTimer = window.setTimeout(() => commitView(viewRef.current), 160);\n    };\n\n    const onVisibility = () => (document.hidden ? stop() : schedule());\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      if (inView) schedule();\n      else stop();\n    });\n    io.observe(svg);\n\n    svg.addEventListener(\"pointerdown\", onPointerDown);\n    svg.addEventListener(\"pointermove\", onPointerMove);\n    svg.addEventListener(\"pointerup\", onPointerUp);\n    svg.addEventListener(\"pointercancel\", onPointerUp);\n    svg.addEventListener(\"wheel\", onWheel, { passive: false });\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    return () => {\n      stop();\n      io.disconnect();\n      window.clearTimeout(commitTimer);\n      svg.removeEventListener(\"pointerdown\", onPointerDown);\n      svg.removeEventListener(\"pointermove\", onPointerMove);\n      svg.removeEventListener(\"pointerup\", onPointerUp);\n      svg.removeEventListener(\"pointercancel\", onPointerUp);\n      svg.removeEventListener(\"wheel\", onWheel);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      controlsRef.current = null;\n      simRef.current = null;\n    };\n  }, [seed]);\n\n  // Changing the forces reheats the running simulation instead of starting over. Turning on\n  // reduced motion (which may only be known after hydration) finishes any settling at once.\n  React.useEffect(() => {\n    const sim = simRef.current;\n    if (!sim) return;\n    if (sim.distance !== linkDistance || sim.charge !== charge) controlsRef.current?.reheat(0.6);\n    else if (reduceMotion) controlsRef.current?.reheat(0);\n  }, [linkDistance, charge, reduceMotion]);\n\n  const fitNow = React.useEffectEvent(() => {\n    const sim = simRef.current;\n    const points = sim ? sim.bodies.map((b): Point => [b.x, b.y]) : positions;\n    movedRef.current = false;\n    setView(fitView(points, W, H));\n  });\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  // Until the user pans or zooms, keep the graph fitted to the box as it resizes.\n  React.useEffect(() => {\n    if (!movedRef.current) fitNow();\n  }, [W, H]);\n\n  /* -------------------------------- keyboard ------------------------------- */\n\n  const focusNode = (i: number) => svgRef.current?.querySelector<SVGElement>(`[data-fg-focus=\"${i}\"]`)?.focus();\n\n  /** The nearest node in a direction, scored by distance along it plus sideways drift; neighbours win ties. */\n  const nearestIn = (from: number, dx: number, dy: number) => {\n    const sim = simRef.current;\n    const pts = sim ? sim.bodies.map((b): Point => [b.x, b.y]) : positions;\n    const [fx, fy] = pts[from];\n    const pick = (candidates: number[]) => {\n      let best = -1;\n      let bestScore = Infinity;\n      for (const j of candidates) {\n        if (j === from) continue;\n        const tx = pts[j][0] - fx;\n        const ty = pts[j][1] - fy;\n        const along = tx * dx + ty * dy;\n        if (along <= 0.5) continue;\n        const score = along + Math.abs(tx * dy - ty * dx) * 2;\n        if (score < bestScore) {\n          bestScore = score;\n          best = j;\n        }\n      }\n      return best;\n    };\n    const viaLink = pick(graph.nodes[from].neighbours);\n    return viaLink >= 0 ? viaLink : pick(graph.nodes.map((_, j) => j));\n  };\n\n  const onNodeKeyDown = (event: React.KeyboardEvent, i: number) => {\n    const dirs: Record<string, Point> = { ArrowRight: [1, 0], ArrowLeft: [-1, 0], ArrowDown: [0, 1], ArrowUp: [0, -1] };\n    const dir = dirs[event.key];\n    if (!dir) return;\n    event.preventDefault();\n    const next = nearestIn(i, dir[0], dir[1]);\n    if (next >= 0) focusNode(next);\n  };\n\n  /** Keeps a keyboard-focused node inside the box, panning if it was out of view. */\n  const revealNode = (i: number) => {\n    const sim = simRef.current;\n    const [x, y] = sim ? [sim.bodies[i].x, sim.bodies[i].y] : positions[i];\n    const sx = W / 2 + view.px + x * view.k;\n    const sy = H / 2 + view.py + y * view.k;\n    const m = 32;\n    if (sx >= m && sx <= W - m && sy >= m && sy <= H - m) return;\n    movedRef.current = true;\n    setView({ ...view, px: r2(-x * view.k), py: r2(-y * view.k) });\n  };\n\n  const zoomBy = (factor: number) => {\n    movedRef.current = true;\n    const k = Math.min(MAX_K, Math.max(MIN_K, view.k * factor));\n    setView({ k: Math.round(k * 1000) / 1000, px: r2((view.px * k) / view.k), py: r2((view.py * k) / view.k) });\n  };\n\n  const onRootKeyDown = (event: React.KeyboardEvent) => {\n    if (event.altKey || event.ctrlKey || event.metaKey) return;\n    if (event.key === \"+\" || event.key === \"=\") zoomBy(1.25);\n    else if (event.key === \"-\" || event.key === \"_\") zoomBy(0.8);\n    else if (event.key === \"0\") fitNow();\n    else return;\n    event.preventDefault();\n  };\n\n  /* --------------------------------- render -------------------------------- */\n\n  const activeNode = active === null ? null : graph.nodes[active];\n  const showLabel = (i: number) =>\n    labels === \"all\" || i === active || (labels === \"auto\" && (graph.nodes[i].hub || (lit !== null && lit.has(i))));\n\n  const buttonClass =\n    \"grid size-7 place-items-center rounded-md text-zinc-600 transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-950 outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--fg-accent)] dark:text-zinc-300 dark:hover:bg-white/10 dark:hover:text-white\";\n\n  return (\n    <div\n      className={`flex w-full min-w-0 flex-col gap-2 text-zinc-950 dark:text-zinc-50 ${className}`}\n      style={{ [\"--fg-accent\" as string]: accent, ...style }}\n      onKeyDown={onRootKeyDown}\n      {...props}\n    >\n      <div\n        ref={boxRef}\n        className=\"relative w-full overflow-hidden rounded-xl border border-zinc-900/10 bg-zinc-900/[0.015] dark:border-white/10 dark:bg-white/[0.02]\"\n        style={{ height: H }}\n      >\n        <svg\n          ref={svgRef}\n          width={W}\n          height={H}\n          role=\"group\"\n          aria-label={label}\n          aria-roledescription=\"network graph\"\n          aria-describedby={hintId}\n          className=\"block cursor-grab touch-none select-none active:cursor-grabbing\"\n          onPointerLeave={(e) => e.pointerType === \"mouse\" && setHoverId(null)}\n        >\n          <g ref={viewportRef} transform={viewTransform(view, W, H)}>\n            <g aria-hidden=\"true\" strokeLinecap=\"round\">\n              {graph.links.map((l, i) => {\n                const on = lit !== null && (l.source === active || l.target === active);\n                const [x1, y1] = positions[l.source];\n                const [x2, y2] = positions[l.target];\n                return (\n                  <line\n                    key={`${l.source}-${l.target}`}\n                    data-fg-link={i}\n                    x1={x1}\n                    y1={y1}\n                    x2={x2}\n                    y2={y2}\n                    stroke={on ? \"var(--fg-accent)\" : \"currentColor\"}\n                    strokeWidth={r2((on ? 1.6 : 1) * Math.min(4, 0.8 + Math.sqrt(l.value) * 0.4))}\n                    vectorEffect=\"non-scaling-stroke\"\n                    className={`text-zinc-400 transition-[stroke-opacity] duration-200 motion-reduce:transition-none dark:text-zinc-500 ${\n                      lit === null ? \"[stroke-opacity:0.5]\" : on ? \"[stroke-opacity:0.95]\" : \"[stroke-opacity:0.12]\"\n                    }`}\n                  />\n                );\n              })}\n            </g>\n            <g role=\"list\" aria-label={`${label} nodes`}>\n              {graph.nodes.map((n, i) => {\n                const [x, y] = positions[i];\n                const dim = lit !== null && !lit.has(i);\n                return (\n                  <g\n                    key={n.id}\n                    data-fg-node={i}\n                    transform={`translate(${x},${y})`}\n                    className={`group cursor-pointer transition-opacity duration-200 motion-reduce:transition-none ${dim ? \"opacity-25\" : \"opacity-100\"}`}\n                  >\n                    {/* Focus ring, keyboard focus only. */}\n                    <circle\n                      aria-hidden=\"true\"\n                      r={n.radius + 4}\n                      fill=\"none\"\n                      strokeWidth={2}\n                      vectorEffect=\"non-scaling-stroke\"\n                      className=\"pointer-events-none stroke-zinc-950 opacity-0 group-has-[:focus-visible]:opacity-100 dark:stroke-white\"\n                    />\n                    <circle\n                      data-fg-focus={i}\n                      role=\"listitem\"\n                      tabIndex={0}\n                      aria-label={`${n.label}${n.group ? `, ${n.group}` : \"\"}: ${n.degree} ${n.degree === 1 ? \"connection\" : \"connections\"}`}\n                      r={n.radius}\n                      fill={n.color}\n                      strokeWidth={i === active ? 2.5 : 1.5}\n                      vectorEffect=\"non-scaling-stroke\"\n                      className={`outline-none ${i === active ? \"stroke-[var(--fg-accent)]\" : \"stroke-white dark:stroke-[#131619]\"}`}\n                      onPointerEnter={(e) => e.pointerType === \"mouse\" && setHoverId(i)}\n                      onPointerLeave={(e) => e.pointerType === \"mouse\" && setHoverId(null)}\n                      onFocus={(e) => {\n                        setFocusId(i);\n                        if (e.currentTarget.matches(\":focus-visible\")) revealNode(i);\n                      }}\n                      onBlur={() => setFocusId(null)}\n                      onKeyDown={(e) => onNodeKeyDown(e, i)}\n                    />\n                    {showLabel(i) && (\n                      <text\n                        aria-hidden=\"true\"\n                        x={n.radius + 5}\n                        dy=\"0.35em\"\n                        className={`pointer-events-none fill-current text-[11px] tracking-[-0.01em] [paint-order:stroke] [stroke-linejoin:round] [stroke-width:3px] stroke-white/80 dark:stroke-black/60 ${\n                          i === active ? \"font-semibold\" : \"font-medium\"\n                        }`}\n                        style={{ fontSize: r2(11 / Math.max(0.6, view.k)) }}\n                      >\n                        {n.label}\n                      </text>\n                    )}\n                  </g>\n                );\n              })}\n            </g>\n          </g>\n        </svg>\n\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-3 top-3 max-w-[60%] rounded-md border border-zinc-900/10 bg-white/85 px-2.5 py-1.5 backdrop-blur-sm dark:border-white/10 dark:bg-zinc-900/85\"\n        >\n          {activeNode ? (\n            <>\n              <span className=\"block truncate text-[13px] font-semibold tracking-[-0.01em]\">{activeNode.label}</span>\n              <span className=\"block font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400\">\n                {activeNode.group ? `${activeNode.group} · ` : \"\"}\n                {activeNode.degree} {activeNode.degree === 1 ? \"link\" : \"links\"}\n              </span>\n            </>\n          ) : (\n            <span className=\"block font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400\">\n              {graph.nodes.length} nodes · {graph.links.length} links\n            </span>\n          )}\n        </div>\n\n        <div\n          role=\"group\"\n          aria-label=\"Zoom\"\n          className=\"absolute right-3 top-3 flex items-center gap-0.5 rounded-lg border border-zinc-900/10 bg-white/85 p-0.5 backdrop-blur-sm dark:border-white/10 dark:bg-zinc-900/85\"\n        >\n          <button type=\"button\" aria-label=\"Zoom out\" className={buttonClass} onClick={() => zoomBy(0.8)}>\n            <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" aria-hidden=\"true\">\n              <path d=\"M3.5 8h9\" />\n            </svg>\n          </button>\n          <button type=\"button\" aria-label=\"Zoom in\" className={buttonClass} onClick={() => zoomBy(1.25)}>\n            <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" aria-hidden=\"true\">\n              <path d=\"M3.5 8h9M8 3.5v9\" />\n            </svg>\n          </button>\n          <button type=\"button\" aria-label=\"Fit graph to view\" className={buttonClass} onClick={() => fitNow()}>\n            <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" aria-hidden=\"true\">\n              <path d=\"M2.5 6V2.5H6M10 2.5h3.5V6M13.5 10v3.5H10M6 13.5H2.5V10\" />\n            </svg>\n          </button>\n        </div>\n      </div>\n\n      <p id={hintId} className=\"sr-only\">\n        Tab moves through the nodes. Arrow keys jump to the nearest node in that direction, preferring connected ones. Plus and minus zoom, 0 fits the graph. With a pointer, drag nodes or the background, and zoom with Ctrl or Command and the wheel.\n      </p>\n      <div className=\"sr-only\">\n        <p>{label}: adjacency list</p>\n        <ul>\n          {graph.nodes.map((n) => (\n            <li key={n.id}>\n              {n.label}\n              {n.group ? ` (${n.group})` : \"\"}:{\" \"}\n              {n.neighbours.length ? `connected to ${n.neighbours.map((j) => graph.nodes[j].label).join(\", \")}` : \"no connections\"}\n            </li>\n          ))}\n        </ul>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/force-graph"
}
