Skip to content

Commit 287a386

Browse files
committed
Add gene/protein hovering
1 parent eea6bba commit 287a386

4 files changed

Lines changed: 296 additions & 11 deletions

File tree

app.js

Lines changed: 156 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ function buildAliasResults(resolvedGenes, sourceTaxid, targetTaxids) {
451451

452452
for (const { query, proteinId } of found) {
453453
html += `<div class="result-section">`;
454-
html += `<div class="result-section-title"><span class="result-gene-badge">${esc(query)}</span>`;
454+
html += `<div class="result-section-title"><span class="result-gene-badge" data-pid="${esc(proteinId)}" data-taxid="${esc(sourceTaxid)}">${esc(query)}</span>`;
455455
const name = getPreferredName(proteinId, sourceTaxid);
456456
if (name !== query) html += ` → ${esc(name)}`;
457457
html += `</div>`;
@@ -483,8 +483,8 @@ function buildAliasResults(resolvedGenes, sourceTaxid, targetTaxids) {
483483
const matchAliases = (targetData.aliases?.[matchPid] || []).slice(0, 5).join(', ');
484484
html += `<tr>
485485
<td>${italicSpeciesName(targetName)}</td>
486-
<td><code>${esc(matchPid)}</code></td>
487-
<td>${esc(matchName)}</td>
486+
<td><code data-pid="${esc(matchPid)}" data-taxid="${esc(targetTaxid)}">${esc(matchPid)}</code></td>
487+
<td data-pid="${esc(matchPid)}" data-taxid="${esc(targetTaxid)}">${esc(matchName)}</td>
488488
<td class="alias-text">${esc(matchAliases)}</td>
489489
</tr>`;
490490
foundMatch = true;
@@ -521,7 +521,7 @@ function buildPPIResults(resolvedGenes, sourceTaxid) {
521521

522522
for (const { query, proteinId } of found) {
523523
html += `<div class="result-section">`;
524-
html += `<div class="result-section-title"><span class="result-gene-badge">${esc(query)}</span>`;
524+
html += `<div class="result-section-title"><span class="result-gene-badge" data-pid="${esc(proteinId)}" data-taxid="${esc(sourceTaxid)}">${esc(query)}</span>`;
525525
const name = getPreferredName(proteinId, sourceTaxid);
526526
if (name !== query) html += ` → ${esc(name)}`;
527527
html += `</div>`;
@@ -550,8 +550,8 @@ function buildPPIResults(resolvedGenes, sourceTaxid) {
550550
const scoreClass = s >= 900 ? 'score-high' : s >= 700 ? 'score-med' : 'score-low';
551551
const annotation = data.info?.[p]?.annotation || '';
552552
html += `<tr>
553-
<td><code>${esc(p)}</code></td>
554-
<td>${esc(iName)}</td>
553+
<td><code data-pid="${esc(p)}" data-taxid="${esc(sourceTaxid)}">${esc(p)}</code></td>
554+
<td data-pid="${esc(p)}" data-taxid="${esc(sourceTaxid)}">${esc(iName)}</td>
555555
<td><span class="score ${scoreClass}">${s}</span></td>
556556
<td class="alias-text">${esc(truncate(annotation, 100))}</td>
557557
</tr>`;
@@ -578,7 +578,7 @@ function buildPPINetwork(resolvedGenes, sourceTaxid) {
578578

579579
const getNameFn = (pid) => getPreferredName(pid, sourceTaxid);
580580

581-
const result = window.PPINetwork.buildPPINetworkSVG(resolvedGenes, data.ppi, data.info, state.scoreThreshold, getNameFn);
581+
const result = window.PPINetwork.buildPPINetworkSVG(resolvedGenes, data.ppi, data.info, state.scoreThreshold, getNameFn, sourceTaxid);
582582

583583
container.innerHTML = '';
584584
if (result && result.svg) {
@@ -632,7 +632,7 @@ function renderHubGenesTable(nodes, container, taxid) {
632632
633633
return `
634634
<tr ${rowClass}>
635-
<td>
635+
<td data-pid="${esc(n.id)}" data-taxid="${esc(taxid)}">
636636
<div><strong>${esc(name)}</strong></div>
637637
<div style="font-size:0.75rem;color:var(--text-muted)">${esc(n.id)}</div>
638638
</td>
@@ -670,7 +670,7 @@ function buildGOResults(resolvedGenes, sourceTaxid) {
670670
if (!goTerms || goTerms.length === 0) continue;
671671

672672
html += `<div class="result-section">`;
673-
html += `<div class="result-section-title"><span class="result-gene-badge">${esc(query)}</span></div>`;
673+
html += `<div class="result-section-title"><span class="result-gene-badge" data-pid="${esc(proteinId)}" data-taxid="${esc(sourceTaxid)}">${esc(query)}</span></div>`;
674674

675675
const grouped = {};
676676
for (const t of goTerms) {
@@ -750,7 +750,7 @@ function buildKEGGResults(resolvedGenes, sourceTaxid) {
750750
if (keggTerms.length === 0 && pathwayMatches.length === 0 && keggLikeTerms.length === 0) continue;
751751

752752
html += `<div class="result-section">`;
753-
html += `<div class="result-section-title"><span class="result-gene-badge">${esc(query)}</span>`;
753+
html += `<div class="result-section-title"><span class="result-gene-badge" data-pid="${esc(proteinId)}" data-taxid="${esc(sourceTaxid)}">${esc(query)}</span>`;
754754
if (prefName !== query) html += ` → ${esc(prefName)}`;
755755
html += `</div>`;
756756

@@ -1210,4 +1210,150 @@ function makeTableSortable(table) {
12101210
Array.from(tbody.querySelectorAll('tr')).forEach((tr, i) => tr.dataset.originalIndex = i);
12111211
}
12121212

1213+
// ===== Gene Tooltip =====
1214+
const tooltipEl = document.getElementById('gene-tooltip');
1215+
let tooltipTimer = null;
1216+
1217+
function buildTooltipHTML(pid, taxid) {
1218+
const data = state.cache[taxid];
1219+
if (!data) return null;
1220+
1221+
const info = data.info?.[pid];
1222+
const name = info?.name || pid;
1223+
const annotation = info?.annotation || '';
1224+
const size = info?.size || '';
1225+
const goTerms = (data.go?.[pid] || []).slice(0, 4);
1226+
const pathwayNames = data.keggPathways?.pathways || {};
1227+
1228+
// KEGG gene_pathways is keyed by gene name, not protein ID — try multiple keys
1229+
let keggPaths = [];
1230+
if (data.keggPathways?.gene_pathways) {
1231+
const gp = data.keggPathways.gene_pathways;
1232+
if (gp[pid]) keggPaths = gp[pid];
1233+
else if (gp[name]) keggPaths = gp[name];
1234+
else if (data.aliases?.[pid]) {
1235+
for (const alias of data.aliases[pid]) {
1236+
if (gp[alias]) { keggPaths = gp[alias]; break; }
1237+
}
1238+
}
1239+
}
1240+
1241+
let html = `<div class="gene-tooltip-header">`;
1242+
html += `<span class="gene-tooltip-name">${esc(name)}</span>`;
1243+
if (name !== pid) html += `<span class="gene-tooltip-pid">${esc(pid)}</span>`;
1244+
if (size) html += `<span class="gene-tooltip-size">${esc(size)} aa</span>`;
1245+
html += `</div>`;
1246+
1247+
if (annotation) {
1248+
html += `<div class="gene-tooltip-annotation">${esc(annotation)}</div>`;
1249+
}
1250+
1251+
if (goTerms.length > 0) {
1252+
html += `<div class="gene-tooltip-section">`;
1253+
html += `<div class="gene-tooltip-section-title">GO Terms</div>`;
1254+
html += `<ul class="gene-tooltip-terms">`;
1255+
for (const t of goTerms) {
1256+
html += `<li><a href="https://amigo.geneontology.org/amigo/term/${encodeURIComponent(t.term)}" target="_blank" rel="noopener">${esc(t.term)}</a> ${esc(t.description)}</li>`;
1257+
}
1258+
html += `</ul></div>`;
1259+
}
1260+
1261+
if (keggPaths.length > 0) {
1262+
html += `<div class="gene-tooltip-section">`;
1263+
html += `<div class="gene-tooltip-section-title">KEGG Pathways</div>`;
1264+
html += `<ul class="gene-tooltip-terms">`;
1265+
for (const kp of keggPaths.slice(0, 3)) {
1266+
const cleanId = kp.replace(/^path:/, '');
1267+
const desc = pathwayNames[cleanId] || pathwayNames[kp] || cleanId;
1268+
html += `<li><a href="https://www.genome.jp/entry/${encodeURIComponent(cleanId)}" target="_blank" rel="noopener">${esc(cleanId)}</a> ${esc(desc)}</li>`;
1269+
}
1270+
html += `</ul></div>`;
1271+
}
1272+
1273+
// Reference links
1274+
html += `<div class="gene-tooltip-refs">`;
1275+
html += `<a href="https://www.uniprot.org/uniprot/${encodeURIComponent(pid)}" target="_blank" rel="noopener">UniProt</a>`;
1276+
html += `<a href="https://string-db.org/network/${encodeURIComponent(taxid + '.' + pid)}" target="_blank" rel="noopener">STRING</a>`;
1277+
html += `</div>`;
1278+
1279+
return html;
1280+
}
1281+
1282+
function showGeneTooltip(pid, taxid, event) {
1283+
if (!pid || !taxid) return;
1284+
clearTimeout(tooltipTimer);
1285+
1286+
tooltipTimer = setTimeout(() => {
1287+
const html = buildTooltipHTML(pid, taxid);
1288+
if (!html) return;
1289+
1290+
tooltipEl.innerHTML = html;
1291+
tooltipEl.hidden = false;
1292+
1293+
// Position near cursor, avoiding viewport overflow
1294+
const pad = 12;
1295+
const rect = tooltipEl.getBoundingClientRect();
1296+
let x = event.clientX + pad;
1297+
let y = event.clientY + pad;
1298+
1299+
// Measure after making visible but before showing
1300+
tooltipEl.style.left = x + 'px';
1301+
tooltipEl.style.top = y + 'px';
1302+
tooltipEl.classList.add('visible');
1303+
1304+
// Adjust if overflowing
1305+
const ttRect = tooltipEl.getBoundingClientRect();
1306+
if (ttRect.right > window.innerWidth - 8) {
1307+
x = event.clientX - ttRect.width - pad;
1308+
}
1309+
if (ttRect.bottom > window.innerHeight - 8) {
1310+
y = event.clientY - ttRect.height - pad;
1311+
}
1312+
tooltipEl.style.left = Math.max(4, x) + 'px';
1313+
tooltipEl.style.top = Math.max(4, y) + 'px';
1314+
}, 200);
1315+
}
1316+
1317+
function hideGeneTooltip() {
1318+
clearTimeout(tooltipTimer);
1319+
tooltipEl.classList.remove('visible');
1320+
tooltipTimer = setTimeout(() => {
1321+
tooltipEl.hidden = true;
1322+
}, 150);
1323+
}
1324+
1325+
// Event delegation for [data-pid] elements in results
1326+
document.addEventListener('mouseover', (e) => {
1327+
// Don't hide if mouse enters the tooltip itself
1328+
if (tooltipEl.contains(e.target)) {
1329+
clearTimeout(tooltipTimer);
1330+
return;
1331+
}
1332+
const target = e.target.closest('[data-pid]');
1333+
if (target) {
1334+
const pid = target.dataset.pid;
1335+
const taxid = target.dataset.taxid;
1336+
showGeneTooltip(pid, taxid, e);
1337+
}
1338+
});
1339+
1340+
document.addEventListener('mouseout', (e) => {
1341+
const target = e.target.closest('[data-pid]');
1342+
if (target) {
1343+
// Check if mouse is moving to the tooltip
1344+
const related = e.relatedTarget;
1345+
if (related && tooltipEl.contains(related)) return;
1346+
hideGeneTooltip();
1347+
}
1348+
});
1349+
1350+
// Hide tooltip when leaving the tooltip itself
1351+
tooltipEl.addEventListener('mouseleave', () => {
1352+
hideGeneTooltip();
1353+
});
1354+
1355+
// Expose for network.js
1356+
window.showGeneTooltip = showGeneTooltip;
1357+
window.hideGeneTooltip = hideGeneTooltip;
1358+
12131359
document.addEventListener('DOMContentLoaded', init);

index.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,9 @@ <h2 class="panel-title">
216216
</p>
217217
</footer>
218218

219+
<!-- Gene Tooltip -->
220+
<div id="gene-tooltip" class="gene-tooltip" hidden></div>
221+
219222
<script src="enrichment.js"></script>
220223
<script src="plots.js"></script>
221224
<script src="export.js"></script>

network.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* Build and render a PPI network for the given genes.
1414
* @returns {Object|null} { svg, nodes }
1515
*/
16-
function buildPPINetworkSVG(resolvedGenes, ppiData, infoData, scoreThreshold, getNameFn) {
16+
function buildPPINetworkSVG(resolvedGenes, ppiData, infoData, scoreThreshold, getNameFn, taxid) {
1717
if (!ppiData) return null;
1818

1919
const queryIds = new Set(resolvedGenes.filter(g => g.proteinId).map(g => g.proteinId));
@@ -320,6 +320,18 @@ function renderNetworkViewer(nodes, edges, width, height) {
320320
g.appendChild(t);
321321
}
322322

323+
// Tooltip data attributes
324+
if (taxid) {
325+
g.dataset.pid = n.id;
326+
g.dataset.taxid = taxid;
327+
g.addEventListener('mouseenter', (e) => {
328+
if (window.showGeneTooltip) window.showGeneTooltip(n.id, taxid, e);
329+
});
330+
g.addEventListener('mouseleave', () => {
331+
if (window.hideGeneTooltip) window.hideGeneTooltip();
332+
});
333+
}
334+
323335
container.appendChild(g);
324336
nodeElements[n.id] = g;
325337
}

style.css

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,4 +1252,128 @@ th.sortable.desc::after {
12521252
to {
12531253
opacity: 1;
12541254
}
1255+
}
1256+
1257+
/* ---- Gene Tooltip ---- */
1258+
.gene-tooltip {
1259+
position: fixed;
1260+
z-index: 10000;
1261+
max-width: 400px;
1262+
min-width: 260px;
1263+
padding: 0.75rem 1rem;
1264+
background: var(--bg-panel);
1265+
border: 1px solid var(--border);
1266+
border-radius: var(--radius);
1267+
box-shadow: var(--shadow-lg);
1268+
font-family: var(--font-main);
1269+
font-size: 0.85rem;
1270+
color: var(--text);
1271+
pointer-events: none;
1272+
opacity: 0;
1273+
transition: opacity 0.15s ease;
1274+
line-height: 1.45;
1275+
}
1276+
1277+
.gene-tooltip.visible {
1278+
opacity: 1;
1279+
pointer-events: auto;
1280+
}
1281+
1282+
.gene-tooltip-header {
1283+
display: flex;
1284+
align-items: baseline;
1285+
gap: 0.5rem;
1286+
margin-bottom: 0.4rem;
1287+
padding-bottom: 0.35rem;
1288+
border-bottom: 1px solid var(--border);
1289+
}
1290+
1291+
.gene-tooltip-name {
1292+
font-weight: 600;
1293+
font-size: 0.95rem;
1294+
color: var(--text);
1295+
}
1296+
1297+
.gene-tooltip-pid {
1298+
font-size: 0.75rem;
1299+
color: var(--text-muted);
1300+
font-family: monospace;
1301+
}
1302+
1303+
.gene-tooltip-size {
1304+
font-size: 0.75rem;
1305+
color: var(--text-muted);
1306+
margin-left: auto;
1307+
}
1308+
1309+
.gene-tooltip-annotation {
1310+
margin: 0.35rem 0;
1311+
color: var(--text-secondary);
1312+
font-style: italic;
1313+
}
1314+
1315+
.gene-tooltip-section {
1316+
margin-top: 0.4rem;
1317+
}
1318+
1319+
.gene-tooltip-section-title {
1320+
font-size: 0.7rem;
1321+
font-weight: 600;
1322+
text-transform: uppercase;
1323+
letter-spacing: 0.05em;
1324+
color: var(--text-muted);
1325+
margin-bottom: 0.2rem;
1326+
}
1327+
1328+
.gene-tooltip-terms {
1329+
list-style: none;
1330+
padding: 0;
1331+
margin: 0;
1332+
}
1333+
1334+
.gene-tooltip-terms li {
1335+
font-size: 0.8rem;
1336+
color: var(--text-secondary);
1337+
padding: 0.1rem 0;
1338+
}
1339+
1340+
.gene-tooltip-terms a {
1341+
color: var(--text-muted);
1342+
font-family: monospace;
1343+
font-size: 0.7rem;
1344+
text-decoration: none;
1345+
}
1346+
1347+
.gene-tooltip-terms a:hover {
1348+
text-decoration: underline;
1349+
}
1350+
1351+
.gene-tooltip-refs {
1352+
margin-top: 0.5rem;
1353+
padding-top: 0.35rem;
1354+
border-top: 1px solid var(--border);
1355+
display: flex;
1356+
gap: 0.75rem;
1357+
}
1358+
1359+
.gene-tooltip-refs a {
1360+
font-size: 0.7rem;
1361+
color: var(--text-muted);
1362+
text-decoration: none;
1363+
font-weight: 500;
1364+
}
1365+
1366+
.gene-tooltip-refs a:hover {
1367+
color: var(--text);
1368+
text-decoration: underline;
1369+
}
1370+
1371+
/* Hoverable gene elements */
1372+
[data-pid] {
1373+
cursor: help;
1374+
}
1375+
1376+
[data-pid]:hover {
1377+
text-decoration: underline;
1378+
text-decoration-style: dotted;
12551379
}

0 commit comments

Comments
 (0)