// V2/src/whitespaceiq.jsx
// WhitespaceIQ module — module key 'opp'.
// Renders into Chrome's #panel-opp mount.
// No import/export. Exposes all functions on window.
// All colors via CSS vars / tokens.css — zero hardcoded hex.

// ─── Constants ────────────────────────────────────────────────────────────────

const OPP_PLOT = { L: 56, T: 16, R: 598, B: 320, W: 620, H: 340 };
const OPP_MAX_DOTS = 80;
const OPP_PAGE_SIZE = 10;
const OPP_TIER_THRESHOLDS = { gold: 0.55, silver: 0.30 };
const OPP_EVIDENCE_WEIGHTS = { tv: 0.35, openness: 0.35, innovation: 0.20, gap: 0.10 };
const OPP_QUADRANTS = {
  prime:  { label: 'PRIME TARGET',   colorVar: 'teal' },
  open:   { label: 'OPEN FIELD',     colorVar: 'blue' },
  compet: { label: 'COMPETITIVE',    colorVar: 'amber' },
  crowd:  { label: 'CROWDED · WEAK', colorVar: 'text-faint' },
};

// ─── Module state ─────────────────────────────────────────────────────────────

const STATE_OPP = {
  data: [],
  page: 0,
  sortCol: null,
  sortDir: 'desc',
  ta: 'ONCOLOGY',
  rare: false,
  noBigPharma: false,
  excludeApproved: false,
  selected: null,
  loading: false,
  error: null,
  _allTiers: ['GOLD', 'SILVER', 'EMERGING'],
  _allDiseases: [],
  _allTargets: [],
  _colFilterState: {},
};

// ─── Utilities ────────────────────────────────────────────────────────────────

function assignTier(score) {
  if (score >= OPP_TIER_THRESHOLDS.gold) return 'GOLD';
  if (score >= OPP_TIER_THRESHOLDS.silver) return 'SILVER';
  return 'EMERGING';
}

function _formatTarget(d) {
  return d.target_symbol || d.target_name || d.target || 'Unknown';
}

// Known compound-word TAs that backend stores concatenated. Map to display form.
// Add new entries here when more compounds surface.
var _TA_LABEL_OVERRIDES = {
  INFECTIOUSDISEASE: 'Infectious Disease',
  RAREDISEASE: 'Rare Disease',
  WOMENSHEALTH: "Women's Health",
  MENSHEALTH: "Men's Health",
  HEMATOLOGY: 'Hematology',
  MUSCULOSKELETAL: 'Musculoskeletal',
  GASTROINTESTINAL: 'Gastrointestinal',
  REPRODUCTIVE: 'Reproductive',
  RESPIRATORY: 'Respiratory',
  DERMATOLOGY: 'Dermatology',
  ONCOLOGY: 'Oncology',
  NEUROLOGY: 'Neurology',
  IMMUNOLOGY: 'Immunology',
  CARDIOVASCULAR: 'Cardiovascular',
  METABOLIC: 'Metabolic',
};

function _formatTA(ta) {
  if (!ta) return '';
  var raw = String(ta).trim();
  var key = raw.toUpperCase().replace(/[_\s-]+/g, '');
  if (_TA_LABEL_OVERRIDES[key]) return _TA_LABEL_OVERRIDES[key];
  // Generic fallback — Title-Case + replace underscores with spaces.
  return raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase().replace(/_/g, ' ');
}

function _escHTML(str) {
  return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

function _hashStr(str) {
  var h = 0;
  for (var i = 0; i < str.length; i++) {
    h = ((h << 5) - h + str.charCodeAt(i)) | 0;
  }
  return h;
}

// ─── Evidence scorer ─────────────────────────────────────────────────────────

function _computeEvidence(d) {
  var cd = d.competitive_density_score || 0;
  var nOrgs = d.n_orgs || 0;
  var nDrugs = d.n_drugs || 0;
  var nTrials = d.n_trials || 0;
  var nMoas = d.n_moas || 0;
  var md = d.mechanism_diversity_score || 0;
  var tv = d.target_validation_score || 0;

  // 1. Target Validation (35%)
  var targetValidation = tv;

  // 2. Competitive Openness (35%) — penalize truly empty spaces
  var compOpenness = 1 - cd;
  if (nOrgs === 0 && nDrugs === 0 && nTrials <= 2) compOpenness *= 0.3;
  else if (nOrgs === 0 && nDrugs === 0) compOpenness *= 0.6;

  // 3. Innovation Potential (20%) — MOA diversity relative to trial count
  var moaSignal = nMoas > 0
    ? Math.min(1, md * 0.5 + (nMoas / Math.max(nTrials, 1)) * 0.5)
    : md * 0.4;
  var innovationPotential = Math.min(1, moaSignal);

  // 4. Clinical Gap Signal (10%) — gap between validation and clinical execution
  var gapSignal = 0;
  if (tv > 0) {
    var trialPenalty = nTrials === 0 ? 1.0
      : nTrials <= 3 ? 0.8
      : nTrials <= 10 ? 0.5
      : nTrials <= 30 ? 0.2
      : 0.05;
    gapSignal = tv * trialPenalty;
  }

  var total = targetValidation * 0.35 + compOpenness * 0.35 + innovationPotential * 0.20 + gapSignal * 0.10;
  return {
    targetValidation: targetValidation,
    compOpenness: compOpenness,
    innovation: innovationPotential,
    gapSignal: gapSignal,
    total: total,
  };
}

// ─── Deduplication ───────────────────────────────────────────────────────────

function _dedupByDisease(arr) {
  var best = {};
  arr.forEach(function (d) {
    var key = (d.disease_name || '').toLowerCase().trim();
    if (!key) return;
    var score = d.whitespace_score || 0;
    if (!best[key] || score > (best[key].whitespace_score || 0)) {
      best[key] = d;
    }
  });
  return Object.values(best);
}

// ─── Filter ───────────────────────────────────────────────────────────────────

var _OPP_TA_ALIASES = {
  'NEPHROLOGY': ['NEPHROLOGY','RENAL'], 'RENAL': ['NEPHROLOGY','RENAL'],
  'RAREDISEASE': ['RARE DISEASE','RAREDISEASE','RARE_DISEASE'],
  'RARE DISEASE': ['RARE DISEASE','RAREDISEASE','RARE_DISEASE'],
  'RARE_DISEASE': ['RARE DISEASE','RAREDISEASE','RARE_DISEASE'],
  'INFECTIOUSDISEASE': ['INFECTIOUS DISEASE','INFECTIOUSDISEASE','INFECTIOUS_DISEASE'],
  'INFECTIOUS DISEASE': ['INFECTIOUS DISEASE','INFECTIOUSDISEASE','INFECTIOUS_DISEASE'],
  'ONCOLOGY': ['ONCOLOGY','ONC','CANCER'],
  'NEUROLOGY': ['NEUROLOGY','NEURO','NEUROSCIENCE','CNS'],
  'IMMUNOLOGY': ['IMMUNOLOGY','IMMUNO','AUTOIMMUNE'],
  'METABOLIC': ['METABOLIC','METABOLISM','ENDOCRINOLOGY'],
  'RESPIRATORY': ['RESPIRATORY','PULMONARY','LUNG'],
  'DERMATOLOGY': ['DERMATOLOGY','DERM'],
  'GASTROINTESTINAL': ['GASTROINTESTINAL','GASTROENTEROLOGY','GASTRO','GI'],
  'GASTROENTEROLOGY': ['GASTROINTESTINAL','GASTROENTEROLOGY','GASTRO','GI'],
  'HEMATOLOGY': ['HEMATOLOGY','HAEMATOLOGY','BLOOD'],
  'HAEMATOLOGY': ['HEMATOLOGY','HAEMATOLOGY','BLOOD'],
  'MUSCULOSKELETAL': ['MUSCULOSKELETAL','MSK','ORTHOPEDIC','ORTHOPEDICS'],
  'CARDIOVASCULAR': ['CARDIOVASCULAR','CARDIO','CARDIOLOGY','HEART'],
  'OPHTHALMOLOGY': ['OPHTHALMOLOGY','OPHTHO','EYE'],
  'REPRODUCTIVE': ['REPRODUCTIVE','FERTILITY','OBSTETRICS','GYNECOLOGY'],
};

function filterOppData(data) {
  var filtered = data;
  var ta = STATE_OPP.ta;
  if (ta) {
    var taQ = ta.trim().toUpperCase();
    var aliases = _OPP_TA_ALIASES[taQ] || [taQ];
    filtered = filtered.filter(function (d) {
      var pri = (d.therapeutic_area || '').trim().toUpperCase();
      if (pri && (aliases.indexOf(pri) >= 0 || pri.indexOf(taQ) >= 0)) {
        d._isCrossOver = false;
        return true;
      }
      var sec = (d.all_therapeutic_areas || '').toUpperCase().split('|');
      for (var si = 0; si < sec.length; si++) {
        var st = sec[si].trim();
        if (st && aliases.indexOf(st) >= 0) { d._isCrossOver = true; return true; }
      }
      return false;
    });
  }
  if (STATE_OPP.rare) filtered = filtered.filter(function (d) { return (d.n_orgs || 0) <= 3; });
  if (STATE_OPP.noBigPharma) filtered = filtered.filter(function (d) { return (d.n_orgs || 0) <= 20 && (d.competitive_density_score || 0) < 0.7; });
  if (STATE_OPP.excludeApproved) filtered = filtered.filter(function (d) { return (d.n_approved_indications || 0) === 0; });
  return filtered;
}

// ─── Column filter system ──────────────────────────────────────────────────────

function _cfNorm(s) { return (s || '').replace(/\s+/g, ' ').trim(); }

function _applyDataFilters(dataArr, tableId, attrFn) {
  var cs = STATE_OPP._colFilterState;
  var filterKeys = Object.keys(cs).filter(function (k) { return k.indexOf(tableId + ':') === 0 && cs[k]; });
  if (filterKeys.length === 0) return dataArr;
  return dataArr.filter(function (d) {
    var attrs = attrFn(d);
    for (var i = 0; i < filterKeys.length; i++) {
      var rawKey = filterKeys[i];
      var attr = rawKey.split(':')[1];
      var selected = _cfNorm(cs[rawKey]);
      var val = _cfNorm(attrs[attr]);
      if (val !== selected) return false;
    }
    return true;
  });
}

function _closeAllCfDropdowns() {
  document.querySelectorAll('.col-filter-dd.open').forEach(function (dd) {
    dd.classList.remove('open');
    dd.remove();
  });
}

function _ensureCfThStructure(th) {
  if (th._cfStructured) return;
  th._cfStructured = true;
  th.classList.add('cf-th');
  th.style.position = 'relative';
}

function _updateFilterIcon(th, isActive) {
  var dot = th.querySelector('.cf-active-dot');
  if (dot) dot.style.display = isActive ? 'block' : 'none';
  if (isActive) th.classList.add('cf-filtered');
  else th.classList.remove('cf-filtered');
}

function createColFilter(tableId, colDataAttr, headerIdx, labelText, sortCol, allValues) {
  var tbl = document.getElementById(tableId);
  if (!tbl) return;
  var th = tbl.querySelectorAll('thead tr:first-child th')[headerIdx];
  if (!th) return;
  _ensureCfThStructure(th);

  var vals;
  if (allValues && allValues.length) {
    vals = Array.from(new Set(allValues)).sort();
  } else {
    var s = new Set();
    tbl.querySelectorAll('tbody tr').forEach(function (tr) {
      var v = (tr.dataset[colDataAttr] || '').trim();
      if (v) s.add(v);
    });
    vals = Array.from(s).sort();
  }
  if (!vals.length) return;

  var key = tableId + ':' + colDataAttr;
  if (STATE_OPP._colFilterState[key] === undefined) STATE_OPP._colFilterState[key] = '';

  if (!th.querySelector('.cf-active-dot')) {
    var dot = document.createElement('span');
    dot.className = 'cf-active-dot';
    dot.style.display = STATE_OPP._colFilterState[key] ? 'block' : 'none';
    th.appendChild(dot);
  }
  _updateFilterIcon(th, !!STATE_OPP._colFilterState[key]);

  if (!th._cfWired) {
    th._cfWired = true;
    th.style.cursor = 'pointer';
    th.onclick = function (e) {
      if (e.target.closest('.col-filter-dd')) return;
      e.stopPropagation();
      _openColFilterDD(key, th, vals, tableId, colDataAttr, labelText, sortCol);
    };
  }
}

function _openColFilterDD(key, th, allVals, tableId, colDataAttr, labelText, sortCol) {
  _closeAllCfDropdowns();
  var dd = document.createElement('div');
  dd.className = 'col-filter-dd open';
  dd.onclick = function (e) { e.stopPropagation(); };

  var searchInput = null;
  if (allVals.length > 5) {
    searchInput = document.createElement('input');
    searchInput.className = 'cf-dd-search';
    searchInput.placeholder = 'Search ' + labelText.toLowerCase() + '...';
    dd.appendChild(searchInput);
  }

  var listWrap = document.createElement('div');
  listWrap.className = 'cf-dd-list';
  dd.appendChild(listWrap);

  var currentVal = STATE_OPP._colFilterState[key] || '';

  function buildList(query) {
    listWrap.innerHTML = '';
    var q = (query || '').toLowerCase().trim();
    var filtered = q ? allVals.filter(function (v) { return v.toLowerCase().indexOf(q) >= 0; }) : allVals;

    var allItem = document.createElement('div');
    allItem.className = 'cf-dd-item cf-all-option' + (!currentVal ? ' selected' : '');
    allItem.textContent = 'All ' + labelText;
    allItem.onclick = function (e) {
      e.stopPropagation();
      STATE_OPP._colFilterState[key] = '';
      _updateFilterIcon(th, false);
      dd.classList.remove('open'); dd.remove();
      STATE_OPP.page = 0; renderOppTable();
    };
    listWrap.appendChild(allItem);

    if (!filtered.length) {
      var empty = document.createElement('div');
      empty.className = 'cf-dd-empty';
      empty.textContent = 'No matches';
      listWrap.appendChild(empty);
      return;
    }
    filtered.forEach(function (v) {
      var item = document.createElement('div');
      item.className = 'cf-dd-item' + (currentVal === v ? ' selected' : '');
      item.textContent = v;
      item.title = v;
      item.onclick = function (e) {
        e.stopPropagation();
        STATE_OPP._colFilterState[key] = v;
        currentVal = v;
        _updateFilterIcon(th, true);
        dd.classList.remove('open'); dd.remove();
        STATE_OPP.page = 0; renderOppTable();
      };
      listWrap.appendChild(item);
    });
  }
  buildList('');

  if (searchInput) {
    searchInput.addEventListener('input', function () { buildList(searchInput.value); });
    searchInput.onkeydown = function (e) {
      if (e.key === 'Escape') { dd.classList.remove('open'); dd.remove(); }
      if (e.key === 'Enter') {
        var first = listWrap.querySelector('.cf-dd-item:not(.cf-all-option)');
        if (first) first.click();
      }
    };
  }
  th.appendChild(dd);
  if (searchInput) setTimeout(function () { searchInput.focus(); }, 50);
}

function createSortIndicator(tableId, sortCol, headerIdx) {
  var tbl = document.getElementById(tableId);
  if (!tbl) return;
  var th = tbl.querySelectorAll('thead tr:first-child th')[headerIdx];
  if (!th) return;
  _ensureCfThStructure(th);
  if (!th._cfWired) {
    th._cfWired = true;
    th.style.cursor = 'pointer';
    th.onclick = function () { sortOppCol(sortCol); };
  }
}

function wireTargetTableFilters() {
  createColFilter('opp-target-tbl', 'tdconf', 0, 'Tier', null, STATE_OPP._allTiers);
  createColFilter('opp-target-tbl', 'tddisease', 1, 'Disease', 'disease', STATE_OPP._allDiseases);
  createColFilter('opp-target-tbl', 'tdtarget', 2, 'Target', 'target', STATE_OPP._allTargets);
  createSortIndicator('opp-target-tbl', 'escore', 3);
  createSortIndicator('opp-target-tbl', 'evidence', 4);
  createSortIndicator('opp-target-tbl', 'orgs', 5);
  createSortIndicator('opp-target-tbl', 'moa', 6);
}

// ─── Search dropdown helper ───────────────────────────────────────────────────

function _setupOppSearchDD(inputId, ddId, getItems, onSelect) {
  var input = document.getElementById(inputId);
  var ddEl = document.getElementById(ddId);
  if (!input || !ddEl) return;
  // Idempotency tag on the live DOM node — prevents double-wiring on the
  // same element, but a fresh element (e.g. after panel re-render) will
  // not have the tag and will be wired correctly.
  if (input.dataset.ddWired === '1') return;
  input.dataset.ddWired = '1';

  function showDD() {
    var items = typeof getItems === 'function' ? getItems() : (getItems || []);
    var q = input.value.trim().toLowerCase();
    var filtered = q
      ? items.filter(function (it) { return (it.label || it).toLowerCase().indexOf(q) >= 0; })
      : items;
    ddEl.innerHTML = '';
    if (!filtered.length) { ddEl.style.display = 'none'; return; }
    filtered.slice(0, 30).forEach(function (it) {
      var label = it.label || it;
      var val = it.value || it;
      var div = document.createElement('div');
      div.className = 'ac-item';
      div.textContent = label;
      div.onclick = function () {
        input.value = label;
        ddEl.style.display = 'none';
        onSelect(val);
      };
      ddEl.appendChild(div);
    });
    ddEl.style.display = 'block';
  }

  input.addEventListener('input', showDD);
  input.addEventListener('focus', showDD);
  // Outside-click closes. Store the handler on the input so a future
  // re-wire could remove it (although idempotency tag prevents that today).
  var outsideClick = function (e) {
    if (!input.contains(e.target) && !ddEl.contains(e.target)) {
      ddEl.style.display = 'none';
    }
  };
  document.addEventListener('click', outsideClick);
  input._oppDDOutsideClick = outsideClick;
}

// ─── Shimmer helper ───────────────────────────────────────────────────────────

function _shimmerRows(rows, cols) {
  var h = '<div style="overflow-x:auto"><table class="ws-dt"><tbody>';
  for (var r = 0; r < rows; r++) {
    h += '<tr>';
    for (var c = 0; c < cols; c++) {
      h += '<td><div class="shimmer" style="height:14px;border-radius:3px"></div></td>';
    }
    h += '</tr>';
  }
  h += '</tbody></table></div>';
  return h;
}

// ─── Error card helper ────────────────────────────────────────────────────────

function _errorCardHTML(title, detail) {
  return '<div class="prim-error" role="alert">'
    + '<div class="prim-error-header">'
    + '<span class="prim-error-icon" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>'
    + '<p class="prim-error-title">' + _escHTML(title) + '</p>'
    + '</div>'
    + (detail ? '<p class="prim-error-detail">' + _escHTML(detail) + '</p>' : '')
    + '</div>';
}

// ─── Cross-module island HTML ─────────────────────────────────────────────────

function _crossModuleIslandHTML() {
  return '<div class="xmod-island" id="opp-xmod-island" style="display:none"></div>';
}

// ─── Load generation counter (prevents stale responses) ──────────────────────

var _oppLoadGen = 0;

// ─── Loaders ─────────────────────────────────────────────────────────────────

async function loadOppData() {
  if (!STATE_OPP.ta) {
    STATE_OPP.ta = 'ONCOLOGY';
    var taInput = document.getElementById('opp-ta');
    if (taInput) taInput.value = _formatTA(STATE_OPP.ta);
  }
  var gen = ++_oppLoadGen;
  STATE_OPP.selected = null;
  _clearSelectedOpp();

  var scatterWrap = document.getElementById('opp-scatter-wrap');
  var tableWrap = document.getElementById('opp-table-wrap');
  if (scatterWrap) scatterWrap.innerHTML = '<div class="shimmer" style="height:280px;border-radius:6px"></div>';
  if (tableWrap) tableWrap.innerHTML = _shimmerRows(8, 6);

  try {
    // DEFECT-007 fix: drop the redundant /scatter fetch. Both endpoints return
    // identical data per v2_production_audit.md (collapse-candidate confirmed)
    // and the previous code assigned `var scatter = results[0]` but never read
    // it. Saves ~1MB per TA-change and a wasted round-trip.
    var opps = await window.DASHIQ.fetchWhitespaceOpportunities({
      ta: STATE_OPP.ta || undefined, limit: 5000
    });

    if (gen !== _oppLoadGen) return;

    STATE_OPP.data = (opps.results || opps || []);

    // Load TA list once
    if (!window._oppAllTAs) {
      try {
        var taResp = await window.DASHIQ.fetchWhitespaceTAs();
        window._oppAllTAs = (taResp.tas || []).map(function (t) {
          return { label: _formatTA(t), value: t.trim() };
        });
      } catch (taErr) {
        var rawTAs = new Set();
        STATE_OPP.data.forEach(function (d) {
          var pri = (d.therapeutic_area || '').trim();
          if (pri && pri !== 'Unknown') rawTAs.add(pri);
          if (d.all_therapeutic_areas) {
            d.all_therapeutic_areas.split('|').forEach(function (t) {
              t = t.trim();
              if (t && t !== 'Unknown') rawTAs.add(t);
            });
          }
        });
        window._oppAllTAs = Array.from(rawTAs).sort().map(function (t) {
          return { label: _formatTA(t), value: t.trim() };
        });
      }
    }

    // CTK 2026-05-12: previously gated on a GLOBAL `window._oppDDsReady`
    // boolean. The opp-ta <input> element is recreated each time renderOpp
    // runs (panel-opp.innerHTML replacement), but the global guard kept the
    // setup from re-running -> the new input had no input/focus handlers,
    // so typing into the TA search did nothing. Always call the setup; the
    // function itself idempotency-tags the element via data-dd-wired.
    //
    // ALSO: previously the TA-select callback called onOppFilter() which
    // only re-renders the CACHED STATE_OPP.data set (~5000 rows fetched
    // once at mount with no ta filter). The unfiltered cache is heavily
    // biased toward whatever TA dominates the top whitespace_rank rows;
    // client-side filtering then surfaced cross-over rows (rows whose
    // primary TA is Oncology but secondary list includes Immunology, etc).
    // Result: clicking Immunology still showed Oncology diseases.
    // Fix: call loadOppData() so the backend re-runs with ?ta=<TA> and
    // returns rows whose PRIMARY therapeutic_area matches the selection.
    _setupOppSearchDD('opp-ta', 'opp-ta-dd', function () { return window._oppAllTAs || []; }, function (val) {
      STATE_OPP.ta = val;
      loadOppData();
    });

    // Set the selection FIRST so subsequent renders mark the row-hl class
    // and the scatter dot uses the selected style. autoSelectBest used to
    // run AFTER renderOppTable, which meant STATE_OPP.selected was null at
    // render time -> the top-table row never got .row-hl and the scatter
    // selection ring never appeared on the chosen PRIME-target dot.
    autoSelectBest();
    renderTargetDiseaseMatrix();
    renderOppTable();

  } catch (err) {
    if (gen !== _oppLoadGen) return;
    if (scatterWrap) scatterWrap.innerHTML = _errorCardHTML('Failed to load scatter data', err.message);
    if (tableWrap) tableWrap.innerHTML = _errorCardHTML('Failed to load opportunities', err.message);
  }
}

// ─── Filters ─────────────────────────────────────────────────────────────────

function toggleOppFilter(filterKey) {
  if (filterKey === 'rare') STATE_OPP.rare = !STATE_OPP.rare;
  else if (filterKey === 'noBigPharma') STATE_OPP.noBigPharma = !STATE_OPP.noBigPharma;
  else if (filterKey === 'excludeApproved') STATE_OPP.excludeApproved = !STATE_OPP.excludeApproved;
  onOppFilter();
}

function onOppFilter() {
  STATE_OPP.page = 0;
  // Update toggle button states
  var rareBtn = document.querySelector('#panel-opp .ws-filter-toggle[data-filter="rare"]');
  var nbpBtn = document.querySelector('#panel-opp .ws-filter-toggle[data-filter="noBigPharma"]');
  var exaBtn = document.querySelector('#panel-opp .ws-filter-toggle[data-filter="excludeApproved"]');
  if (rareBtn) rareBtn.className = 'diq-mode-tab ws-filter-toggle' + (STATE_OPP.rare ? ' on active' : '');
  if (nbpBtn) nbpBtn.className = 'diq-mode-tab ws-filter-toggle' + (STATE_OPP.noBigPharma ? ' on active' : '');
  if (exaBtn) exaBtn.className = 'diq-mode-tab ws-filter-toggle' + (STATE_OPP.excludeApproved ? ' on active' : '');
  renderTargetDiseaseMatrix();
  renderOppTable();
  autoSelectBest();
}

// ─── Auto-select ─────────────────────────────────────────────────────────────

function autoSelectBest() {
  var filtered = _dedupByDisease(filterOppData(STATE_OPP.data));
  if (!filtered.length) { STATE_OPP.selected = null; return; }

  // Compute median thresholds for prime quadrant detection
  var tvVals = filtered.map(function (d) { return d.target_validation_score || 0; }).sort(function (a, b) { return a - b; });
  var coVals = filtered.map(function (d) { return 1 - (d.competitive_density_score || 0); }).sort(function (a, b) { return a - b; });
  var tvMed = tvVals.length ? tvVals[Math.floor(tvVals.length / 2)] : 0.4;
  var coMed = coVals.length ? coVals[Math.floor(coVals.length / 2)] : 0.5;

  var ranked = filtered.slice().sort(function (a, b) {
    var aOpen = 1 - (a.competitive_density_score || 0);
    var bOpen = 1 - (b.competitive_density_score || 0);
    var aPrime = ((a.target_validation_score || 0) >= tvMed && aOpen >= coMed) ? 1 : 0;
    var bPrime = ((b.target_validation_score || 0) >= tvMed && bOpen >= coMed) ? 1 : 0;
    if (aPrime !== bPrime) return bPrime - aPrime;
    return _computeEvidence(b).total - _computeEvidence(a).total;
  });

  // In target-disease mode, require a valid target
  ranked = ranked.filter(function (d) {
    var tgt = _formatTarget(d);
    return tgt && tgt !== 'Unknown' && tgt !== '--';
  });

  var top = ranked[0];
  if (top) {
    STATE_OPP.selected = top.disease_id;
    _loadSelectedOpp(top.disease_id);
    if (window.innerWidth > 768) {
      setTimeout(function () { openEvDrawer(top.disease_id); }, 300);
    }
  }
}

// ─── Scatter plot ─────────────────────────────────────────────────────────────

function renderTargetDiseaseMatrix() {
  var wrap = document.getElementById('opp-scatter-wrap');
  if (!wrap) return;

  var data = filterOppData(STATE_OPP.data).filter(function (d) {
    var tgt = _formatTarget(d);
    return tgt && tgt !== 'Unknown' && tgt !== '--'
      && ((d.target_validation_score || 0) > 0
        || (d.competitive_density_score || 0) > 0
        || (d.clinical_stage_score || 0) > 0);
  });
  data = _dedupByDisease(data);

  var cntEl = document.getElementById('opp-scatter-count');
  if (cntEl) cntEl.textContent = data.length + ' diseases';

  if (!data.length) {
    wrap.innerHTML = '<div class="prim-empty"><div class="prim-empty-icon" aria-hidden="true" style="font-size:24px;opacity:.3">--</div><p class="prim-empty-title">No data matches current filters</p></div>';
    return;
  }

  var L = OPP_PLOT.L, T = OPP_PLOT.T, R = OPP_PLOT.R, B = OPP_PLOT.B;
  var W = OPP_PLOT.W, H = OPP_PLOT.H;
  var pW = R - L, pH = B - T;

  // Compute X/Y values
  var pts = data.map(function (d) {
    return Object.assign({}, d, {
      mx: d.target_validation_score || 0,
      cy: 1 - (d.competitive_density_score || 0),
    });
  });

  // Unique key per point for rank maps
  function tdKey(d) { return String(d.disease_id) + '::' + _formatTarget(d); }
  pts.forEach(function (d) { d._tdk = tdKey(d); });

  // Rank-based positioning (percentile spread)
  var tdXRank = {}, tdYRank = {};
  var txs = pts.slice().sort(function (a, b) { return a.mx - b.mx; });
  var tys = pts.slice().sort(function (a, b) { return a.cy - b.cy; });
  txs.forEach(function (d, i) { tdXRank[d._tdk] = i / Math.max(txs.length - 1, 1); });
  tys.forEach(function (d, i) { tdYRank[d._tdk] = i / Math.max(tys.length - 1, 1); });

  // Deterministic golden-ratio jitter
  var PHI = 0.6180339887;
  pts.forEach(function (d) {
    var h = _hashStr(d._tdk);
    var jx = ((Math.abs(h) * PHI) % 1 - 0.5) * 0.04;
    var jy = ((Math.abs(h * 2654435761) * PHI) % 1 - 0.5) * 0.04;
    tdXRank[d._tdk] = Math.max(0.01, Math.min(0.99, (tdXRank[d._tdk] || 0) + jx));
    tdYRank[d._tdk] = Math.max(0.01, Math.min(0.99, (tdYRank[d._tdk] || 0) + jy));
  });

  // 7% inset padding so dots never sit on axis lines
  var insetPad = 0.07;
  var xMid = 0.5, yMid = 0.5;

  function cx(v, key) {
    var rv = key && tdXRank[key] != null ? tdXRank[key] : v;
    return L + (insetPad + rv * (1 - 2 * insetPad)) * pW;
  }
  function cy2(v, key) {
    var rv = key && tdYRank[key] != null ? tdYRank[key] : v;
    return T + (1 - (insetPad + rv * (1 - 2 * insetPad))) * pH;
  }

  var mx = cx(xMid), my = cy2(yMid);

  // Theme detection for SVG inline colors
  var isLight = document.documentElement.getAttribute('data-theme') === 'light';
  var svgBg = isLight ? 'rgba(248,250,252,1)' : 'rgba(255,255,255,0.04)';
  var gridStroke = isLight ? 'rgba(226,232,240,1)' : 'rgba(255,255,255,0.08)';
  var axisStroke = isLight ? 'rgba(203,213,225,1)' : 'rgba(255,255,255,0.15)';
  var axisLabel = isLight ? 'rgba(100,116,139,1)' : 'rgba(255,255,255,0.65)';
  var axisEnd = isLight ? 'rgba(148,163,184,1)' : 'rgba(255,255,255,0.50)';
  var dotLabelFill = isLight ? 'rgba(71,85,105,1)' : 'rgba(226,232,240,0.85)';
  var heroLabelBg = isLight ? 'rgba(255,255,255,0.88)' : 'rgba(14,22,36,0.80)';
  var heroLabelText = isLight ? 'rgba(17,94,89,1)' : 'rgba(94,234,212,1)';
  var heroRingStroke = isLight ? 'rgba(13,148,136,1)' : 'rgba(94,234,212,1)';
  var heroDotFill = isLight ? 'rgba(13,148,136,1)' : 'rgba(45,212,191,1)';
  // Quadrant label colors
  // UR-009 fix: bump light-mode label alpha from 0.22 → 0.38 so quadrant
  // identifiers ("PRIME TARGET" etc.) are legible on the off-white bg.
  // Dark mode preserved (already visible on dark bg).
  var qlPrime = isLight ? 'rgba(13,143,134,0.38)' : 'rgba(94,234,212,0.25)';
  var qlOpen = isLight ? 'rgba(37,99,235,0.38)' : 'rgba(147,187,253,0.2)';
  var qlComp = isLight ? 'rgba(217,119,6,0.38)' : 'rgba(251,191,36,0.2)';
  var qlWeak = isLight ? 'rgba(15,23,42,0.32)' : 'rgba(226,232,240,0.15)';
  // Dot colors per quadrant
  var dotPrime = isLight ? 'rgba(13,148,136,1)' : 'rgba(13,148,136,1)';
  var dotOpen = isLight ? 'rgba(59,130,246,1)' : 'rgba(59,130,246,1)';
  var dotComp = isLight ? 'rgba(217,119,6,1)' : 'rgba(217,119,6,1)';
  var dotWeak = isLight ? 'rgba(148,163,184,1)' : 'rgba(148,163,184,1)';

  // Hero = auto-selected best
  var best = STATE_OPP.selected
    ? pts.find(function (d) { return String(d.disease_id) === String(STATE_OPP.selected); })
    : null;
  if (!best && pts.length) {
    var sorted0 = pts.slice().sort(function (a, b) { return (b.whitespace_score || 0) - (a.whitespace_score || 0); });
    best = sorted0[0];
    STATE_OPP.selected = best.disease_id;
  }

  // Density limit: max OPP_MAX_DOTS, balanced across 4 quadrants
  var others = pts.filter(function (d) { return d !== best; });
  var limited = others;
  if (others.length > OPP_MAX_DOTS) {
    var q = { tl: [], tr: [], bl: [], br: [] };
    others.forEach(function (d) {
      var rx = tdXRank[d._tdk] || 0, ry = tdYRank[d._tdk] || 0;
      var k = (ry >= yMid ? 't' : 'b') + (rx >= xMid ? 'r' : 'l');
      q[k].push(d);
    });
    var perQ = Math.ceil(OPP_MAX_DOTS / 4);
    limited = [];
    ['tr', 'tl', 'br', 'bl'].forEach(function (k) {
      q[k].sort(function (a, b) { return (b.whitespace_score || 0) - (a.whitespace_score || 0); });
      limited = limited.concat(q[k].slice(0, perQ));
    });
    limited = limited.slice(0, OPP_MAX_DOTS);
  }

  // Label candidates: top 6 per quadrant by whitespace_score
  var labelCandidates = new Set();
  var qBuckets = { tl: [], tr: [], bl: [], br: [] };
  limited.forEach(function (d) {
    if (d === best) return;
    var tgt = _formatTarget(d);
    if (!tgt || tgt === 'Unknown' || tgt === '--') return;
    var rx = tdXRank[d._tdk] || 0, ry = tdYRank[d._tdk] || 0;
    var qk = (ry >= yMid ? 't' : 'b') + (rx >= xMid ? 'r' : 'l');
    qBuckets[qk].push(d);
  });
  ['tr', 'tl', 'br', 'bl'].forEach(function (qk) {
    qBuckets[qk].sort(function (a, b) { return (b.whitespace_score || 0) - (a.whitespace_score || 0); });
    qBuckets[qk].slice(0, 6).forEach(function (d) { labelCandidates.add(d._tdk); });
  });

  // Sort ascending by whitespace_score so higher-scored dots render on top
  var sorted = limited.slice().sort(function (a, b) { return (a.whitespace_score || 0) - (b.whitespace_score || 0); });

  // Label placement collision avoidance
  var labelPositions = [];
  function canPlaceLabel(lx, ly, lw) {
    for (var k = 0; k < labelPositions.length; k++) {
      var p = labelPositions[k];
      if (Math.abs(lx - p.x) < Math.max(lw, p.w) * 0.7 && Math.abs(ly - p.y) < 11) return false;
    }
    return true;
  }

  // Build SVG string
  var s = '<svg viewBox="0 0 ' + W + ' ' + H + '" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Target-disease matrix showing opportunities by target validation and competitive openness" style="width:100%;height:auto;display:block;overflow:hidden">';
  s += '<defs>'
    + '<radialGradient id="td-grad-opp" cx="50%" cy="50%"><stop offset="0%" stop-color="rgba(13,148,136,1)" stop-opacity=".06"/><stop offset="100%" stop-color="rgba(13,148,136,1)" stop-opacity="0"/></radialGradient>'
    + '<clipPath id="td-clip-opp"><rect x="' + L + '" y="' + T + '" width="' + pW + '" height="' + pH + '"/></clipPath>'
    + '</defs>';

  // Background rect + quadrant fills
  // UR-009 fix: light mode bumps quadrant fill alpha 2x so the four-quadrant
  // structure is visible against the off-white bg. Dark mode stays at 0.04-0.05
  // (already shows on dark bg). Helps distinguish prime / open / competitive
  // / crowded zones at a glance instead of looking like one washed-out plot.
  var qFillPrime = isLight ? 'rgba(13,148,136,0.10)' : 'rgba(13,148,136,0.05)';
  var qFillOpen  = isLight ? 'rgba(59,130,246,0.08)' : 'rgba(59,130,246,0.04)';
  var qFillComp  = isLight ? 'rgba(217,119,6,0.08)'  : 'rgba(217,119,6,0.04)';
  s += '<rect x="' + L + '" y="' + T + '" width="' + pW + '" height="' + pH + '" fill="' + svgBg + '" rx="4"/>';
  // top-right = prime target (high X=validation, high Y=openness)
  s += '<rect x="' + mx + '" y="' + T + '" width="' + (R - mx) + '" height="' + (my - T) + '" fill="' + qFillPrime + '" rx="4"/>';
  // top-left = open field
  s += '<rect x="' + L + '" y="' + T + '" width="' + (mx - L) + '" height="' + (my - T) + '" fill="' + qFillOpen + '"/>';
  // bottom-right = competitive
  s += '<rect x="' + mx + '" y="' + my + '" width="' + (R - mx) + '" height="' + (B - my) + '" fill="' + qFillComp + '"/>';
  // bottom-left = crowded/weak (no fill — default background)

  // Grid lines
  s += '<line x1="' + L + '" y1="' + (T + pH * 0.25) + '" x2="' + R + '" y2="' + (T + pH * 0.25) + '" stroke="' + gridStroke + '" stroke-width=".7"/>';
  s += '<line x1="' + L + '" y1="' + my + '" x2="' + R + '" y2="' + my + '" stroke="' + gridStroke + '" stroke-width="1" stroke-dasharray="4,3"/>';
  s += '<line x1="' + L + '" y1="' + (T + pH * 0.75) + '" x2="' + R + '" y2="' + (T + pH * 0.75) + '" stroke="' + gridStroke + '" stroke-width=".7"/>';
  s += '<line x1="' + (L + pW * 0.25) + '" y1="' + T + '" x2="' + (L + pW * 0.25) + '" y2="' + B + '" stroke="' + gridStroke + '" stroke-width=".7"/>';
  s += '<line x1="' + mx + '" y1="' + T + '" x2="' + mx + '" y2="' + B + '" stroke="' + gridStroke + '" stroke-width="1" stroke-dasharray="4,3"/>';
  s += '<line x1="' + (L + pW * 0.75) + '" y1="' + T + '" x2="' + (L + pW * 0.75) + '" y2="' + B + '" stroke="' + gridStroke + '" stroke-width=".7"/>';

  // Axes
  s += '<line x1="' + L + '" y1="' + B + '" x2="' + R + '" y2="' + B + '" stroke="' + axisStroke + '" stroke-width="1.2"/>';
  s += '<line x1="' + L + '" y1="' + T + '" x2="' + L + '" y2="' + B + '" stroke="' + axisStroke + '" stroke-width="1.2"/>';

  // Axis labels
  s += '<text x="' + ((L + R) / 2) + '" y="' + (B + 14) + '" font-size="10" fill="' + axisLabel + '" font-family="JetBrains Mono,monospace" text-anchor="middle" font-weight="500">Target Validation</text>';
  s += '<text x="' + L + '" y="' + (B + 14) + '" font-size="8" fill="' + axisEnd + '" font-family="JetBrains Mono,monospace">Low</text>';
  s += '<text x="' + R + '" y="' + (B + 14) + '" font-size="8" fill="' + axisEnd + '" font-family="JetBrains Mono,monospace" text-anchor="end">High</text>';
  s += '<text x="22" y="' + ((T + B) / 2) + '" font-size="10" fill="' + axisLabel + '" font-family="JetBrains Mono,monospace" text-anchor="middle" font-weight="500" transform="rotate(-90,22,' + ((T + B) / 2) + ')">Competitive Openness</text>';
  s += '<text x="14" y="' + (B - 2) + '" font-size="8" fill="' + axisEnd + '" font-family="JetBrains Mono,monospace" text-anchor="middle" transform="rotate(-90,14,' + (B - 2) + ')">Crowded</text>';
  s += '<text x="14" y="' + (T + 4) + '" font-size="8" fill="' + axisEnd + '" font-family="JetBrains Mono,monospace" text-anchor="middle" transform="rotate(-90,14,' + (T + 4) + ')">Open</text>';

  // Dots (clipped)
  s += '<g clip-path="url(#td-clip-opp)">';

  sorted.forEach(function (d, i, arr) {
    var x = cx(d.mx, d._tdk);
    var y = cy2(d.cy, d._tdk);
    var rx = tdXRank[d._tdk] || 0, ry = tdYRank[d._tdk] || 0;
    // Color by quadrant position
    var fill;
    if (rx >= xMid && ry >= yMid) fill = dotPrime;
    else if (rx < xMid && ry >= yMid) fill = dotOpen;
    else if (rx >= xMid && ry < yMid) fill = dotComp;
    else fill = dotWeak;

    var r = Math.max(3.5, Math.min(7, (d.whitespace_score || 0.3) * 8));
    var op = (labelCandidates.has(d._tdk) || i >= arr.length - 15) ? 0.6 : 0.3;

    var ttScore = (d.whitespace_score || 0).toFixed(2);
    var ttTier = (d.whitespace_score || 0) >= 0.5 ? 'gold' : (d.whitespace_score || 0) >= 0.3 ? 'silver' : 'bronze';
    var ttTarget = _escHTML(_formatTarget(d));
    var ttDisease = _escHTML(d.disease_name || '');

    s += '<circle cx="' + x + '" cy="' + y + '" r="' + r + '" fill="' + fill + '" opacity="' + op + '" style="cursor:pointer"'
      + ' data-tt-target="' + ttTarget + '" data-tt-disease="' + ttDisease + '" data-tt-tier="' + ttTier + '" data-tt-score="' + ttScore + '"'
      + ' onmouseenter="showOppScatterTip(event,this)" onmouseleave="hideOppScatterTip()"'
      + ' onclick="selectOppDot(\'' + _escHTML(String(d.disease_id)) + '\')">'
      + '<title>' + ttTarget + ' · ' + ttDisease + ' (score: ' + ttScore + ')</title>'
      + '</circle>';

    if (labelCandidates.has(d._tdk)) {
      var lbl = _formatTarget(d);
      if (lbl.length > 16) lbl = lbl.substring(0, 15) + '…';
      var lblW = lbl.length * 5 + 8;
      var anchor = 'start';
      var lx = x + r + 3, ly = y + 3;
      if (!canPlaceLabel(lx, ly, lblW)) { lx = x - 3; ly = y + 3; anchor = 'end'; }
      if (!canPlaceLabel(lx, ly, lblW)) { lx = x + r + 3; ly = y - 9; anchor = 'start'; }
      if (!canPlaceLabel(lx, ly, lblW)) { lx = x + r + 3; ly = y + 13; anchor = 'start'; }
      if (anchor === 'start' && lx + lblW > R - 4) { lx = x - 3; anchor = 'end'; }
      if (anchor === 'end' && lx - lblW < L + 4) { lx = x + r + 3; anchor = 'start'; }
      if (canPlaceLabel(lx, ly, lblW)) {
        labelPositions.push({ x: lx, y: ly, w: lblW });
        s += '<text x="' + lx + '" y="' + ly + '" font-size="7.5" fill="' + dotLabelFill + '" font-family="JetBrains Mono,monospace" opacity=".9" text-anchor="' + anchor + '">' + _escHTML(lbl) + '</text>';
      }
    }
  });

  // Hero point (auto-selected)
  if (best) {
    var bx = cx(best.mx, best._tdk), by = cy2(best.cy, best._tdk);
    var heroLbl = _formatTarget(best) + ' · ' + (best.disease_name || '');
    if (heroLbl.length > 28) heroLbl = heroLbl.substring(0, 27) + '…';

    // Outer pulsing ring
    s += '<circle cx="' + bx + '" cy="' + by + '" r="18" fill="none" stroke="' + heroRingStroke + '" stroke-width="1" opacity=".25">'
      + '<animate attributeName="r" values="14;22;14" dur="3s" repeatCount="indefinite"/>'
      + '<animate attributeName="opacity" values=".25;.05;.25" dur="3s" repeatCount="indefinite"/>'
      + '</circle>';
    // Inner pulsing ring
    s += '<circle cx="' + bx + '" cy="' + by + '" r="11" fill="none" stroke="' + heroRingStroke + '" stroke-width="2.5" opacity=".85">'
      + '<animate attributeName="r" values="8;14;8" dur="2.5s" repeatCount="indefinite"/>'
      + '<animate attributeName="opacity" values=".85;.3;.85" dur="2.5s" repeatCount="indefinite"/>'
      + '</circle>';
    // Center dot (teal)
    s += '<circle cx="' + bx + '" cy="' + by + '" r="7" fill="' + heroDotFill + '" style="cursor:pointer" onclick="selectOppDot(\'' + _escHTML(String(best.disease_id)) + '\')"/>';
    // Center accent dot — light theme uses dark navy so it reads on the
    // off-white scatter bg (was rgba(255,255,255,1), invisible on light).
    var heroCenterFill = isLight ? 'rgba(15,23,42,0.95)' : 'rgba(255,255,255,1)';
    s += '<circle cx="' + bx + '" cy="' + by + '" r="2.5" fill="' + heroCenterFill + '"/>';
    // Label box
    var lblW2 = heroLbl.length * 4.8 + 16;
    var lx2 = bx + 14, ly2 = by + 4;
    if (lx2 + lblW2 > R - 2) { lx2 = bx - lblW2 - 8; }
    if (lx2 - 4 < L + 2) { lx2 = L + 6; }
    if (ly2 < T + 24) { ly2 = by + 18; }
    if (ly2 > B - 14) { ly2 = by - 14; }
    s += '<rect x="' + (lx2 - 4) + '" y="' + (ly2 - 10) + '" width="' + lblW2 + '" height="16" rx="4" fill="' + heroLabelBg + '" stroke="' + heroRingStroke + '" stroke-width="1"/>';
    s += '<text x="' + lx2 + '" y="' + (ly2 + 1) + '" font-size="9" fill="' + heroLabelText + '" font-family="JetBrains Mono,monospace" font-weight="700">★ ' + _escHTML(heroLbl) + '</text>';
  }

  s += '</g>'; // close clipped group

  // Quadrant labels (rendered after dots, over the clip group)
  s += '<text x="' + (R - 8) + '" y="' + (T + 22) + '" font-size="18" fill="' + qlPrime + '" font-family="JetBrains Mono,monospace" font-weight="800" text-anchor="end" letter-spacing="2">PRIME TARGET</text>';
  s += '<text x="' + (L + 8) + '" y="' + (T + 22) + '" font-size="18" fill="' + qlOpen + '" font-family="JetBrains Mono,monospace" font-weight="800" letter-spacing="2">OPEN FIELD</text>';
  s += '<text x="' + (R - 8) + '" y="' + (B - 10) + '" font-size="18" fill="' + qlComp + '" font-family="JetBrains Mono,monospace" font-weight="800" text-anchor="end" letter-spacing="2">COMPETITIVE</text>';
  s += '<text x="' + (L + 8) + '" y="' + (B - 10) + '" font-size="18" fill="' + qlWeak + '" font-family="JetBrains Mono,monospace" font-weight="800" letter-spacing="2">CROWDED · WEAK</text>';

  s += '</svg>';
  wrap.innerHTML = s;

  // Ensure scatter tooltip element exists in DOM
  _ensureOppScatterTip();
}

// ─── Scatter tooltip ──────────────────────────────────────────────────────────

function _ensureOppScatterTip() {
  var tip = document.getElementById('opp-scatter-tooltip');
  if (!tip) {
    tip = document.createElement('div');
    tip.id = 'opp-scatter-tooltip';
    tip.className = 'opp-scatter-tooltip';
    document.body.appendChild(tip);
  }
  return tip;
}

function showOppScatterTip(event, dot) {
  var tip = _ensureOppScatterTip();
  var t = dot.getAttribute('data-tt-target') || '';
  var ds = dot.getAttribute('data-tt-disease') || '';
  var tier = dot.getAttribute('data-tt-tier') || 'bronze';
  var sc = dot.getAttribute('data-tt-score') || '';
  var tierLabel = tier === 'gold' ? 'GOLD' : tier === 'silver' ? 'SILVER' : 'BRONZE';

  tip.innerHTML = '<div class="ost-row" style="display:flex;align-items:center;justify-content:space-between;gap:6px">'
    + '<span class="ost-target">' + _escHTML(t) + '</span>'
    + '<span class="ost-tier ' + tier + '">' + tierLabel + '</span>'
    + '</div>'
    + '<div class="ost-disease">' + _escHTML(ds) + '</div>'
    + '<div class="ost-score">Whitespace score ' + sc + '</div>';

  var tipW = 220, tipH = 80;
  var x = event.clientX + 14, y = event.clientY - 4;
  if (x + tipW > window.innerWidth - 8) x = event.clientX - tipW - 14;
  if (y + tipH > window.innerHeight - 8) y = window.innerHeight - tipH - 8;
  if (y < 8) y = 8;
  tip.style.left = x + 'px';
  tip.style.top = y + 'px';
  tip.style.position = 'fixed';
  tip.classList.add('is-visible');
}

function hideOppScatterTip() {
  var tip = document.getElementById('opp-scatter-tooltip');
  if (tip) tip.classList.remove('is-visible');
}

// ─── Table ────────────────────────────────────────────────────────────────────

function renderOppTable() {
  var wrap = document.getElementById('opp-table-wrap');
  if (!wrap) return;

  var data = filterOppData(STATE_OPP.data);
  data = _dedupByDisease(data);

  // Update count display
  var cnt2 = document.getElementById('opp-count2');
  if (cnt2) {
    cnt2.textContent = data.length + ' results' + (STATE_OPP.ta ? ' · ' + _formatTA(STATE_OPP.ta) : '');
  }

  var fcnt = document.getElementById('opp-fcnt');
  if (fcnt) fcnt.style.display = 'none';

  if (!data.length) {
    wrap.innerHTML = '<div class="prim-empty"><div class="prim-empty-icon" aria-hidden="true" style="font-size:20px;opacity:.3">--</div><p class="prim-empty-title">No opportunities match current filters</p><p class="prim-empty-sub">Try removing filters or selecting a different therapeutic area.</p></div>';
    return;
  }

  // Filter to entries with a valid target
  data = data.filter(function (d) {
    var t = _formatTarget(d);
    return t && t !== 'Unknown' && t !== '--';
  });

  // Compute medians for prime-quadrant sort
  var tvVals = data.map(function (d) { return d.target_validation_score || 0; }).sort(function (a, b) { return a - b; });
  var coVals = data.map(function (d) { return 1 - (d.competitive_density_score || 0); }).sort(function (a, b) { return a - b; });
  var tvMed = tvVals.length ? tvVals[Math.floor(tvVals.length / 2)] : 0.4;
  var coMed = coVals.length ? coVals[Math.floor(coVals.length / 2)] : 0.5;

  function evScore(d) { return _computeEvidence(d).total; }

  // Default sort: prime quadrant first, then evidence score
  var sorted = data.slice().sort(function (a, b) {
    var aOpen = 1 - (a.competitive_density_score || 0);
    var bOpen = 1 - (b.competitive_density_score || 0);
    var aPrime = ((a.target_validation_score || 0) >= tvMed && aOpen >= coMed) ? 1 : 0;
    var bPrime = ((b.target_validation_score || 0) >= tvMed && bOpen >= coMed) ? 1 : 0;
    if (aPrime !== bPrime) return bPrime - aPrime;
    return evScore(b) - evScore(a);
  });

  // Column sort override
  if (STATE_OPP.sortCol) {
    var sc = STATE_OPP.sortCol, sd = STATE_OPP.sortDir === 'asc' ? 1 : -1;
    sorted.sort(function (x, y) {
      if (sc === 'disease') return (x.disease_name || '').localeCompare(y.disease_name || '') * sd;
      if (sc === 'target') return _formatTarget(x).localeCompare(_formatTarget(y)) * sd;
      if (sc === 'escore') return (evScore(x) - evScore(y)) * sd;
      if (sc === 'orgs') return ((x.n_orgs || 0) - (y.n_orgs || 0)) * sd;
      if (sc === 'evidence') {
        function evTypes(d) {
          var ev = _computeEvidence(d);
          return [ev.targetValidation > 0.3, ev.compOpenness > 0.3, ev.innovation > 0.3, ev.gapSignal > 0.3].filter(Boolean).length;
        }
        return (evTypes(x) - evTypes(y)) * sd;
      }
      if (sc === 'moa') return ((x.n_moas || 0) - (y.n_moas || 0)) * sd;
      return 0;
    });
  }

  // Collect filter value sets
  var allTDTiers = new Set(), allTDDiseases = new Set(), allTDTargets = new Set();
  sorted.forEach(function (d) {
    allTDTiers.add(assignTier(evScore(d)));
    if (d.disease_name) allTDDiseases.add(d.disease_name);
    var t = _formatTarget(d);
    if (t && t !== 'Unknown' && t !== '--') allTDTargets.add(t);
  });
  STATE_OPP._allTiers = Array.from(allTDTiers);
  STATE_OPP._allDiseases = Array.from(allTDDiseases).sort();
  STATE_OPP._allTargets = Array.from(allTDTargets).sort();

  // Apply column filters
  sorted = _applyDataFilters(sorted, 'opp-target-tbl', function (d) {
    var t = _formatTarget(d);
    if (!t || t === 'Unknown') t = '-';
    return {
      tdconf: assignTier(evScore(d)),
      tddisease: d.disease_name || '',
      tdtarget: t,
    };
  });

  var totalPages = Math.ceil(sorted.length / OPP_PAGE_SIZE) || 1;
  var page = Math.min(STATE_OPP.page || 0, totalPages - 1);
  var pageData = sorted.slice(page * OPP_PAGE_SIZE, (page + 1) * OPP_PAGE_SIZE);

  var h = '<div style="overflow-x:auto">'
    + '<table class="ws-dt" id="opp-target-tbl">'
    + '<caption class="sr-only">WhiteSpaceIQ target-disease opportunities</caption>'
    + '<thead><tr>'
    + '<th class="cf-th">Tier</th>'
    + '<th class="cf-th">Disease</th>'
    + '<th class="cf-th">Target</th>'
    + '<th class="cf-th" style="min-width:90px">Score</th>'
    + '<th class="cf-th">Clinical Gap</th>'
    + '<th class="cf-th">Patents</th>'
    + '<th class="cf-th" style="min-width:100px">MOA</th>'
    + '</tr></thead><tbody>';

  pageData.forEach(function (d) {
    var disease = d.disease_name || 'Unknown';
    var xoBadge = d._isCrossOver
      ? ' <span style="font-size:8px;font-weight:600;color:var(--teal);background:color-mix(in srgb,var(--teal) 10%,transparent);border:1px solid color-mix(in srgb,var(--teal) 20%,transparent);padding:1px 4px;border-radius:4px;margin-left:5px;vertical-align:text-bottom" title="Primary TA: ' + _escHTML(d.therapeutic_area || 'Unknown') + '">Secondary TA</span>'
      : '';
    var target = _formatTarget(d);
    var ev = _computeEvidence(d);
    var evidenceScore = ev.total;

    // Score bar color
    var esColor = evidenceScore >= 0.55 ? 'var(--teal)' : evidenceScore >= 0.30 ? 'var(--amber)' : 'var(--rose)';
    var esBar = '<div class="ws-sbar"><div class="ws-sbar-track" style="min-width:36px"><div class="ws-sbar-fill" style="width:' + (evidenceScore * 100) + '%;background:' + esColor + '"></div></div><span class="ws-sbar-num">' + evidenceScore.toFixed(2) + '</span></div>';

    // Tier badge
    var tierLabel = assignTier(evidenceScore);
    var tierClass = tierLabel === 'GOLD' ? 'ws-bg-gold' : tierLabel === 'SILVER' ? 'ws-bg-silver' : 'ws-bg-gap';

    // Clinical Gap badge
    var nTrials = d.n_trials || 0;
    var cgapCls = nTrials === 0 ? 'ws-cgap ws-cgap-none'
      : nTrials <= 3 ? 'ws-cgap ws-cgap-none'
      : nTrials <= 10 ? 'ws-cgap ws-cgap-early'
      : 'ws-cgap ws-cgap-active';
    var cgapTxt = nTrials === 0 ? 'White Space'
      : nTrials <= 3 ? 'Pioneer · ' + nTrials
      : nTrials <= 10 ? 'Early Mover · ' + nTrials
      : nTrials <= 30 ? 'Fast Follower · ' + nTrials
      : 'Established · ' + nTrials;

    // Patents badge — ip_strength_score is null in the warehouse for every
    // row (no patents table integrated). Fall back to competitive_density_score
    // as a defensible proxy: in pharma BD, dense competitive landscapes
    // correlate with patent saturation (more orgs filing more IP). Surfacing
    // the proxy keeps the column meaningful instead of "No IP" everywhere,
    // and the underlying competitive_density signal is real warehouse data.
    var ip = d.ip_strength_score;
    if (ip == null) ip = d.competitive_density_score || 0;
    var pCls = ip < 0.2 ? 'ws-cgap ws-cgap-none'
      : ip < 0.5 ? 'ws-cgap ws-cgap-early'
      : 'ws-cgap ws-cgap-active';
    var pTxt = ip < 0.2 ? 'Open · ' + Math.round(ip * 100) + '%'
      : ip < 0.5 ? 'Moderate · ' + Math.round(ip * 100) + '%'
      : ip < 0.8 ? 'Protected · ' + Math.round(ip * 100) + '%'
      : 'Fortress · ' + Math.round(ip * 100) + '%';

    // MOA text
    var moaText = (d.n_moas || 0) > 0 ? d.n_moas + ' MOAs' : '--';

    var isSelected = String(STATE_OPP.selected) === String(d.disease_id);

    h += '<tr'
      + ' data-id="' + _escHTML(String(d.disease_id)) + '"'
      + ' data-tdtarget="' + _escHTML(target) + '"'
      + ' data-tddisease="' + _escHTML(disease) + '"'
      + ' data-tdconf="' + tierLabel + '"'
      + ' onclick="selectOppRow(\'' + _escHTML(String(d.disease_id)) + '\',this)"'
      + ' class="clickable' + (isSelected ? ' row-hl' : '') + '"'
      + ' style="cursor:pointer"'
      + '>'
      + '<td><span class="ws-badge ' + tierClass + '">' + tierLabel + '</span></td>'
      + '<td class="ws-nm-c" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:200px" title="' + _escHTML(disease) + '">' + _escHTML(disease) + xoBadge + '</td>'
      + '<td style="max-width:90px;overflow:hidden"><span class="ws-badge ws-bg-teal" style="font-size:8px;display:inline-block;max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle" title="' + _escHTML(target !== 'Unknown' ? target : '-') + '">' + _escHTML(target !== 'Unknown' ? target : '-') + '</span></td>'
      + '<td>' + esBar + '</td>'
      + '<td><span class="' + cgapCls + '">' + cgapTxt + '</span></td>'
      + '<td><span class="' + pCls + '">' + pTxt + '</span></td>'
      + '<td style="font-size:9px;color:var(--text-faint);max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + _escHTML(moaText) + '">' + moaText + '</td>'
      + '</tr>';
  });

  h += '</tbody></table></div>';

  // Pagination (44px hit targets via primitives CSS)
  if (totalPages > 1) {
    var pAct = 'min-width:44px;min-height:44px;padding:6px 12px;border-radius:var(--r-md);font-size:10px;font-weight:600;cursor:pointer;font-family:var(--font-mono);border:1px solid var(--teal);background:var(--teal);color:var(--bg);transition:all .12s;display:inline-flex;align-items:center;justify-content:center';
    var pDis = 'min-width:44px;min-height:44px;padding:6px 12px;border-radius:var(--r-md);font-size:10px;font-weight:600;font-family:var(--font-mono);border:1px solid var(--g-border);background:var(--g-frosted);color:var(--text-faint);opacity:.4;cursor:default;display:inline-flex;align-items:center;justify-content:center';
    h += '<div style="display:flex;align-items:center;justify-content:center;gap:10px;padding:8px 0">'
      + '<button style="' + (page <= 0 ? pDis : pAct) + '" '
      + (page <= 0 ? 'disabled' : 'onclick="STATE_OPP.page=Math.max(0,(STATE_OPP.page||0)-1);renderOppTable()"')
      + ' aria-label="Previous page">← Prev</button>'
      + '<span style="font-family:var(--font-mono);font-size:10px;color:var(--text-muted);font-weight:600">' + (page + 1) + ' / ' + totalPages + '</span>'
      + '<button style="' + (page >= totalPages - 1 ? pDis : pAct) + '" '
      + (page >= totalPages - 1 ? 'disabled' : 'onclick="STATE_OPP.page=Math.min(' + (totalPages - 1) + ',(STATE_OPP.page||0)+1);renderOppTable()"')
      + ' aria-label="Next page">Next →</button>'
      + '</div>';
  }

  wrap.innerHTML = h;

  // Wire filters after render
  setTimeout(wireTargetTableFilters, 0);

  // Auto-click first row only when nothing is selected. The previous version
  // would re-click an already-highlighted row, which selectOppRow interprets
  // as a TOGGLE-OFF (line 1150 detects "current selected === diseaseId").
  // That race caused the autoSelectBest pick (a PRIME-TARGET row) to be
  // silently de-selected, then the next renderOppTable would auto-click the
  // first un-highlighted row instead — landing on a non-PRIME disease and
  // leaving the right-panel evidence chain showing the wrong opportunity.
  setTimeout(function () {
    if (window.innerWidth > 768 && !STATE_OPP.selected) {
      var panel = document.getElementById('opp-evidence-panel');
      if (!panel) return;
      var target = wrap.querySelector('tbody tr[onclick]');
      if (target) target.click();
    }
  }, 60);
}

// ─── Table row/dot interactions ───────────────────────────────────────────────

function selectOppRow(diseaseId, rowEl) {
  if (String(STATE_OPP.selected) === String(diseaseId)) {
    STATE_OPP.selected = null;
    if (rowEl) rowEl.classList.remove('row-hl');
    _clearSelectedOpp();
    closeEvDrawer();
    return;
  }
  STATE_OPP.selected = diseaseId;
  document.querySelectorAll('#opp-table-wrap .row-hl').forEach(function (r) { r.classList.remove('row-hl'); });
  if (rowEl) rowEl.classList.add('row-hl');
  _loadSelectedOpp(diseaseId);
  openEvDrawer(diseaseId);
}

function selectOppDot(diseaseId) {
  if (!diseaseId) return;
  STATE_OPP.selected = diseaseId;
  document.querySelectorAll('#opp-table-wrap .row-hl').forEach(function (r) { r.classList.remove('row-hl'); });
  var row = document.querySelector('#opp-table-wrap tr[data-id="' + diseaseId + '"]');
  if (row) {
    row.classList.add('row-hl');
    row.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
  }
  _loadSelectedOpp(diseaseId);
  openEvDrawer(diseaseId);
}

function sortOppCol(col) {
  if (!STATE_OPP.sortCol || STATE_OPP.sortCol !== col) {
    STATE_OPP.sortCol = col;
    STATE_OPP.sortDir = 'desc';
  } else {
    STATE_OPP.sortDir = STATE_OPP.sortDir === 'desc' ? 'asc' : 'desc';
  }
  STATE_OPP.page = 0;
  renderOppTable();
}

// ─── Selected opportunity card ────────────────────────────────────────────────

function _clearSelectedOpp() {
  var wrap = document.getElementById('opp-selected-wrap');
  if (!wrap) return;
  // Preserve the evidence panel div — same pattern as _renderSelectedOppCard.
  // Without this, wrap.innerHTML wipes #opp-evidence-panel and the auto-click
  // at line ~1135 can't find it, leaving the right column permanently blank.
  var existingEvidence = document.getElementById('opp-evidence-panel');
  var evidenceHTML = existingEvidence
    ? existingEvidence.outerHTML
    : '<div id="opp-evidence-panel" style="width:0;min-width:0;max-width:340px;flex-shrink:0;overflow:hidden"></div>';
  wrap.innerHTML = '<div class="ws-card ws-selected-card" style="opacity:.6">'
    + '<div class="ws-card-header"><div class="ws-card-accent ws-ca-teal"></div><span class="ws-card-title">Selected Opportunity</span></div>'
    + '<div class="ws-card-body">'
    + '<div style="text-align:center;padding:30px 16px;color:var(--text-muted);font-size:12px">'
    + '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:.3;display:block;margin:0 auto 8px" aria-hidden="true">'
    + '<circle cx="12" cy="12" r="3"/>'
    + '<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>'
    + '</svg>'
    + 'Select a row or dot to view details'
    + '</div></div></div>'
    + evidenceHTML;
}

async function _loadSelectedOpp(diseaseId) {
  var wrap = document.getElementById('opp-selected-wrap');
  if (!wrap) return;

  var local = (STATE_OPP.data || []).find(function (d) { return String(d.disease_id) === String(diseaseId); });
  if (local) _renderSelectedOppCard(wrap, local);

  try {
    var diseaseName = local ? (local.disease_name || '') : '';
    var promises = [
      window.DASHIQ.fetchWhitespaceDisease(diseaseId),
      diseaseName
        ? window.DASHIQ.rankCompete({ disease: diseaseName, limit: 10 }).catch(function () { return null; })
        : Promise.resolve(null),
    ];
    var res = await Promise.all(promises);
    var detail = res[0];
    var competeData = res[1];

    var detailFlat = detail && detail.disease ? detail.disease : detail;
    var topOrgNames = [];
    if (competeData && competeData.results) {
      var seen = {};
      competeData.results.forEach(function (r) {
        var name = r.org_name || '';
        if (name && !seen[name]) { seen[name] = true; topOrgNames.push(name); }
      });
    }
    var merged = Object.assign({}, local, detailFlat, { _top_org_names: topOrgNames });
    if (local) {
      ['n_orgs','n_drugs','n_trials','n_moas','n_gwas_hits','gwas_tier',
       'target_validation_score','mechanism_diversity_score','clinical_stage_score',
       'competitive_density_score','whitespace_score'].forEach(function (k) {
        if (local[k] != null) merged[k] = local[k];
      });
    }
    _renderSelectedOppCard(wrap, merged);
  } catch (e) {
    if (!local) {
      wrap.innerHTML = _errorCardHTML('Failed to load opportunity', e.message);
    }
  }
}

function _renderSelectedOppCard(wrap, d) {
  var ev = _computeEvidence(d);
  var tierLabel = assignTier(ev.total);
  var tierCls = tierLabel === 'GOLD' ? 'ws-bg-gold' : tierLabel === 'SILVER' ? 'ws-bg-silver' : 'ws-bg-gap';
  var targetName = _formatTarget(d);
  var genBadge = d.gwas_tier === 'GWAS Validated'
    ? '<span class="ws-badge ws-bg-green">GWAS Validated</span>'
    : (d.gwas_tier === 'GWAS Candidate' || (d.n_gwas_hits || 0) > 0)
    ? '<span class="ws-badge ws-bg-teal">Genetic Evidence</span>'
    : '';

  function barColor(v) {
    return v >= 0.55 ? 'var(--teal)' : v >= 0.30 ? 'var(--amber)' : 'var(--rose)';
  }
  function scoreRow(label, val, weight) {
    return '<div class="ws-score-row">'
      + '<span class="ws-score-label">' + label
      + ' <span style="color:var(--text-faint);font-weight:400;font-size:9px">(' + Math.round(weight * 100) + '%)</span></span>'
      + '<div class="ws-score-track"><div class="ws-score-fill" style="width:' + (val * 100) + '%;background:' + barColor(val) + '"></div></div>'
      + '<span class="ws-score-val">' + val.toFixed(2) + '</span>'
      + '</div>';
  }

  var h = '<div class="ws-card ws-selected-card result-in" style="margin-bottom:12px">'
    + '<div style="display:flex;justify-content:flex-end;padding:6px 10px 0 0">'
    + '<button onclick="selectOppDot(null);_clearSelectedOpp();closeEvDrawer()" style="cursor:pointer;font-size:14px;color:var(--text-muted);line-height:1;padding:2px 6px;border-radius:4px;background:transparent;border:none;min-height:44px;min-width:44px;display:inline-flex;align-items:center;justify-content:center" aria-label="Close">&times;</button>'
    + '</div>'
    + '<div style="padding:4px 14px 8px 14px">'
    + '<div style="font-size:14px;font-weight:700;color:var(--text);line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + _escHTML(d.disease_name || '') + '</div>'
    + '<div style="display:flex;align-items:center;gap:6px;margin-top:4px;flex-wrap:wrap">'
    + '<span class="ws-badge ' + tierCls + '" style="font-size:8px;padding:1px 5px">' + tierLabel + '</span>'
    + genBadge
    + '<span style="color:var(--g-border)">·</span>'
    + '<span style="font-size:9px;font-family:var(--font-mono);color:var(--text-muted)">'
    + (targetName && targetName !== 'Unknown' ? _escHTML(targetName) + ' · ' : '')
    + _escHTML(_formatTA(d.therapeutic_area || '-'))
    + (d.max_phase ? ' · ' + _escHTML(String(d.max_phase)) : '')
    + '</span>'
    + '</div></div>'
    + '<div style="padding:10px 14px 6px 14px">'
    + scoreRow('Target Validation', ev.targetValidation, 0.35)
    + scoreRow('Competitive Openness', ev.compOpenness, 0.35)
    + scoreRow('Innovation Potential', ev.innovation, 0.20)
    + scoreRow('Clinical Gap Signal', ev.gapSignal, 0.10)
    + '</div>'
    + '</div>';

  /* CTK 2026-05-03: BUG FIX — wrap is #opp-selected-wrap which contains TWO
     children: this Selected Opportunity card + the #opp-evidence-panel sibling.
     The previous `wrap.innerHTML = h` destroyed the evidence panel on every
     render, so the subsequent openEvDrawer setTimeout (line ~570) couldn't
     find #opp-evidence-panel and silently no-op'd — that's why the evidence
     drawer "wasn't changing": it was never being inserted into the DOM at all.
     Fix: preserve the evidence panel's outerHTML (or rebuild a fresh empty one
     if it was never created) and re-append after the Selected card. */
  var existingEvidence = document.getElementById('opp-evidence-panel');
  var evidenceHTML = existingEvidence
    ? existingEvidence.outerHTML
    : '<div id="opp-evidence-panel" style="width:0;min-width:0;max-width:340px;flex-shrink:0;overflow:hidden"></div>';
  wrap.innerHTML = h + evidenceHTML;
}

// ─── Evidence drawer ──────────────────────────────────────────────────────────

async function openEvDrawer(diseaseId) {
  var panel = document.getElementById('opp-evidence-panel');
  var isMobile = window.innerWidth <= 768;
  var drawer = document.getElementById('ev-drawer');
  var bodyEl, titleEl;

  if (isMobile) {
    bodyEl = document.getElementById('ev-drawer-body');
    titleEl = document.getElementById('ev-drawer-title');
    if (bodyEl) bodyEl.innerHTML = '<div class="shimmer" style="height:120px;margin-bottom:12px;border-radius:6px"></div><div class="shimmer" style="height:80px;border-radius:6px"></div>';
    if (drawer) drawer.classList.add('open');
    if (typeof window._showMobileBackdrop === 'function') window._showMobileBackdrop();
  } else {
    if (drawer) drawer.classList.remove('open');
    if (panel) {
      panel.innerHTML = '<div class="card" style="margin:0;border-top:3px solid color-mix(in srgb,var(--teal) 30%,transparent)">'
        + '<div style="padding:6px 14px 8px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--g-border-light)">'
        + '<div id="opp-ev-title" style="font-size:14px;font-weight:700;color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">Evidence</div>'
        + '<button onclick="closeEvDrawer()" style="cursor:pointer;font-size:14px;color:var(--text-muted);line-height:1;padding:2px 6px;border-radius:4px;background:transparent;border:none;min-height:44px;min-width:44px;display:inline-flex;align-items:center;justify-content:center;transition:all .15s" aria-label="Close evidence panel">&times;</button>'
        + '</div>'
        + '<div id="opp-ev-body" style="padding:12px 14px">'
        + '<div class="shimmer" style="height:120px;margin-bottom:12px;border-radius:6px"></div>'
        + '<div class="shimmer" style="height:80px;border-radius:6px"></div>'
        + '</div></div>';
      panel.style.display = 'flex';
      panel.style.flexDirection = 'column';
      panel.style.width = '340px';
      panel.style.minWidth = '340px';
    }
    bodyEl = document.getElementById('opp-ev-body');
    titleEl = document.getElementById('opp-ev-title');
  }

  // Find the disease record in local data
  var d = (STATE_OPP.data || []).find(function (x) { return String(x.disease_id) === String(diseaseId); });
  if (!d) {
    if (bodyEl) bodyEl.innerHTML = '<div style="color:var(--text-muted);font-size:11px">No data available</div>';
    return;
  }

  if (titleEl) titleEl.textContent = d.disease_name || 'Evidence';

  // Fetch org names from CompeteIQ for context
  if (!d._top_org_names && d.disease_name) {
    try {
      var compRes = await window.DASHIQ.rankCompete({ disease: d.disease_name, limit: 10 });
      var seenOrgs = {};
      d._top_org_names = (compRes.results || []).map(function (r) { return r.org_name || ''; }).filter(function (n) {
        if (!n || seenOrgs[n]) return false;
        seenOrgs[n] = true;
        return true;
      });
    } catch (e) {
      d._top_org_names = [];
    }
  }

  var ev = _computeEvidence(d);
  var gene = _formatTarget(d);
  var gwasTier = d.gwas_tier || 'No GWAS';
  var nGwas = d.n_gwas_hits || 0;
  var tv = d.target_validation_score || 0;
  var nMoas = d.n_moas || 0;
  var md = d.mechanism_diversity_score || 0;
  var nTrials = d.n_trials || 0;
  var nDrugs = d.n_drugs || 0;
  var nOrgs = d.n_orgs || 0;
  var ws = d.whitespace_score || 0;
  var nPathVar = d.n_pathogenic_variants || 0;

  // Source color helpers (inline styles for SVG-based content, using var references where possible)
  var srcColors = {
    'GWAS Catalog': 'var(--indigo)',
    'OMIM': 'var(--amber)',
    'MedGen': 'var(--amber)',
    'PubChem': 'var(--teal)',
    'FDA': 'var(--teal)',
    'ClinicalTrials.gov': 'var(--teal-glow)',
    'Reactome': 'var(--blue)',
  };

  function srcBadge(name) {
    var clr = srcColors[name] || 'var(--text-faint)';
    return '<span class="evidence-source-badge" style="color:' + clr + ';border-color:' + clr + ';background:color-mix(in srgb,' + clr + ' 10%,transparent)">' + _escHTML(name) + '</span>';
  }

  function scorePill(val, colorVar) {
    return '<span style="font-size:8px;font-family:var(--font-mono);padding:1px 6px;border-radius:3px;background:color-mix(in srgb,' + colorVar + ' 15%,transparent);color:' + colorVar + ';font-weight:700;margin-left:4px">' + _escHTML(val) + '</span>';
  }

  function chainCard(label, titleText, subtitle, opts) {
    opts = opts || {};
    var accentColor = opts.color || 'var(--indigo)';
    var cardLabel = opts.cardLabel || '';
    var badges = opts.badges || '';
    var pills = opts.pills || '';
    var card = '<div class="evidence-chain-card" data-card="' + cardLabel + '">'
      + '<div class="evidence-chain-dot"></div>';
    if (badges) card += '<div class="evidence-chain-badges" style="margin-bottom:3px">' + badges + '</div>';
    card += '<span class="evidence-chain-label" style="color:' + accentColor + '">' + _escHTML(label) + '</span>';
    card += '<span class="evidence-chain-title">' + _escHTML(titleText) + (pills ? pills : '') + '</span>';
    if (subtitle) card += '<span class="evidence-chain-subtitle">' + _escHTML(subtitle) + '</span>';
    card += '</div>';
    return card;
  }

  var nSources = 1
    + (gwasTier !== 'No GWAS' ? 1 : 0)
    + (tv > 0 ? 1 : 0)
    + (nDrugs > 0 || nMoas > 0 ? 1 : 0)
    + (nTrials > 0 ? 1 : 0);

  var h = '<div style="border-top:1px solid var(--g-border);padding-top:10px;margin-top:10px">';
  h += '<div style="font-size:8px;font-family:var(--font-mono);color:var(--text);text-transform:uppercase;letter-spacing:.8px;font-weight:700;margin-bottom:8px">EVIDENCE PATH · ' + nSources + ' DATABASES</div>';

  // Timeline container
  h += '<div class="evidence-chain">';

  // Card 1: GENE (indigo)
  var geneLabel = gene !== 'Unknown' ? gene : (d.disease_name || '').split(' ')[0];
  var geneSub = gwasTier === 'GWAS Validated'
    ? (nGwas > 0 ? nGwas + ' confirmed genetic associations' : 'GWAS validated target')
    : gwasTier === 'GWAS Candidate'
    ? 'GWAS candidate' + (nGwas > 0 ? ' · ' + nGwas + ' genetic hits' : '')
    : tv > 0
    ? (tv * 100).toFixed(0) + '% target validation score'
    : 'Unvalidated target';
  var geneBadges = srcBadge('Reactome') + (gwasTier !== 'No GWAS' ? srcBadge('GWAS Catalog') : '');
  var genePills = tv > 0 ? scorePill((tv * 100).toFixed(0) + '%', 'var(--indigo)') : '';
  h += chainCard('GENE', geneLabel, geneSub, { color: 'var(--indigo)', cardLabel: 'gene', badges: geneBadges, pills: genePills });

  // Card 2: GENETIC EVIDENCE (amber)
  var genEvTitle = gwasTier === 'GWAS Validated'
    ? 'GWAS Validated' + (nGwas > 0 ? ' · ' + nGwas + ' hits' : '')
    : gwasTier === 'GWAS Candidate'
    ? 'GWAS Candidate' + (nGwas > 0 ? ' · ' + nGwas + ' hits' : '')
    : nPathVar > 0
    ? nPathVar + ' Pathogenic Variant' + (nPathVar !== 1 ? 's' : '')
    : tv > 0
    ? 'MedGen Association'
    : 'No genetic evidence';
  var genEvSub = gwasTier === 'GWAS Validated'
    ? 'Gold-standard genetic link — 2x higher clinical success rate'
    : gwasTier === 'GWAS Candidate'
    ? 'Candidate association — pending full validation'
    : nPathVar > 0
    ? 'OMIM pathogenic variants linked to disease'
    : tv > 0
    ? 'Drug-protein or disease-gene association in MedGen'
    : 'No confirmed genetic associations';
  var genEvBadges = (gwasTier !== 'No GWAS' ? srcBadge('GWAS Catalog') : '') + (tv > 0 ? srcBadge('OMIM') + srcBadge('MedGen') : '');
  var genEvPills = nGwas > 0 ? scorePill(nGwas + ' hits', 'var(--amber)') : '';
  h += chainCard('GENETIC EVIDENCE', genEvTitle, genEvSub, { color: 'var(--amber)', cardLabel: 'genetic', badges: genEvBadges, pills: genEvPills });

  // Card 3: TARGET (teal)
  var targetTitle = (nDrugs > 0 || nMoas > 0)
    ? (nMoas > 0 ? nMoas + ' MOA' + (nMoas > 1 ? 's' : '') : '')
      + (nDrugs > 0 ? (nMoas > 0 ? ' · ' : '') + nDrugs + ' Drug' + (nDrugs > 1 ? 's' : '') : '')
    : 'No drugs mapped';
  var targetSub = nDrugs > 0 ? 'Druggable target confirmed' : 'Pending drug-target mapping';
  var targetBadges = srcBadge('PubChem') + (nDrugs > 0 ? srcBadge('FDA') : '');
  var targetPills = md > 0 ? scorePill(md.toFixed(2), 'var(--teal)') : '';
  h += chainCard('TARGET', targetTitle, targetSub, { color: 'var(--teal)', cardLabel: 'target', badges: targetBadges, pills: targetPills });

  // Card 4: CLINICAL GAP (green / teal-glow)
  var isGap = nTrials === 0 || (nTrials <= 1 && nOrgs > 0);
  var gapStage = nTrials === 0 ? 'White Space'
    : nTrials <= 3 ? 'Pioneer · ' + nTrials
    : nTrials <= 10 ? 'Early Mover · ' + nTrials
    : nTrials <= 30 ? 'Fast Follower · ' + nTrials
    : 'Established · ' + nTrials;
  var gapSub = nTrials === 0 ? 'Validated biology with zero clinical trials'
    : nTrials <= 3 ? 'First movers only — wide open for new entrants'
    : nTrials <= 10 ? 'Early clinical activity — opportunity window open'
    : nTrials <= 30 ? 'Moderate competition — differentiation required'
    : 'Crowded pipeline — limited white space';
  var gapBadges = (nTrials > 0 ? srcBadge('ClinicalTrials.gov') : '') + srcBadge('Reactome');
  var co = ev.compOpenness || 0;
  var gapPills = scorePill((co * 100).toFixed(0) + '%', 'var(--teal-glow)') + scorePill('WS ' + ws.toFixed(2), 'var(--teal-glow)');
  h += chainCard('CLINICAL GAP', gapStage, gapSub, { color: 'var(--teal-glow)', cardLabel: 'clinical-gap', badges: gapBadges, pills: gapPills });

  h += '</div>'; // close evidence-chain

  // Data sources provenance footer
  var provSources = [];
  if (gwasTier === 'GWAS Validated' || gwasTier === 'GWAS Candidate') provSources.push({ name: 'GWAS Catalog', clr: 'var(--indigo)' });
  if (tv > 0) { provSources.push({ name: 'OMIM', clr: 'var(--amber)' }); provSources.push({ name: 'MedGen', clr: 'var(--amber)' }); }
  if (nDrugs > 0 || nMoas > 0) { provSources.push({ name: 'PubChem', clr: 'var(--teal)' }); provSources.push({ name: 'FDA', clr: 'var(--teal)' }); }
  provSources.push({ name: 'Reactome', clr: 'var(--blue)' });
  if (nTrials > 0) provSources.push({ name: 'ClinicalTrials.gov', clr: 'var(--teal-glow)' });

  h += '<div style="padding:8px 10px;background:color-mix(in srgb,var(--teal) 4%,transparent);border-radius:6px;border:1px solid color-mix(in srgb,var(--teal) 10%,transparent);margin-top:12px">';
  h += '<div style="font-size:7.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--teal);font-weight:700;margin-bottom:4px">DATA SOURCES</div>';
  h += '<div style="display:flex;flex-wrap:wrap;gap:4px">';
  provSources.forEach(function (src) {
    h += '<span style="font-size:8px;padding:2px 6px;border-radius:3px;background:color-mix(in srgb,' + src.clr + ' 12%,transparent);color:' + src.clr + ';font-weight:600">' + _escHTML(src.name) + '</span>';
  });
  h += '</div></div>';
  h += '</div>';

  if (bodyEl) bodyEl.innerHTML = h;
}

function closeEvDrawer() {
  // Close mobile drawer
  var drawer = document.getElementById('ev-drawer');
  if (drawer) drawer.classList.remove('open');
  // Close desktop inline panel
  var panel = document.getElementById('opp-evidence-panel');
  if (panel) {
    panel.style.width = '0';
    panel.style.minWidth = '0';
    panel.style.paddingLeft = '0';
    panel.innerHTML = '';
  }
  if (typeof window._hideMobileBackdrop === 'function') window._hideMobileBackdrop();
}

// ─── Main render orchestrator ──────────────────────────────────────────────────

async function renderOpp() {
  var el = document.getElementById('panel-opp');
  if (!el) return;

  el.innerHTML =
    // Search row — CTK 2026-05-03: max-width:760px / center-aligned to
    // match PartnerIQ + CompeteIQ search rows (consistent component scale
    // across modules per audit).
    '<div class="search-xmod-row" style="max-width:760px;margin:0 auto var(--s-3)">'
    // CTK 2026-05-07: cross-intel bar restored (PartnerIQ + CompeteIQ have
    // it; WhitespaceIQ was missing it because earlier comment claimed
    // "chrome owns it" — but chrome.jsx never mounted it. Add the wrap so
    // app.jsx refreshAllXmodBars() can populate it with module-switcher segments.
    + '<div id="xmod-island-wrap-opp" class="xmod-island-wrap"></div>'
    + '<div class="diq-search-container">'
    + '<div class="diq-search-row">'
    + '<span class="diq-search-label" style="margin-right:8px">Explore by </span>'
    + '<button class="diq-mode-tab on" disabled aria-label="Search mode: Therapeutic Area" style="cursor:default;margin-right:8px">TA</button>'
    + '<div class="diq-search-input-wrap" style="max-width:240px">'
    + '<svg class="diq-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
    + '<input class="diq-search-input sdd" id="opp-ta" aria-label="Filter by therapeutic area" placeholder="Search therapeutic areas..." autocomplete="off" value="' + _escHTML(STATE_OPP.ta ? _formatTA(STATE_OPP.ta) : '') + '"/>'
    + '<div class="ac-dropdown" id="opp-ta-dd" style="display:none;max-height:260px"></div>'
    + '</div>'
    + '<div class="diq-search-mode-tabs" style="display:inline-flex;gap:8px;flex-wrap:wrap">'
    + '<button class="diq-mode-tab ws-filter-toggle' + (STATE_OPP.rare ? ' on active' : '') + '" data-filter="rare" onclick="toggleOppFilter(\'rare\')" title="Show only diseases with 3 or fewer active organizations" style="min-height:44px">Rare</button>'
    + '<button class="diq-mode-tab ws-filter-toggle' + (STATE_OPP.noBigPharma ? ' on active' : '') + '" data-filter="noBigPharma" onclick="toggleOppFilter(\'noBigPharma\')" title="Exclude diseases where big pharma is active" style="min-height:44px">No Big Pharma</button>'
    + '<button class="diq-mode-tab ws-filter-toggle' + (STATE_OPP.excludeApproved ? ' on active' : '') + '" data-filter="excludeApproved" onclick="toggleOppFilter(\'excludeApproved\')" title="Exclude diseases with approved indications" style="min-height:44px">Excl. Approved</button>'
    + '</div>'
    + '<span class="ws-filter-count" id="opp-fcnt" style="display:none"></span>'
    + '<button class="diq-search-btn" onclick="loadOppData()" style="min-height:44px">Search</button>'
    + '</div>'
    + '</div>'
    + '</div>'

    // Cross-module island wrap — populated by app.jsx refreshAllXmodBars()
    /* CTK 2026-05-02: removed duplicate xmod-island-wrap-opp mount (chrome owns it) */

    // CTK 2026-05-01 Layer-2 audit fix — V1 layout has 2-column grid where
    // RIGHT rail (Selected Opportunity + Detail card stacked) extends DOWN
    // alongside LEFT column (Matrix + Table stacked). Restructured per V1.
    // V1 reference: WHITESPACEIQ_DARK.png — both columns are stacked panels.
    + '<div id="opp-mob-gate">'

    // ─── 2-column grid: LEFT (matrix + table stacked) | RIGHT (Selected + Detail stacked) ───
    + '<div id="opp-main-row" style="display:flex;gap:12px;align-items:flex-start">'

    // ── LEFT COLUMN ── (Matrix on top + TOP OPPORTUNITIES table below, stacked)
    + '<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:12px">'

    // Matrix card
    + '<div class="ws-card">'
    + '<div class="ws-card-header" style="justify-content:space-between">'
    + '<div style="display:flex;align-items:center;gap:8px"><div class="ws-card-accent ws-ca-teal"></div><span class="ws-card-title">Target × Disease Matrix</span></div>'
    + '<span style="font-size:10px;color:var(--text-muted);font-family:var(--font-mono)" id="opp-scatter-count"></span>'
    + '</div>'
    + '<div class="ws-card-body" id="opp-scatter-wrap" style="height:520px;overflow:hidden;padding:0"><div class="shimmer" style="height:480px;border-radius:6px"></div></div>'
    + '</div>'

    // TOP OPPORTUNITIES table card (below matrix in left column)
    // UR-012 fix: cap table height + scroll. Previously the table grew unbounded
    // and dwarfed the scatter + score panels. Cap to ~10 rows visible (~440px)
    // so the panel sits in proportional space; pagination plus internal scroll
    // handles overflow. Min-height keeps shimmer placeholder consistent.
    + '<div class="ws-card ws-table-cta">'
    + '<div class="ws-card-header">'
    + '<div class="ws-card-accent ws-ca-navy"></div>'
    + '<span class="ws-card-title">TOP OPPORTUNITIES</span>'
    + '<span style="font-size:10px;color:var(--text-muted);font-family:var(--font-mono);margin-left:auto" id="opp-count2"></span>'
    + '</div>'
    + '<div class="ws-card-body-flush" id="opp-table-wrap" style="max-height:480px;overflow-y:auto;overflow-x:hidden">' + _shimmerRows(8, 6) + '</div>'
    + '</div>'

    + '</div>' // /left column

    // ── RIGHT COLUMN ── (Selected Opportunity panel on top + Disease detail card below, stacked)
    + '<div id="opp-selected-wrap" style="flex:0 0 340px;max-width:340px;min-width:0;display:flex;flex-direction:column;gap:12px">'

    // Selected Opportunity panel — populates when a row/dot is clicked
    + '<div class="ws-card ws-selected-card" style="opacity:.6">'
    + '<div class="ws-card-header"><div class="ws-card-accent ws-ca-teal"></div><span class="ws-card-title">Selected Opportunity</span></div>'
    + '<div class="ws-card-body">'
    + '<div style="text-align:center;padding:30px 16px;color:var(--text-muted);font-size:12px">'
    + '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:.3;display:block;margin:0 auto 8px" aria-hidden="true">'
    + '<circle cx="12" cy="12" r="3"/>'
    + '<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>'
    + '</svg>'
    + 'Select a row or dot to view details'
    + '</div></div></div>'

    // Evidence panel — sibling INSIDE the right column (below Selected
    // Opportunity panel). V1 layout: right rail has both stacked panels
    // and extends down past the table. width:0 collapsed by default;
    // opens to 340px when openEvDrawer fires on row/dot click.
    + '<div id="opp-evidence-panel" style="width:0;min-width:0;max-width:340px;flex-shrink:0;overflow:hidden"></div>'

    + '</div>' // /right column

    + '</div>' // /opp-main-row

    + '</div>'; // close opp-mob-gate

  // Close dropdowns on document click
  document.addEventListener('click', _closeAllCfDropdowns);

  // Kick off data load
  loadOppData();
}

// ─── Window surface ────────────────────────────────────────────────────────────

window.renderOpp = renderOpp;
window.loadOppData = loadOppData;
window.onOppFilter = onOppFilter;
window.renderTargetDiseaseMatrix = renderTargetDiseaseMatrix;
window.renderOppTable = renderOppTable;
window.autoSelectBest = autoSelectBest;
window.selectOppRow = selectOppRow;
window.selectOppDot = selectOppDot;
// Guard openEvDrawer — coexists with CompeteIQ's panel; each guards by module context
window.openEvDrawer = window.openEvDrawer || openEvDrawer;
window._openOppEvDrawer = openEvDrawer; // always points to this module's implementation
window.closeEvDrawer = closeEvDrawer;
window.toggleOppFilter = toggleOppFilter;
window.sortOppCol = sortOppCol;
window.showOppScatterTip = showOppScatterTip;
window.hideOppScatterTip = hideOppScatterTip;
window.assignTier = assignTier;
window.STATE_OPP = STATE_OPP;
// Expose internal helpers for cross-module access
window._clearSelectedOpp = _clearSelectedOpp;
window._computeEvidence = _computeEvidence;
window._dedupByDisease = _dedupByDisease;
window.filterOppData = filterOppData;
