// MatrixView.jsx - Region × Value-chain segment matrix // Rows: Global (default) → reveal 7 regional rows on demand // Columns: 11 fine-grained segments grouped under Grids / Production / Power // Cells: dots, one per (org, cell) appearance. Colour = primary influencer role. // // Interactions: // - Hover/click a dot → highlight every cell that org occupies // - Right-click a dot → context menu (Open / Highlight everywhere / Show all collaborators) // - Click a row or column head → modal force-directed network of orgs in that row/column // - Toolbar: Show as heatmap (cell colour = initiative count) const { useMemo: useMemoM, useState: useStateM, useEffect: useEffectM, useRef: useRefM } = React; // Click-and-drag horizontal scroll for table wrappers. // Returns { ref, onMouseDown } to spread onto the container div. function useDragScroll() { const ref = useRefM(null); const drag = useRefM({ active: false, startX: 0, scrollLeft: 0, moved: false }); function onMouseDown(e) { // Only activate on left-button, and not on interactive elements if (e.button !== 0) return; const el = ref.current; if (!el) return; drag.current = { active: true, startX: e.clientX, scrollLeft: el.scrollLeft, moved: false }; el.style.cursor = 'grabbing'; el.style.userSelect = 'none'; } function onMouseMove(e) { const d = drag.current; if (!d.active) return; const dx = e.clientX - d.startX; if (Math.abs(dx) > 3) d.moved = true; if (d.moved) ref.current.scrollLeft = d.scrollLeft - dx; } function onMouseUp() { const el = ref.current; drag.current.active = false; if (el) { el.style.cursor = ''; el.style.userSelect = ''; } } // Attach move/up to window so releasing outside the div still ends the drag useEffectM(() => { window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); }; }, []); return { ref, onMouseDown }; } // ───────────────────────── Column model ───────────────────────── // Column order follows the value-chain flow with the cross-cutting layers // pulled into the middle (feedback, 2026-06): // Power production (Generation) // → Grids & System operation (Transmission, Distribution, Decentralised RES - // decentralised RES sits between distribution and demand) // → Overarching (Grid flexibility/storage, Digitalisation - between grids and // demand, since they don't really touch demand directly) // → Power demand (Agriculture ... Services) // → Enablers (Tech providers, Policymakers - trailing support group) // Each group has a distinct tint and a divider before it, for clearer // separation between buckets (feedback: e.g. generation vs demand). const M_GROUPS = [ { id: 'production', label: 'Power production', tint: 'rgba(253,206,7,0.20)', cols: [ { id: 'prod-utility', label: 'Generation' }, ], }, { id: 'sysgrids', label: 'Grids & System operation', tint: 'rgba(132,201,143,0.22)', gapBefore: true, cols: [ { id: 'transmission', label: 'Transmission' }, { id: 'distribution', label: 'Distribution' }, { id: 'prod-decentralised', label: 'Decentralised RES' }, ], }, { id: 'overarching', label: 'Overarching', tint: 'rgba(140,89,131,0.22)', gapBefore: true, cols: [ { id: 'storage', label: 'Grid flexibility &', sub: 'energy storage' }, { id: 'grid-digital', label: 'Grid digitalisation &', sub: 'data providers' }, ], }, { id: 'power', label: 'Power demand', tint: 'rgba(74,144,180,0.22)', gapBefore: true, cols: [ { id: 'demand-agriculture',label: 'Agriculture' }, { id: 'demand-industry', label: 'Industry' }, { id: 'demand-transport', label: 'Transport' }, { id: 'demand-households', label: 'Households &', sub: 'Cooking' }, { id: 'demand-services', label: 'Services &', sub: 'other' }, ], }, { id: 'enablers', label: 'Enablers', tint: 'rgba(150,155,165,0.20)', gapBefore: true, cols: [ { id: 'tech-provision', label: 'Tech providers &', sub: 'manufacturers' }, { id: 'policy', label: 'Policymakers &', sub: 'regulators' }, // 'financial' (Commercial banks & private finance) removed from the Heat Map // per feedback (Andrea & Rintati) - kept only in the Ecosystem Infographic. ], }, ]; const M_COLS = M_GROUPS.flatMap(g => g.cols.map((c, i) => ({ ...c, group: g.id, groupLabel: g.label, gapBefore: !!(g.gapBefore && i === 0) }))); const M_COL_BY_ID = Object.fromEntries(M_COLS.map(c => [c.id, c])); // Energy-access metric per geography - PLACEHOLDER (feedback: Workshop · Makena, // Andrea). A raw count of organizations in a cell is a misleading "gap" signal // because it ignores how much energy-access need sits behind each geography. // These sub-labels reserve the slot where the real per-region need metric (e.g. // population without reliable electricity access) will be shown so gaps can be // read against need. Replace the TBC strings with the agreed metric when ready. // Electricity access (% of population, 2023 est.) and reliability-of-supply // score (0-100, 2019 est.) shown under each region row head, so cell counts // read against need. // Energy access % (2023 est.) and reliability-of-supply category (based on the // World Bank "getting electricity" score, 2019 est.). Both `reliability` (the // Low/Medium/High label) and `tier` (the row colour) are taken verbatim from the // source slide, so the colour split is the slide's gap-severity tiering - not a // mechanical threshold on the label (e.g. Global reads "Medium" yet sits in the // red tier). Source: World Bank, Sustainable Energy for All (SE4ALL) Global // Electrification Database / Tracking SDG 7. const REGION_ACCESS_METRIC = { 'Global': { access: '~92%', reliability: 'Medium', tier: 'low' }, 'Sub-Saharan Africa': { access: '~53%', reliability: 'Low', tier: 'low' }, 'South Asia': { access: '~99%', reliability: 'Low', tier: 'low' }, 'East Asia and Pacific': { access: '~98%', reliability: 'Medium', tier: 'med' }, 'Latin America and Caribbean': { access: '~98%', reliability: 'Medium', tier: 'med' }, 'Middle East and North Africa': { access: '~97%', reliability: 'Medium', tier: 'med' }, 'Europe': { access: '~100%', reliability: 'High', tier: 'high' }, 'Central Asia': { access: '~100%', reliability: 'High', tier: 'high' }, 'North America': { access: '~100%', reliability: 'High', tier: 'high' }, }; // Row tier → colour, matched to the source slide: coral-red (largest gap), // golden-yellow (moderate), lime-green (smallest gap). const TIER_COLOR = { low: '#ee5b54', med: '#f4c20d', high: '#9cc63b' }; const tierColor = (m) => (m && TIER_COLOR[m.tier]) || 'var(--text-mid)'; // Pillar → columns highlighted. "both" columns light up for either pillar // (mirrors the Energy Ecosystem diagram, where Decentralised RES, storage, // grid digitalisation and tech providers belong to both pillars). const COL_PILLARS = { 'prod-utility': ['powering'], 'prod-decentralised': ['powering', 'grids'], 'transmission': ['grids'], 'distribution': ['grids'], 'storage': ['powering', 'grids'], 'grid-digital': ['powering', 'grids'], 'demand-agriculture': ['powering'], 'demand-industry': ['powering'], 'demand-transport': ['powering'], 'demand-households': ['powering'], 'demand-services': ['powering'], 'tech-provision': ['powering', 'grids'], 'policy': [], }; function colMatchesPillar(colId, activePillars) { if (!activePillars || !activePillars.length) return true; const cps = COL_PILLARS[colId] || []; // Policy / Finance (no pillar) are overarching - always shown alongside the // selected pillar so the bottom-of-diagram bars stay visible. if (cps.length === 0) return true; for (const p of activePillars) if (cps.includes(p)) return true; return false; } // ─────────────────────── Org type → dot colour ─────────────────────── // Dots are coloured by organization type. Role/barrier filtering is independent. const TYPE_TINT_HEX = { 'MDB/ IFI /DFI': '#3b6ea8', 'Bilateral development agency': '#c87a4e', 'Intergovernmental organization (IGO)': '#8c5983', 'Coalition or multi-stakeholder platform': '#5a8c66', 'NGO': '#d9b441', 'Philanthropic foundation': '#e07b4a', 'Private company (IPP)': '#2a7a5a', 'Private investor': '#7a5da8', 'Commercial system actor': '#b05c7a', 'Research & Academic institution': '#6a9bb8', }; const typeColor = (org) => TYPE_TINT_HEX[org && org.type] || '#7a9082'; // ─────────────────────── Influencer → role colour ─────────────────────── // Roles map directly onto the "Barriers addressed" data (org.barriers, legacy vocab). // Labels mirror the Energy Ecosystem barrier names exactly so the Heat Map's // Barriers legend reads the same as the schematic (Andrea + Rintati, 2026-05-28). const ROLE_DEFS_BASE = [ { id: 'direct-finance', label: 'Finance', color: '#0e2c66', sourceKey: 'Finance' }, { id: 'policy-influence', label: 'Policy & regulation', color: '#84c98f', sourceKey: 'Policy & regulation' }, { id: 'technical-assistance', label: 'Technology', color: '#fdce07', sourceKey: 'Technology' }, { id: 'coordination', label: 'Awareness & coordination', color: '#4a4a4a', sourceKey: 'Awareness & coordination' }, { id: 'capabilities-skills', label: 'Skills & capacity', color: '#8c5983', sourceKey: 'Skills & capacity' }, ]; const ROLE_INDUSTRY = { id: 'industry-players', label: 'Industry players', color: '#0a0a0a', sourceKey: 'industry-players' }; const ROLE_DEFS_INDIA = [...ROLE_DEFS_BASE, ROLE_INDUSTRY]; const ROLE_DEFS = ROLE_DEFS_INDIA; // expose all for colour lookup; legend chooses which to show const ROLE_BY_SOURCE = Object.fromEntries(ROLE_DEFS.map(r => [r.sourceKey, r])); const ROLE_PRIORITY = ['industry-players', 'direct-finance', 'policy-influence', 'technical-assistance', 'coordination', 'capabilities-skills']; function rolesFor(org) { // Map "Barriers addressed" (org.barriers) → role ids. Preserve listed order. // India industry players are tagged via influencer, so include those too. const out = []; for (const src of [...(org.barriers || []), ...(org.influencer || [])]) { const r = ROLE_BY_SOURCE[src]; if (r && !out.includes(r.id)) out.push(r.id); } return out; } function primaryRoleFor(org) { const rs = rolesFor(org); if (!rs.length) return null; for (const p of ROLE_PRIORITY) if (rs.includes(p)) return p; return rs[0]; } // ─────────────────────── Org → cells (column placement) ─────────────────────── function colsForOrg(org) { // India dataset bakes placement in directly if (org.valueChainCells && org.valueChainCells.length) return org.valueChainCells; const cells = new Set(); const vc = new Set(org.valueChain || []); // normalised canonical segments const raw = new Set(org.vcRaw || []); // raw segments (includes Policymakers, Households, etc.) const tp = new Set(org.topics || []); // Production if (vc.has('Production (utility scale)')) cells.add('prod-utility'); if (vc.has('Production (decentralised)')) cells.add('prod-decentralised'); // System operation & grids if (vc.has('Transmission (TSOs)')) cells.add('transmission'); if (vc.has('Distribution (DSOs)')) cells.add('distribution'); if (vc.has('Energy storage') || tp.has('Grids - Energy Storage')) cells.add('storage'); if (vc.has('Grid digitalisation & data providers') || tp.has('Grids - Digitalisation')) cells.add('grid-digital'); // Demand - driven by raw VC terms and topics if (raw.has('Agriculture')) cells.add('demand-agriculture'); if (raw.has('Industry')) cells.add('demand-industry'); if (raw.has('Households & cooking') || tp.has('Power - Clean cooking')) cells.add('demand-households'); if (raw.has('Transport')) cells.add('demand-transport'); if (raw.has('Services & other') || raw.has('Buildings')) cells.add('demand-services'); if (tp.has('Power - Energy efficiency')) { cells.add('demand-industry'); cells.add('demand-services'); } // Overarching if (raw.has('Policymakers & regulators')) cells.add('policy'); if (tp.has('Grids - Physical infrastructure') || raw.has('Tech providers & manufacturers')) cells.add('tech-provision'); // 'financial' (commercial banks & private finance) column removed from the // Heat Map per feedback - finance is inferred from the value chain segments // an organization funds, and the column is retained only in the Ecosystem // Infographic. return Array.from(cells); } // ─────────────────────── Build placement index ─────────────────────── // rowMode: 'regions' | 'barriers' - which org field to group rows by function buildIndex(orgs, rowOrder, rowMode) { // index[rowId][colId] = [org, ...] const index = {}; for (const r of rowOrder) { index[r] = {}; for (const c of M_COLS) index[r][c.id] = []; } const rowField = rowMode === 'barriers' ? 'barriers' : 'regions'; for (const o of orgs) { const cols = colsForOrg(o); const rows = o[rowField] && o[rowField].length ? o[rowField] : []; for (const row of rows) { if (!index[row]) continue; for (const c of cols) index[row][c].push(o); } } // Sort each cell's initiatives by organization type (so same-coloured dots // cluster, matching the type legend), alphabetical within each type. The // primary/industry split and the filter-matched-first reorder at render time // are both stable, so this type grouping is preserved within each group. for (const r of rowOrder) { for (const c of M_COLS) { index[r][c.id].sort((a, b) => { const ta = a.type || '', tb = b.type || ''; if (ta !== tb) return ta.localeCompare(tb); return a.name.localeCompare(b.name); }); } } return index; } // ─────────────────────── Force layout (small, for modal) ─────────────────────── function layoutForce(orgs, edges, W, H) { const cx = W / 2, cy = H / 2; const pos = new Map(); const n = orgs.length; orgs.forEach((o, i) => { const a = (i / Math.max(n, 1)) * Math.PI * 2; pos.set(o.id, { x: cx + Math.cos(a) * Math.min(W, H) * 0.32, y: cy + Math.sin(a) * Math.min(W, H) * 0.32, vx: 0, vy: 0 }); }); const ITER = 280, REPULSE = 5200, SPRING = 0.06, SPRING_LEN = 110, CENTER = 0.012, DAMP = 0.82; const ids = orgs.map(o => o.id); for (let it = 0; it < ITER; it++) { 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 d = Math.sqrt(d2); const f = REPULSE / d2; const fx = (dx / d) * f, fy = (dy / d) * f; a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy; } } for (const e of edges) { 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; } for (const id of ids) { const p = pos.get(id); p.vx += (cx - p.x) * CENTER; p.vy += (cy - p.y) * CENTER; p.vx *= DAMP; p.vy *= DAMP; p.x += p.vx; p.y += p.vy; p.x = Math.max(50, Math.min(W - 50, p.x)); p.y = Math.max(40, Math.min(H - 40, p.y)); } } return pos; } // ─────────────────────── Network modal ─────────────────────── function NetworkModal({ title, subtitle, orgs, edges, onClose, onPickOrg, visibleRoles, colorFor = typeColor, typePalette = TYPE_TINT_HEX, hideNetwork = false }) { const W = 1280, H = 760; const liveEdges = useMemoM(() => { const ids = new Set(orgs.map(o => o.id)); return edges.filter(e => ids.has(e.a) && ids.has(e.b)); }, [orgs, edges]); const basePositions = useMemoM(() => layoutForce(orgs, liveEdges, W, H), [orgs, liveEdges]); const [hover, setHover] = useStateM(null); const [pinned, setPinned] = useStateM(null); const [roleFilter, setRoleFilter] = useStateM(() => new Set()); // Active = pinned (sticky) overrides hover const active = pinned || hover; const roleFilterActive = roleFilter.size > 0; const matchesRoleFilter = (o) => !roleFilterActive || roleFilter.has(primaryRoleFor(o)); const toggleRole = (rid) => { const next = new Set(roleFilter); if (next.has(rid)) next.delete(rid); else next.add(rid); setRoleFilter(next); }; const focusNbrs = useMemoM(() => { if (!active) return null; const s = new Set([active]); for (const e of liveEdges) { if (e.a === active) s.add(e.b); if (e.b === active) s.add(e.a); } return s; }, [active, liveEdges]); // When pinned, re-layout: focus nodes stay (re-centered on left); non-focus park to the right column const positions = useMemoM(() => { if (!pinned || !focusNbrs) return basePositions; const focusIds = orgs.filter(o => focusNbrs.has(o.id)).map(o => o.id); const otherIds = orgs.filter(o => !focusNbrs.has(o.id)).map(o => o.id); const pos = new Map(); // Focus subgraph: compute its own force layout in the left ~60% of canvas const focusOrgs = orgs.filter(o => focusNbrs.has(o.id)); const focusEdges = liveEdges.filter(e => focusNbrs.has(e.a) && focusNbrs.has(e.b)); const FW = Math.round(W * 0.58), FH = H; const focusPos = layoutForce(focusOrgs, focusEdges, FW, FH); for (const id of focusIds) { const p = focusPos.get(id); if (p) pos.set(id, { x: p.x, y: p.y }); } // Non-focus: pack into right pane so all nodes fit, regardless of count const RX0 = Math.round(W * 0.61) + 14; const RW = W - RX0 - 8; const RH = H - 24; const N = otherIds.length || 1; // Pick cols/rows from aspect ratio so cells are roughly square let cols = Math.max(1, Math.round(Math.sqrt(N * RW / RH))); let rows = Math.ceil(N / cols); while (cols > 1 && (cols - 1) * rows >= N) cols -= 1; rows = Math.ceil(N / cols); const dx = RW / cols; const dy = RH / rows; otherIds.forEach((id, i) => { const c = i % cols, r = Math.floor(i / cols); pos.set(id, { x: RX0 + dx * (c + 0.5), y: 12 + dy * (r + 0.5) }); }); return pos; }, [pinned, focusNbrs, basePositions, orgs, liveEdges]); // Radius for parked nodes scales down when many - so they fit cleanly const parkedR = useMemoM(() => { if (!pinned || !focusNbrs) return 10; const N = orgs.length - focusNbrs.size; if (N <= 16) return 8; if (N <= 36) return 6; if (N <= 64) return 5; return 4; }, [pinned, focusNbrs, orgs.length]); // Legend roles = those actually present in this slice (intersected with visibleRoles) const legendRoles = useMemoM(() => { const present = new Set(); for (const o of orgs) { const p = primaryRoleFor(o); if (p) present.add(p); } const pool = visibleRoles || ROLE_DEFS; return pool.filter(r => present.has(r.id)); }, [orgs, visibleRoles]); const roleCounts = useMemoM(() => { const c = {}; for (const o of orgs) { const p = primaryRoleFor(o); if (p) c[p] = (c[p] || 0) + 1; } return c; }, [orgs]); return (
e.stopPropagation()}>
{subtitle}

{title}

{orgs.length} organization{orgs.length === 1 ? '' : 's'} {!hideNetwork && {liveEdges.length} public collaboration{liveEdges.length === 1 ? '' : 's'}} {!hideNetwork && liveEdges.length === 0 && No collaborations between these organizations are recorded.}
{(() => { const present = new Set(orgs.map(o => o.type).filter(Boolean)); // Dedup by colour so palettes with alias keys (e.g. "MDB/IFI" and // "MDB/ IFI") collapse to a single legend chip. const seen = new Set(); const typeList = Object.keys(typePalette).filter(t => { if (!present.has(t)) return false; const c = typePalette[t]; if (seen.has(c)) return false; seen.add(c); return true; }); if (typeList.length === 0) return null; return (
Types {typeList.map(t => ( {t} ))}
); })()} {!hideNetwork && legendRoles.length > 0 && (
Filter by role {legendRoles.map(r => { const isOn = roleFilter.has(r.id); const isOff = roleFilterActive && !isOn; return ( ); })}
)} {!hideNetwork && (
{ if (pinned) setPinned(null); }}> {orgs.length === 0 ? (
No organizations in this slice.
) : ( {/* Divider visible only when pinned */} {pinned && ( )} {liveEdges.map((e, i) => { const a = positions.get(e.a), b = positions.get(e.b); if (!a || !b) return null; const oA = orgs.find(o => o.id === e.a); const oB = orgs.find(o => o.id === e.b); const passRole = !roleFilterActive || (oA && oB && matchesRoleFilter(oA) && matchesRoleFilter(oB)); const isActive = active && (e.a === active || e.b === active); const isFocusEdge = focusNbrs && focusNbrs.has(e.a) && focusNbrs.has(e.b); let opacity, stroke; if (!active && !roleFilterActive) { opacity = 0.35; stroke = 0.8; } else if (isActive && passRole) { opacity = 0.95; stroke = 2.0; } else if (isFocusEdge && passRole) { opacity = 0.55; stroke = 1.2; } else if (passRole && !active) { opacity = 0.45; stroke = 1.0; } else { opacity = 0.10; stroke = 0.8; } return ; })} {orgs.map(o => { const p = positions.get(o.id); if (!p) return null; const color = colorFor(o); const isFocus = focusNbrs && focusNbrs.has(o.id); const matchesRole = matchesRoleFilter(o); // Dim if outside focus (pin/hover), OR doesn't match active role filter const dim = (focusNbrs && !isFocus) || (!focusNbrs && roleFilterActive && !matchesRole); const isRoot = active === o.id; const isParked = pinned && focusNbrs && !isFocus; const label = o.name.length > 28 ? o.name.slice(0, 26) + '...' : o.name; const isHovered = hover === o.id; const rad = isRoot ? 13 : (isParked ? parkedR : 10); // Parked labels: shorter + smaller so they fit in the dense grid const parkedLabel = o.name.length > 18 ? o.name.slice(0, 16) + '...' : o.name; const displayLabel = isParked && !isHovered ? parkedLabel : label; const labelSize = isRoot ? 18 : (isParked ? (isHovered ? 16 : 14) : 16); return ( setHover(o.id)} onMouseLeave={() => setHover(null)} onClick={(ev) => { ev.stopPropagation(); setPinned(pinned === o.id ? null : o.id); }} onDoubleClick={(ev) => { ev.stopPropagation(); onPickOrg(o); onClose(); }}> {displayLabel} ); })} )}
)} {/* All organizations in this slice, listed below the network so they're readable even when the graph is dense or has no recorded edges. */} {orgs.length > 0 && (
All organizations in this view {orgs.length}
{[...orgs].sort((a, b) => a.name.localeCompare(b.name)).map(o => ( ))}
)}
); } // ─────────────────────── Context menu ─────────────────────── function ContextMenu({ x, y, items, onClose }) { useEffectM(() => { const onDoc = () => onClose(); document.addEventListener('click', onDoc); document.addEventListener('contextmenu', onDoc); return () => { document.removeEventListener('click', onDoc); document.removeEventListener('contextmenu', onDoc); }; }, [onClose]); // Clamp inside viewport const W = 240, H = items.length * 36 + 12; const left = Math.min(x, window.innerWidth - W - 8); const top = Math.min(y, window.innerHeight - H - 8); return (
{items.map((it, i) => ( ))}
); } // ─────────────────────── Hover card ─────────────────────── function HoverCard({ org, x, y, facets }) { if (!org) return null; const W = 380, H = 260; const margin = 14; let left = x + 16, top = y + 16; if (left + W + margin > window.innerWidth) left = x - W - 16; if (top + H + margin > window.innerHeight) top = y - H - 16; if (left < margin) left = margin; if (top < margin) top = margin; const typeShort = facets.types.short[org.type] || facets.types.labels[org.type] || org.type || '-'; const desc = (org.description || '').trim(); const regionTags = (org.regions || []).map(r => facets.regions.labels[r] || r); return (

{org.name}

{typeShort}
{/* Budget / founded year removed - deprioritised data points (Andrea & Rintati). */} {org.regions?.length > 0 && (
{org.regions.length} region{org.regions.length === 1 ? '' : 's'}
)} {desc &&

{desc}

} {regionTags.length > 0 && (
{regionTags.map((t, i) => {t})}
)}
); } // ─────────────────────── Dot ─────────────────────── function Dot({ org, roles, isFocus, isCollab, isMatch, isDim, onHover, onClick, onContext }) { // Dot colour reflects the organization type. Roles are still used by parent for filtering/dimming. const bg = typeColor(org); return ( onHover(org.id, e)} onMouseMove={(e) => onHover(org.id, e)} onMouseLeave={() => onHover(null)} onClick={(e) => { e.stopPropagation(); onClick(org); }} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); onContext(org, e.clientX, e.clientY); }} /> ); } // ─────────────────────── Heatmap colour ramp ─────────────────────── // A clear multi-hue scale (cool green → yellow → orange → red) so density // differences read at a glance rather than as faint lightness steps. `t` is the // cell's share of the busiest cell (0-1). Returns the fill plus whether dark // text is needed (light fills → dark text, via a rough luminance test). const HEAT_STOPS = [ [0.00, [ 38, 128, 92]], // few - green [0.45, [253, 206, 7]], // mid - yellow [0.75, [240, 136, 62]], // orange [1.00, [224, 58, 49]], // many - red ]; function heatColor(t) { t = Math.max(0, Math.min(1, t)); let a = HEAT_STOPS[0], b = HEAT_STOPS[HEAT_STOPS.length - 1]; for (let i = 0; i < HEAT_STOPS.length - 1; i++) { if (t >= HEAT_STOPS[i][0] && t <= HEAT_STOPS[i + 1][0]) { a = HEAT_STOPS[i]; b = HEAT_STOPS[i + 1]; break; } } const f = (t - a[0]) / ((b[0] - a[0]) || 1); const c = [0, 1, 2].map(k => Math.round(a[1][k] + (b[1][k] - a[1][k]) * f)); const lum = (0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]) / 255; return { fill: `rgba(${c[0]}, ${c[1]}, ${c[2]}, 0.9)`, dark: lum > 0.6 }; } // ─────────────────────── Cell ─────────────────────── function Cell({ orgs, focusId, collabSet, collabRoot, highlightSet, matchOrg, filterActive, heatmap, heatMax, onHover, onClick, onContext, onBlank, onCellClick, columnLabel, rowLabel, gapBefore, pillarDim }) { const empty = orgs.length === 0; // Heatmap mode - replace the dot cloud with a single count, the cell tinted by // how many initiatives it holds (relative to the busiest cell). Respects the // active highlight filter so the colour tracks what the dot view would show. if (heatmap) { const count = filterActive ? orgs.filter(matchOrg).length : orgs.length; const t = heatMax > 0 ? count / heatMax : 0; const hc = count === 0 ? null : heatColor(t); return ( 0 ? `${count} initiative${count === 1 ? '' : 's'} · click to list them` : undefined} onClick={(e) => { e.stopPropagation(); if (count > 0) onCellClick(); else onBlank(); }} >
{count > 0 && {count}}
); } // Dots stop propagation on click, so any click that reaches the cell is a // background click: open this cell's collaboration network when it has orgs, // otherwise just clear any active highlight. const handleCellBg = (e) => { e.stopPropagation(); if (orgs.length > 0) onCellClick(); else onBlank(); }; return ( 0 ? 'Click to list the initiatives in this cell · click a dot to open its passport' : undefined} onClick={handleCellBg} >
{(() => { const decorate = (o) => { const orgRoles = rolesFor(o); const matched = !filterActive || matchOrg(o); const isRoot = collabRoot === o.id || focusId === o.id; const isCollab = collabSet && collabSet.has(o.id) && collabRoot !== o.id; // Unified filter highlight - same style for every filter type. const isMatch = filterActive && matched && !isRoot && !isCollab; const isDim = (focusId && focusId !== o.id && !(collabSet && collabSet.has(o.id))) || (collabSet && !collabSet.has(o.id)) || (filterActive && !matched); return { o, orgRoles, isRoot, isCollab, isMatch, matched, isDim }; }; const isIndustry = (orgRoles) => orgRoles.length === 1 && orgRoles[0] === 'industry-players'; const primary = []; const industry = []; for (const o of orgs) { const d = decorate(o); (isIndustry(d.orgRoles) ? industry : primary).push(d); } // Bring filter-matched dots to the front so they read top-left. // Stable in modern engines, so unmatched keep their relative order. if (filterActive) { const matchedFirst = (a, b) => Number(b.matched) - Number(a.matched); primary.sort(matchedFirst); industry.sort(matchedFirst); } const renderDot = (d) => ( ); return ( <> {primary.length > 0 && (
{primary.map(renderDot)}
)} {industry.length > 0 && (
0 ? ' mx-cell-group--divided' : '')}> {industry.map(renderDot)}
)} ); })()}
); } // ─────────────────────── Main view ─────────────────────── function MatrixView({ orgs, allOrgs, edges, onPickOrg, filters, setFilters, dataset, highlightOrgId, onOpenMethodology }) { // Clickable ? bubble that deep-links to a section of the Methodology tab. const MHelp = ({ section, title }) => ( ); const ds = dataset || window.INITIATIVES_DATA; const { facets } = ds; const dragScroll = useDragScroll(); const tableRef = useRefM(null); // Zoom - applied as CSS `zoom` on the table so the scroll wrapper adapts. const [zoom, setZoom] = useStateM(1); const ZMIN = 0.4, ZMAX = 2; const clampZoom = (z) => Math.max(ZMIN, Math.min(ZMAX, z)); const zoomIn = () => setZoom(z => clampZoom(Math.round(z * 110) / 100)); const zoomOut = () => setZoom(z => clampZoom(Math.round(z * 100 / 110) / 100)); const fitHeight = () => { const wrap = dragScroll.ref.current, table = tableRef.current; if (!wrap || !table) return; const natural = table.getBoundingClientRect().height / zoom; // unzoomed height if (!natural) return; const top = wrap.getBoundingClientRect().top; const avail = window.innerHeight - top - 24; // viewport below the table's top setZoom(clampZoom(avail / natural)); }; const isIndia = ds === window.INDIA_DATA; // Show Industry players role only when relevant (India view) const visibleRoles = isIndia ? ROLE_DEFS_INDIA : ROLE_DEFS_BASE; const aggregateRowId = isIndia ? 'all-india' : 'Global'; const aggregateRowLabel = isIndia ? 'All India' : 'Multiregion'; const regionOrder = facets.regions.order; const regionLabels = facets.regions.labels; const regional = regionOrder.filter(r => r !== aggregateRowId); const barrierOrder = (facets.barriers && facets.barriers.order) || []; const [rowMode, setRowMode] = useStateM('regions'); // 'regions' | 'barriers' const rowOrder = rowMode === 'barriers' ? barrierOrder : regionOrder; const rowLabels = rowMode === 'barriers' ? Object.fromEntries(barrierOrder.map(b => [b, b])) : { ...regionLabels, [aggregateRowId]: aggregateRowLabel }; const hasAggregate = rowMode !== 'barriers'; const regularRows = rowMode === 'barriers' ? barrierOrder : regional; const index = useMemoM(() => buildIndex(orgs, rowOrder, rowMode), [orgs, rowOrder, rowMode]); const showRegional = true; const [hoverOrg, setHoverOrg] = useStateM(null); // hover const [hoverPos, setHoverPos] = useStateM(null); // {x,y} const [roleFilter, setRoleFilter] = useStateM([]); // [roleId] - Barriers filter (Geography rows) const [geoFilter, setGeoFilter] = useStateM([]); // [regionId] - Geography filter (Barriers rows) const [typeFilter, setTypeFilter] = useStateM([]); // [type] - org-type highlight (both modes) const onHoverDot = (id, e) => { setHoverOrg(id); if (id && e) setHoverPos({ x: e.clientX, y: e.clientY }); else setHoverPos(null); }; // Pillar filter - highlights value-chain columns matching the selected // pillar (Andrea, 2026-05-28; mirrors the schematic's Powering/Grids cards). const [pillarFilter, setPillarFilter] = useStateM([]); // ['powering' | 'grids'] const togglePillarFilter = (p) => setPillarFilter(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p] ); const [stickyOrg, setStickyOrg] = useStateM(null); // click-locked highlight everywhere // Sync external highlight (from search panel) into sticky React.useEffect(() => { if (highlightOrgId) { setStickyOrg(highlightOrgId); setCollabFor(null); } }, [highlightOrgId]); const [collabFor, setCollabFor] = useStateM(null); // org id whose collaborators we highlight const [heatmap, setHeatmap] = useStateM(false); // count heatmap instead of dots const [ctxMenu, setCtxMenu] = useStateM(null); // {x,y,items} const [popup, setPopup] = useStateM(null); // {title,subtitle,orgs,edges} // ‘Active org for highlighting everywhere’ - hover takes precedence over sticky const focusId = hoverOrg || stickyOrg; const collabSet = useMemoM(() => { if (!collabFor) return null; const set = new Set([collabFor]); for (const e of edges) { if (e.a === collabFor) set.add(e.b); if (e.b === collabFor) set.add(e.a); } return set; }, [collabFor, edges]); const highlightSet = collabSet; // alias for clarity in Cell // ─── Unified highlight filters ─── // Every "filter" (Types, plus Barriers in Geography rows / Geography in // Barriers rows) shares one behaviour: matching dots are highlighted and // pulled to the top-left of their cell; the rest are dimmed. Nothing is // removed. The available y-axis filter swaps with the row mode so it never // duplicates the row dimension (no Barriers filter while rows ARE barriers). const activeRoleFilter = rowMode === 'barriers' ? [] : roleFilter; const activeGeoFilter = rowMode === 'barriers' ? geoFilter : []; const filterActive = typeFilter.length > 0 || activeRoleFilter.length > 0 || activeGeoFilter.length > 0; const matchOrg = (o) => { if (typeFilter.length && !typeFilter.includes(o.type)) return false; if (activeRoleFilter.length) { const rs = rolesFor(o); if (!rs.some(r => activeRoleFilter.includes(r))) return false; } if (activeGeoFilter.length) { const regs = o.regions || []; if (!regs.some(r => activeGeoFilter.includes(r))) return false; } return true; }; function openContextMenu(org, x, y) { setCtxMenu({ x, y, items: [ { label: 'Open details', onClick: () => onPickOrg(org) }, { label: 'Show all collaborators', onClick: () => { setCollabFor(org.id); setStickyOrg(null); } }, ...(stickyOrg || collabFor ? [{ label: 'Clear highlight', onClick: () => { setStickyOrg(null); setCollabFor(null); } }] : []), ], }); } function openRowPopup(regionId) { const set = new Set(); for (const c of M_COLS) for (const o of index[regionId][c.id]) set.add(o.id); const list = [...set].map(id => orgs.find(o => o.id === id)).filter(Boolean); setPopup({ title: rowLabels[regionId] || regionId, subtitle: rowMode === 'barriers' ? 'Initiatives within barrier' : 'Initiatives within row', orgs: list, }); } function openColPopup(colId) { const set = new Set(); for (const r of rowOrder) for (const o of index[r][colId]) set.add(o.id); const list = [...set].map(id => orgs.find(o => o.id === id)).filter(Boolean); const col = M_COL_BY_ID[colId]; setPopup({ title: `${col.label}${col.sub ? ' ' + col.sub : ''}`, subtitle: `Initiatives within column · ${col.groupLabel}`, orgs: list, }); } function openCellPopup(rowId, colId) { const set = new Set(); for (const o of index[rowId][colId]) set.add(o.id); const list = [...set].map(id => orgs.find(o => o.id === id)).filter(Boolean); if (list.length === 0) return; const col = M_COL_BY_ID[colId]; setPopup({ title: `${rowLabels[rowId] || rowId} · ${col.label}${col.sub ? ' ' + col.sub : ''}`, subtitle: rowMode === 'barriers' ? 'Initiatives within cell · barrier × segment' : 'Initiatives within cell · region × segment', orgs: list, }); } // Per-cell initiative count for the heatmap. When a highlight filter is // active the count reflects only matching initiatives (dimmed dots are // effectively filtered out), so the heatmap responds to the same filters as // the dot view. `heatMax` normalises the colour ramp across visible cells. const heatCount = (list) => filterActive ? list.filter(matchOrg).length : list.length; const heatMax = useMemoM(() => { let m = 0; const regs = showRegional ? rowOrder : (hasAggregate ? [aggregateRowId] : rowOrder); for (const r of regs) for (const c of M_COLS) { const n = heatCount(index[r][c.id]); if (n > m) m = n; } return m; }, [index, showRegional, aggregateRowId, rowOrder, hasAggregate, filterActive, typeFilter, activeRoleFilter, activeGeoFilter]); // Clear sticky/collab if user clicks the empty backdrop const clearHighlights = () => { setStickyOrg(null); setCollabFor(null); }; function renderRow(regionId, isGlobal) { return ( { openRowPopup(regionId); }} title="Click to list all initiatives in this row" > {rowLabels[regionId] || regionId} {rowMode === 'regions' && REGION_ACCESS_METRIC[regionId] && (() => { const m = REGION_ACCESS_METRIC[regionId]; const col = tierColor(m); return ( {m.access} access {m.reliability && ( reliability {m.reliability} )} ); })()} {isGlobal && ( click for initiatives ↗ )} {!isGlobal && initiatives ↗} {M_COLS.map(c => ( onPickOrg(o)} onContext={openContextMenu} onBlank={clearHighlights} onCellClick={() => openCellPopup(regionId, c.id)} columnLabel={c.label + (c.sub ? ' ' + c.sub : '')} rowLabel={rowLabels[regionId] || regionId} gapBefore={c.gapBefore} pillarDim={pillarFilter.length > 0 && !colMatchesPillar(c.id, pillarFilter)} /> ))} ); } return (
{/* Prominent Rows axis toggle - kept above the toolbar because changing the y-axis between Geography and Barriers fundamentally re-frames what the heat map is showing. */}
e.stopPropagation()}> Show rows as Toggle the y-axis to compare organizations by region or by which barrier they address.
{/* Toolbar */}
e.stopPropagation()}>
Click any organization to open its passport · right-click for collaborators · click row/column header to list its initiatives
{heatmap && (
Fewer More{heatMax ? ` (max ${heatMax})` : ''}
)}
{(stickyOrg || collabFor) && ( )}
{/* Pillar filter - mirrors the Energy Ecosystem schematic's Powering / Grids cards. Highlights matching value-chain columns; overarching columns (policy, finance) stay visible because they aren't pillar-coded in the diagram either. */}
e.stopPropagation()}> Filter by value-chain pillar {[ { id: 'powering', label: 'Energy access', dot: '#fdce07' }, { id: 'grids', label: 'Grid modernization', dot: '#84c98f' }, ].map(p => { const on = pillarFilter.includes(p.id); const off = pillarFilter.length > 0 && !on; return ( ); })} {pillarFilter.length > 0 && ( )}
{/* Types legend - click to filter */}
Types {(() => { const present = new Set(allOrgs.map(o => o.type).filter(Boolean)); const anyActive = typeFilter.length > 0; return Object.keys(TYPE_TINT_HEX).filter(t => present.has(t)).map(t => { const active = typeFilter.includes(t); return ( ); }); })()} {typeFilter.length > 0 && ( )}
{/* Barriers legend (click to highlight) - only when rows are Geography, so it doesn't duplicate the row dimension in Barriers mode. */} {rowMode !== 'barriers' && (
Barriers {visibleRoles.map(r => { const active = roleFilter.includes(r.id); const anyActive = roleFilter.length > 0; return ( ); })} {roleFilter.length > 0 && ( )}
)} {/* Geography legend (click to highlight) - replaces the Barriers legend when rows are Barriers, so each mode offers the y-axis it doesn't already use as a row. */} {rowMode === 'barriers' && (
Geography {regionOrder.map(rid => { const active = geoFilter.includes(rid); const anyActive = geoFilter.length > 0; return ( ); })} {geoFilter.length > 0 && ( )}
)} {/* Status banner when highlights are active */} {collabFor && (
Showing {orgs.find(o => o.id === collabFor)?.name} and its{' '} {(collabSet?.size || 1) - 1} collaborator{(collabSet?.size || 1) - 1 === 1 ? '' : 's'}.
)} {stickyOrg && !collabFor && (
Showing every cell {orgs.find(o => o.id === stickyOrg)?.name} appears in.
)} {/* Table */}
e.stopPropagation()}> {M_GROUPS.map(g => { const groupHasMatch = g.cols.some(c => colMatchesPillar(c.id, pillarFilter)); const groupDim = pillarFilter.length > 0 && !groupHasMatch; return ( ); })} {M_COLS.map(c => { const dim = pillarFilter.length > 0 && !colMatchesPillar(c.id, pillarFilter); return ( ); })} {hasAggregate && renderRow(aggregateRowId, true)} {regularRows.map(r => renderRow(r, false))}
{g.label}
openColPopup(c.id)} title="Click to list all initiatives in this column" >
{c.label}
{c.sub &&
{c.sub}
}
initiatives ↗

Organisations are plotted in every cell where they operate - same org may appear many times. Dot colour shows the organization's primary influencer role.

{/* Context menu */} {ctxMenu && setCtxMenu(null)} />} {/* Network popup */} {popup && ( setPopup(null)} /> )} {/* Hover card */} {hoverOrg && hoverPos && ( o.id === hoverOrg)} x={hoverPos.x} y={hoverPos.y} facets={facets} /> )}
); } window.MatrixView = MatrixView; // Exported so other views (e.g. the Regional initiatives table) can reuse the // same network modal - pass `colorFor` / `typePalette` for view-specific colours. window.MatrixNetworkModal = NetworkModal;