// 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 (
    <div className="mx-modal-scrim" onClick={onClose}>
      <div className="mx-modal" onClick={e => e.stopPropagation()}>
        <header className="mx-modal-head">
          <div>
            <div className="mx-modal-eyebrow">{subtitle}</div>
            <h3>{title}</h3>
          </div>
          <button className="mx-modal-close" onClick={onClose} aria-label="Close">×</button>
        </header>
        <div className="mx-modal-meta">
          <span><b>{orgs.length}</b> organization{orgs.length === 1 ? '' : 's'}</span>
          {!hideNetwork && <span><b>{liveEdges.length}</b> public collaboration{liveEdges.length === 1 ? '' : 's'}</span>}
          {!hideNetwork && liveEdges.length === 0 && <span className="mx-modal-warn">No collaborations between these organizations are recorded.</span>}
        </div>
        {(() => {
          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 (
            <div className="mx-modal-legend mx-modal-legend--types">
              <span className="mx-modal-legend-label">Types</span>
              {typeList.map(t => (
                <span key={t} className="mx-modal-legend-chip is-static" title={t}>
                  <span className="mx-modal-legend-dot" style={{ background: typePalette[t] }}></span>
                  {t}
                </span>
              ))}
            </div>
          );
        })()}
        {!hideNetwork && legendRoles.length > 0 && (
          <div className="mx-modal-legend">
            <span className="mx-modal-legend-label">Filter by role</span>
            {legendRoles.map(r => {
              const isOn  = roleFilter.has(r.id);
              const isOff = roleFilterActive && !isOn;
              return (
                <button
                  key={r.id}
                  type="button"
                  className={'mx-modal-legend-chip' + (isOn ? ' is-on' : '') + (isOff ? ' is-off' : '')}
                  onClick={() => toggleRole(r.id)}
                  title={`${r.label} · ${roleCounts[r.id] || 0} org${(roleCounts[r.id]||0)===1?'':'s'}`}
                >
                  <span className="mx-modal-legend-dot" style={{ background: r.color }}></span>
                  {r.label}
                  <span className="mx-modal-legend-count">{roleCounts[r.id] || 0}</span>
                </button>
              );
            })}
            <button
              type="button"
              className={'mx-modal-legend-clear' + ((roleFilterActive || pinned) ? '' : ' is-disabled')}
              disabled={!roleFilterActive && !pinned}
              onClick={() => { setRoleFilter(new Set()); setPinned(null); }}
            >
              Reset
            </button>
          </div>
        )}
        {!hideNetwork && (
        <div className="mx-modal-canvas" onClick={() => { if (pinned) setPinned(null); }}>
          {orgs.length === 0 ? (
            <div className="mx-modal-empty">No organizations in this slice.</div>
          ) : (
            <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet">
              {/* Divider visible only when pinned */}
              {pinned && (
                <line x1={Math.round(W * 0.61)} y1="10" x2={Math.round(W * 0.61)} y2={H - 10}
                  stroke="#84c98f" strokeOpacity="0.18" strokeWidth="1" strokeDasharray="4 6" />
              )}
              <g>
                {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 <line key={i} x1={a.x} y1={a.y} x2={b.x} y2={b.y}
                    stroke="#84c98f" strokeWidth={stroke} opacity={opacity}
                    style={{ transition: 'opacity 300ms ease' }} />;
                })}
              </g>
              <g>
                {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 (
                    <g key={o.id}
                       style={{
                         transform: `translate(${p.x}px, ${p.y}px)`,
                         transition: 'transform 480ms cubic-bezier(0.22,0.61,0.36,1), opacity 280ms ease',
                         opacity: dim ? 0.7 : 1,
                         cursor: 'pointer',
                       }}
                       onMouseEnter={() => setHover(o.id)}
                       onMouseLeave={() => setHover(null)}
                       onClick={(ev) => {
                         ev.stopPropagation();
                         setPinned(pinned === o.id ? null : o.id);
                       }}
                       onDoubleClick={(ev) => { ev.stopPropagation(); onPickOrg(o); onClose(); }}>
                      <circle r={rad} fill={color}
                        stroke={isRoot ? '#fdce07' : '#ffffff'}
                        strokeWidth={isRoot ? 2.5 : 1.4} />
                      <text y={-(rad + 5)} textAnchor="middle"
                        fontSize={labelSize}
                        fontWeight={isRoot ? 700 : 400}
                        fill="#f4f3ed"
                        style={{ paintOrder: 'stroke', stroke: 'rgba(4,40,32,0.85)', strokeWidth: 3, pointerEvents: 'none' }}>
                        {displayLabel}
                      </text>
                    </g>
                  );
                })}
              </g>
            </svg>
          )}
        </div>
        )}
        {/* 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 && (
          <div className="mx-modal-list">
            <div className="mx-modal-list-head">
              All organizations in this view <span className="mx-modal-list-count">{orgs.length}</span>
            </div>
            <div className="mx-modal-list-items">
              {[...orgs].sort((a, b) => a.name.localeCompare(b.name)).map(o => (
                <button
                  key={o.id}
                  type="button"
                  className="mx-modal-list-item"
                  onMouseEnter={() => setHover(o.id)}
                  onMouseLeave={() => setHover(null)}
                  onClick={() => { onPickOrg(o); onClose(); }}
                  title={`Open ${o.name}`}
                >
                  <span className="mx-modal-list-dot" style={{ background: colorFor(o) }}></span>
                  <span className="mx-modal-list-name">{o.name}</span>
                  {o.type && <span className="mx-modal-list-type">{o.type}</span>}
                </button>
              ))}
            </div>
          </div>
        )}
        <footer className="mx-modal-foot">
          {hideNetwork
            ? 'Click any organization to open its details.'
            : pinned
              ? 'Pinned - click empty space or the pinned node to release. Double-click any node to open details.'
              : 'Hover a node to preview partners. Click to pin the highlight. Double-click to open details.'}
        </footer>
      </div>
    </div>
  );
}

// ─────────────────────── 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 (
    <div className="mx-ctxmenu" style={{ left, top }}>
      {items.map((it, i) => (
        <button key={i} className="mx-ctxmenu-item" onClick={(e) => { e.stopPropagation(); it.onClick(); onClose(); }}>
          {it.label}
        </button>
      ))}
    </div>
  );
}

// ─────────────────────── 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 (
    <div className="mx-hover" style={{ left, top, width: W }}>
      <div className="mx-hover-stripe"></div>
      <div className="mx-hover-body">
        <div className="mx-hover-head">
          <h4 className="mx-hover-title">{org.name}</h4>
          <span className="mx-hover-type">{typeShort}</span>
        </div>
        {/* Budget / founded year removed - deprioritised data points (Andrea & Rintati). */}
        {org.regions?.length > 0 && (
          <div className="mx-hover-stats">
            <span><b>{org.regions.length}</b> <em>region{org.regions.length === 1 ? '' : 's'}</em></span>
          </div>
        )}
        {desc && <p className="mx-hover-desc">{desc}</p>}
        {regionTags.length > 0 && (
          <div className="mx-hover-tags">
            {regionTags.map((t, i) => <span key={'r' + i} className="mx-hover-tag mx-hover-tag--region">{t}</span>)}
          </div>
        )}
      </div>
    </div>
  );
}

// ─────────────────────── 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 (
    <span
      className={
        'mx-dot' +
        (isFocus ? ' is-focus' : '') +
        (isCollab ? ' is-collab' : '') +
        (isMatch ? ' is-match' : '') +
        (isDim ? ' is-dim' : '')
      }
      style={{ background: bg }}
      onMouseEnter={(e) => 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 (
      <td
        className={
          'mx-cell mx-cell--heat' +
          (count === 0 ? ' is-empty' : ' is-clickable') +
          (gapBefore ? ' mx-gap-before' : '') +
          (pillarDim ? ' is-pillar-dim' : '')
        }
        style={{ background: hc ? hc.fill : 'transparent' }}
        aria-label={`${rowLabel} · ${columnLabel} - ${count} initiative${count === 1 ? '' : 's'}`}
        title={count > 0 ? `${count} initiative${count === 1 ? '' : 's'} · click to list them` : undefined}
        onClick={(e) => { e.stopPropagation(); if (count > 0) onCellClick(); else onBlank(); }}
      >
        <div className="mx-cell-inner mx-cell-inner--heat">
          {count > 0 && <span className={'mx-heat-num' + (hc.dark ? ' is-dark' : '')}>{count}</span>}
        </div>
      </td>
    );
  }
  // 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 (
    <td
      className={
        'mx-cell' +
        (empty ? ' is-empty' : '') +
        (gapBefore ? ' mx-gap-before' : '') +
        (pillarDim ? ' is-pillar-dim' : '') +
        (empty ? '' : ' is-clickable')
      }
      aria-label={`${rowLabel} · ${columnLabel} - ${orgs.length} organization${orgs.length === 1 ? '' : 's'}`}
      title={orgs.length > 0 ? 'Click to list the initiatives in this cell · click a dot to open its passport' : undefined}
      onClick={handleCellBg}
    >
      <div
        className="mx-cell-inner"
        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) => (
            <Dot
              key={d.o.id}
              org={d.o}
              roles={d.orgRoles}
              isFocus={d.isRoot}
              isCollab={d.isCollab}
              isMatch={d.isMatch}
              isDim={d.isDim}
              onHover={onHover}
              onClick={onClick}
              onContext={onContext}
            />
          );
          return (
            <>
              {primary.length > 0 && (
                <div className="mx-cell-group">{primary.map(renderDot)}</div>
              )}
              {industry.length > 0 && (
                <div className={'mx-cell-group mx-cell-group--industry' + (primary.length > 0 ? ' mx-cell-group--divided' : '')}>
                  {industry.map(renderDot)}
                </div>
              )}
            </>
          );
        })()}
      </div>
    </td>
  );
}

// ─────────────────────── 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 }) => (
    <button
      type="button"
      className="mhelp mhelp--link"
      title={title || 'Open the methodology section'}
      onClick={(e) => { e.stopPropagation(); onOpenMethodology && onOpenMethodology(section); }}
    >?</button>
  );
  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 (
      <tr key={regionId} className={'mx-row' + (isGlobal ? ' mx-row--global' : '')}>
        <th
          scope="row"
          className="mx-rowhead"
          onClick={() => {
            openRowPopup(regionId);
          }}
          title="Click to list all initiatives in this row"
        >
          <span className="mx-rowhead-label">{rowLabels[regionId] || regionId}</span>
          {rowMode === 'regions' && REGION_ACCESS_METRIC[regionId] && (() => {
            const m = REGION_ACCESS_METRIC[regionId];
            const col = tierColor(m);
            return (
            <span
              className="mx-rowhead-metric"
              title={`Energy access (${m.access}, 2023 est.): the percentage of population that can access electricity.${m.reliability ? `\n\nReliability of supply (${m.reliability}, 2019 est.): a Low / Medium / High rating based on the World Bank "getting electricity" score, which combines the duration and frequency of power outages with the strength of the regulatory framework for monitoring outages, restoring supply, and ensuring tariff transparency.` : ''}\n\nSource: World Bank, Sustainable Energy for All (SE4ALL) Global Electrification Database, drawing on the Tracking SDG 7 report.`}
            >
              <span style={{ display: 'block' }}>
                <span style={{ color: col, fontWeight: 600 }}>{m.access}</span> access
              </span>
              {m.reliability && (
                <span style={{ display: 'block', whiteSpace: 'nowrap' }}>
                  reliability <span style={{ color: col, fontWeight: 600 }}>{m.reliability}</span>
                  <MHelp section="methodology-access-metric" title="Methodology - regional energy-access metric" />
                </span>
              )}
            </span>
            );
          })()}
          {isGlobal && (
            <span className="mx-rowhead-hint">
              click for initiatives ↗
            </span>
          )}
          {!isGlobal && <span className="mx-rowhead-hint">initiatives ↗</span>}
        </th>
        {M_COLS.map(c => (
          <Cell
            key={c.id}
            orgs={index[regionId][c.id]}
            focusId={focusId}
            collabSet={collabSet}
            collabRoot={collabFor}
            highlightSet={null}
            matchOrg={matchOrg}
            filterActive={filterActive}
            heatmap={heatmap}
            heatMax={heatMax}
            onHover={onHoverDot}
            onClick={(o) => 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)}
          />
        ))}
      </tr>
    );
  }

  return (
    <div className="mx-view" onClick={clearHighlights}>
      {/* 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. */}
      <div className="mx-rowmode-banner" onClick={e => e.stopPropagation()}>
        <span className="mx-rowmode-banner-label">Show rows as</span>
        <button
          type="button"
          className={'mx-rowmode-banner-btn' + (rowMode === 'regions' ? ' is-on' : '')}
          onClick={() => setRowMode('regions')}
        >Geography</button>
        <MHelp section="methodology-geography" title="Methodology - Geography & regional assignment" />
        <button
          type="button"
          className={'mx-rowmode-banner-btn' + (rowMode === 'barriers' ? ' is-on' : '')}
          onClick={() => setRowMode('barriers')}
        >Barriers</button>
        <span className="mx-rowmode-banner-hint">
          Toggle the y-axis to compare organizations by region or by which barrier they address.
        </span>
      </div>

      {/* Toolbar */}
      <div className="mx-toolbar" data-tour="matrix-toolbar" onClick={e => e.stopPropagation()}>
        <div className="mx-toolbar-hint">
          Click any organization to open its passport · right-click for collaborators · click row/column header to list its initiatives
          <MHelp section="methodology-scope" title="Methodology - what we map (scope & inclusion)" />
        </div>
        <div className="mx-toolbar-spacer"></div>
        <button
          type="button"
          className={'mx-btn mx-btn--heat' + (heatmap ? ' is-on' : '')}
          onClick={() => setHeatmap(v => !v)}
          title="Show a heatmap of how many initiatives fall in each cell instead of the dots (responds to active filters)"
        >
          <span className="mx-btn-dot mx-btn-dot--heat"></span>
          {heatmap ? 'Show as dots' : 'Show as heatmap'}
        </button>
        {heatmap && (
          <div className="mx-heat-legend" title="Cell colour = number of initiatives in the cell">
            <span className="mx-heat-legend-cap">Fewer</span>
            <span className="mx-heat-legend-bar"></span>
            <span className="mx-heat-legend-cap">More{heatMax ? ` (max ${heatMax})` : ''}</span>
          </div>
        )}
        <div className="tbl-zoom" role="group" aria-label="Zoom">
          <button type="button" className="tbl-zoom-btn" onClick={zoomOut} disabled={zoom <= ZMIN} title="Zoom out" aria-label="Zoom out">−</button>
          <button type="button" className="tbl-zoom-val" onClick={() => setZoom(1)} title="Reset zoom to 100%">{Math.round(zoom * 100)}%</button>
          <button type="button" className="tbl-zoom-btn" onClick={zoomIn} disabled={zoom >= ZMAX} title="Zoom in" aria-label="Zoom in">+</button>
          <button type="button" className="tbl-zoom-fit" onClick={fitHeight} title="Fit the whole table height to the frame">Fit height</button>
        </div>
        {(stickyOrg || collabFor) && (
          <button className="mx-btn mx-btn--clear" onClick={clearHighlights}>
            Clear highlight
          </button>
        )}
      </div>

      {/* 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. */}
      <div className="mx-legend mx-legend--pillars" onClick={e => e.stopPropagation()}>
        <span className="mx-legend-label">Filter by value-chain pillar</span>
        {[
          { 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 (
            <button
              key={p.id}
              type="button"
              className={'mx-legend-item is-clickable' + (on ? ' is-on' : '') + (off ? ' is-faded' : '')}
              onClick={() => togglePillarFilter(p.id)}
              title={on ? `Stop highlighting ${p.label}` : `Highlight ${p.label} columns`}
            >
              <span className="mx-legend-dot" style={{ background: p.dot }}></span>
              {p.label}
            </button>
          );
        })}
        {pillarFilter.length > 0 && (
          <button type="button" className="mx-legend-clear" onClick={() => setPillarFilter([])}>Clear</button>
        )}
      </div>

      {/* Types legend - click to filter */}
      <div className="mx-legend mx-legend--types">
        <span className="mx-legend-label">Types</span>
        {(() => {
          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 (
              <button
                key={t}
                type="button"
                className={'mx-legend-item is-clickable' + (active ? ' is-on' : '') + (anyActive && !active ? ' is-faded' : '')}
                title={active ? `Stop highlighting: ${t}` : `Highlight ${t}`}
                onClick={(e) => {
                  e.stopPropagation();
                  setTypeFilter(active ? typeFilter.filter(x => x !== t) : [...typeFilter, t]);
                }}
              >
                <span className="mx-legend-dot" style={{ background: TYPE_TINT_HEX[t] }}></span>
                {t}
              </button>
            );
          });
        })()}
        {typeFilter.length > 0 && (
          <button type="button" className="mx-legend-clear" onClick={(e) => { e.stopPropagation(); setTypeFilter([]); }}>
            Clear
          </button>
        )}
      </div>

      {/* Barriers legend (click to highlight) - only when rows are Geography,
          so it doesn't duplicate the row dimension in Barriers mode. */}
      {rowMode !== 'barriers' && (
      <div className="mx-legend">
        <span className="mx-legend-label">Barriers<MHelp section="methodology-barriers" title="Methodology - barriers addressed" /></span>
        {visibleRoles.map(r => {
          const active = roleFilter.includes(r.id);
          const anyActive = roleFilter.length > 0;
          return (
            <button
              key={r.id}
              type="button"
              className={'mx-legend-item is-clickable' + (active ? ' is-on' : '') + (anyActive && !active ? ' is-faded' : '')}
              onClick={(e) => {
                e.stopPropagation();
                setRoleFilter(active ? roleFilter.filter(x => x !== r.id) : [...roleFilter, r.id]);
              }}
              title={active ? `Stop filtering by ${r.label}` : `Highlight organizations with ${r.label}`}
            >
              {r.label}
            </button>
          );
        })}
        {roleFilter.length > 0 && (
          <button type="button" className="mx-legend-clear" onClick={(e) => { e.stopPropagation(); setRoleFilter([]); }}>
            Clear
          </button>
        )}
      </div>
      )}

      {/* 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' && (
      <div className="mx-legend">
        <span className="mx-legend-label">Geography<MHelp section="methodology-geography" title="Methodology - geography & regional assignment" /></span>
        {regionOrder.map(rid => {
          const active = geoFilter.includes(rid);
          const anyActive = geoFilter.length > 0;
          return (
            <button
              key={rid}
              type="button"
              className={'mx-legend-item is-clickable' + (active ? ' is-on' : '') + (anyActive && !active ? ' is-faded' : '')}
              onClick={(e) => {
                e.stopPropagation();
                setGeoFilter(active ? geoFilter.filter(x => x !== rid) : [...geoFilter, rid]);
              }}
              title={active ? `Stop highlighting ${regionLabels[rid] || rid}` : `Highlight organizations in ${regionLabels[rid] || rid}`}
            >
              {regionLabels[rid] || rid}
            </button>
          );
        })}
        {geoFilter.length > 0 && (
          <button type="button" className="mx-legend-clear" onClick={(e) => { e.stopPropagation(); setGeoFilter([]); }}>
            Clear
          </button>
        )}
      </div>
      )}

      {/* Status banner when highlights are active */}
      {collabFor && (
        <div className="mx-banner">
          Showing <b>{orgs.find(o => o.id === collabFor)?.name}</b> and its{' '}
          <b>{(collabSet?.size || 1) - 1}</b> collaborator{(collabSet?.size || 1) - 1 === 1 ? '' : 's'}.
        </div>
      )}
      {stickyOrg && !collabFor && (
        <div className="mx-banner">
          Showing every cell <b>{orgs.find(o => o.id === stickyOrg)?.name}</b> appears in.
        </div>
      )}

      {/* Table */}
      <div className="mx-tablewrap" ref={dragScroll.ref} onMouseDown={dragScroll.onMouseDown} onClick={e => e.stopPropagation()}>
        <table className="mx-table" ref={tableRef} style={{ zoom }}>
          <thead>
            <tr className="mx-grouphead">
              <th className="mx-corner"></th>
              {M_GROUPS.map(g => {
                const groupHasMatch = g.cols.some(c => colMatchesPillar(c.id, pillarFilter));
                const groupDim = pillarFilter.length > 0 && !groupHasMatch;
                return (
                  <th
                    key={g.id}
                    colSpan={g.cols.length}
                    className={'mx-grouphead-cell' + (g.gapBefore ? ' mx-gap-before' : '') + (groupDim ? ' is-pillar-dim' : '')}
                    style={{ background: `linear-gradient(${g.tint}, ${g.tint}), var(--panel)` }}
                  >
                    <span>{g.label}</span>
                  </th>
                );
              })}
            </tr>
            <tr className="mx-colhead">
              <th className="mx-corner mx-corner--sub"></th>
              {M_COLS.map(c => {
                const dim = pillarFilter.length > 0 && !colMatchesPillar(c.id, pillarFilter);
                return (
                  <th
                    key={c.id}
                    className={'mx-colhead-cell' + (c.gapBefore ? ' mx-gap-before' : '') + (dim ? ' is-pillar-dim' : '')}
                    onClick={() => openColPopup(c.id)}
                    title="Click to list all initiatives in this column"
                  >
                    <div className="mx-colhead-label">{c.label}</div>
                    {c.sub && <div className="mx-colhead-sub">{c.sub}</div>}
                    <div className="mx-colhead-hint">initiatives ↗</div>
                  </th>
                );
              })}
            </tr>
          </thead>
          <tbody>
            {hasAggregate && renderRow(aggregateRowId, true)}
            {regularRows.map(r => renderRow(r, false))}
          </tbody>
        </table>
      </div>

      <p className="mx-foot">
        Organisations are plotted in every cell where they operate - same org may appear many times. Dot colour shows the organization's primary influencer role.
      </p>

      {/* Context menu */}
      {ctxMenu && <ContextMenu {...ctxMenu} onClose={() => setCtxMenu(null)} />}

      {/* Network popup */}
      {popup && (
        <NetworkModal
          title={popup.title}
          subtitle={popup.subtitle}
          orgs={popup.orgs}
          edges={edges}
          onPickOrg={onPickOrg}
          visibleRoles={visibleRoles}
          hideNetwork={true}
          onClose={() => setPopup(null)}
        />
      )}

      {/* Hover card */}
      {hoverOrg && hoverPos && (
        <HoverCard org={orgs.find(o => o.id === hoverOrg)} x={hoverPos.x} y={hoverPos.y} facets={facets} />
      )}
    </div>
  );
}

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;
