// 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}) => ( {label && ( {label} )} ); // ============ 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 ( onClick && onClick(n)} onMouseEnter={() => onHover && onHover(n)} onMouseLeave={() => onHover && onHover(null)}> {/* recommend pulse */} {recommended && !selected && ( )} {/* selected ring */} {(selected || focal) && ( )} {/* hover halo */} {hovered && !selected && ( )} {/* shape */} {isPerson && } {isProject && } {isOrg && } {!isPerson && !isProject && !isOrg && ( )} {/* label */} {n.label && (hovered || selected || focal || (n.alwaysLabel && !dim)) && ( {n.label} )} ); }; const PersonNode = ({n, r, c, selected, focal}) => ( {focal && } 14 ? 10 : 9} fontWeight="600" fill={c}> {n.initials} ); 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 ( 14 ? 10 : 9} fontWeight="600" fill={c}> {n.initials} ); }; const OrgNode = ({n, r, c}) => { // rounded square const s = r * 1.7; return ( 14 ? 10 : 9} fontWeight="600" fill={c}> {n.initials} ); }; // ============ 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 ( onClick && onClick(e)}> {/* glow on hover/selected */} {(selected || hovered) && ( )} {/* hit area */} {isAlert && !dim && } {/* directional arrow on Need-offer / Method transfer */} {(e.type === 'Need-offer' || e.type === 'Method transfer' || e.type === 'Complementarity') && ( )} ); }; 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 ; }; // ============ 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 (
{/* cluster halos */} {clusters && clusters.map((c,i) => )} {/* concentric rings */} {rings && rings.map((ring, i) => ( {ring.label} ))} {/* edges */} {edges.map((e, i) => ( ))} {/* nodes */} {nodes.map(n => ( e.recommended && (e.s === n.id || e.t === n.id))} dim={adjacent && !adjacent.has(n.id)} focal={n.focal} onClick={onNodeClick} onHover={onNodeHover}/> ))} {overlay}
); }; Object.assign(window, { GraphCanvas, NodeShape, Cluster, GraphLayouts: { ATLAS, ORBITAL, ANTIDUP, NEED_OFFER, placeOrbital, buildFocus, buildPath }, }); })();