// NetworkView.jsx - Collaboration Network (force-directed-ish) // Nodes = organisations; edges = symmetric collaborations from Collaborations sheet // Layout: deterministic force simulation (no D3 dep) - runs once on mount const { useMemo: useMemoN, useState: useStateN, useEffect: useEffectN, useRef: useRefN } = React; const TYPE_COLORS = { 'MDB/ IFI / DFI': '#2a6a86', // ocean blue 'Bilateral development agency': '#aa6227', // earth clay 'Intergovernmental organization (IGO)': '#8c5983', // faded dewberry 'Coalition or multi-stakeholder platform': '#2e8055', // verdant green 'NGO': '#fdce07', // solar yellow 'Philanthropic foundation': '#e07b4a', // warm coral 'Private company (IPP)': '#2a7a5a', // pine green 'Private investor': '#7a5da8', // violet 'Commercial system actor': '#b05c7a', // muted rose 'Research & Academic institution': '#8bcdd2', // sky blue }; window.TYPE_COLORS_NET = TYPE_COLORS; function NetworkView({ orgs, allOrgs, edges, onPickOrg, highlightOrgId, dataset }) { const facets = (dataset || window.INITIATIVES_DATA).facets; const [hover, setHover] = useStateN(null); const [positions, setPositions] = useStateN(null); const [tick, setTick] = useStateN(0); // Filter sets - drive node dimming. Empty set = no filter. const [filterTypes, setFilterTypes] = useStateN(() => new Set()); const [filterBarriers, setFilterBarriers] = useStateN(() => new Set()); const W = 1600, H = 1000; // Symbolic centre node - "Communities". Not connected; sits at the middle of // the canvas with an exclusion radius that pushes all org nodes outward so // they circle around it. const COMMUNITIES_R = 110; // visible radius const COMMUNITIES_EXCL = 195; // exclusion radius for the force simulation // Filter edges to currently visible orgs const visibleIds = useMemoN(() => new Set(orgs.map(o => o.id)), [orgs]); const liveEdges = useMemoN(() => edges.filter(e => visibleIds.has(e.a) && visibleIds.has(e.b)), [edges, visibleIds]); // Degree const degree = useMemoN(() => { const m = new Map(); for (const o of orgs) m.set(o.id, 0); for (const e of liveEdges) { m.set(e.a, (m.get(e.a) || 0) + 1); m.set(e.b, (m.get(e.b) || 0) + 1); } return m; }, [orgs, liveEdges]); // Force simulation - runs once when orgs/edges change useEffectN(() => { const cx = W / 2, cy = H / 2; // Hard clamp - projects a position outside the Communities exclusion zone. const clampOutside = (p) => { const ex = p.x - cx, ey = p.y - cy; const dist = Math.sqrt(ex * ex + ey * ey); if (dist < COMMUNITIES_EXCL) { const target = COMMUNITIES_EXCL + 8; if (dist < 0.5) { p.x = cx + target; p.y = cy; } else { const s = target / dist; p.x = cx + ex * s; p.y = cy + ey * s; } p.vx = 0; p.vy = 0; } }; // Stable seeded init: spread on larger ellipse, with degree influencing radius const sorted = [...orgs].sort((a, b) => degree.get(b.id) - degree.get(a.id)); const n = sorted.length; const pos = new Map(); sorted.forEach((o, i) => { const deg = degree.get(o.id); // Initial radius: connected hubs inner ring, mid orgs middle, isolates outer. // Inner ring sits outside the Communities exclusion zone so nodes start clear of the centre. const r = deg === 0 ? 440 : (deg > 8 ? 260 : 360); const a = (i / n) * Math.PI * 2 + (deg === 0 ? Math.PI / 5 : 0); pos.set(o.id, { x: cx + Math.cos(a) * r * 1.5 + (Math.random() - 0.5) * 60, y: cy + Math.sin(a) * r + (Math.random() - 0.5) * 60, vx: 0, vy: 0, }); }); // Run sim - tuned for label legibility (more spacing) const ITER = 460; const REPULSE = 22000; const SPRING = 0.04; const SPRING_LEN = 230; const CENTER_PULL = 0.004; const DAMP = 0.80; // Elliptical "soft canvas" - nodes clamp to an ellipse that fits the // visible area. Removes the rectangular corners that the previous bounds // clamp turned into accumulation zones. const ELLIPSE_A = W / 2 - 70; const ELLIPSE_B = H / 2 - 50; const ids = orgs.map(o => o.id); for (let it = 0; it < ITER; it++) { // Repulsion for (let i = 0; i < ids.length; i++) { const a = pos.get(ids[i]); for (let j = i + 1; j < ids.length; j++) { const b = pos.get(ids[j]); let dx = a.x - b.x, dy = a.y - b.y; let d2 = dx * dx + dy * dy; if (d2 < 1) d2 = 1; const f = REPULSE / d2; const d = Math.sqrt(d2); const fx = (dx / d) * f, fy = (dy / d) * f; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; } } // Spring (edges) for (const e of liveEdges) { const a = pos.get(e.a), b = pos.get(e.b); if (!a || !b) continue; const dx = b.x - a.x, dy = b.y - a.y; const d = Math.sqrt(dx * dx + dy * dy) || 1; const f = (d - SPRING_LEN) * SPRING; const fx = (dx / d) * f, fy = (dy / d) * f; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; } // Center pull + damp + integrate for (const id of ids) { const p = pos.get(id); p.vx += (cx - p.x) * CENTER_PULL; p.vy += (cy - p.y) * CENTER_PULL; // Communities exclusion: push outward if inside the centre zone so org // nodes form a ring around the symbolic centre node. const ex = p.x - cx, ey = p.y - cy; const dist = Math.sqrt(ex * ex + ey * ey) || 1; if (dist < COMMUNITIES_EXCL) { const push = (COMMUNITIES_EXCL - dist) * 1.2; p.vx += (ex / dist) * push; p.vy += (ey / dist) * push; } p.vx *= DAMP; p.vy *= DAMP; p.x += p.vx; p.y += p.vy; // Elliptical clamp - project nodes back onto the ellipse boundary when // they drift outside it. Removes the rectangular corners. const nx = (p.x - cx) / ELLIPSE_A; const ny = (p.y - cy) / ELLIPSE_B; const norm = Math.sqrt(nx * nx + ny * ny); if (norm > 1) { p.x = cx + (p.x - cx) / norm; p.y = cy + (p.y - cy) / norm; p.vx *= 0.3; p.vy *= 0.3; } clampOutside(p); } } // Label de-collision pass: nudge nodes apart vertically if their label boxes overlap const arr = [...pos.entries()].map(([id, p]) => ({ id, p })); const LABEL_W = 180, LABEL_H = 26; const ellipseClamp = (p) => { const nx = (p.x - cx) / ELLIPSE_A; const ny = (p.y - cy) / ELLIPSE_B; const norm = Math.sqrt(nx * nx + ny * ny); if (norm > 1) { p.x = cx + (p.x - cx) / norm; p.y = cy + (p.y - cy) / norm; } }; for (let pass = 0; pass < 40; pass++) { let moved = false; for (let i = 0; i < arr.length; i++) { for (let j = i + 1; j < arr.length; j++) { const a = arr[i].p, b = arr[j].p; const dx = Math.abs(a.x - b.x), dy = Math.abs(a.y - b.y); if (dx < LABEL_W && dy < LABEL_H) { const push = (LABEL_H - dy) / 2 + 1; if (a.y < b.y) { a.y -= push; b.y += push; } else { a.y += push; b.y -= push; } ellipseClamp(a); ellipseClamp(b); moved = true; } } } if (!moved) break; } for (const id of ids) clampOutside(pos.get(id)); setPositions(pos); setTick(t => t + 1); }, [orgs, liveEdges]); if (!positions) return
Hover a node to highlight its partners. Click for full details. Edges are symmetric - each pair appears once.