'use strict'; const V = 'p5'; // bump on each data/app redeploy to bust caches // ponytail: data is served by the project's GitHub Pages (CORS: access-control-allow-origin *), // so this static Space stays ~25KB and shares one source of truth. Point at local 'data' if self-hosting. const DATA = 'https://joevondahab.github.io/pharmacology-graph/data'; const bust = u => `${u}${u.includes('?') ? '&' : '?'}v=${V}`; const CHEMBL_CMP = id => `https://www.ebi.ac.uk/chembl/explore/compound/${id}`; const CHEMBL_TGT = id => `https://www.ebi.ac.uk/chembl/explore/target/${id}`; const MESH = id => `https://meshb.nlm.nih.gov/record/ui?ui=${id}`; const EXT = { drug: CHEMBL_CMP, protein: CHEMBL_TGT, mesh: MESH }; // entity-type config for search + display const TYPES = { drug: { label: 'Drug', chip: 'drug', file: id => `predictions/${id}.json`, index: 'drugs.json' }, indication: { label: 'Indication', chip: 'ind', file: id => `by_indication/${id}.json`, index: 'indications.json' }, protein: { label: 'Protein', chip: 'prot', file: id => `by_protein/${id}.json`, index: 'proteins.json' }, }; let ENTITIES = []; // combined search index: {type,id,name,...counts} let current = null; // {type, view:[{key,label,linkType}], data} let activeList = null; let statusFilter = 'all'; const $ = s => document.querySelector(s); const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); async function init() { try { const [drugs, inds, prots, meta] = await Promise.all([ fetch(bust(`${DATA}/drugs.json`)).then(r => r.json()), fetch(bust(`${DATA}/indications.json`)).then(r => r.json()), fetch(bust(`${DATA}/proteins.json`)).then(r => r.json()), fetch(bust(`${DATA}/meta.json`)).then(r => r.json()).catch(() => null), ]); ENTITIES = [ ...drugs.map(d => ({ ...d, type: 'drug' })), ...inds.map(d => ({ ...d, type: 'indication' })), ...prots.map(d => ({ ...d, type: 'protein' })), ]; if (meta) { $('#meta-line').textContent = `${meta.num_drugs.toLocaleString()} drugs · ${meta.num_proteins.toLocaleString()} proteins · ` + `${meta.num_indications.toLocaleString()} indications · top-${meta.top_k} each way`; if (meta.metrics) { const m = meta.metrics; $('#metrics-note').textContent = `Model test metrics — Drug–Protein PR-AUC ${m.dp.pr_auc.toFixed(3)}, Hits@10 ${m.dp['hits@10'].toFixed(3)} · ` + `Drug–Indication PR-AUC ${m.di.pr_auc.toFixed(3)}, Hits@10 ${m.di['hits@10'].toFixed(3)}`; } } buildExamples(); wireSearch(); if (location.hash.length > 1) { const [t, id] = decodeURIComponent(location.hash.slice(1)).split(':'); if (id) select(t, id); } } catch (e) { $('#empty-state').innerHTML = `

Failed to load data (${e}). Serve this folder over HTTP.

`; } } function buildExamples() { const picks = [ ['drug', 'SUNITINIB'], ['indication', 'Breast Neoplasms'], ['drug', 'ASPIRIN'], ['indication', 'Hypertension'], ['protein', 'Histamine H1 receptor'], ]; const box = $('#examples'); picks.forEach(([type, name]) => { const e = ENTITIES.find(x => x.type === type && x.name.toUpperCase() === name.toUpperCase()); if (!e) return; const b = document.createElement('button'); b.innerHTML = `${TYPES[type].label} ${esc(e.name)}`; b.onclick = () => select(type, e.id); box.appendChild(b); }); } // ---------- search ---------- let activeIdx = -1, matches = []; function wireSearch() { const input = $('#search'), list = $('#suggestions'); input.addEventListener('input', () => { const q = input.value.trim().toLowerCase(); if (!q) { list.hidden = true; return; } matches = ENTITIES .filter(e => e.name.toLowerCase().includes(q) || e.id.toLowerCase().includes(q)) .sort((a, b) => (a.name.toLowerCase().indexOf(q) - b.name.toLowerCase().indexOf(q)) || a.name.length - b.name.length) .slice(0, 14); renderSuggestions(q); }); input.addEventListener('keydown', e => { if (list.hidden) return; if (e.key === 'ArrowDown') { activeIdx = Math.min(activeIdx + 1, matches.length - 1); paint(); e.preventDefault(); } else if (e.key === 'ArrowUp') { activeIdx = Math.max(activeIdx - 1, 0); paint(); e.preventDefault(); } else if (e.key === 'Enter') { const m = matches[activeIdx] || matches[0]; if (m) select(m.type, m.id); } else if (e.key === 'Escape') list.hidden = true; }); document.addEventListener('click', e => { if (!e.target.closest('.search-box')) list.hidden = true; }); } function renderSuggestions(q) { const list = $('#suggestions'); activeIdx = -1; if (!matches.length) { list.hidden = true; return; } list.innerHTML = matches.map((e, i) => { const t = TYPES[e.type]; const sub = e.type === 'drug' ? `${e.kp}+${e.ki} known` : `${e.kd} known drugs`; return `
  • ${t.label} ${hl(e.name, q)}${e.id} · ${sub}
  • `; }).join(''); [...list.children].forEach((li, i) => li.onclick = () => select(matches[i].type, matches[i].id)); list.hidden = false; } function paint() { [...$('#suggestions').children].forEach(li => li.classList.toggle('active', +li.dataset.i === activeIdx)); } function hl(text, q) { const i = text.toLowerCase().indexOf(q); return i < 0 ? esc(text) : esc(text.slice(0, i)) + '' + esc(text.slice(i, i + q.length)) + '' + esc(text.slice(i + q.length)); } // ---------- selection ---------- async function select(type, id) { $('#suggestions').hidden = true; $('#search').value = ''; let data; try { data = await fetch(bust(`${DATA}/${TYPES[type].file(id)}`)).then(r => { if (!r.ok) throw new Error(r.status); return r.json(); }); } catch (e) { alert(`No predictions found for ${type} "${id}".`); return; } let view, head; if (type === 'drug') { view = [{ key: 'proteins', label: 'Protein targets', linkType: 'protein' }, { key: 'indications', label: 'Indications', linkType: 'mesh' }]; head = { name: data.drug.name, id: data.drug.id, ext: 'drug' }; } else if (type === 'indication') { view = [{ key: 'drugs', label: 'Predicted drugs', linkType: 'drug' }]; head = { name: data.indication.name, id: data.indication.id, ext: 'mesh' }; } else { view = [{ key: 'drugs', label: 'Predicted drugs', linkType: 'drug' }]; head = { name: data.protein.name, id: data.protein.id, ext: 'protein' }; } current = { type, view, data, head }; activeList = view[0].key; statusFilter = 'all'; history.replaceState(null, '', `#${type}:${id}`); $('#empty-state').hidden = true; $('#result').hidden = false; renderHead(); renderControls(); renderTable(); } function renderHead() { const { type, head, view, data } = current; $('#drug-name').textContent = head.name; $('#type-chip').className = `chip ${TYPES[type].chip}`; $('#type-chip').textContent = TYPES[type].label; const link = $('#drug-link'); link.href = EXT[head.ext](head.id); link.textContent = `${head.id} ↗`; const cards = []; const count = (key, st) => (data[key] || []).filter(r => r.status === st).length; if (type === 'drug') { cards.push(['known', count('proteins', 'known'), 'known prot'], ['novel', count('proteins', 'novel'), 'novel prot'], ['known', count('indications', 'known'), 'known ind'], ['novel', count('indications', 'novel'), 'novel ind']); } else { cards.push(['known', count('drugs', 'known'), 'known drugs'], ['novel', count('drugs', 'novel'), 'novel drugs']); } $('#stat-cards').innerHTML = cards.map(([c, n, l]) => `
    ${n}${l}
    `).join(''); } function renderControls() { const tabs = $('#tabs'); if (current.view.length > 1) { tabs.hidden = false; tabs.innerHTML = current.view.map(v => ``).join(''); } else { tabs.hidden = true; } } function activeRows() { const rows = current.data[activeList] || []; const q = $('#table-filter').value.trim().toLowerCase(); return rows.filter(r => statusFilter === 'all' || r.status === statusFilter) .filter(r => !q || r.name.toLowerCase().includes(q) || r.id.toLowerCase().includes(q)); } function renderTable() { const v = current.view.find(x => x.key === activeList); const linkFn = EXT[v.linkType]; const colHead = v.linkType === 'drug' ? 'Drug' : (v.linkType === 'protein' ? 'Target' : 'Indication'); $('#th-target').textContent = colHead; const rows = activeRows(); $('#pred-body').innerHTML = rows.map(r => ` ${r.rank} ${esc(r.name)} ${r.id} ↗
    ${r.score.toFixed(3)}
    ${r.status} `).join(''); $('#no-rows').hidden = rows.length > 0; } // ---------- controls ---------- document.addEventListener('click', e => { const t = e.target; if (t.classList.contains('tab')) { activeList = t.dataset.list; renderControls(); renderTable(); } if (t.classList.contains('chip') && t.dataset.status) { statusFilter = t.dataset.status; document.querySelectorAll('#status-filter .chip').forEach(x => x.classList.toggle('active', x === t)); renderTable(); } if (t.id === 'dl-csv') downloadCSV(); if (t.id === 'dl-json') downloadJSON(); }); document.addEventListener('input', e => { if (e.target.id === 'table-filter') renderTable(); }); // ---------- downloads ---------- function downloadCSV() { if (!current) return; const v = current.view.find(x => x.key === activeList); const what = v.linkType === 'drug' ? 'drug' : (v.linkType === 'protein' ? 'protein' : 'indication'); const head = ['rank', `${what}_id`, `${what}_name`, 'score', 'raw_score', 'status']; const lines = [head.join(',')]; for (const r of activeRows()) lines.push([r.rank, r.id, `"${r.name.replace(/"/g, '""')}"`, r.score, r.raw ?? '', r.status].join(',')); save(lines.join('\n'), `${current.head.id}_${activeList}.csv`, 'text/csv'); } function downloadJSON() { if (!current) return; save(JSON.stringify(current.data, null, 2), `${current.head.id}_predictions.json`, 'application/json'); } function save(text, name, type) { const url = URL.createObjectURL(new Blob([text], { type })); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); } init();