;
};
// ============ 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 (
{overlay}
);
};
Object.assign(window, {
GraphCanvas, NodeShape, Cluster,
GraphLayouts: { ATLAS, ORBITAL, ANTIDUP, NEED_OFFER, placeOrbital, buildFocus, buildPath },
});
})();