mutvar / landscape.html
edohollou's picture
Remove mutfunc.html links from all nav menus
6446e4b verified
Raw
History Blame Contribute Delete
18.1 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Mutational Landscape β€” MutVar</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,wght@0,300;0,400;0,600;1,400&family=DM+Sans:wght@300;400;500&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{font-size:14px;-webkit-font-smoothing:antialiased}
:root{
--font-body:'Source Serif 4',Georgia,serif;
--font-ui:'DM Sans',system-ui,sans-serif;
--font-mono:'JetBrains Mono','Fira Code',monospace;
--font-serif:var(--font-body);--font-sans:var(--font-ui);
--bg:#fafaf8;--bg-primary:#fafaf8;--bg-secondary:#f4f3ef;--bg-card:#ffffff;
--bg-surface:#f3f2ee;--bg-inset:#eceae5;
--border:#e0dfd8;--border-strong:#c8c7bf;--border-light:#e8e5df;
--text:#1a1a18;--text-primary:#1a1a18;--text-secondary:#5a5a52;
--text-2:#4a4845;--text-muted:#9a9a90;--text-3:#7a776f;
--red:#c0392b;--green:#1e8449;--amber:#d68910;
--accent:#2d5a8e;--accent-light:#e8f0f8;
}
body{font-family:var(--font-ui);background:var(--bg);color:var(--text);min-height:100vh;display:flex;flex-direction:column}
/* Header */
.site-header{border-bottom:1px solid var(--border);background:var(--bg-card);position:sticky;top:0;z-index:100}
.header-inner{max-width:1100px;margin:0 auto;padding:0 2rem;height:52px;display:flex;align-items:center;justify-content:space-between}
.site-logo{font-family:var(--font-ui);font-size:.9rem;font-weight:500;color:var(--text-primary);text-decoration:none;display:flex;align-items:center;gap:.4rem;letter-spacing:.01em}
.logo-mark{color:var(--accent);font-size:.6rem}
.logo-name{color:var(--text-secondary)}
.logo-divider{color:var(--text-muted)}
.logo-tool{color:var(--text-primary);font-weight:500}
.site-nav{display:flex;gap:1.8rem;align-items:center}
.nav-link{font-family:var(--font-ui);font-size:.82rem;color:var(--text-secondary);text-decoration:none;letter-spacing:.02em;transition:color .15s}
.nav-link:hover{color:var(--accent)}
.nav-link.active{color:var(--text-primary);font-weight:500}
.nav-dropdown{position:relative}
.nav-dropdown-trigger{cursor:pointer;user-select:none}
.nav-dropdown-menu{display:none;position:absolute;top:calc(100% + 6px);left:50%;transform:translateX(-50%);background:var(--bg-card);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.08);min-width:220px;padding:.4rem;z-index:200}
.nav-dropdown:hover .nav-dropdown-menu{display:block}.nav-dropdown-menu::before{content:'';position:absolute;top:-6px;left:0;right:0;height:6px}
.nav-dropdown-item{display:flex;justify-content:space-between;align-items:center;padding:.55rem .8rem;border-radius:5px;text-decoration:none;transition:background .1s}
.nav-dropdown-item:not(.disabled):hover{background:var(--bg-secondary)}
.nav-dropdown-item.disabled{opacity:.45;cursor:not-allowed;pointer-events:none}
.dropdown-item-name{font-family:var(--font-ui);font-size:.83rem;color:var(--text-primary);font-weight:500}
.dropdown-item-desc{font-family:var(--font-ui);font-size:.75rem;color:var(--text-muted)}
.dropdown-item-badge{font-family:var(--font-ui);font-size:.65rem;color:var(--text-muted);background:var(--bg-secondary);border:1px solid var(--border);padding:.1rem .35rem;border-radius:3px}
.nav-dropdown-divider{height:1px;background:var(--border);margin:.3rem .5rem}
/* Controls */
#controls{padding:.8rem 2rem;border-bottom:1px solid var(--border);background:var(--bg-card);position:sticky;top:52px;z-index:90;display:flex;align-items:center;gap:.8rem;flex-wrap:wrap}
#controls label{font-family:var(--font-ui);font-size:.8rem;color:var(--text-secondary)}
#protein-input{font-family:var(--font-mono);font-size:.82rem;padding:.3rem .6rem;border:1px solid var(--border-strong);border-radius:4px;background:var(--bg-card);color:var(--text);outline:none;width:140px;transition:border-color .15s,box-shadow .15s}
#protein-input:focus{border-color:var(--accent);box-shadow:0 0 0 2px rgba(45,90,142,.1)}
#score-select{font-family:var(--font-ui);font-size:.82rem;padding:.3rem .6rem;border:1px solid var(--border-strong);border-radius:4px;background:var(--bg-card);color:var(--text);outline:none;cursor:pointer}
#btn-load{padding:.35rem 1rem;background:var(--accent);color:white;border:none;border-radius:4px;font-family:var(--font-ui);font-size:.82rem;font-weight:500;cursor:pointer;transition:background .15s}
#btn-load:hover{background:#1e4070}
#btn-load:disabled{opacity:.4;cursor:not-allowed}
#status-text{font-family:var(--font-mono);font-size:.75rem;color:var(--text-muted)}
/* Legend */
#legend{padding:.6rem 2rem;border-bottom:1px solid var(--border);display:none;align-items:center;gap:1.2rem;font-family:var(--font-ui);font-size:.75rem;color:var(--text-muted)}
.legend-grad{width:140px;height:10px;border-radius:3px;background:linear-gradient(to right,#4682b4,#f5f4f0,#c0392b)}
.legend-labels{display:flex;justify-content:space-between;width:140px;font-size:.7rem;margin-top:2px}
.legend-item{display:flex;flex-direction:column;align-items:center}
/* Heatmap */
#heatmap-wrap{flex:1;overflow:auto;padding:1.5rem 2rem 3rem;position:relative}
canvas#heatmap{display:block;cursor:crosshair}
#empty-msg{font-family:var(--font-ui);font-size:.9rem;color:var(--text-muted);padding:3rem 0;text-align:center;display:none}
/* Tooltip */
#tooltip{
position:fixed;background:var(--text-primary);color:#fff;
padding:.7rem .9rem;border-radius:6px;font-family:var(--font-ui);font-size:.78rem;
line-height:1.5;pointer-events:none;display:none;
z-index:1000;white-space:nowrap;box-shadow:0 4px 12px rgba(0,0,0,.2);
}
/* Footer */
.site-footer{border-top:1px solid var(--border);padding:2rem}
.footer-inner{max-width:1100px;margin:0 auto;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:1rem}
.footer-left,.footer-right{font-family:var(--font-ui);font-size:.78rem;color:var(--text-muted)}
.footer-right{display:flex;gap:1.5rem}
.footer-right a{color:var(--text-muted);text-decoration:none}
.footer-right a:hover{color:var(--accent)}
</style>
</head>
<body>
<header class="site-header">
<div class="header-inner">
<a href="landing.html" class="site-logo">
<span class="logo-mark">●</span>
<span class="logo-name">Beltrao Lab</span>
<span class="logo-divider">/</span>
<span class="logo-tool">MutVar</span>
</a>
<nav class="site-nav">
<div class="nav-dropdown">
<span class="nav-link nav-dropdown-trigger">Tools β–Ύ</span>
<div class="nav-dropdown-menu">
<a href="index.html" class="nav-dropdown-item">
<span class="dropdown-item-name">MutVar</span>
<span class="dropdown-item-desc">Missense variant impact</span>
</a>
<a href="vcf-upload.html" class="nav-dropdown-item">
<span class="dropdown-item-name">VCF Upload</span>
<span class="dropdown-item-desc">Batch variant analysis</span>
</a>
<div class="nav-dropdown-divider"></div>
<a href="kinaseatlas.html" class="nav-dropdown-item">
<span class="dropdown-item-name">KinaseAtlas</span>
<span class="dropdown-item-badge">Planned</span>
</a>
</div>
</div>
<a href="landing.html#about" class="nav-link">About</a>
<a href="https://www.biorxiv.org/content/10.1101/2024.05.29.596373v1" class="nav-link" target="_blank">Preprint β†—</a>
</nav>
</div>
</header>
<div id="controls">
<label for="protein-input">Protein</label>
<input id="protein-input" type="text" placeholder="e.g. Q7Z4H8" autocomplete="off" autocapitalize="characters"/>
<label for="score-select">Score</label>
<select id="score-select">
<option value="am_pathogenicity">AlphaMissense pathogenicity</option>
<option value="esm1b_llr">ESM1b LLR</option>
<option value="pred_ddg">ΔΔG (stability)</option>
</select>
<button id="btn-load">Load landscape</button>
<span id="status-text"></span>
</div>
<div id="legend">
<div class="legend-item">
<div class="legend-grad"></div>
<div class="legend-labels"><span>Benign</span><span>Pathogenic</span></div>
</div>
<span>Β· click a cell to view variant Β· grey = no data</span>
</div>
<div id="heatmap-wrap">
<canvas id="heatmap"></canvas>
<div id="empty-msg">No landscape data available for this protein / score combination.</div>
</div>
<div id="tooltip"></div>
<footer class="site-footer">
<div class="footer-inner">
<div class="footer-left">Beltrao Lab Β· Institute of Molecular Systems Biology Β· ETH Zurich</div>
<div class="footer-right">
<a href="landing.html" target="_blank">About β†—</a>
<a href="https://www.biorxiv.org/content/10.1101/2024.05.29.596373v1" target="_blank">Preprint β†—</a>
</div>
</div>
</footer>
<script>
'use strict';
// 20 standard amino acids in biochemical group order (Y axis of heatmap)
const AMINO_ACIDS = [
'A','V','L','I','P', // aliphatic
'F','W','Y', // aromatic
'S','T','C','M', // polar uncharged
'D','E', // negatively charged
'K','R','H', // positively charged
'G', // special
'N','Q', // amide
];
// Resolve API base: same-origin in prod/Docker, localhost:8000 in local dev
const API_BASE = (location.hostname === 'localhost' || location.hostname === '127.0.0.1')
? 'http://localhost:8000'
: '';
let landscapeData = null;
let sortedPositions = [];
let lookup = {}; // lookup[position][aa_to] = score
// ── Colour scale ──────────────────────────────────────────────────────────────
// Normalised [0,1] β†’ blue #4682b4 (benign) β†’ warm white #f5f4f0 β†’ red #c0392b (pathogenic)
function normToColor(n) {
n = Math.max(0, Math.min(1, n));
if (n < 0.5) {
const t = n * 2;
return `rgb(${Math.round(70+t*(245-70))},${Math.round(130+t*(244-130))},${Math.round(180+t*(240-180))})`;
}
const t = (n - 0.5) * 2;
return `rgb(${Math.round(245+t*(192-245))},${Math.round(244+t*(57-244))},${Math.round(240+t*(43-240))})`;
}
function scoreToNorm(score, scoreType, minS, maxS) {
if (scoreType === 'am_pathogenicity') return score; // already [0,1]
if (scoreType === 'esm1b_llr') return 1 - (score - minS) / (maxS - minS); // flip: negative=red
return (score - minS) / (maxS - minS); // pred_ddg: positive=red
}
// ── Render ────────────────────────────────────────────────────────────────────
function renderHeatmap(data) {
const canvas = document.getElementById('heatmap');
const scoreType = data.score_type;
lookup = {};
const aaFrom = {}; // aaFrom[position] = wildtype amino acid
let minS = Infinity, maxS = -Infinity;
const posSet = new Set();
for (const d of data.data) {
if (!lookup[d.position]) lookup[d.position] = {};
lookup[d.position][d.aa_to] = d; // store full object for tooltip
if (d.aa_from) aaFrom[d.position] = d.aa_from;
posSet.add(d.position);
if (d.score < minS) minS = d.score;
if (d.score > maxS) maxS = d.score;
}
sortedPositions = [...posSet].sort((a, b) => a - b);
const nPos = sortedPositions.length;
const nAA = AMINO_ACIDS.length;
// Dynamic cell width: target max canvas width ~1400px
const CELL_W = Math.max(2, Math.min(8, Math.floor(1400 / nPos)));
const CELL_H = 14;
const ML = 26, MT = 20, MR = 10, MB = 22;
const dpr = window.devicePixelRatio || 1;
canvas.width = (ML + nPos * CELL_W + MR) * dpr;
canvas.height = (MT + nAA * CELL_H + MB) * dpr;
canvas.style.width = (ML + nPos * CELL_W + MR) + 'px';
canvas.style.height = (MT + nAA * CELL_H + MB) + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Cells
sortedPositions.forEach((pos, xi) => {
AMINO_ACIDS.forEach((aa, yi) => {
const entry = lookup[pos]?.[aa];
if (entry !== undefined) {
const norm = scoreToNorm(entry.score, scoreType, minS, maxS);
ctx.fillStyle = normToColor(norm);
} else {
ctx.fillStyle = '#ece9e3';
}
ctx.fillRect(ML + xi * CELL_W, MT + yi * CELL_H, CELL_W - 0.5, CELL_H - 0.5);
});
});
// Y-axis labels
ctx.fillStyle = '#4a4845';
ctx.font = `10px "JetBrains Mono", monospace`;
ctx.textAlign = 'right';
AMINO_ACIDS.forEach((aa, yi) => {
ctx.fillText(aa, ML - 4, MT + yi * CELL_H + CELL_H - 3);
});
// X-axis: show position numbers at intervals
ctx.textAlign = 'center';
ctx.fillStyle = '#7a776f';
ctx.font = `9px "JetBrains Mono", monospace`;
const step = nPos <= 100 ? 10 : nPos <= 300 ? 25 : nPos <= 600 ? 50 : 100;
sortedPositions.forEach((pos, xi) => {
if (pos % step === 0 || xi === 0 || xi === nPos - 1) {
ctx.fillText(pos, ML + xi * CELL_W + CELL_W / 2, MT + nAA * CELL_H + 13);
}
});
document.getElementById('legend').style.display = 'flex';
document.getElementById('empty-msg').style.display = 'none';
// ── Tooltip & click ────────────────────────────────────────────────────────
const tooltip = document.getElementById('tooltip');
function getCell(e) {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left - ML;
const my = e.clientY - rect.top - MT;
const xi = Math.floor(mx / CELL_W);
const yi = Math.floor(my / CELL_H);
if (xi < 0 || xi >= nPos || yi < 0 || yi >= nAA) return null;
return { pos: sortedPositions[xi], aa: AMINO_ACIDS[yi], xi, yi };
}
canvas.onmousemove = (e) => {
const cell = getCell(e);
if (!cell) { tooltip.style.display = 'none'; return; }
const entry = lookup[cell.pos]?.[cell.aa];
tooltip.style.display = 'block';
tooltip.style.left = (e.clientX + 14) + 'px';
tooltip.style.top = (e.clientY - 36) + 'px';
if (entry) {
const norm = scoreToNorm(entry.score, scoreType, minS, maxS);
const pct = (norm * 100).toFixed(0);
tooltip.innerHTML =
`<b>${data.protein_id} Β· pos ${cell.pos} β†’ ${cell.aa}</b><br>` +
`${scoreType}: <b>${entry.score.toFixed(3)}</b>` +
(entry.mechanism && entry.mechanism !== 'Unassigned'
? `<br>mechanism: ${entry.mechanism}` : '');
} else {
tooltip.innerHTML = `pos ${cell.pos} β†’ ${cell.aa}<br><span style="color:#aaa">no data</span>`;
}
};
canvas.onmouseleave = () => { tooltip.style.display = 'none'; };
canvas.onclick = (e) => {
const cell = getCell(e);
if (!cell || !lookup[cell.pos]?.[cell.aa]) return;
const wt = aaFrom[cell.pos];
// Build proper mutation code e.g. A126C (wt + position + mutant)
const mutation = wt ? `${wt}${cell.pos}${cell.aa}` : null;
const query = mutation
? `${data.protein_id} ${mutation}`
: data.protein_id;
window.location.href = `index.html#q=${encodeURIComponent(query).replace(/%20/g, '+')}`;
};
}
// ── Load ──────────────────────────────────────────────────────────────────────
async function loadLandscape() {
const proteinId = document.getElementById('protein-input').value.trim().toUpperCase();
const score = document.getElementById('score-select').value;
const btn = document.getElementById('btn-load');
const status = document.getElementById('status-text');
if (!proteinId) {
status.textContent = 'Enter a protein ID first.';
return;
}
btn.disabled = true;
status.textContent = 'Loading…';
document.getElementById('empty-msg').style.display = 'none';
document.getElementById('legend').style.display = 'none';
try {
const resp = await fetch(`${API_BASE}/api/protein/${proteinId}/landscape?score=${score}`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
landscapeData = await resp.json();
if (!landscapeData.data?.length) {
document.getElementById('empty-msg').style.display = 'block';
status.textContent = 'No data.';
return;
}
renderHeatmap(landscapeData);
status.textContent = `${landscapeData.data.length.toLocaleString()} data points Β· ${[...new Set(landscapeData.data.map(d=>d.position))].length} positions`;
} catch(e) {
status.textContent = `Error: ${e.message}`;
document.getElementById('empty-msg').style.display = 'block';
} finally {
btn.disabled = false;
}
}
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('btn-load').onclick = loadLandscape;
document.getElementById('score-select').onchange = () => {
if (landscapeData) loadLandscape();
};
// Pre-fill from URL: landscape.html?protein=Q7Z4H8
const params = new URLSearchParams(location.search);
const pid = params.get('protein');
if (pid) {
document.getElementById('protein-input').value = pid.toUpperCase();
loadLandscape();
}
// Also accept main app hash format: #q=Q7Z4H8
if (!pid && location.hash.startsWith('#q=')) {
const q = location.hash.slice(3).split('+')[0].split(',')[0];
if (q) {
document.getElementById('protein-input').value = q.toUpperCase();
loadLandscape();
}
}
});
</script>
</body>
</html>