const {
  useState: useS2,
  useEffect: useE2,
  useMemo: useM2,
  useRef: useR2,
  useCallback: useC2,
} = React;

// Evidence encoding
const EV_COLOR = {
  confirmed: "oklch(74% 0.14 155)", // green
  probable: "oklch(72% 0.14 230)", // blue
  inferred: "oklch(78% 0.14 70)", // amber
};
const EV_DASH = {
  confirmed: null,
  probable: "5 4",
  inferred: "2 3",
};

// Curved-path util: quadratic bezier with perpendicular offset
function curvePath(ax, ay, bx, by, curv) {
  const mx = (ax + bx) / 2;
  const my = (ay + by) / 2;
  const dx = bx - ax,
    dy = by - ay;
  const len = Math.sqrt(dx * dx + dy * dy) || 1;
  // perpendicular
  const px = -dy / len,
    py = dx / len;
  const cx = mx + px * curv;
  const cy = my + py * curv;
  return { d: `M${ax},${ay} Q${cx},${cy} ${bx},${by}`, cx, cy };
}

function RelationshipGraph({
  center,
  layout,
  activeThemes,
  highlightNews,
  onNode,
}) {
  const containerRef = useR2(null);
  const dimsRef = useR2({ w: 720, h: 520 });
  const [dimsTrigger, setDimsTrigger] = useS2(0);
  useE2(() => {
    const el = containerRef.current?.parentElement;
    if (!el) return;
    const obs = new ResizeObserver((entries) => {
      for (const e of entries) {
        dimsRef.current = {
          w: Math.max(e.contentRect.width, 400),
          h: Math.max(e.contentRect.height, 400),
        };
        setDimsTrigger((c) => c + 1);
      }
    });
    obs.observe(el);
    dimsRef.current = {
      w: el.clientWidth || 720,
      h: Math.max(el.clientHeight, 400),
    };
    setDimsTrigger((c) => c + 1);
    return () => obs.disconnect();
  }, []);
  const W = dimsRef.current.w,
    H = dimsRef.current.h;
  const svgRef = useR2(null);
  const [nodes, setNodes] = useS2([]);
  const [hover, setHover] = useS2(null);
  const [drag, setDrag] = useS2(null); // {id, dx, dy}
  const alphaRef = useR2(1);
  const draggedRef = useR2(false); // persists past pointerup so onClick can check it

  // Build the sub-universe: center + neighbors, extended by theme peers and 2-hop chains
  const { nodeList, edges } = useM2(() => {
    const include = new Set([center]);
    // direct neighbors
    window.LINKS.forEach((l) => {
      if (l.from === center) include.add(l.to);
      if (l.to === center) include.add(l.from);
    });
    // 2-hop downstream (center -> customer -> end user / external)
    const firstHop = [...include];
    firstHop.forEach((id) => {
      window.LINKS.forEach((l) => {
        if (l.from === id && l.type === "supplier") include.add(l.to);
      });
    });
    // upstream suppliers of center's direct customers (forms triangles)
    firstHop.forEach((id) => {
      window.LINKS.forEach((l) => {
        if (l.to === id && l.type === "supplier") include.add(l.from);
      });
    });
    // theme peers — add a couple from each theme the center belongs to
    const centerThemes = window.THEMES.filter((th) =>
      (th.tickers || []).includes(center),
    );
    centerThemes.forEach((th) => {
      th.tickers.slice(0, 5).forEach((t) => {
        if (window.STOCKS[t]) include.add(t);
      });
    });
    const list = [...include];
    const es = window.LINKS.filter(
      (l) => include.has(l.from) && include.has(l.to),
    ).map((l) => ({ ...l, key: `${l.from}->${l.to}` }));
    return { nodeList: list, edges: es };
  }, [center]);

  // Layout init
  useE2(() => {
    const rand = (s) => {
      s = (s * 9301 + 49297) % 233280;
      return s / 233280;
    };
    const cx = W / 2,
      cy = H / 2;
    let init;
    if (layout === "radial") {
      init = nodeList.map((id, i) => {
        if (id === center)
          return { id, x: cx, y: cy, vx: 0, vy: 0, fixed: true };
        const isExt = id.startsWith("ext_");
        // external entities pushed to outer ring
        const a = (i / nodeList.length) * Math.PI * 2;
        const r = isExt ? 210 : 150;
        return {
          id,
          x: cx + Math.cos(a) * r,
          y: cy + Math.sin(a) * r,
          vx: 0,
          vy: 0,
        };
      });
    } else if (layout === "hierarchy") {
      // columns by sector stage (Materials → Equipment → Manufacturer → Integrator → End User / External)
      const cols = [[], [], [], [], []];
      nodeList.forEach((id) => {
        if (id.startsWith("ext_")) {
          cols[4].push(id);
          return;
        }
        const s = window.STOCKS[id];
        if (!s) return;
        const order = {
          Materials: 0,
          Equipment: 1,
          Manufacturer: 2,
          Integrator: 3,
          "End User": 4,
        };
        cols[order[s.sector] ?? 2].push(id);
      });
      init = [];
      cols.forEach((col, ci) => {
        const colX = 70 + ci * ((W - 140) / 4);
        col.forEach((id, ri) => {
          const colY = 50 + (ri + 0.5) * ((H - 100) / Math.max(col.length, 1));
          init.push({
            id,
            x: colX,
            y: colY,
            vx: 0,
            vy: 0,
            fixed: id === center,
          });
        });
      });
    } else {
      // force: scattered start on a circle with jitter
      const nCount = nodeList.length;
      init = nodeList.map((id, i) => {
        if (id === center)
          return { id, x: cx, y: cy, vx: 0, vy: 0, fixed: true };
        const a = (i / Math.max(nCount - 1, 1)) * Math.PI * 2 + i * 1.37;
        const r = 140 + ((i * 53) % 90);
        return {
          id,
          x: cx + Math.cos(a) * r,
          y: cy + Math.sin(a) * r,
          vx: 0,
          vy: 0,
        };
      });
    }
    setNodes(init);
    alphaRef.current = 1;
  }, [center, layout, nodeList.join(",")]);

  // Simulation — runs continuously when layout==="force", cooling down
  useE2(() => {
    if (!nodes.length || layout !== "force") return;
    alphaRef.current = 1.0; // Reset energy on every restart
    let raf;
    let alive = true;
    const sim = () => {
      if (!alive) return;
      // Read current dimensions from ref (not stale closure)
      const cW = dimsRef.current.w,
        cH = dimsRef.current.h;
      setNodes((prev) => {
        const next = prev.map((n) => ({ ...n }));
        // Hard pair separation (pre-pass)
        const MIN_SEP = 52;
        for (let pass = 0; pass < 2; pass++) {
          for (let i = 0; i < next.length; i++) {
            for (let j = i + 1; j < next.length; j++) {
              const a = next[i],
                b = next[j];
              const dx = a.x - b.x,
                dy = a.y - b.y;
              const d = Math.sqrt(dx * dx + dy * dy) || 0.01;
              if (d < MIN_SEP) {
                const push = (MIN_SEP - d) / 2;
                const ux = dx / d,
                  uy = dy / d;
                if (!a.fixed) {
                  a.x += ux * push;
                  a.y += uy * push;
                }
                if (!b.fixed) {
                  b.x -= ux * push;
                  b.y -= uy * push;
                }
              }
            }
          }
        }
        // repulsion force (smooth long-range)
        for (let i = 0; i < next.length; i++) {
          for (let j = i + 1; j < next.length; j++) {
            const a = next[i],
              b = next[j];
            const dx = a.x - b.x,
              dy = a.y - b.y;
            const d2 = dx * dx + dy * dy + 1;
            const d = Math.sqrt(d2);
            const f = 12000 / d2;
            const fx = (dx / d) * f,
              fy = (dy / d) * f;
            if (!a.fixed) {
              a.vx += fx;
              a.vy += fy;
            }
            if (!b.fixed) {
              b.vx -= fx;
              b.vy -= fy;
            }
          }
        }
        // edge springs
        edges.forEach((e) => {
          const a = next.find((n) => n.id === e.from),
            b = next.find((n) => n.id === e.to);
          if (!a || !b) return;
          const dx = b.x - a.x,
            dy = b.y - a.y;
          const d = Math.sqrt(dx * dx + dy * dy) || 1;
          const target = 170;
          const f = (d - target) * 0.012 * (0.5 + e.strength);
          const fx = (dx / d) * f,
            fy = (dy / d) * f;
          if (!a.fixed) {
            a.vx += fx;
            a.vy += fy;
          }
          if (!b.fixed) {
            b.vx -= fx;
            b.vy -= fy;
          }
        });
        // weak gravity toward center
        next.forEach((n) => {
          if (n.fixed) return;
          n.vx += (cW / 2 - n.x) * 0.0008;
          n.vy += (cH / 2 - n.y) * 0.0008;
          n.vx *= 0.82;
          n.vy *= 0.82;
          n.x += n.vx * 0.6 * alphaRef.current;
          n.y += n.vy * 0.6 * alphaRef.current;
          n.x = Math.max(48, Math.min(cW - 48, n.x));
          n.y = Math.max(48, Math.min(cH - 48, n.y));
        });
        // Post-integration hard separation
        const POST_SEP = 58;
        for (let pass = 0; pass < 3; pass++) {
          for (let i = 0; i < next.length; i++) {
            for (let j = i + 1; j < next.length; j++) {
              const a = next[i],
                b = next[j];
              const dx = a.x - b.x,
                dy = a.y - b.y;
              const d = Math.sqrt(dx * dx + dy * dy) || 0.01;
              if (d < POST_SEP) {
                const push = (POST_SEP - d) / 2;
                const ux = dx / d,
                  uy = dy / d;
                if (!a.fixed) {
                  a.x += ux * push;
                  a.y += uy * push;
                }
                if (!b.fixed) {
                  b.x -= ux * push;
                  b.y -= uy * push;
                }
              }
            }
          }
        }
        return next;
      });
      alphaRef.current = Math.max(0.1, alphaRef.current * 0.998);
      if (alive) raf = requestAnimationFrame(sim);
    };
    raf = requestAnimationFrame(sim);
    return () => {
      alive = false;
      cancelAnimationFrame(raf);
    };
  }, [edges.length, layout, center, nodes.length]);

  // Drag handlers — convert screen → SVG coords
  const toSvg = (clientX, clientY) => {
    const svg = svgRef.current;
    if (!svg) return { x: 0, y: 0 };
    const pt = svg.createSVGPoint();
    pt.x = clientX;
    pt.y = clientY;
    const m = svg.getScreenCTM();
    if (!m) return { x: 0, y: 0 };
    const p = pt.matrixTransform(m.inverse());
    return { x: p.x, y: p.y };
  };

  const isTouchDevice =
    typeof window !== "undefined" &&
    ("ontouchstart" in window || navigator.maxTouchPoints > 0);
  const onPointerDown = (id) => (e) => {
    e.stopPropagation();
    // Skip drag on touch devices — causes scroll issues
    if (isTouchDevice) return;
    const n = nodes.find((nn) => nn.id === id);
    if (!n) return;
    const { x, y } = toSvg(e.clientX, e.clientY);
    setDrag({
      id,
      dx: x - n.x,
      dy: y - n.y,
      startX: e.clientX,
      startY: e.clientY,
      moved: false,
    });
    draggedRef.current = false;
    alphaRef.current = 1;
    e.currentTarget.setPointerCapture?.(e.pointerId);
  };
  const onPointerMove = (e) => {
    if (!drag) return;
    const { x, y } = toSvg(e.clientX, e.clientY);
    const moved =
      drag.moved ||
      Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) > 4;
    if (moved) {
      setNodes((prev) =>
        prev.map((n) =>
          n.id === drag.id
            ? {
                ...n,
                x: x - drag.dx,
                y: y - drag.dy,
                vx: 0,
                vy: 0,
                fixed: true,
                dragged: true,
              }
            : n,
        ),
      );
      alphaRef.current = 0.7;
      draggedRef.current = true;
      if (!drag.moved) setDrag((d) => (d ? { ...d, moved: true } : d));
    }
  };
  const onPointerUp = () => {
    if (!drag) return;
    setNodes((prev) =>
      prev.map((n) =>
        n.id === drag.id && n.id !== center ? { ...n, fixed: false } : n,
      ),
    );
    setDrag(null);
    // Keep draggedRef true through the upcoming click event; clear it on next tick
    setTimeout(() => {
      draggedRef.current = false;
    }, 0);
  };

  const findNode = (id) => nodes.find((n) => n.id === id);
  const centerThemes = window.THEMES.filter((th) =>
    (th.tickers || []).includes(center),
  );

  // Connectivity: set of nodes/edges connected to hover
  const connected = useM2(() => {
    if (!hover) return null;
    const ns = new Set([hover]);
    const es = new Set();
    edges.forEach((e, i) => {
      if (e.from === hover || e.to === hover) {
        ns.add(e.from);
        ns.add(e.to);
        es.add(e.key);
      }
    });
    return { ns, es };
  }, [hover, edges]);

  return (
    <svg
      ref={svgRef}
      viewBox={`0 0 ${W} ${H}`}
      className="rel-graph"
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
      onPointerLeave={onPointerUp}
    >
      <defs>
        <radialGradient id="hub-glow" cx="50%" cy="50%" r="50%">
          <stop offset="0" stopColor="oklch(82% 0.16 250)" stopOpacity="0.55" />
          <stop offset="1" stopColor="oklch(82% 0.16 250)" stopOpacity="0" />
        </radialGradient>
        <filter id="node-glow">
          <feGaussianBlur stdDeviation="3" />
        </filter>
        {Object.entries(EV_COLOR).map(([ev, col]) => (
          <marker
            key={ev}
            id={`arrow-${ev}`}
            viewBox="0 0 10 10"
            refX="9"
            refY="5"
            markerWidth="5"
            markerHeight="5"
            orient="auto"
          >
            <path d="M0 0 L10 5 L0 10 z" fill={col} />
          </marker>
        ))}
      </defs>

      {/* theme halos (radial) */}
      {layout === "radial" &&
        centerThemes.map((th, i) => (
          <circle
            key={th.id}
            cx={W / 2}
            cy={H / 2}
            r={150 + i * 22}
            fill="none"
            stroke={th.color}
            strokeOpacity="0.14"
            strokeDasharray="2 6"
          />
        ))}
      {/* hierarchy column labels */}
      {layout === "hierarchy" &&
        [
          "Materials",
          "Equipment",
          "Manufacturer",
          "Integrator",
          "End User",
        ].map((lbl, i) => (
          <text
            key={lbl}
            x={70 + i * ((W - 140) / 4)}
            y={22}
            className="rg-col-label"
            textAnchor="middle"
          >
            {lbl}
          </text>
        ))}

      {/* edges */}
      {(() => {
        // Dedupe chips: same product should only get ONE chip across the whole graph
        const shownProducts = new Set();
        const showChipFor = new Set();
        const sortedForChips = [...edges].sort((a, b) => {
          const aw =
            (a.evidence === "confirmed"
              ? 10
              : a.evidence === "probable"
                ? 5
                : 1) + (a.strength || 0);
          const bw =
            (b.evidence === "confirmed"
              ? 10
              : b.evidence === "probable"
                ? 5
                : 1) + (b.strength || 0);
          return bw - aw;
        });
        sortedForChips.forEach((e) => {
          if (!e.product) return;
          if (!shownProducts.has(e.product)) {
            shownProducts.add(e.product);
            showChipFor.add(e.key);
          }
        });
        return edges.map((e, i) => {
          const a = findNode(e.from),
            b = findNode(e.to);
          if (!a || !b) return null;
          const isHi = hover && (e.from === hover || e.to === hover);
          const dim = connected && !connected.es.has(e.key);
          const col = EV_COLOR[e.evidence] || "oklch(72% 0.1 280)";
          const dash = EV_DASH[e.evidence];

          // offset parallel edges slightly so they don't overlap
          const twins = edges.filter(
            (x) =>
              (x.from === e.from && x.to === e.to) ||
              (x.from === e.to && x.to === e.from),
          );
          const twinIdx = twins.findIndex((x) => x.key === e.key);
          const curv = (twinIdx - (twins.length - 1) / 2) * 22;

          const { d, cx, cy } = curvePath(a.x, a.y, b.x, b.y, curv);
          // compute arrow endpoint stopping short of node radius (~16 for company, ~10 ext)
          const isExtTarget = b.id.startsWith("ext_");
          const targetR = isExtTarget ? 12 : 18;
          // shorten path endpoint toward curve control
          const dx = b.x - cx,
            dy = b.y - cy;
          const dl = Math.sqrt(dx * dx + dy * dy) || 1;
          const endX = b.x - (dx / dl) * targetR;
          const endY = b.y - (dy / dl) * targetR;
          // rebuild path ending short
          const path = `M${a.x},${a.y} Q${cx},${cy} ${endX},${endY}`;

          const affected =
            highlightNews &&
            highlightNews.tickers?.includes(e.from) &&
            highlightNews.tickers?.includes(e.to);
          const strokeW = (isHi ? 2.2 : 1.1) + e.strength * 0.6;

          return (
            <g
              key={e.key}
              className={"rg-edge" + (dim ? " dim" : "") + (isHi ? " hi" : "")}
            >
              <path
                d={path}
                stroke={col}
                strokeOpacity={dim ? 0.08 : isHi ? 0.95 : 0.4}
                strokeWidth={strokeW}
                fill="none"
                strokeDasharray={dash || undefined}
                markerEnd={
                  e.type === "supplier"
                    ? `url(#arrow-${e.evidence})`
                    : undefined
                }
                strokeLinecap="round"
              />
              {affected && (
                <path
                  d={path}
                  stroke="oklch(88% 0.17 80)"
                  strokeWidth="2.4"
                  fill="none"
                >
                  <animate
                    attributeName="stroke-opacity"
                    values="0.9;0;0.9"
                    dur="1.6s"
                    repeatCount="indefinite"
                  />
                </path>
              )}
              {/* Product label chip — only ONE per product name across the graph */}
              {e.product && showChipFor.has(e.key) && (
                <g className="rg-edge-chip" opacity={dim ? 0 : isHi ? 1 : 0}>
                  <rect
                    x={cx - e.product.length * 3.1}
                    y={cy - 7}
                    width={e.product.length * 6.2}
                    height="14"
                    rx="3"
                    fill="var(--bg-1)"
                    stroke={col}
                    strokeOpacity={isHi ? 0.7 : 0.3}
                    strokeWidth="0.7"
                  />
                  <text
                    x={cx}
                    y={cy + 3.2}
                    textAnchor="middle"
                    className="rg-edge-label"
                    fill={isHi ? "var(--fg-0)" : "var(--fg-2)"}
                  >
                    {e.product}
                  </text>
                </g>
              )}
            </g>
          );
        });
      })()}

      {/* nodes */}
      {nodes.map((n) => {
        const isExt = n.id.startsWith("ext_");
        const stock = !isExt ? window.STOCKS[n.id] : null;
        const ext = isExt
          ? (window.EXTERNAL || {})[n.id] ||
            window.STOCKS[n.id] || {
              name: n.id.replace("ext_", "").replace(/_/g, " "),
            }
          : null;
        if (!stock && !ext) return null;
        const isC = n.id === center;
        const isHover = hover === n.id;
        const dim = connected && !connected.ns.has(n.id);
        const r = isExt
          ? 7
          : isC
            ? 22
            : 14 +
              Math.min(6, Math.log10(Math.max(1, stock.mcap || 1) / 400) * 2);
        const up = stock ? stock.chg >= 0 : false;
        const col = isExt
          ? "oklch(72% 0.02 260)"
          : up
            ? "oklch(78% 0.16 155)"
            : "oklch(68% 0.18 25)";
        const themes = !isExt
          ? window.THEMES.filter((th) => (th.tickers || []).includes(n.id))
          : [];
        const themeDim =
          activeThemes &&
          activeThemes.size > 0 &&
          !isExt &&
          !themes.some((th) => activeThemes.has(th.id));
        const nodeDim = dim || themeDim;

        return (
          <g
            key={n.id}
            transform={`translate(${n.x},${n.y})`}
            className={
              "rg-node" +
              (isC ? " c" : "") +
              (nodeDim ? " dim" : "") +
              (isExt ? " ext" : "")
            }
            onMouseEnter={() => setHover(n.id)}
            onMouseLeave={() => setHover(null)}
            onPointerDown={onPointerDown(n.id)}
            onClick={(e) => {
              if (isExt) return;
              if (draggedRef.current) return; // suppress nav after a drag
              onNode && onNode(n.id);
            }}
          >
            {isC && <circle r="56" fill="url(#hub-glow)" />}
            {isC && (
              <circle
                r="40"
                fill="none"
                stroke="oklch(82% 0.16 250)"
                strokeOpacity="0.35"
              >
                <animate
                  attributeName="r"
                  values="40;48;40"
                  dur="3s"
                  repeatCount="indefinite"
                />
                <animate
                  attributeName="stroke-opacity"
                  values="0.35;0.05;0.35"
                  dur="3s"
                  repeatCount="indefinite"
                />
              </circle>
            )}
            {isExt ? (
              <>
                {/* external: dashed hex-ish (just dashed circle) */}
                <circle
                  r={r}
                  fill="var(--bg-0)"
                  stroke={col}
                  strokeDasharray="2 2"
                  strokeWidth="1"
                />
                <circle r={r - 3} fill={col} fillOpacity="0.08" />
              </>
            ) : (
              <>
                <circle
                  r={r}
                  fill="var(--bg-1)"
                  stroke={col}
                  strokeWidth={isC ? 2 : 1.4}
                />
                <circle r={r - 3} fill={col} fillOpacity={isC ? 0.22 : 0.12} />
                {/* theme pie */}
                {themes.length > 1 &&
                  themes.map((th, i) => {
                    const a0 = (i / themes.length) * Math.PI * 2 - Math.PI / 2;
                    const a1 =
                      ((i + 1) / themes.length) * Math.PI * 2 - Math.PI / 2;
                    const x0 = Math.cos(a0) * (r + 2),
                      y0 = Math.sin(a0) * (r + 2);
                    const x1 = Math.cos(a1) * (r + 2),
                      y1 = Math.sin(a1) * (r + 2);
                    return (
                      <path
                        key={i}
                        d={`M${x0} ${y0} A${r + 2} ${r + 2} 0 0 1 ${x1} ${y1}`}
                        fill="none"
                        stroke={th.color}
                        strokeWidth="2.5"
                      />
                    );
                  })}
              </>
            )}

            {/* ticker/label */}
            {isExt ? (
              <text y={r + 11} textAnchor="middle" className="rg-ext-label">
                {ext.name}
              </text>
            ) : (
              <>
                {/* In-circle label: full company name, wrapped to fit */}
                {(() => {
                  const words = (stock.name || "").split(/\s+/);
                  const lines = [];
                  const maxChars = isC ? 11 : 9;
                  let cur = "";
                  for (const w of words) {
                    if (!cur) {
                      cur = w;
                      continue;
                    }
                    if ((cur + " " + w).length <= maxChars) cur += " " + w;
                    else {
                      lines.push(cur);
                      cur = w;
                    }
                  }
                  if (cur) lines.push(cur);
                  // if still too long single line, split by char
                  const out = [];
                  for (const ln of lines) {
                    if (ln.length <= maxChars) out.push(ln);
                    else {
                      for (let i = 0; i < ln.length; i += maxChars)
                        out.push(ln.slice(i, i + maxChars));
                    }
                  }
                  const shown = out.slice(0, isC ? 3 : 2);
                  if (out.length > shown.length)
                    shown[shown.length - 1] =
                      shown[shown.length - 1].slice(0, maxChars - 1) + "…";
                  const lh = isC ? 11 : 9.5;
                  const y0 = -((shown.length - 1) * lh) / 2 + (isC ? 3.5 : 3);
                  return shown.map((ln, i) => (
                    <text
                      key={i}
                      y={y0 + i * lh}
                      textAnchor="middle"
                      className={"rg-label" + (isC ? " c" : "")}
                    >
                      {ln}
                    </text>
                  ));
                })()}
                {/* Below-circle sublabel: ticker number */}
                <text y={r + 14} textAnchor="middle" className="rg-sublabel">
                  {n.id.replace(".T", "")}
                </text>
                {(isC || isHover) && (
                  <text
                    y={r + 26}
                    textAnchor="middle"
                    className="rg-chg"
                    fill={col}
                  >
                    {fmtPct(stock.chg)}
                  </text>
                )}
              </>
            )}
          </g>
        );
      })}

      {/* news ripple on affected nodes */}
      {highlightNews &&
        highlightNews.tickers?.map((tk) => {
          const n = findNode(tk);
          if (!n) return null;
          return (
            <circle
              key={tk + "ripple"}
              cx={n.x}
              cy={n.y}
              r="10"
              fill="none"
              stroke="oklch(88% 0.17 80)"
              pointerEvents="none"
            >
              <animate
                attributeName="r"
                values="10;48"
                dur="1.6s"
                repeatCount="indefinite"
              />
              <animate
                attributeName="stroke-opacity"
                values="0.9;0"
                dur="1.6s"
                repeatCount="indefinite"
              />
            </circle>
          );
        })}

      {/* Legend (inside SVG, bottom-left) */}
      <g transform={`translate(14, ${H - 70})`} className="rg-legend">
        <rect
          x="-4"
          y="-14"
          width="232"
          height="68"
          rx="6"
          fill="var(--bg-1)"
          fillOpacity="0.9"
          stroke="var(--bd-0)"
          strokeWidth="0.7"
        />
        <text x="2" y="0" className="rg-legend-title">
          EVIDENCE
        </text>
        {[
          ["confirmed", "Confirmed"],
          ["probable", "Probable"],
          ["inferred", "Inferred"],
        ].map(([k, l], i) => (
          <g key={k} transform={`translate(${2 + i * 74}, 14)`}>
            <line
              x1="0"
              y1="0"
              x2="18"
              y2="0"
              stroke={EV_COLOR[k]}
              strokeWidth="1.8"
              strokeDasharray={EV_DASH[k] || undefined}
            />
            <text x="22" y="3" className="rg-legend-lbl">
              {l}
            </text>
          </g>
        ))}
        <g transform="translate(2, 36)">
          <circle
            r="5"
            cx="5"
            cy="0"
            fill="var(--bg-1)"
            stroke={col_muted()}
            strokeWidth="1.2"
          />
          <text x="16" y="3" className="rg-legend-lbl">
            Tracked ticker
          </text>
        </g>
        <g transform="translate(108, 36)">
          <circle
            r="4.5"
            cx="5"
            cy="0"
            fill="var(--bg-0)"
            stroke={col_muted()}
            strokeDasharray="2 2"
            strokeWidth="1"
          />
          <text x="14" y="3" className="rg-legend-lbl">
            External entity
          </text>
        </g>
      </g>
    </svg>
  );
}

function col_muted() {
  return "oklch(72% 0.02 260)";
}

window.RelationshipGraph = RelationshipGraph;
