// H2Learn — graph engine (SVG, hand-tuned + light physics)
// Curated layouts so the network always reads beautifully.
//
// Exports: GraphCanvas, NodeShape, useGraphState, layouts (atlas/orbital/focus/antidup/needoffer/path)

(function() {
  const D = window.H2Data;

  // ============ NODE / EDGE DATA MODEL ============
  // We compose a graph of typed nodes with curated coordinates per-layout.
  // Edges carry: type, strength (0..1), confidence, and explanation.

  // Helper for cluster halos
  const Cluster = ({cx, cy, rx, ry, color, label, opacity=0.18}) => (
    <g>
      <ellipse cx={cx} cy={cy} rx={rx} ry={ry} fill={color} opacity={opacity}/>
      <ellipse cx={cx} cy={cy} rx={rx*0.95} ry={ry*0.95} fill="none" stroke={color} strokeOpacity={0.18} strokeDasharray="2 6" strokeWidth="1"/>
      {label && (
        <text x={cx} y={cy - ry - 6} textAnchor="middle"
              fontFamily="Instrument Serif, serif" fontSize="14" fill={color}
              opacity="0.85" letterSpacing="0.02em">{label}</text>
      )}
    </g>
  );

  // ============ NODE SHAPES (one per entity kind) ============
  const NodeShape = ({n, hovered, selected, recommended, dim, onClick, onHover, focal}) => {
    const c = window.NodeColor[n.kind] || '#16140f';
    const scale = (selected || hovered || focal) ? 1.12 : 1;
    const op = dim ? 0.22 : 1;
    const baseR = (n.r || 12);
    const r = baseR * scale;
    const isPerson = n.kind === 'person';
    const isProject = n.kind === 'project';
    const isOrg = n.kind === 'org';

    return (
      <g style={{cursor:'pointer', transition:'opacity 200ms'}}
         opacity={op}
         onClick={() => onClick && onClick(n)}
         onMouseEnter={() => onHover && onHover(n)}
         onMouseLeave={() => onHover && onHover(null)}>

        {/* recommend pulse */}
        {recommended && !selected && (
          <circle cx={n.x} cy={n.y} r={r + 8}
                  fill="none" stroke={c} strokeWidth="1.5"
                  className="pulse-glow" opacity="0.4"/>
        )}
        {/* selected ring */}
        {(selected || focal) && (
          <circle cx={n.x} cy={n.y} r={r + 7} fill="none" stroke={c} strokeWidth="2" opacity="0.5"/>
        )}
        {/* hover halo */}
        {hovered && !selected && (
          <circle cx={n.x} cy={n.y} r={r + 5} fill={c} opacity="0.12"/>
        )}

        {/* shape */}
        {isPerson && <PersonNode n={n} r={r} c={c} selected={selected} focal={focal}/>}
        {isProject && <ProjectNode n={n} r={r} c={c} selected={selected}/>}
        {isOrg && <OrgNode n={n} r={r} c={c} selected={selected}/>}
        {!isPerson && !isProject && !isOrg && (
          <circle cx={n.x} cy={n.y} r={r * 0.7} fill="#fff" stroke={c} strokeWidth="1.6"/>
        )}

        {/* label */}
        {n.label && (hovered || selected || focal || (n.alwaysLabel && !dim)) && (
          <g>
            <rect x={n.x - (n.label.length * 3.2 + 6)} y={n.y + r + 4}
                  width={n.label.length * 6.4 + 12} height="18" rx="4"
                  fill="var(--surface-2)" stroke="var(--line)" strokeWidth="0.8"/>
            <text x={n.x} y={n.y + r + 16}
                  textAnchor="middle" fontFamily="Inter Tight, sans-serif"
                  fontSize="11" fontWeight={selected||focal?600:500} fill="var(--ink)">
              {n.label}
            </text>
          </g>
        )}
      </g>
    );
  };

  const PersonNode = ({n, r, c, selected, focal}) => (
    <g>
      <circle cx={n.x} cy={n.y} r={r} fill="#fff" stroke={c} strokeWidth={focal?2.2:1.8}/>
      {focal && <circle cx={n.x} cy={n.y} r={r-3} fill="#fff7e6" stroke={c} strokeWidth="1"/>}
      <text x={n.x} y={n.y + 3} textAnchor="middle"
            fontFamily="Inter Tight, sans-serif" fontSize={r > 14 ? 10 : 9}
            fontWeight="600" fill={c}>
        {n.initials}
      </text>
    </g>
  );

  const ProjectNode = ({n, r, c}) => {
    // hexagon
    const s = r * 1.05;
    const pts = Array.from({length:6}).map((_,i) => {
      const a = -Math.PI/2 + i * Math.PI/3;
      return [n.x + s*Math.cos(a), n.y + s*Math.sin(a)].join(',');
    }).join(' ');
    return (
      <g>
        <polygon points={pts} fill="#fff" stroke={c} strokeWidth="1.8"/>
        <text x={n.x} y={n.y + 3} textAnchor="middle"
              fontFamily="Inter Tight, sans-serif" fontSize={r > 14 ? 10 : 9}
              fontWeight="600" fill={c}>
          {n.initials}
        </text>
      </g>
    );
  };

  const OrgNode = ({n, r, c}) => {
    // rounded square
    const s = r * 1.7;
    return (
      <g>
        <rect x={n.x - s/2} y={n.y - s/2} width={s} height={s} rx="3"
              fill="#fff" stroke={c} strokeWidth="1.8"/>
        <text x={n.x} y={n.y + 3} textAnchor="middle"
              fontFamily="Inter Tight, sans-serif" fontSize={r > 14 ? 10 : 9}
              fontWeight="600" fill={c}>
          {n.initials}
        </text>
      </g>
    );
  };

  // ============ EDGE ============
  const Edge = ({e, nodes, selected, hovered, dim, onClick}) => {
    const a = nodes[e.s], b = nodes[e.t];
    if (!a || !b) return null;
    const meta = window.RelMeta[e.type] || window.RelMeta.Similarity;
    const c = meta.color;
    const strength = e.strength || 0.5;
    const baseWidth = 0.8 + strength * 2.6;
    const isAlert = e.type === 'Anti-duplication';
    const isUncertain = e.type === 'Shared uncertainty';
    const isMethod = e.type === 'Method transfer';
    const dash = isAlert ? '5 4' : isUncertain ? '2 5' : isMethod ? '3 2' : null;
    const opacity = dim ? 0.06 : (selected ? 1 : (hovered ? 0.95 : 0.65 - (1-strength)*0.25));
    const w = (selected || hovered) ? baseWidth + 1.4 : baseWidth;

    // curve for non-similarity types
    const dx = b.x - a.x, dy = b.y - a.y;
    const dist = Math.sqrt(dx*dx + dy*dy);
    const curveFactor = (e.type === 'Bridge' || e.type === 'Complementarity') ? 0.18 : 0.05;
    const mx = (a.x+b.x)/2 - dy * curveFactor;
    const my = (a.y+b.y)/2 + dx * curveFactor;
    const d = `M ${a.x} ${a.y} Q ${mx} ${my} ${b.x} ${b.y}`;

    return (
      <g style={{cursor:'pointer'}} onClick={() => onClick && onClick(e)}>
        {/* glow on hover/selected */}
        {(selected || hovered) && (
          <path d={d} stroke={c} strokeWidth={w + 6} opacity="0.18" fill="none" strokeLinecap="round"/>
        )}
        {/* hit area */}
        <path d={d} stroke="transparent" strokeWidth="14" fill="none"/>
        <path d={d} stroke={c} strokeWidth={w} fill="none"
              strokeLinecap="round" strokeDasharray={dash}
              opacity={opacity}>
          {isAlert && !dim && <animate attributeName="stroke-dashoffset" from="0" to="-9" dur="1.8s" repeatCount="indefinite"/>}
        </path>
        {/* directional arrow on Need-offer / Method transfer */}
        {(e.type === 'Need-offer' || e.type === 'Method transfer' || e.type === 'Complementarity') && (
          <Arrowhead x={b.x} y={b.y} dx={dx} dy={dy} c={c} dim={dim}/>
        )}
      </g>
    );
  };

  const Arrowhead = ({x,y,dx,dy,c,dim}) => {
    const len = Math.sqrt(dx*dx + dy*dy);
    if (!len) return null;
    const ux = dx/len, uy = dy/len;
    // back the tip off the node
    const offset = 14;
    const tx = x - ux*offset, ty = y - uy*offset;
    const size = 5;
    const lx = tx - ux*size + uy*size*0.6;
    const ly = ty - uy*size - ux*size*0.6;
    const rx = tx - ux*size - uy*size*0.6;
    const ry = ty - uy*size + ux*size*0.6;
    return <polygon points={`${tx},${ty} ${lx},${ly} ${rx},${ry}`}
                    fill={c} opacity={dim?0.2:0.85}/>;
  };

  // ============ ATLAS LAYOUT ============
  // Force-directed look but hand-curated for hero quality.
  // Coordinates are within a ~1200 × 720 logical viewport.

  const ATLAS = {
    clusters: [
      { id:'cl_prod',  cx: 320, cy: 290, rx: 200, ry: 180, color: 'var(--c-person)',  label: 'Production / Materials' },
      { id:'cl_pilot', cx: 720, cy: 230, rx: 180, ry: 150, color: 'var(--c-project)', label: 'Projects / Pilots' },
      { id:'cl_edu',   cx: 940, cy: 480, rx: 200, ry: 170, color: 'var(--c-org)',     label: 'Education / Bridges' },
      { id:'cl_digital', cx: 540, cy: 540, rx: 180, ry: 140, color: 'var(--c-method)',label: 'Digital methods' },
    ],
    nodes: [
      // production cluster
      { id:'p_sara',     kind:'person',  initials:'SA', label:'Sara Arif',      x: 280, y: 280, r: 16, focal:true, alwaysLabel:true },
      { id:'p_liesbeth', kind:'person',  initials:'LK', label:'Liesbeth Klein', x: 380, y: 220, r: 14, alwaysLabel:true },
      { id:'p_bram',     kind:'person',  initials:'BJ', label:'Bram Janssen',   x: 220, y: 360, r: 12, alwaysLabel:true },
      { id:'d_pem',      kind:'domain',  initials:'',   label:'PEM',            x: 320, y: 180, r: 10 },
      { id:'d_membrane', kind:'domain',  initials:'',   label:'Membrane',       x: 200, y: 240, r: 9 },
      { id:'d_catalyst', kind:'domain',  initials:'',   label:'Catalysts',      x: 420, y: 320, r: 9 },
      { id:'m_lca',      kind:'method',  initials:'',   label:'LCA',            x: 350, y: 380, r: 8 },

      // pilot cluster
      { id:'pj_hydronl', kind:'project', initials:'HN', label:'HYDRO-NL Pilot',  x: 740, y: 230, r: 18, alwaysLabel:true },
      { id:'pj_greenh2', kind:'project', initials:'GH', label:'GreenH2-PEM',     x: 640, y: 160, r: 13, alwaysLabel:true },
      { id:'p_nora',     kind:'person',  initials:'NH', label:'Nora Helmus',     x: 820, y: 320, r: 13, alwaysLabel:true },
      { id:'o_greenlab', kind:'org',     initials:'GL', label:'GreenLab',        x: 800, y: 140, r: 12 },
      { id:'o_tno',      kind:'org',     initials:'TN', label:'TNO',             x: 540, y: 110, r: 12 },

      // education / bridges
      { id:'p_iteke',    kind:'person',  initials:'IH', label:'Iteke van Hille', x: 940, y: 460, r: 15, alwaysLabel:true },
      { id:'p_lori',     kind:'person',  initials:'LD', label:'Lori DiVito',     x: 980, y: 530, r: 13, alwaysLabel:true },
      { id:'p_frank',    kind:'person',  initials:'FB', label:'Frank de Bakker', x: 880, y: 540, r: 11 },
      { id:'pj_elyse',   kind:'project', initials:'EL', label:'ELYSE',           x: 1040, y:430, r: 13, alwaysLabel:true },
      { id:'o_hva',      kind:'org',     initials:'H',  label:'HvA',             x: 940, y: 380, r: 12 },
      { id:'o_uva',      kind:'org',     initials:'U',  label:'UvA',             x: 880, y: 470, r: 10 },
      { id:'o_tudelft',  kind:'org',     initials:'TD', label:'TU Delft',        x: 300, y: 130, r: 12 },

      // digital methods cluster
      { id:'m_dt',       kind:'method',  initials:'',   label:'Digital twins',  x: 540, y: 540, r: 9 },
      { id:'m_tea',      kind:'method',  initials:'',   label:'TEA',            x: 620, y: 590, r: 8 },
      { id:'m_pred',     kind:'method',  initials:'',   label:'Predictive maint.', x: 460, y: 580, r: 8 },
    ],
    edges: [
      // similarity (intra-cluster, calm blue)
      { s:'p_sara', t:'p_liesbeth', type:'Similarity',         strength: 0.92 },
      { s:'p_sara', t:'p_bram',     type:'Similarity',         strength: 0.7 },
      { s:'p_sara', t:'d_pem',      type:'Similarity',         strength: 0.85 },
      { s:'p_sara', t:'d_membrane', type:'Similarity',         strength: 0.8 },
      { s:'p_liesbeth', t:'d_pem',  type:'Similarity',         strength: 0.78 },
      { s:'p_liesbeth', t:'d_catalyst', type:'Similarity',     strength: 0.8 },
      { s:'p_bram', t:'d_membrane', type:'Similarity',         strength: 0.7 },
      { s:'p_sara', t:'m_lca',      type:'Similarity',         strength: 0.5 },
      { s:'p_liesbeth', t:'m_lca',  type:'Similarity',         strength: 0.5 },

      // org affiliation (light)
      { s:'p_sara', t:'o_tudelft',  type:'Similarity',         strength: 0.4 },
      { s:'p_bram', t:'o_tudelft',  type:'Similarity',         strength: 0.4 },
      { s:'p_liesbeth', t:'o_tno',  type:'Similarity',         strength: 0.4 },
      { s:'p_iteke', t:'o_hva',     type:'Similarity',         strength: 0.4 },
      { s:'p_lori', t:'o_hva',      type:'Similarity',         strength: 0.4 },
      { s:'p_frank', t:'o_uva',     type:'Similarity',         strength: 0.4 },
      { s:'p_nora', t:'o_greenlab', type:'Similarity',         strength: 0.4 },
      { s:'pj_hydronl', t:'o_greenlab', type:'Similarity',     strength: 0.5 },
      { s:'pj_hydronl', t:'o_tno',  type:'Similarity',         strength: 0.5 },
      { s:'pj_greenh2', t:'o_tudelft', type:'Similarity',      strength: 0.5 },
      { s:'pj_elyse', t:'o_hva',    type:'Similarity',         strength: 0.6 },

      // complementarity / need-offer (the headline matches)
      { s:'p_sara', t:'pj_hydronl', type:'Complementarity',    strength: 0.88, recommended: true, matchId:'m_01' },
      { s:'p_nora', t:'pj_hydronl', type:'Method transfer',    strength: 0.76, matchId:'m_04' },

      // anti-duplication
      { s:'p_bram', t:'p_liesbeth', type:'Anti-duplication',   strength: 0.81, alert:true, matchId:'m_03' },

      // bridges
      { s:'p_iteke', t:'p_lori',    type:'Bridge',             strength: 0.84, matchId:'m_05' },
      { s:'p_iteke', t:'p_frank',   type:'Bridge',             strength: 0.7 },
      { s:'p_iteke', t:'pj_elyse',  type:'Similarity',         strength: 0.8 },
      { s:'p_iteke', t:'pj_hydronl',type:'Bridge',             strength: 0.55 },
      { s:'p_lori', t:'pj_hydronl', type:'Bridge',             strength: 0.45 },

      // shared uncertainty
      { s:'p_sara', t:'p_nora',     type:'Shared uncertainty', strength: 0.71, matchId:'m_06' },

      // method links
      { s:'p_nora', t:'m_dt',       type:'Similarity',         strength: 0.85 },
      { s:'p_nora', t:'m_tea',      type:'Similarity',         strength: 0.7 },
      { s:'p_nora', t:'m_pred',     type:'Similarity',         strength: 0.7 },
      { s:'pj_hydronl', t:'m_tea',  type:'Similarity',         strength: 0.55 },
    ],
  };

  // ============ ORBITAL ============
  const ORBITAL = {
    rings: [
      { r: 80, label: 'Core research' },
      { r: 160, label: 'Applied research' },
      { r: 240, label: 'Bridge actors' },
      { r: 320, label: 'Practice / industry' },
      { r: 400, label: 'Education / workforce' },
    ],
    cx: 600, cy: 360,
    nodes: [
      // core
      { id:'p_sara',     kind:'person',  initials:'SA', label:'Sara',     ring: 0, theta: -1.0, r: 14, focal:true, alwaysLabel:true },
      { id:'p_liesbeth', kind:'person',  initials:'LK', label:'Liesbeth', ring: 0, theta:  0.4, r: 13, alwaysLabel:true },
      { id:'p_bram',     kind:'person',  initials:'BJ', label:'Bram',     ring: 0, theta:  2.0, r: 11, alwaysLabel:true },
      // applied
      { id:'pj_greenh2', kind:'project', initials:'GH', label:'GreenH2',  ring: 1, theta: -0.6, r: 13, alwaysLabel:true },
      { id:'pj_hydronl', kind:'project', initials:'HN', label:'HYDRO-NL', ring: 1, theta:  0.8, r: 16, alwaysLabel:true },
      { id:'p_nora',     kind:'person',  initials:'NH', label:'Nora',     ring: 1, theta:  2.4, r: 12, alwaysLabel:true },
      // bridges
      { id:'p_iteke',    kind:'person',  initials:'IH', label:'Iteke',    ring: 2, theta: -0.2, r: 14, alwaysLabel:true },
      { id:'p_lori',     kind:'person',  initials:'LD', label:'Lori',     ring: 2, theta:  1.3, r: 12, alwaysLabel:true },
      { id:'p_frank',    kind:'person',  initials:'FB', label:'Frank',    ring: 2, theta:  3.1, r: 11, alwaysLabel:true },
      // industry
      { id:'o_greenlab', kind:'org',     initials:'GL', label:'GreenLab', ring: 3, theta: -0.4, r: 12 },
      { id:'o_tno',      kind:'org',     initials:'TN', label:'TNO',      ring: 3, theta:  1.0, r: 12 },
      { id:'o_tudelft',  kind:'org',     initials:'TD', label:'TU Delft', ring: 3, theta:  2.5, r: 12 },
      // education
      { id:'pj_elyse',   kind:'project', initials:'EL', label:'ELYSE',    ring: 4, theta:  0.0, r: 13, alwaysLabel:true },
      { id:'o_hva',      kind:'org',     initials:'H',  label:'HvA',      ring: 4, theta:  1.5, r: 12 },
      { id:'o_uva',      kind:'org',     initials:'U',  label:'UvA',      ring: 4, theta:  2.8, r: 11 },
    ],
    // reuse atlas-equivalent edges (keyed)
    edges: ATLAS.edges,
  };

  // resolve orbital node positions
  function placeOrbital(layout) {
    const out = {};
    for (const n of layout.nodes) {
      const ring = layout.rings[n.ring];
      const x = layout.cx + ring.r * Math.cos(n.theta);
      const y = layout.cy + ring.r * Math.sin(n.theta);
      out[n.id] = { ...n, x, y };
    }
    return out;
  }

  // ============ FOCUS / EGO  ============
  function buildFocus(focusId) {
    // Place focusId at center; first-degree connections in inner ring;
    // second-degree in outer ring. Color edges by relationship type.
    const cx = 600, cy = 360;
    const focusNode = ATLAS.nodes.find(n => n.id === focusId) || ATLAS.nodes[0];
    const adj = ATLAS.edges.filter(e => e.s === focusId || e.t === focusId);
    const firstIds = new Set(adj.map(e => e.s === focusId ? e.t : e.s));
    const secondIds = new Set();
    for (const e of ATLAS.edges) {
      if (firstIds.has(e.s) && !firstIds.has(e.t) && e.t !== focusId) secondIds.add(e.t);
      if (firstIds.has(e.t) && !firstIds.has(e.s) && e.s !== focusId) secondIds.add(e.s);
    }
    secondIds.delete(focusId);
    [...firstIds].forEach(id => secondIds.delete(id));

    const nodes = [{...focusNode, x: cx, y: cy, r: 18, focal:true, alwaysLabel:true}];
    const firstArr = [...firstIds];
    firstArr.forEach((id, i) => {
      const n = ATLAS.nodes.find(x => x.id === id);
      if (!n) return;
      // distance keyed by edge strength
      const e = adj.find(e => (e.s === id || e.t === id));
      const r = 130 + (1 - (e?.strength||0.5)) * 80;
      const theta = (i / firstArr.length) * Math.PI * 2 - Math.PI/2;
      nodes.push({...n, x: cx + r * Math.cos(theta), y: cy + r * Math.sin(theta), r: 13, alwaysLabel:true});
    });
    const secondArr = [...secondIds].slice(0, 12);
    secondArr.forEach((id, i) => {
      const n = ATLAS.nodes.find(x => x.id === id);
      if (!n) return;
      const r = 280;
      const theta = (i / secondArr.length) * Math.PI * 2 - Math.PI/2 + 0.15;
      nodes.push({...n, x: cx + r * Math.cos(theta), y: cy + r * Math.sin(theta), r: 10});
    });
    const allowed = new Set(nodes.map(n => n.id));
    const edges = ATLAS.edges.filter(e => allowed.has(e.s) && allowed.has(e.t));
    return { nodes, edges, focusId };
  }

  // ============ ANTI-DUP ============
  // Highlight clusters of overlapping work.
  const ANTIDUP = {
    clusters: [
      { cx: 320, cy: 280, rx: 170, ry: 130, color: 'var(--e-antidup)', label: 'Membrane durability protocols ×3', opacity: 0.12 },
      { cx: 720, cy: 420, rx: 170, ry: 120, color: 'var(--e-antidup)', label: 'Pilot data sharing ×2', opacity: 0.12 },
      { cx: 540, cy: 130, rx: 140, ry: 90,  color: 'var(--c-uncertainty)', label: 'H₂ safety curriculum ×2', opacity: 0.10 },
    ],
    nodes: [
      { id:'p_sara', kind:'person', initials:'SA', label:'Sara', x:280, y:240, r:14, alwaysLabel:true },
      { id:'p_liesbeth', kind:'person', initials:'LK', label:'Liesbeth', x:380, y:300, r:14, alwaysLabel:true },
      { id:'p_bram', kind:'person', initials:'BJ', label:'Bram', x:300, y:340, r:12, alwaysLabel:true },
      { id:'pj_hydronl', kind:'project', initials:'HN', label:'HYDRO-NL', x:760, y:380, r:16, alwaysLabel:true },
      { id:'pj_greenh2', kind:'project', initials:'GH', label:'GreenH2', x:680, y:460, r:14, alwaysLabel:true },
      { id:'pj_elyse', kind:'project', initials:'EL', label:'ELYSE', x:540, y:120, r:13, alwaysLabel:true },
      { id:'p_iteke', kind:'person', initials:'IH', label:'Iteke', x:600, y:160, r:13, alwaysLabel:true },
    ],
    edges: [
      { s:'p_sara', t:'p_liesbeth', type:'Anti-duplication', strength:0.85, alert:true, matchId:'m_03' },
      { s:'p_bram', t:'p_liesbeth', type:'Anti-duplication', strength:0.81, alert:true, matchId:'m_03' },
      { s:'p_sara', t:'p_bram',     type:'Anti-duplication', strength:0.7,  alert:true },
      { s:'pj_hydronl', t:'pj_greenh2', type:'Anti-duplication', strength:0.72, alert:true },
      { s:'pj_elyse', t:'p_iteke',  type:'Similarity', strength: 0.7 },
    ],
  };

  // ============ NEED-OFFER (Sankey-like) ============
  const NEED_OFFER = {
    needs: [
      { id:'n_dur',  label:'Membrane durability data', who: 'HYDRO-NL', wid:'pj_hydronl' },
      { id:'n_pilot',label:'Pilot site access',        who: 'Sara',     wid:'p_sara'    },
      { id:'n_lca',  label:'LCA expertise',            who: 'GreenH2',  wid:'pj_greenh2' },
      { id:'n_curr', label:'Curriculum module',        who: 'HYDRO-NL', wid:'pj_hydronl' },
      { id:'n_ind',  label:'Industry partner',         who: 'Sara',     wid:'p_sara'    },
      { id:'n_test', label:'Test bench',               who: 'Bram',     wid:'p_bram'    },
    ],
    offers: [
      { id:'o_method', label:'Membrane test method',   who: 'Sara',     wid:'p_sara'     },
      { id:'o_pilot',  label:'Pilot site access',      who: 'HYDRO-NL', wid:'pj_hydronl' },
      { id:'o_lca',    label:'LCA practitioner',       who: 'Liesbeth', wid:'p_liesbeth' },
      { id:'o_curr',   label:'Curriculum module',      who: 'ELYSE',    wid:'pj_elyse'   },
      { id:'o_dt',     label:'Digital twin modelling', who: 'Nora',     wid:'p_nora'     },
      { id:'o_data',   label:'Operational data',       who: 'HYDRO-NL', wid:'pj_hydronl' },
      { id:'o_stake',  label:'Stakeholder access',     who: 'Iteke',    wid:'p_iteke'    },
    ],
    flows: [
      { from:'n_dur', to:'o_method',  strength: 0.95, label:'Sara × HYDRO-NL', matchId:'m_01' },
      { from:'n_pilot', to:'o_pilot', strength: 0.85 },
      { from:'n_lca', to:'o_lca',     strength: 0.78 },
      { from:'n_curr', to:'o_curr',   strength: 0.72 },
      { from:'n_ind', to:'o_data',    strength: 0.7 },
      { from:'n_test', to:'o_method', strength: 0.55 },
    ],
  };

  // ============ PATH ============
  function buildPath(steps) {
    // steps: [{id, kind, label, initials, edgeReason?}]
    const cx0 = 100, dy = 360, dx = 200;
    const nodes = steps.map((s, i) => ({
      ...s, x: cx0 + i*dx, y: dy, r: i===0||i===steps.length-1 ? 16 : 13,
      alwaysLabel: true, focal: i===0,
    }));
    const edges = [];
    for (let i = 0; i < steps.length - 1; i++) {
      edges.push({ s: steps[i].id, t: steps[i+1].id, type: steps[i].relationTo || 'Similarity', strength: 0.7, label: steps[i].edgeReason });
    }
    return { nodes, edges };
  }

  // ============ GraphCanvas (host) ============
  // Renders cluster halos + edges + nodes, handles pan/zoom and selection.

  const GraphCanvas = ({
    layout, nodes, edges, clusters, rings, ringCenter,
    selectedNodeId, hoveredNodeId, dimNonAdjacent,
    onNodeClick, onNodeHover, onEdgeClick,
    height=620, viewBox='0 0 1200 720',
    overlay,
  }) => {
    const [{tx, ty, scale}, setView] = useState({tx:0, ty:0, scale:1});
    const dragging = useRef(null);

    const onMouseDown = (e) => {
      if (e.button !== 0) return;
      dragging.current = { x: e.clientX, y: e.clientY, tx, ty };
    };
    useEffect(() => {
      const move = (e) => {
        if (!dragging.current) return;
        const dx = e.clientX - dragging.current.x;
        const dy = e.clientY - dragging.current.y;
        setView(v => ({...v, tx: dragging.current.tx + dx, ty: dragging.current.ty + dy}));
      };
      const up = () => { dragging.current = null; };
      window.addEventListener('mousemove', move);
      window.addEventListener('mouseup', up);
      return () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
    }, []);
    const onWheel = (e) => {
      e.preventDefault();
      const delta = -e.deltaY * 0.0015;
      setView(v => ({...v, scale: Math.max(0.5, Math.min(2.5, v.scale + delta))}));
    };

    // map nodes by id
    const nodeMap = useMemo(() => {
      const m = {}; for (const n of nodes) m[n.id] = n; return m;
    }, [nodes]);

    // adjacency for dim-non-adjacent
    const adjacent = useMemo(() => {
      if (!selectedNodeId || !dimNonAdjacent) return null;
      const set = new Set([selectedNodeId]);
      edges.forEach(e => {
        if (e.s === selectedNodeId) set.add(e.t);
        if (e.t === selectedNodeId) set.add(e.s);
      });
      return set;
    }, [selectedNodeId, edges, dimNonAdjacent]);

    return (
      <div className="canvas-bg" style={{position:'relative', width:'100%', height, borderRadius: 'var(--r-4)', overflow: 'hidden', border:'1px solid var(--line)'}}>
        <svg viewBox={viewBox} width="100%" height="100%"
             onMouseDown={onMouseDown} onWheel={onWheel}
             style={{display:'block', cursor: dragging.current ? 'grabbing' : 'grab', userSelect:'none'}}>
          <g transform={`translate(${tx}, ${ty}) scale(${scale})`}>
            {/* cluster halos */}
            {clusters && clusters.map((c,i) => <Cluster key={i} {...c}/>)}

            {/* concentric rings */}
            {rings && rings.map((ring, i) => (
              <g key={i}>
                <circle cx={ringCenter.cx} cy={ringCenter.cy} r={ring.r}
                        fill="none" stroke="var(--line-2)" strokeWidth="1" strokeDasharray="2 4" opacity="0.7"/>
                <text x={ringCenter.cx} y={ringCenter.cy - ring.r - 4}
                      textAnchor="middle"
                      fontFamily="Instrument Serif, serif"
                      fontSize="11" fill="var(--ink-3)"
                      letterSpacing="0.06em">{ring.label}</text>
              </g>
            ))}

            {/* edges */}
            <g>
              {edges.map((e, i) => (
                <Edge key={i} e={e} nodes={nodeMap}
                  selected={selectedNodeId && (e.s === selectedNodeId || e.t === selectedNodeId)}
                  hovered={hoveredNodeId && (e.s === hoveredNodeId || e.t === hoveredNodeId)}
                  dim={adjacent && !(adjacent.has(e.s) && adjacent.has(e.t))}
                  onClick={onEdgeClick}
                />
              ))}
            </g>

            {/* nodes */}
            <g>
              {nodes.map(n => (
                <NodeShape key={n.id} n={n}
                  hovered={n.id === hoveredNodeId}
                  selected={n.id === selectedNodeId}
                  recommended={n.recommended || edges.some(e => e.recommended && (e.s === n.id || e.t === n.id))}
                  dim={adjacent && !adjacent.has(n.id)}
                  focal={n.focal}
                  onClick={onNodeClick} onHover={onNodeHover}/>
              ))}
            </g>
          </g>
        </svg>
        {overlay}
      </div>
    );
  };

  Object.assign(window, {
    GraphCanvas, NodeShape, Cluster,
    GraphLayouts: { ATLAS, ORBITAL, ANTIDUP, NEED_OFFER, placeOrbital, buildFocus, buildPath },
  });
})();
