/* global React, Icon, Avatar, TONES, DEGREE, scoreBand, avColors, PERSONA_DEFS, clusterPeople, primaryOf */
// ============================================================
// Get introduced — the UNIFIED MAP graph (organism: NetworkMapGraph).
//
// Layout is driven by d3-force (the standard graph-physics engine), NOT by
// hand-rolled trig. We only feed it a structured seed + a set of forces; d3
// settles the positions so every edge connects center-to-center and nodes
// never overlap. Node VISUALS are our own DOM/SVG overlay drawn on top of the
// simulated positions, so we keep full control of the design.
//
//   Forces:  link (holds the hierarchy) · charge (spread) · collide (no
//   overlap) · radial (You=0 → contacts ring → clusters outer ring) · a
//   custom "fan" force that arcs an expanded company's people outward.
//
// You → your 1st-degree contacts (inner ring) → company CLUSTERS (outer ring).
// A company expands in place into the people you can reach there. A
// specifically-targeted individual is a 1-person cluster. A company with no
// path sits detached in the outer ring, faded, with no connecting line.
// Nodes are draggable; the rest of the graph reflows around them.
// ============================================================
const { useState: useGm, useRef: useGmRef, useEffect: useGmEff, useMemo: useGmMemo } = React;
const D3 = typeof window !== "undefined" ? window.d3 : null;

// ---- Real company marks. Downloaded once into /assets/logos as monochrome
// ink SVGs (the brand silhouette, recolored to our foreground ink so the map
// stays cohesive instead of a clash of brand colors). Keyed by the display
// name used across the network data. A company with no mark on file falls back
// to a serif monogram tile — same visual family, so the mix reads intentional.
const LOGO_SLUGS = {
  Stripe: "stripe", Linear: "linear", Shopify: "shopify", Gusto: "gusto", Vercel: "vercel",
  Brex: "brex", Adyen: "adyen", Wise: "wise", Coinbase: "coinbase", Datadog: "datadog",
  Robinhood: "robinhood", Reddit: "reddit", Loom: "loom", Airbnb: "airbnb", Notion: "notion",
  Webflow: "webflow", Discord: "discord", Figma: "figma",
};
const LOGO_BASE = "../../assets/logos/";

// A company's identity mark inside a cluster tile: the real logo when we have
// one, else a serif monogram. onError degrades a broken image to the monogram
// so the tile is never blank.
function CompanyLogo({ name, size = 26, muted = false }) {
  const slug = LOGO_SLUGS[name];
  const [broken, setBroken] = useGm(false);
  if (slug && !broken) {
    return (
      <img src={LOGO_BASE + slug + ".svg"} alt={name + " logo"} width={size} height={size}
        onError={() => setBroken(true)} draggable={false}
        style={{ width: size, height: size, objectFit: "contain", display: "block", opacity: muted ? 0.42 : 1, pointerEvents: "none" }}/>
    );
  }
  return (
    <span style={{ fontFamily: "var(--font-display)", fontSize: Math.round(size * 0.78), fontWeight: 600, lineHeight: 1, color: muted ? "var(--fg-3)" : "var(--fg-1)" }}>{name[0]}</span>
  );
}

const degTone = (d) => (DEGREE[d] ? DEGREE[d].tone : "neutral");
const idOf = (v) => (v && typeof v === "object" ? v.id : v);

// ---- Build the graph model (nodes + links) from the product data. Positions
// are SEEDED on a radial skeleton, then relaxed by the simulation. Previous
// positions are carried over (via posRef) so filtering / expanding doesn't
// teleport the whole graph. ----
function buildGraph({ clusters, connectors, expandedId, size, maxTotal, prev }) {
  const cx = size.w / 2, cy = size.h / 2;
  const base = Math.min(size.w, size.h);
  const R1 = base * 0.20, R2 = base * 0.40, R3 = base * 0.47;

  const detached = clusters.filter(c => c.isolated);
  const linked = clusters.filter(c => !c.isolated);

  // Angular sectors, one per introducer (+ a "direct" sector), weighted by size.
  const groups = [];
  connectors.forEach(c => {
    const items = linked.filter(x => x.via === c.id);
    if (items.length) groups.push({ id: c.id, intro: c, items });
  });
  const directItems = linked.filter(x => x.via === "direct");
  if (directItems.length) groups.push({ id: "direct", intro: null, items: directItems });

  const weights = groups.map(g => Math.max(g.items.length, 0.8));
  const totalW = weights.reduce((a, b) => a + b, 0) || 1;

  const nodes = [], links = [], nodeById = {};
  const push = (n) => { nodes.push(n); nodeById[n.id] = n; return n; };

  push({ id: "you", kind: "you", vr: 27, r: 46, fx: cx, fy: cy, x: cx, y: cy });

  let a = -Math.PI / 2;
  groups.forEach((g, gi) => {
    const span = 2 * Math.PI * (weights[gi] / totalW);
    const mid = a + span / 2; a += span;
    let intro = null;
    if (g.intro) {
      const sz = 28 + Math.round((g.intro.total / (maxTotal || 1)) * 14);
      intro = push({ id: "in:" + g.intro.id, kind: "intro", data: g.intro, size: sz, vr: sz / 2, r: sz / 2 + 22,
        targetR: R1, x: cx + R1 * Math.cos(mid), y: cy + R1 * Math.sin(mid) });
      links.push({ id: "t-" + g.intro.id, source: "you", target: intro.id, kind: "trunk" });
    }
    const n = g.items.length, fan = Math.min(span * 0.82, 1.15);
    g.items.forEach((it, ii) => {
      const ta = n === 1 ? mid : mid - fan / 2 + fan * (ii / (n - 1));
      // Expanded company shrinks to a small X hub, so its vr must shrink too or
      // every edge (incoming + satellites) would stop ~15px short of the X button.
      // Its collision r GROWS instead, reserving the satellite-fan zone (~98px)
      // so other clusters don't crowd into the expanded company's people.
      push({ id: "cl:" + it.id, kind: "cluster", data: it, vr: expandedId === it.id ? 15 : 26, r: expandedId === it.id ? 120 : 44, viaId: g.id,
        targetR: R2, x: cx + R2 * Math.cos(ta), y: cy + R2 * Math.sin(ta) });
      links.push({ id: "e-" + it.id, source: g.intro ? intro.id : "you", target: "cl:" + it.id,
        kind: "cluster", tone: degTone(it.degree) });
    });
  });

  // Detached (no-path / isolated) companies — outer ring, no edge, faded.
  detached.forEach((it, i) => {
    const ang = -Math.PI / 2 + ((i + 0.5) / Math.max(1, detached.length)) * 2 * Math.PI + 0.35;
    push({ id: "cl:" + it.id, kind: "cluster", data: it, vr: 26, r: 44, detached: true,
      targetR: R3, x: cx + R3 * Math.cos(ang), y: cy + R3 * Math.sin(ang) });
  });

  // Expanded company → its reachable people (satellites), fanned outward.
  const expNode = expandedId ? nodeById["cl:" + expandedId] : null;
  if (expNode && expNode.data.kind === "company") {
    const people = clusterPeople(expNode.data), nP = people.length;
    const outAng = Math.atan2(expNode.y - cy, expNode.x - cx);
    people.forEach((pp, ki) => {
      const seed = nP === 1 ? outAng : outAng - 0.85 + 1.7 * (ki / (nP - 1));
      push({ id: "sat:" + expandedId + ":" + pp.key, kind: "satellite", companyId: expNode.id,
        def: pp.def, persona: pp.persona, vr: 17, r: 30, fanIndex: ki, fanCount: nP,
        x: expNode.x + 86 * Math.cos(seed), y: expNode.y + 86 * Math.sin(seed) });
      links.push({ id: "s-" + expandedId + ":" + pp.key, source: expNode.id,
        target: "sat:" + expandedId + ":" + pp.key, kind: "satellite", tone: degTone(primaryOf(pp.persona).degree) });
    });
  }

  // Carry over prior positions for nodes that persist.
  if (prev) nodes.forEach(n => {
    const p = prev[n.id];
    if (p) { n.x = p.x; n.y = p.y; n.vx = p.vx; n.vy = p.vy; }
    if (n.kind === "you") { n.fx = cx; n.fy = cy; }
  });

  return { nodes, links, nodeById, cx, cy, R2, R3, expNode: expNode && expNode.data.kind === "company" ? expNode : null };
}

// NOTE: satellites are NOT physics bodies. A company's people are placed
// deterministically on a fixed-radius fan around the company at render time
// (see the satellite pre-pass in NetworkMapGraph), computed identically for the
// node and its edge — so every satellite link always connects, with no
// physics tug-of-war and no degenerate zero-length stubs.

// ---- Focus: given a hovered/selected token, which nodes + edges light up ----
function computeFocus(focus, graph) {
  if (!focus) return null;
  const N = new Set(["you"]), E = new Set();
  const addCluster = (raw) => {
    const cn = graph.nodeById["cl:" + raw]; if (!cn) return;
    N.add(cn.id); E.add("e-" + raw);
    if (cn.viaId && cn.viaId !== "direct") { N.add("in:" + cn.viaId); E.add("t-" + cn.viaId); }
    graph.nodes.forEach(s => { if (s.kind === "satellite" && s.companyId === cn.id) { N.add(s.id); E.add(s.id.replace("sat:", "s-")); } });
  };
  if (focus === "you") { graph.nodes.forEach(n => { if (n.kind === "cluster" && n.viaId === "direct") addCluster(n.data.id); }); return { N, E }; }
  const intro = graph.nodeById["in:" + focus];
  if (intro) { N.add(intro.id); E.add("t-" + focus); graph.nodes.forEach(n => { if (n.kind === "cluster" && n.viaId === focus) addCluster(n.data.id); }); return { N, E }; }
  const sat = graph.nodes.find(s => s.id === focus && s.kind === "satellite");
  if (sat) { N.add(sat.id); E.add(sat.id.replace("sat:", "s-")); const c = graph.nodeById[sat.companyId]; if (c) addCluster(c.data.id); return { N, E }; }
  if (graph.nodeById["cl:" + focus]) { addCluster(focus); return { N, E }; }
  return { N, E };
}

// Curved edge from a→b, trimmed to each node's visual radius so it meets the
// circle border rather than the center.
function edgePath(a, b, curve) {
  const dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1;
  const ux = dx / len, uy = dy / len;
  // Cap each trim at 40% of the edge length so a short link can never reverse
  // into a floating stub — it always renders forward, a→b.
  const trimA = Math.min(a.vr + 2, len * 0.4);
  const trimB = Math.min(b.vr + 3, len * 0.4);
  const ax = a.x + ux * trimA, ay = a.y + uy * trimA;
  const bx = b.x - ux * trimB, by = b.y - uy * trimB;
  const mx = (ax + bx) / 2, my = (ay + by) / 2;
  const cxp = mx - uy * len * curve, cyp = my + ux * len * curve;
  return `M ${ax} ${ay} Q ${cxp} ${cyp} ${bx} ${by}`;
}

function NetworkMapGraph({ clusters, connectors, expandedId, onToggleCluster, onOpenPerson, focusId, onFocusIntro, locked, spotlightId, zoom, pan, onZoom, onPan }) {
  const wrapRef = useGmRef(null);
  const [size, setSize] = useGm({ w: 900, h: 600 });
  const [hover, setHover] = useGm(null);
  const [, setTick] = useGm(0);
  const simRef = useGmRef(null);
  const posRef = useGmRef({});
  const dragRef = useGmRef(null);
  const panDrag = useGmRef(null);

  // live values for the drag math (avoid stale closures)
  const offRef = useGmRef({ x: 0, y: 0 });
  const panRef = useGmRef(pan); panRef.current = pan;
  const zoomRef = useGmRef(zoom); zoomRef.current = zoom;
  const sizeRef = useGmRef(size); sizeRef.current = size;

  useGmEff(() => {
    const el = wrapRef.current; if (!el) return;
    const measure = () => { const r = el.getBoundingClientRect(); setSize({ w: Math.max(320, r.width), h: Math.max(320, r.height) }); };
    measure();
    if (typeof ResizeObserver === "undefined") { window.addEventListener("resize", measure); return () => window.removeEventListener("resize", measure); }
    const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect();
  }, []);

  const maxTotal = useGmMemo(() => Math.max(1, ...connectors.map(c => c.total)), [connectors]);

  // Structural signature: rebuild the model only when the STRUCTURE changes
  // (not on every pan/zoom/hover render).
  const sig = clusters.map(c => c.id).join(",") + "|" + connectors.map(c => c.id).join(",") + "|" + (expandedId || "") + "|" + size.w + "x" + size.h;
  const graph = useGmMemo(() => buildGraph({ clusters, connectors, expandedId, size, maxTotal, prev: posRef.current }), [sig]); // eslint-disable-line

  // Run the simulation whenever the model changes.
  useGmEff(() => {
    if (!D3) { setTick(t => t + 1); return; } // no engine → static seed layout
    const g = graph;
    // Satellites are excluded from the simulation entirely (placed
    // deterministically at render time). The sim lays out you / contacts /
    // clusters only, so satellite links never fight the physics.
    const simNodes = g.nodes.filter(n => n.kind !== "satellite");
    const simLinks = g.links.filter(l => l.kind !== "satellite");
    const sim = D3.forceSimulation(simNodes)
      .force("link", D3.forceLink(simLinks).id(d => d.id)
        .distance(l => l.kind === "trunk" ? g.R2 * 0.5 : g.R2 * 0.52)
        .strength(0.12))
      .force("charge", D3.forceManyBody().strength(-150).distanceMax(simRange(g) * 0.9))
      .force("collide", D3.forceCollide().radius(d => d.r + 6).iterations(2))
      .force("radial", D3.forceRadial(d => d.targetR || 0, g.cx, g.cy)
        .strength(d => d.kind === "you" ? 0 : d.detached ? 0.6 : 0.32))
      .alpha(0.9).alphaDecay(0.03).velocityDecay(0.42);
    simRef.current = sim;
    sim.on("tick", () => {
      g.nodes.forEach(n => { posRef.current[n.id] = { x: n.x, y: n.y, vx: n.vx, vy: n.vy }; });
      setTick(t => t + 1);
    });
    return () => { sim.stop(); simRef.current = null; };
  }, [graph]); // eslint-disable-line

  const focus = hover || focusId || expandedId || spotlightId || null;
  const fx = useGmMemo(() => computeFocus(focus, graph), [focus, graph]);
  const on = (id) => !fx || fx.N.has(id);
  const onE = (k) => !fx || fx.E.has(k);

  // Deterministically fan an expanded company's people around it at a FIXED
  // radius, computed here (once per render, before the recenter offset) so the
  // node render and the edge draw use the exact same point. This is why every
  // satellite edge connects cleanly instead of collapsing into a stub.
  graph.nodes.forEach(n => {
    if (n.kind !== "satellite") return;
    const c = graph.nodeById[n.companyId]; if (!c) return;
    const outAng = Math.atan2(c.y - graph.cy, c.x - graph.cx);
    const spread = Math.min(1.9, 0.55 + n.fanCount * 0.3);
    const ang = n.fanCount <= 1 ? outAng : outAng - spread / 2 + spread * (n.fanIndex / (n.fanCount - 1));
    const R = 98;
    n.x = c.x + R * Math.cos(ang);
    n.y = c.y + R * Math.sin(ang);
  });

  // Recenter offset: frame an expanded company (+ its people) at the middle,
  // baked into rendered coords (like a layout translate, not a CSS hack).
  let off = { x: 0, y: 0 };
  if (graph.expNode) {
    const pts = [graph.expNode, ...graph.nodes.filter(n => n.kind === "satellite")];
    const mxp = pts.reduce((s, p) => s + p.x, 0) / pts.length;
    const myp = pts.reduce((s, p) => s + p.y, 0) / pts.length;
    off = { x: graph.cx - mxp, y: graph.cy - myp };
  }
  offRef.current = off;
  const P = (n) => ({ x: n.x + off.x, y: n.y + off.y, vr: n.vr });

  // ---- Node drag: pin the node under the pointer, reheat the sim, reflow ----
  const startNodeDrag = (nodeId, e, onClickAct) => {
    e.stopPropagation();
    const n = graph.nodeById[nodeId]; if (!n) return;
    const rect = wrapRef.current.getBoundingClientRect();
    const start = { x: e.clientX, y: e.clientY }; let moved = false;
    const toSim = (ev) => {
      const w = sizeRef.current.w, h = sizeRef.current.h, z = zoomRef.current, pn = panRef.current, o = offRef.current;
      const lx = (ev.clientX - rect.left - pn.x - w / 2) / z + w / 2;
      const ly = (ev.clientY - rect.top - pn.y - h / 2) / z + h / 2;
      return { x: lx - o.x, y: ly - o.y };
    };
    if (nodeId !== "you" && simRef.current) simRef.current.alphaTarget(0.3).restart();
    const move = (ev) => {
      if (Math.hypot(ev.clientX - start.x, ev.clientY - start.y) > 4) moved = true;
      if (nodeId === "you") return;
      const p = toSim(ev); n.fx = p.x; n.fy = p.y; n.x = p.x; n.y = p.y;
      setTick(t => t + 1); // dragged node tracks the pointer 1:1, independent of sim ticks
    };
    const up = () => {
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
      dragRef.current = null;
      if (simRef.current) simRef.current.alphaTarget(0);
      if (nodeId !== "you") { n.fx = null; n.fy = null; }
      if (!moved && onClickAct) onClickAct();
    };
    dragRef.current = { id: nodeId };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
  };

  // ---- Background pan / wheel zoom ----
  const wheel = (e) => { e.preventDefault(); const f = e.deltaY < 0 ? 1.08 : 0.926; onZoom(Math.max(0.55, Math.min(2.0, zoom * f))); };
  const down = (e) => { if (e.button !== 0 || dragRef.current) return; panDrag.current = { x: e.clientX, y: e.clientY, px: pan.x, py: pan.y }; };
  const move = (e) => { if (!panDrag.current) return; onPan({ x: panDrag.current.px + (e.clientX - panDrag.current.x), y: panDrag.current.py + (e.clientY - panDrag.current.y) }); };
  const up = () => { panDrag.current = null; };

  return (
    <div ref={wrapRef} style={{ position: "absolute", inset: 0, overflow: "hidden", cursor: panDrag.current ? "grabbing" : "grab" }}
      onWheel={wheel} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerLeave={up}>
      <div style={{ position: "absolute", inset: 0, transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, transformOrigin: "center center", transition: (panDrag.current || dragRef.current) ? "none" : "transform 200ms cubic-bezier(0.2,0.7,0.1,1)" }}>
        <svg width={size.w} height={size.h} style={{ position: "absolute", inset: 0, pointerEvents: "none", overflow: "visible" }}>
          {graph.links.map(l => {
            const a = graph.nodeById[idOf(l.source)], b = graph.nodeById[idOf(l.target)];
            if (!a || !b) return null;
            const lit = onE(l.id), dim = fx && !lit;
            const col = l.kind === "trunk" ? TONES.slate.color : TONES[l.tone || "slate"].color;
            return <path key={l.id} d={edgePath(P(a), P(b), l.kind === "satellite" ? 0.06 : 0.1)} fill="none" stroke={col}
              strokeWidth={lit && focus ? 3 : l.kind === "satellite" ? 1.6 : l.kind === "trunk" ? 2.4 : 2}
              strokeLinecap="round" style={{ opacity: dim ? 0.06 : focus ? 0.92 : 0.5, transition: "opacity 160ms, stroke-width 160ms" }}/>;
          })}
        </svg>

        {graph.nodes.map(n => {
          const p = P(n);
          if (n.kind === "you") return <YouNode key="you" x={p.x} y={p.y} dim={fx && !on("you")}/>;
          if (n.kind === "intro") {
            const raw = n.data.id;
            return <ContactNode key={n.id} n={n.data} size={n.size} x={p.x} y={p.y} active={focusId === raw} dim={fx && !on(n.id)}
              onHover={setHover} onDown={(e) => startNodeDrag(n.id, e, () => onFocusIntro(focusId === raw ? null : raw))}/>;
          }
          if (n.kind === "cluster") {
            const t = n.data;
            // Caption goes on the INWARD side (opposite the outward satellite fan),
            // where only the single thin incoming edge runs — never over the fan.
            const outA = Math.atan2(n.y - graph.cy, n.x - graph.cx);
            const labelDir = { x: -Math.cos(outA), y: -Math.sin(outA) };
            return <ClusterNode key={n.id} t={t} x={p.x} y={p.y} labelDir={labelDir} expanded={expandedId === t.id} detached={n.detached}
              dim={fx && !on(n.id)} hot={hover === t.id} show={hover === t.id || (!!fx && fx.N.has(n.id))} locked={locked}
              onHover={setHover} onDown={(e) => startNodeDrag(n.id, e, () => t.kind === "person" ? onOpenPerson(t.person.path, null) : onToggleCluster(expandedId === t.id ? null : t.id))}/>;
          }
          return <PersonaSatellite key={n.id} s={n} x={p.x} y={p.y} dim={fx && !on(n.id)} locked={locked}
            onHover={setHover} onDown={(e) => startNodeDrag(n.id, e, () => onOpenPerson(n.persona, n.def))}/>;
        })}
      </div>

      <Legend/>
      {!D3 && <span style={{ position: "absolute", right: 12, top: 10, fontSize: 10.5, color: "var(--fg-3)", fontFamily: "var(--font-mono)" }}>static layout · engine offline</span>}
    </div>
  );
}
const simRange = (g) => Math.max(g.R3 * 2, 600);

// ---- Nodes (visuals unchanged; positions come from the simulation) ----
const YouNode = ({ x, y, dim }) => (
  <div data-node="you" style={{ position: "absolute", left: x, top: y, transform: "translate(-50%,-50%)", display: "flex", flexDirection: "column", alignItems: "center", gap: 6, opacity: dim ? 0.4 : 1, transition: "opacity 160ms", zIndex: 5, pointerEvents: "none" }}>
    <span style={{ padding: 3, borderRadius: 999, background: "#fff", boxShadow: "0 0 0 2px var(--cardinal)" }}><Avatar name="Maya Okonkwo" color="var(--cardinal-50)" textColor="var(--cardinal-700)" size={48}/></span>
    <span style={{ fontSize: 11.5, fontWeight: 700, color: "var(--fg-1)" }}>You</span>
  </div>
);

// Your 1st-degree contacts — a bare avatar (a person you know), linked to You.
const ContactNode = ({ n, size, active, dim, x, y, onHover, onDown }) => (
  <div data-node={"in:" + n.id} onMouseEnter={() => onHover(n.id)} onMouseLeave={() => onHover(null)} onPointerDown={onDown}
    style={{ position: "absolute", left: x, top: y, transform: "translate(-50%,-50%)", display: "flex", flexDirection: "column", alignItems: "center", gap: 5, opacity: dim ? 0.28 : 1, transition: "opacity 160ms", cursor: "grab", zIndex: active ? 6 : 4, touchAction: "none" }}>
    <span style={{ position: "relative", padding: n.hero ? 2 : 0, borderRadius: 999, background: "#fff", boxShadow: n.hero ? "0 0 0 2px var(--cardinal)" : active ? "0 0 0 2px var(--slate)" : "none" }}>
      <Avatar name={n.name} {...avColors(n.tone)} size={size}/>
      {n.hero && <span style={{ position: "absolute", right: -4, bottom: -4, width: 18, height: 18, borderRadius: 999, background: "var(--cardinal)", border: "2px solid var(--paper)", display: "grid", placeItems: "center" }}><Icon name="zap" size={9} style={{ color: "#fff" }}/></span>}
    </span>
    <span style={{ fontSize: 10.5, fontWeight: 600, color: "var(--fg-1)", background: "rgba(246,244,239,0.9)", padding: "1px 6px", borderRadius: 5, whiteSpace: "nowrap" }}>{n.name.split(" ")[0]}</span>
  </div>
);

// A company cluster card. kind "company" expands into its people; kind "person"
// is a single targeted individual and opens its path directly.
const ClusterNode = ({ t, expanded, dim, hot, show, locked, detached, x, y, labelDir, onHover, onDown }) => {
  const band = scoreBand(t.best);
  const isPerson = t.kind === "person";
  if (expanded && !isPerson) {
    return (
      <div data-node={"cl:" + t.id} onMouseEnter={() => onHover(t.id)} onMouseLeave={() => onHover(null)} onPointerDown={onDown}
        style={{ position: "absolute", left: x, top: y, width: 26, height: 26, transform: "translate(-50%,-50%)", cursor: "grab", zIndex: 11, touchAction: "none" }}>
        <span title={"Collapse " + t.name} style={{ position: "absolute", inset: 0, borderRadius: 999, background: "#fff", border: "1.5px solid var(--cardinal)", boxShadow: "0 4px 12px rgba(31,27,22,0.12)", display: "grid", placeItems: "center", color: "var(--cardinal)" }}><Icon name="close" size={12}/></span>
        <span style={{ position: "absolute", left: `calc(50% + ${(labelDir ? labelDir.x : 0) * 20}px)`, top: `calc(50% + ${(labelDir ? labelDir.y : 1) * 20}px)`, transform: `translate(${(labelDir ? labelDir.x : 0) >= 0 ? "7px" : "calc(-100% - 7px)"}, ${(labelDir ? labelDir.y : 1) >= 0 ? "7px" : "calc(-100% - 7px)"})`, display: "inline-flex", alignItems: "center", gap: 6, fontSize: 10.5, fontWeight: 600, color: "var(--fg-1)", background: "rgba(246,244,239,0.94)", padding: "2px 8px", borderRadius: 6, whiteSpace: "nowrap", pointerEvents: "none" }}><Icon name="building" size={11} style={{ color: "var(--fg-2)" }}/>{t.name} · {t.count} reachable</span>
      </div>
    );
  }
  return (
    <div data-node={"cl:" + t.id} onMouseEnter={() => onHover(t.id)} onMouseLeave={() => onHover(null)} onPointerDown={onDown}
      style={{ position: "absolute", left: x, top: y, transform: `translate(-50%,-50%) scale(${expanded ? 1.06 : 1})`, display: "flex", flexDirection: "column", alignItems: "center", gap: 5, cursor: "grab", opacity: dim ? 0.26 : detached ? 0.6 : 1, transition: "opacity 160ms, transform 160ms", zIndex: expanded ? 9 : hot ? 7 : 2, touchAction: "none" }}>
      <div style={{ position: "relative", width: 48, height: 48 }}>
        {!isPerson && !detached && <div style={{ position: "absolute", inset: 0, borderRadius: 12, background: "#fff", border: "1px solid var(--ink-100)", transform: "translate(4px, 4px)", boxShadow: "var(--shadow-resting)" }}/>}
        <div style={{ position: "absolute", inset: 0, borderRadius: 12, background: "#fff", border: expanded ? "1.5px solid var(--cardinal)" : detached ? "1.5px dashed var(--ink-300)" : "1px solid var(--ink-100)", boxShadow: expanded || hot ? "0 8px 20px rgba(31,27,22,0.14)" : detached ? "none" : "var(--shadow-resting)", display: "grid", placeItems: "center" }}>
          <CompanyLogo name={t.name} size={26} muted={detached}/>
        </div>
        {detached
          ? <span title="No path yet" style={{ position: "absolute", right: -7, top: -7, minWidth: 19, height: 19, padding: "0 5px", borderRadius: 999, background: "#fff", color: "var(--fg-3)", border: "1px solid var(--ink-100)", display: "grid", placeItems: "center", fontSize: 10.5, fontWeight: 700 }}>?</span>
          : isPerson
          ? <span style={{ position: "absolute", right: -8, bottom: -8 }}><span style={{ display: "block", borderRadius: 999, border: "2px solid var(--paper)" }}><Avatar name={t.person.name} {...avColors(t.tone)} size={22}/></span></span>
          : <span style={{ position: "absolute", right: -7, top: -7, minWidth: 19, height: 19, padding: "0 5px", borderRadius: 999, background: TONES[band.tone].color, color: "#fff", border: "2px solid var(--paper)", display: "grid", placeItems: "center", fontSize: 10.5, fontWeight: 700, fontFamily: "var(--font-mono)" }}>{t.count}</span>}
        {locked && !detached && <span style={{ position: "absolute", left: -6, top: -6, width: 17, height: 17, borderRadius: 999, background: "var(--cardinal)", border: "2px solid var(--paper)", display: "grid", placeItems: "center", zIndex: 2 }}><Icon name="lock" size={9} style={{ color: "#fff" }}/></span>}
        {!isPerson && !detached && <span style={{ position: "absolute", left: "50%", bottom: -6, transform: "translateX(-50%)", width: 18, height: 18, borderRadius: 999, background: "#fff", border: "1px solid var(--ink-100)", display: "grid", placeItems: "center", color: "var(--fg-2)", zIndex: 2 }}><Icon name={expanded ? "close" : "plus"} size={10}/></span>}
      </div>
      {(show || detached) && !expanded && <span style={{ marginTop: 6, fontSize: hot ? 11.5 : 10.5, fontWeight: 600, color: detached ? "var(--fg-2)" : "var(--fg-1)", background: "rgba(246,244,239,0.92)", padding: "1px 6px", borderRadius: 5, whiteSpace: "nowrap", maxWidth: 170, overflow: "hidden", textOverflow: "ellipsis", textAlign: "center", zIndex: hot ? 10 : 1 }}>
        {t.name}{isPerson && hot ? <span style={{ display: "block", fontWeight: 500, color: "var(--fg-2)", fontSize: 10.5 }}>{t.person.name} · {t.person.title}</span> : null}{detached ? <span style={{ display: "block", fontWeight: 500, color: "var(--fg-3)", fontSize: 9.5 }}>no path yet</span> : null}
      </span>}
    </div>
  );
};

const PersonaSatellite = ({ s, dim, locked, x, y, onHover, onDown }) => {
  const pr = primaryOf(s.persona);
  const band = scoreBand(pr.score);
  const label = s.persona.uncertain ? "Dept. leaders" : s.def.label;
  const above = s.fanCount > 1 ? (s.fanIndex / (s.fanCount - 1)) < 0.5 : true;
  return (
    <div data-node={s.id} onMouseEnter={() => onHover(s.id)} onMouseLeave={() => onHover(null)} onPointerDown={onDown}
      className="cad-fade" style={{ position: "absolute", left: x, top: y, width: 34, height: 34, transform: "translate(-50%,-50%)", display: "grid", placeItems: "center", cursor: "grab", opacity: dim ? 0.3 : 1, transition: "opacity 160ms", zIndex: 10, touchAction: "none" }}>
      <ScoreRing name={pr.target.name} tone={pr.target.tone} band={band} score={pr.score} size={34} locked={locked}/>
      <span style={{ position: "absolute", left: "50%", transform: "translateX(-50%)", ...(above ? { bottom: "calc(100% + 4px)" } : { top: "calc(100% + 4px)" }), fontSize: 9.5, fontWeight: 600, color: "var(--fg-2)", background: "rgba(246,244,239,0.95)", padding: "0 5px", borderRadius: 4, whiteSpace: "nowrap" }}>{label}</span>
    </div>
  );
};

const ScoreRing = ({ name, tone, band, score, size = 44, flagship, locked }) => {
  const r = size / 2 - 2, c = 2 * Math.PI * r, off = c * (1 - (score || 0) / 100);
  return (
    <span style={{ position: "relative", width: size, height: size, display: "inline-grid", placeItems: "center" }}>
      <svg width={size} height={size} style={{ position: "absolute", inset: 0, transform: "rotate(-90deg)" }}>
        <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--ink-100)" strokeWidth={2.5}/>
        {!locked && score > 0 && <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={TONES[band.tone].color} strokeWidth={2.5} strokeDasharray={c} strokeDashoffset={off} strokeLinecap="round" style={{ transition: "stroke-dashoffset 320ms" }}/>}
      </svg>
      <span style={{ filter: locked ? "grayscale(1)" : "none", opacity: locked ? 0.6 : 1, boxShadow: flagship ? "0 0 0 2px var(--cardinal)" : "none", borderRadius: 999 }}><Avatar name={name} {...avColors(tone)} size={size - 12}/></span>
      {locked && <span style={{ position: "absolute", right: -3, bottom: -3, width: 15, height: 15, borderRadius: 999, background: "var(--cardinal)", border: "2px solid var(--paper)", display: "grid", placeItems: "center" }}><Icon name="lock" size={8} style={{ color: "#fff" }}/></span>}
    </span>
  );
};

// ---- Legend (complete, with hover tooltips) ----
const LEGEND = [
  { k: "direct", label: "Direct", tone: "sage", tip: "You already know this person. Reach out yourself, no intro needed." },
  { k: "warm", label: "Warm intro", tone: "slate", tip: "A contact you know can introduce you. The people in the middle ring are those contacts." },
  { k: "nopath", label: "No path yet", tone: "neutral", dashed: true, tip: "A target you have no route to yet. It stays parked in the outer ring, unconnected, until your network opens a path." },
];
const Legend = () => {
  const [tip, setTip] = useGm(null);
  return (
    <div style={{ position: "absolute", left: 14, bottom: 12, display: "flex", alignItems: "center", gap: 4, padding: "6px 8px", background: "rgba(255,255,255,0.85)", backdropFilter: "blur(8px)", border: "1px solid var(--ink-100)", borderRadius: 999, zIndex: 12 }}>
      {LEGEND.map(it => (
        <div key={it.k} onMouseEnter={() => setTip(it.k)} onMouseLeave={() => setTip(null)}
          style={{ position: "relative", display: "inline-flex", alignItems: "center", gap: 6, fontSize: 11, color: "var(--fg-2)", cursor: "help", padding: "2px 6px", borderRadius: 999, background: tip === it.k ? "var(--paper-sunken)" : "transparent" }}>
          <svg width="16" height="4" style={{ flexShrink: 0 }}><line x1="1" y1="2" x2="15" y2="2" stroke={TONES[it.tone].color} strokeWidth="2.5" strokeLinecap="round" strokeDasharray={it.dashed ? "2 3" : "none"}/></svg>
          {it.label}
          {tip === it.k && (
            <span className="cad-fade" style={{ position: "absolute", left: 0, bottom: "calc(100% + 9px)", width: 210, padding: "9px 11px", background: "#1F1B16", color: "#F6F4EF", borderRadius: 9, fontSize: 11.5, lineHeight: 1.45, boxShadow: "0 12px 32px rgba(31,27,22,0.28)", zIndex: 30 }}>
              {it.tip}
              <span style={{ position: "absolute", left: 18, top: "100%", width: 0, height: 0, borderLeft: "6px solid transparent", borderRight: "6px solid transparent", borderTop: "6px solid #1F1B16" }}/>
            </span>
          )}
        </div>
      ))}
    </div>
  );
};

window.NetworkMapGraph = NetworkMapGraph;
