// 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
Computing layout...
; const activeId = hover || highlightOrgId; const focusOrg = activeId ? orgs.find(o => o.id === activeId) : null; const focusNeighbors = new Set(); if (focusOrg) { focusNeighbors.add(focusOrg.id); for (const e of liveEdges) { if (e.a === focusOrg.id) focusNeighbors.add(e.b); if (e.b === focusOrg.id) focusNeighbors.add(e.a); } } const anyFilterActive = filterTypes.size + filterBarriers.size > 0; const matchesFilters = (o) => { if (filterTypes.size && !filterTypes.has(o.type)) return false; if (filterBarriers.size) { const bs = o.barriers || []; let any = false; for (const b of bs) if (filterBarriers.has(b)) { any = true; break; } if (!any) return false; } return true; }; const toggleIn = (set, setter) => (v) => { const next = new Set(set); if (next.has(v)) next.delete(v); else next.add(v); setter(next); }; const toggleType = toggleIn(filterTypes, setFilterTypes); const toggleBarrier = toggleIn(filterBarriers, setFilterBarriers); // Barriers present in the visible orgs - only show chips that can match. const barriersPresent = (() => { const s = new Set(); for (const o of orgs) for (const b of (o.barriers || [])) s.add(b); return [...s].sort(); })(); return (
{/* Filter legend - clickable chips for type / barrier / value-chain part */}
Type {facets.types.order.map(t => { const on = filterTypes.has(t); const off = filterTypes.size > 0 && !on; return ( ); })}
{barriersPresent.length > 0 && (
Barrier {barriersPresent.map(b => { const on = filterBarriers.has(b); const off = filterBarriers.size > 0 && !on; return ( ); })}
)} {anyFilterActive && ( )}
{/* Edges */} {liveEdges.map((e, i) => { const pa = positions.get(e.a), pb = positions.get(e.b); if (!pa || !pb) return null; const isActive = focusOrg && (e.a === focusOrg.id || e.b === focusOrg.id); return ( ); })} {/* Symbolic Communities node - sits above edges so any edges that graze the centre are visually covered. Not connected to anything. */} Communities and local leadership {/* Nodes */} {orgs.map(o => { const p = positions.get(o.id); if (!p) return null; const deg = degree.get(o.id) || 0; const r = 5 + Math.sqrt(deg) * 2.8; const filterMatch = matchesFilters(o); const dim = (focusOrg && !focusNeighbors.has(o.id)) || (anyFilterActive && !filterMatch); const color = TYPE_COLORS[o.type] || '#84c98f'; const isFocus = focusNeighbors.has(o.id) || activeId === o.id; // Show all labels by default; emphasise on focus const showLabel = true; const labelText = o.name.length > 30 ? o.name.slice(0, 28) + '...' : o.name; return ( setHover(o.id)} onMouseLeave={() => setHover(null)} onClick={() => onPickOrg(o)} > {showLabel && ( {labelText} )} ); })}

Hover a node to highlight its partners. Click for full details. Edges are symmetric - each pair appears once.

); } window.NetworkView = NetworkView;