| <!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} |
| |
| |
| .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{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{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-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{ |
| 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); |
| } |
| |
| |
| .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'; |
| |
| |
| const AMINO_ACIDS = [ |
| 'A','V','L','I','P', |
| 'F','W','Y', |
| 'S','T','C','M', |
| 'D','E', |
| 'K','R','H', |
| 'G', |
| 'N','Q', |
| ]; |
| |
| |
| const API_BASE = (location.hostname === 'localhost' || location.hostname === '127.0.0.1') |
| ? 'http://localhost:8000' |
| : ''; |
| |
| let landscapeData = null; |
| let sortedPositions = []; |
| let lookup = {}; |
| |
| |
| |
| 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; |
| if (scoreType === 'esm1b_llr') return 1 - (score - minS) / (maxS - minS); |
| return (score - minS) / (maxS - minS); |
| } |
| |
| |
| function renderHeatmap(data) { |
| const canvas = document.getElementById('heatmap'); |
| const scoreType = data.score_type; |
| |
| lookup = {}; |
| const aaFrom = {}; |
| 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; |
| 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; |
| |
| |
| 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); |
| |
| |
| 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); |
| }); |
| }); |
| |
| |
| 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); |
| }); |
| |
| |
| 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'; |
| |
| |
| 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]; |
| |
| 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, '+')}`; |
| }; |
| } |
| |
| |
| 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; |
| } |
| } |
| |
| |
| document.addEventListener('DOMContentLoaded', () => { |
| document.getElementById('btn-load').onclick = loadLandscape; |
| document.getElementById('score-select').onchange = () => { |
| if (landscapeData) loadLandscape(); |
| }; |
| |
| |
| const params = new URLSearchParams(location.search); |
| const pid = params.get('protein'); |
| if (pid) { |
| document.getElementById('protein-input').value = pid.toUpperCase(); |
| loadLandscape(); |
| } |
| |
| |
| 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> |
|
|