mutvar / index.html
edohollou
Remove outdated ESM1b/ΔΔG coverage warning
4e2d7ac
Raw
History Blame Contribute Delete
92.6 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>MutVar β€” Protein Mutation Impact</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"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/molstar@4/build/viewer/molstar.css"/>
<style>
/* ── Reset ────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 14px; -webkit-font-smoothing: antialiased; }
:root {
/* Design system */
--font-body: 'Source Serif 4', Georgia, serif;
--font-ui: 'DM Sans', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
/* Legacy aliases kept for existing code */
--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;
--red-bg: #fdf0ee;
--red-border: #f0c5c0;
--green: #1e8449;
--green-bg: #edf6f1;
--green-border: #b4dcc7;
--amber: #d68910;
--amber-bg: #fdf3e8;
--amber-border: #f0d5a8;
--mech-stability: #922b21;
--mech-interface: #1a5276;
--mech-pocket: #4a1a6b;
--mech-unassigned: #717d7e;
--accent: #2d5a8e;
--accent-light: #e8f0f8;
--gauge-bg: #e8e5df;
--shadow-sm: 0 1px 3px rgba(0,0,0,.07);
}
body { font-family: var(--font-sans); background: var(--bg); color: var(--text); min-height: 100vh; }
/* ── Views ────────────────────────────────────────────────────── */
#view-search { display: none; }
#view-results { display: none; }
body[data-view="search"] #view-search { display: block; }
body[data-view="results"] #view-results { display: flex; flex-direction: column; height: calc(100vh - 52px); overflow: hidden; }
/* ── 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; }
.header-left { display: flex; align-items: center; gap: 1rem; }
.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; }
.header-back {
font-size: .78rem; font-family: var(--font-ui);
color: var(--text-secondary); background: none; border: 1px solid var(--border);
cursor: pointer; padding: .3rem .8rem; border-radius: 4px; display: none; align-items: center; gap: 4px;
transition: background .15s;
}
.header-back:hover { background: var(--bg-surface); color: var(--text-primary); }
body[data-view="results"] .header-back { display: flex; }
/* ── Landscape banner ─────────────────────────────────────────── */
.landscape-banner { margin: .8rem 0 0; border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 0 6px 6px 0; background: var(--accent-light); padding: .9rem 1.2rem; }
.landscape-banner-inner { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.landscape-banner-text { display: flex; align-items: center; gap: .8rem; }
.landscape-icon { font-size: 1.2rem; color: var(--accent); flex-shrink: 0; }
.landscape-banner-text strong { display: block; font-family: var(--font-ui); font-size: .88rem; font-weight: 500; color: var(--text-primary); }
.landscape-banner-text span { font-family: var(--font-ui); font-size: .8rem; color: var(--text-secondary); }
.landscape-btn { font-family: var(--font-ui); font-size: .83rem; font-weight: 500; color: var(--accent); border: 1px solid var(--accent); padding: .45rem 1rem; border-radius: 4px; text-decoration: none; white-space: nowrap; transition: background .15s; flex-shrink: 0; }
.landscape-btn:hover { background: var(--accent); color: #fff; }
/* ── Footer (search view only) ────────────────────────────────── */
.site-footer { border-top: 1px solid var(--border); margin-top: 4rem; 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); }
/* ── Search View ──────────────────────────────────────────────── */
#view-search { max-width: 640px; margin: 0 auto; padding: 64px 24px 48px; }
.search-hero h1 { font-family: var(--font-serif); font-size: 30px; font-weight: 600; line-height: 1.25; margin-bottom: 8px; }
.search-hero p { font-size: 14px; color: var(--text-3); line-height: 1.6; margin-bottom: 32px; }
.search-hero p a { color: var(--accent); text-decoration: none; }
.search-hero p a:hover { text-decoration: underline; }
.search-form-label { display: block; font-size: 12px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--text-2); margin-bottom: 8px; }
#query-input {
width: 100%; min-height: 120px; padding: 12px 14px;
font-family: var(--font-mono); font-size: 13px; line-height: 1.7;
background: white; border: 1px solid var(--border); border-radius: 6px;
resize: vertical; color: var(--text); outline: none;
transition: border-color .15s, box-shadow .15s;
}
#query-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
#query-input::placeholder { color: var(--text-3); }
.search-format-hint { margin-top: 8px; font-size: 12px; color: var(--text-3); line-height: 1.6; }
.search-format-hint code { font-family: var(--font-mono); background: var(--bg-inset); padding: 1px 5px; border-radius: 3px; color: var(--text-2); }
.search-examples { margin-top: 16px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.search-examples-label { font-size: 11px; color: var(--text-3); font-weight: 600; text-transform: uppercase; letter-spacing: .05em; margin-right: 2px; }
.example-chip { font-family: var(--font-mono); font-size: 11.5px; padding: 4px 10px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 100px; cursor: pointer; color: var(--text-2); transition: all .15s; }
.example-chip:hover { background: var(--text); border-color: var(--text); color: white; }
.search-actions { margin-top: 20px; display: flex; align-items: center; gap: 12px; }
#btn-analyze { padding: 9px 20px; background: var(--text); color: white; border: none; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; transition: opacity .15s; }
#btn-analyze:hover { opacity: .85; }
#btn-analyze:disabled { opacity: .4; cursor: not-allowed; }
#tsv-status { font-size: 12px; font-family: var(--font-mono); color: var(--text-3); }
#search-errors { margin-top: 16px; display: none; }
.error-item { font-size: 12.5px; font-family: var(--font-mono); color: var(--red); padding: 6px 10px; background: var(--red-bg); border: 1px solid var(--red-border); border-radius: 4px; margin-bottom: 4px; }
/* ── Results Layout ───────────────────────────────────────────── */
#results-body { display: grid; grid-template-columns: 1fr 400px; flex: 1; overflow: hidden; }
#panel-left { display: flex; flex-direction: column; overflow: hidden; border-right: 1px solid var(--border); }
#panel-right { display: flex; flex-direction: column; overflow-y: auto; background: var(--bg); }
/* ── Position Landscape Chart ─────────────────────────────────── */
#position-chart {
flex-shrink: 0;
display: none;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
cursor: crosshair;
position: relative;
overflow: hidden;
}
#position-chart canvas { display: block; }
#position-chart .chart-title {
position: absolute; top: 5px; right: 8px;
font-family: var(--font-mono); font-size: 9px;
color: var(--text-3); pointer-events: none;
}
#position-filter-badge {
display: none;
padding: 3px 12px;
background: #eff6ff;
border-bottom: 1px solid #bfdbfe;
font-size: 11px; font-family: var(--font-mono);
color: var(--accent);
flex-shrink: 0;
}
#position-filter-badge button {
background: none; border: none; color: var(--accent);
cursor: pointer; font-size: 11px; font-family: inherit;
margin-left: 6px; text-decoration: underline; padding: 0;
}
/* ── Table Toolbar ────────────────────────────────────────────── */
#table-toolbar {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
background: var(--bg); flex-shrink: 0;
}
.filter-chips { display: flex; gap: 4px; flex-wrap: wrap; }
.chip {
font-size: 11px; font-weight: 500; padding: 3px 9px;
border-radius: 100px; border: 1px solid var(--border);
background: var(--bg); color: var(--text-2); cursor: pointer;
transition: all .12s; white-space: nowrap;
}
.chip:hover { background: var(--bg-surface); }
.chip.active { background: var(--text); color: white; border-color: var(--text); }
.toolbar-right { margin-left: auto; display: flex; align-items: center; gap: 6px; }
#mutation-filter {
font-family: var(--font-mono); font-size: 11px;
padding: 3px 8px; border: 1px solid var(--border);
border-radius: 4px; background: var(--bg); color: var(--text);
outline: none; width: 130px; transition: border-color .15s, box-shadow .15s;
}
#mutation-filter:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(37,99,235,.08); }
#mutation-filter::placeholder { color: var(--text-3); }
#results-count { font-family: var(--font-mono); font-size: 11px; color: var(--text-3); white-space: nowrap; }
#btn-share, #btn-export {
font-size: 11.5px; font-weight: 500; padding: 3px 9px;
background: var(--bg-surface); border: 1px solid var(--border);
border-radius: 4px; cursor: pointer; color: var(--text-2);
transition: all .12s; white-space: nowrap;
}
#btn-share:hover, #btn-export:hover { background: var(--bg-inset); color: var(--text); }
#share-done { display: none; font-size: 10px; font-family: var(--font-mono); color: var(--green); }
/* ── Results table ────────────────────────────────────────────── */
#results-header { padding: 10px 16px 0; flex-shrink: 0; }
.results-title { font-family: var(--font-serif); font-size: 17px; font-weight: 600; color: var(--text); margin-bottom: 2px; }
.results-subtitle { font-size: 11px; color: var(--text-3); font-family: var(--font-mono); }
#table-scroll { overflow: auto; flex: 1; outline: none; }
table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
thead th {
font-size: 10.5px; font-weight: 600; letter-spacing: .06em;
text-transform: uppercase; color: var(--text-3); background: var(--bg-inset);
padding: 7px 8px; border-bottom: 1px solid var(--border);
position: sticky; top: 0; cursor: pointer; user-select: none;
white-space: nowrap; z-index: 10;
}
thead th:hover { color: var(--text-2); }
thead th.sort-asc::after { content: ' ↑'; }
thead th.sort-desc::after { content: ' ↓'; }
thead th::after { opacity: 0.3; }
tbody tr { cursor: pointer; height: 34px; border-bottom: 1px solid var(--border-light); }
tbody tr:hover td { background: var(--bg-surface); }
tbody tr.selected td { background: #eff6ff; border-bottom-color: #bfdbfe; }
tbody tr.selected td:first-child { border-left: 2px solid var(--accent); }
tbody tr.vs-spacer td { pointer-events: none; border: none; padding: 0; }
td { padding: 4px 8px; vertical-align: middle; height: 34px; }
.cell-id { font-family: var(--font-mono); font-size: 12px; font-weight: 500; color: var(--text); white-space: nowrap; }
.cell-id .protein-id { color: var(--text-3); }
/* ── Score Gauges ─────────────────────────────────────────────── */
.gauge-wrap { display: flex; align-items: center; gap: 6px; min-width: 100px; }
.gauge-track { position: relative; flex: 1; height: 5px; background: var(--gauge-bg); border-radius: 3px; overflow: visible; }
.gauge-fill { position: absolute; left: 0; top: 0; height: 100%; border-radius: 3px; }
.gauge-tick { position: absolute; top: -3px; width: 1px; height: 11px; background: var(--text-3); opacity: 0.5; }
.gauge-center { position: absolute; top: -3px; width: 1.5px; height: 11px; background: var(--text-2); opacity: 0.5; transform: translateX(-50%); }
.gauge-val { font-family: var(--font-mono); font-size: 10.5px; color: var(--text-3); min-width: 36px; text-align: right; white-space: nowrap; }
/* ── Badges ───────────────────────────────────────────────────── */
.mech-badge { display: inline-flex; align-items: center; gap: 4px; font-family: var(--font-mono); font-size: 10.5px; padding: 2px 7px; border-radius: 3px; white-space: nowrap; }
.mech-stability { background: #f7ede0; color: var(--mech-stability); border: 1px solid #e8c9a0; }
.mech-interface { background: #e0eef7; color: var(--mech-interface); border: 1px solid #a0c9e8; }
.mech-pocket { background: #ede0f7; color: var(--mech-pocket); border: 1px solid #c9a0e8; }
.mech-unassigned { background: var(--bg-surface); color: var(--mech-unassigned); border: 1px solid var(--border); }
.mech-badge.predicted { opacity: 0.85; border-style: dashed; }
.mech-badge.predicted .pred-marker { font-size: 0.65em; margin-left: 1px; opacity: 0.7; }
.consensus-badge { font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; padding: 2px 8px; border-radius: 100px; white-space: nowrap; }
.consensus-high { background: var(--red-bg); color: var(--red); border: 1px solid var(--red-border); }
.consensus-medium { background: var(--amber-bg); color: var(--amber); border: 1px solid var(--amber-border); }
.consensus-low { background: var(--green-bg); color: var(--green); border: 1px solid var(--green-border); }
/* ── Right Panel: Protein Info ────────────────────────────────── */
#protein-info { padding: 14px 16px; border-bottom: 1px solid var(--border); background: var(--bg); flex-shrink: 0; }
.pinfo-header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 5px; flex-wrap: wrap; }
.pinfo-id { font-family: var(--font-mono); font-size: 13px; font-weight: 500; }
.pinfo-name { font-family: var(--font-serif); font-size: 13px; color: var(--text-2); flex: 1; min-width: 0; }
.pinfo-link { font-size: 11px; color: var(--accent); text-decoration: none; white-space: nowrap; margin-left: auto; }
.pinfo-link:hover { text-decoration: underline; }
.pinfo-meta { display: flex; gap: 14px; font-size: 11.5px; color: var(--text-3); margin-bottom: 6px; flex-wrap: wrap; }
.pinfo-meta b { color: var(--text-2); font-weight: 500; }
.pinfo-function { font-size: 11.5px; color: var(--text-3); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 8px; }
.pinfo-loading { font-size: 11.5px; color: var(--text-3); font-family: var(--font-mono); }
/* Stats bar */
.stats-bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; margin-bottom: 4px; background: var(--gauge-bg); }
.stats-seg { height: 100%; transition: width .3s; }
.stats-legend { display: flex; gap: 10px; font-size: 10px; color: var(--text-3); font-family: var(--font-mono); flex-wrap: wrap; }
.stats-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 3px; vertical-align: middle; }
.mech-chips { margin-top: 6px; display: flex; gap: 4px; flex-wrap: wrap; }
.mech-chip-mini { font-size: 10px; font-family: var(--font-mono); padding: 1px 6px; border-radius: 3px; color: var(--text-3); background: var(--bg-inset); }
/* ── Right Panel: Scatter Plot ───────────────────────────────── */
#scatter-section { border-bottom: 1px solid var(--border); flex-shrink: 0; display: none; }
#scatter-canvas { display: block; width: 100%; height: 130px; cursor: crosshair; }
/* ── Right Panel: Mol* Viewer ─────────────────────────────────── */
#viewer-section { flex-shrink: 0; position: relative; }
#mol-container { width: 100%; height: 320px; background: #111; position: relative; }
#viewer-controls { position: absolute; top: 8px; right: 8px; display: flex; flex-direction: column; gap: 4px; z-index: 20; }
/* Hide Mol* native viewport buttons β€” replaced by our custom controls */
#mol-container .msp-viewport-controls { display: none !important; }
.viewer-btn {
width: 28px; height: 28px; background: rgba(0,0,0,.45);
backdrop-filter: blur(4px); border: 1px solid rgba(255,255,255,.15);
border-radius: 4px; color: rgba(255,255,255,.8); font-size: 13px;
cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .12s;
}
.viewer-btn:hover { background: rgba(0,0,0,.7); color: white; }
#viewer-legend { position: absolute; bottom: 8px; left: 8px; background: rgba(0,0,0,.55); backdrop-filter: blur(4px); border: 1px solid rgba(255,255,255,.1); border-radius: 4px; padding: 6px 8px; z-index: 20; }
.legend-item { display: flex; align-items: center; gap: 5px; font-size: 10px; color: rgba(255,255,255,.75); margin-bottom: 3px; }
.legend-item:last-child { margin-bottom: 0; }
.legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
#viewer-status { position: absolute; bottom: 8px; right: 8px; font-family: var(--font-mono); font-size: 10px; color: rgba(255,255,255,.5); z-index: 20; pointer-events: none; }
#viewer-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(17,17,17,.85); z-index: 30; pointer-events: none; opacity: 0; transition: opacity .2s; }
#viewer-loading.visible { opacity: 1; }
.spinner { width: 24px; height: 24px; border: 2px solid rgba(255,255,255,.2); border-top-color: rgba(255,255,255,.7); border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Right Panel: Mutation Detail ─────────────────────────────── */
#detail-panel { padding: 14px 16px; border-top: 1px solid var(--border); flex: 1; background: var(--bg); }
#detail-panel.empty { display: flex; align-items: center; justify-content: center; }
.detail-empty-msg { font-size: 12px; color: var(--text-3); text-align: center; font-family: var(--font-mono); }
.detail-header { margin-bottom: 10px; }
.detail-variant { font-family: var(--font-mono); font-size: 14px; font-weight: 500; display: block; margin-bottom: 2px; }
.detail-description { font-family: var(--font-serif); font-style: italic; font-size: 13px; color: var(--text-2); }
.detail-scores { margin-bottom: 12px; }
.score-row { display: grid; grid-template-columns: 86px 1fr auto; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--border-light); }
.score-row:last-child { border-bottom: none; }
.score-name { font-size: 10.5px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: .04em; }
.score-verdict { font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; white-space: nowrap; }
.score-verdict.pathogenic { color: var(--red); }
.score-verdict.benign { color: var(--green); }
.score-verdict.ambiguous { color: var(--amber); }
.detail-consensus { display: flex; align-items: flex-start; gap: 10px; padding: 9px 11px; border-radius: 5px; margin-bottom: 11px; }
.detail-consensus.high { background: var(--red-bg); border: 1px solid var(--red-border); }
.detail-consensus.medium { background: var(--amber-bg); border: 1px solid var(--amber-border); }
.detail-consensus.low { background: var(--green-bg); border: 1px solid var(--green-border); }
.consensus-icon { font-size: 18px; line-height: 1; flex-shrink: 0; }
.consensus-title { font-family: var(--font-mono); font-size: 12px; font-weight: 500; margin-bottom: 2px; }
.consensus-title.high { color: var(--red); }
.consensus-title.medium { color: var(--amber); }
.consensus-title.low { color: var(--green); }
.consensus-detail { font-size: 11.5px; color: var(--text-2); line-height: 1.5; }
.detail-links { display: flex; gap: 8px; flex-wrap: wrap; }
.detail-link { font-size: 11.5px; color: var(--accent); text-decoration: none; padding: 4px 10px; border: 1px solid #bfdbfe; background: #eff6ff; border-radius: 4px; transition: all .12s; }
.detail-link:hover { background: #dbeafe; }
/* ── ClinVar panel ────────────────────────────────────────────── */
#clinvar-panel {
border-top: 1px solid var(--border);
padding: 10px 14px;
font-size: 12px;
}
.cv-loading { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-3); }
.cv-not-found { color: var(--text-3); font-family: var(--font-mono); font-size: 11.5px; }
.cv-found { display: flex; flex-direction: column; gap: 5px; }
.cv-badge {
display: inline-block; font-size: 11px; font-weight: 600; font-family: var(--font-mono);
padding: 2px 8px; border-radius: 100px; color: white;
}
.cv-title { font-size: 11.5px; color: var(--text-2); line-height: 1.4; }
.cv-review { font-size: 10.5px; color: var(--text-3); font-family: var(--font-mono); }
.cv-link { font-size: 11.5px; color: var(--accent); text-decoration: none; }
.cv-link:hover { text-decoration: underline; }
.cv-alert {
margin-top: 6px; padding: 6px 9px;
background: #fef3cd; border: 1px solid #fde68a; border-radius: 4px;
font-size: 11px; color: #92400e; line-height: 1.5;
}
.cv-section-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-3); margin-bottom: 5px; }
</style>
</head>
<body data-view="search">
<!-- HEADER -->
<header class="site-header">
<div class="header-inner">
<div class="header-left">
<a href="landing.html" class="site-logo" id="logo-link">
<span class="logo-mark">●</span>
<span class="logo-name">Beltrao Lab</span>
<span class="logo-divider">/</span>
<span class="logo-tool">MutVar</span>
</a>
<button class="header-back" id="btn-back">← New search</button>
</div>
<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>
<!-- ════════════════════════ SEARCH VIEW ════════════════════════ -->
<div id="view-search">
<div class="search-hero">
<h1>Protein Mutation<br/>Impact Explorer</h1>
<p>Query predicted pathogenicity for missense variants across three human proteins.
Scores from <a href="https://alphafold.ebi.ac.uk/" target="_blank">AlphaMissense</a>,
ESM1b, and structural ΔΔG are integrated into a mechanistic assessment.</p>
</div>
<label class="search-form-label" for="query-input">Mutations β€” one per line</label>
<textarea id="query-input" placeholder="P60709 M1A&#10;P68431 A1V&#10;Q71U36"></textarea>
<p class="search-format-hint">
Format: <code>PROTEIN_ID MUTATION</code> &nbsp;Β·&nbsp;
accepts <code>A126C</code> or <code>Ala126Cys</code> &nbsp;Β·&nbsp;
protein-only returns all variants &nbsp;Β·&nbsp;
all ~20,000 human proteins are searchable
</p>
<div class="search-examples">
<span class="search-examples-label">Try:</span>
<button class="example-chip" data-query="P60709 M1A" title="Ξ²-actin (ACTB)">P60709 M1A</button>
<button class="example-chip" data-query="P68431 A1V" title="Histone H3.1">P68431 A1V</button>
<button class="example-chip" data-query="Q71U36 M1A" title="Ξ±-tubulin">Q71U36 M1A</button>
<button class="example-chip" data-query="P60709">All P60709</button>
<button class="example-chip" data-query="Q7Z4H8 A126C">Q7Z4H8 A126C</button>
</div>
<div class="search-actions">
<button id="btn-analyze" disabled>Search</button>
<span id="tsv-status">Loading dataset…</span>
</div>
<div id="search-errors"></div>
</div>
<footer class="site-footer" id="search-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="https://imsb.ethz.ch/research/beltrao.html" target="_blank">Lab website β†—</a>
<a href="https://www.biorxiv.org/content/10.1101/2024.05.29.596373v1" target="_blank">Preprint β†—</a>
<a href="https://www.ebi.ac.uk/ProtVar/" target="_blank">ProtVar β†—</a>
</div>
</div>
</footer>
<!-- ════════════════════════ RESULTS VIEW ════════════════════════ -->
<div id="view-results">
<div id="results-body">
<!-- LEFT panel -->
<div id="panel-left">
<div id="results-header">
<div class="results-title" id="results-title">Results</div>
<div class="results-subtitle" id="results-subtitle"></div>
</div>
<!-- Position landscape chart -->
<div id="position-chart">
<span class="chart-title">AlphaMissense landscape β€” click position to filter</span>
</div>
<!-- Position filter badge -->
<div id="position-filter-badge">
Filtered to position <strong id="pf-pos-label"></strong>
<button id="btn-clear-posfilter">clear Γ—</button>
</div>
<div id="table-toolbar">
<div class="filter-chips" id="filter-chips">
<button class="chip active" data-mech="all">All</button>
<button class="chip" data-mech="Stability">β–² Stability</button>
<button class="chip" data-mech="Interface">⬑ Interface</button>
<button class="chip" data-mech="Pockets">β—‰ Pockets</button>
<button class="chip" data-mech="Unassigned">Β· Unassigned</button>
</div>
<div class="toolbar-right">
<input id="mutation-filter" type="text" placeholder="Filter: A126…" autocomplete="off"/>
<span id="results-count"></span>
<button id="btn-share">⬑ Share</button>
<span id="share-done">Copied!</span>
<button id="btn-export">↓ CSV</button>
</div>
</div>
<div id="table-scroll" tabindex="0">
<table id="results-table">
<thead>
<tr>
<th data-col="mutCode">Variant</th>
<th data-col="amScore">AlphaMissense</th>
<th data-col="esmLLR">ESM1b LLR</th>
<th data-col="predDDG">ΔΔG</th>
<th data-col="mechLabel">Mechanism</th>
<th data-col="consensusScore">Consensus</th>
</tr>
</thead>
<tbody id="results-tbody"></tbody>
</table>
</div>
</div>
<!-- RIGHT panel -->
<div id="panel-right">
<div id="protein-info"><p class="pinfo-loading">Fetching protein information…</p></div>
<div id="scatter-section">
<canvas id="scatter-canvas"></canvas>
</div>
<div id="viewer-section">
<div id="mol-container"></div>
<div id="viewer-controls">
<button class="viewer-btn" id="btn-reset-view" title="Reset view">βŒ‚</button>
<button class="viewer-btn" id="btn-toggle-spin" title="Toggle spin">↻</button>
<button class="viewer-btn" id="btn-toggle-surface" title="Toggle surface">β—ˆ</button>
</div>
<div id="viewer-legend">
<div class="legend-item"><div class="legend-dot" style="background:#e3002c"></div>Mutation site</div>
<div class="legend-item" id="legend-pocket" style="display:none"><div class="legend-dot" style="background:#00bcd4"></div>Pocket</div>
<div class="legend-item" id="legend-interface" style="display:none"><div class="legend-dot" style="background:#ffc107"></div>Interface</div>
</div>
<div id="viewer-status"></div>
<div id="viewer-loading"><div class="spinner"></div></div>
</div>
<div id="detail-panel" class="empty">
<p class="detail-empty-msg">← Select a variant to explore</p>
</div>
<div id="clinvar-panel" style="display:none"></div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/molstar@4/build/viewer/molstar.js"></script>
<script>
'use strict';
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// API CONFIG
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Local dev: backend runs on localhost:8000; prod/Docker: same origin.
const API_BASE = (location.hostname === 'localhost' || location.hostname === '127.0.0.1')
? 'http://localhost:8000'
: '';
let API_MODE = false; // true when backend is reachable
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// STATE
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const App = {
byVariant: new Map(),
byProtein: new Map(),
currentRows: [],
filteredRows: [],
sortCol: 'amScore',
sortDir: 'desc',
filterMech: 'all',
filterText: '',
filterPosition: null,
selectedRow: null,
molViewer: null,
molLoadedProtein: null,
molSpinning: false,
uniprotCache: new Map(),
landscapeData: null, // full protein position data from /api/protein/{id}/landscape
};
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// AMINO ACIDS
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const AA1 = { A:'Alanine',C:'Cysteine',D:'Aspartate',E:'Glutamate',F:'Phenylalanine',G:'Glycine',H:'Histidine',I:'Isoleucine',K:'Lysine',L:'Leucine',M:'Methionine',N:'Asparagine',P:'Proline',Q:'Glutamine',R:'Arginine',S:'Serine',T:'Threonine',V:'Valine',W:'Tryptophan',Y:'Tyrosine' };
const AA3 = { ALA:'A',ARG:'R',ASN:'N',ASP:'D',CYS:'C',GLN:'Q',GLU:'E',GLY:'G',HIS:'H',ILE:'I',LEU:'L',LYS:'K',MET:'M',PHE:'F',PRO:'P',SER:'S',THR:'T',TRP:'W',TYR:'Y',VAL:'V' };
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// TSV LOADING
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
async function loadTSV() {
const resp = await fetch('data/missense_human_subset.tsv');
if (!resp.ok) throw new Error(`TSV ${resp.status}`);
const text = await resp.text();
const lines = text.split('\n');
const hdrs = lines[0].split('\t');
const C = {};
hdrs.forEach((h,i) => { C[h.trim()] = i; });
for (let i = 1; i < lines.length; i++) {
const l = lines[i]; if (!l.trim()) continue;
const f = l.split('\t');
const vid = f[C['variant_id']]; if (!vid) continue;
const slash = vid.indexOf('/');
const pid = vid.slice(0, slash);
const mut = vid.slice(slash + 1);
const amScore = parseFloat(f[C['am_pathogenicity']]) || 0;
const esmLLR = parseFloat(f[C['ESM1b_LLR']]) || 0;
const predDDG = parseFloat(f[C['pred_ddg']]) || 0;
const amLabel = f[C['am_label']] === 'True';
const esmPath = f[C['ESM1b_is_pathogenic']] === 'pathogenic';
const ddgLbl = f[C['pred_ddg_label']] === 'True';
const pathCount = (amLabel?1:0) + (esmPath?1:0) + (ddgLbl?1:0);
const row = {
variantId: vid, proteinId: pid, mutCode: mut, amScore,
amClass: f[C['am_class']], amLabel, esmLLR, esmPathogenic: esmPath,
predDDG, ddgLabel: ddgLbl,
interfacePdockq: f[C['interface_pdockq']] ? parseFloat(f[C['interface_pdockq']]) : null,
interfaceLabel: f[C['interface_label']] === 'True',
pocketLabel: f[C['pocket_label']] === 'True',
mechLabel: f[C['mechanistic_label']] || 'Unassigned',
mlMechLabel: f[C['ml_mechLabel']] || null,
mlConfidence: f[C['ml_confidence']] ? parseFloat(f[C['ml_confidence']]) : null,
pathCount, consensus: pathCount >= 2 ? 'High Risk' : pathCount === 1 ? 'Medium' : 'Low Risk',
};
App.byVariant.set(vid, row);
if (!App.byProtein.has(pid)) App.byProtein.set(pid, []);
App.byProtein.get(pid).push(row);
}
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// INPUT PARSING
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function parseLine(raw) {
const line = raw.trim(); if (!line) return null;
// Accept "P12235/A114D" (slash-separated variant_id format)
const slashIdx = line.indexOf('/');
if (slashIdx > 0 && slashIdx < line.length - 1) {
const pid = line.slice(0, slashIdx).toUpperCase();
const mutPart = line.slice(slashIdx + 1).trim().toUpperCase();
const ms = mutPart.match(/^([A-Z])(\d+)([A-Z])$/);
if (ms) return { pid, mut: `${ms[1]}${ms[2]}${ms[3]}` };
}
const parts = line.split(/\s+/);
const pid = parts[0].toUpperCase();
if (parts.length === 1) return { pid, mut: null };
let m = parts[1].toUpperCase().match(/^([A-Z])(\d+)([A-Z])$/);
if (m) return { pid, mut: `${m[1]}${m[2]}${m[3]}` };
m = parts[1].match(/^([A-Za-z]{3})(\d+)([A-Za-z]{3})$/);
if (m) {
const from = AA3[m[1].toUpperCase()], to = AA3[m[3].toUpperCase()];
if (from && to) return { pid, mut: `${from}${m[2]}${to}` };
}
return { error: `Invalid format: "${raw}" β€” expected e.g. Q7Z4H8 A126C or Q7Z4H8/A126C` };
}
function executeSearch(rawInput) {
const rows = [], errors = [], seen = new Set();
for (const line of rawInput.split('\n')) {
if (!line.trim()) continue;
const parsed = parseLine(line);
if (!parsed) continue;
if (parsed.error) { errors.push(parsed.error); continue; }
if (parsed.mut === null) {
const protein = App.byProtein.get(parsed.pid);
if (!protein) errors.push(`Protein "${parsed.pid}" not found in dataset.`);
else for (const r of protein) { if (!seen.has(r.variantId)) { seen.add(r.variantId); rows.push(r); } }
} else {
const key = `${parsed.pid}/${parsed.mut}`;
const row = App.byVariant.get(key);
if (!row) {
let hint = '';
const mutM = parsed.mut.match(/^([A-Z])(\d+)([A-Z])$/);
if (mutM) {
const protein = App.byProtein.get(parsed.pid);
if (protein) {
const atPos = protein.filter(r => r.mutCode.match(/^[A-Z](\d+)/)?.[1] === mutM[2]);
if (atPos.length > 0) hint = ` Position ${mutM[2]} reference is "${atPos[0].mutCode[0]}", not "${mutM[1]}". Try: ${parsed.pid} ${atPos[0].mutCode[0]}${mutM[2]}${mutM[3]}`;
else hint = ` Position ${mutM[2]} not in dataset for ${parsed.pid}.`;
}
}
errors.push(`Variant "${key}" not found.${hint}`);
} else if (!seen.has(row.variantId)) { seen.add(row.variantId); rows.push(row); }
}
}
return { rows, errors };
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// GAUGE RENDERERS
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function amColor(s) { return s > 0.564 ? 'var(--red)' : s < 0.340 ? 'var(--green)' : 'var(--amber)'; }
function amGauge(score) {
const col = amColor(score);
return `<div class="gauge-wrap"><div class="gauge-track">
<div class="gauge-fill" style="width:${(score*100).toFixed(1)}%;background:${col}"></div>
<div class="gauge-tick" style="left:34%"></div>
<div class="gauge-tick" style="left:56.4%"></div>
</div><span class="gauge-val">${score.toFixed(3)}</span></div>`;
}
function esmGauge(llr) {
if (llr === null || llr === undefined) return `<span class="gauge-val" style="color:var(--text-muted)">β€”</span>`;
const CPct = 25/35*100, c = Math.max(-25,Math.min(10,llr)), p = (c+25)/35*100;
const [l, w, col] = llr < 0 ? [p, CPct-p, 'var(--red)'] : [CPct, p-CPct, 'var(--green)'];
const sign = llr >= 0 ? '+' : '';
return `<div class="gauge-wrap"><div class="gauge-track">
<div class="gauge-fill" style="left:${l.toFixed(1)}%;width:${Math.max(0,w).toFixed(1)}%;background:${col}"></div>
<div class="gauge-center" style="left:${CPct.toFixed(1)}%"></div>
</div><span class="gauge-val" style="color:${col}">${sign}${llr.toFixed(2)}</span></div>`;
}
function ddgGauge(ddg) {
if (ddg === null || ddg === undefined) return `<span class="gauge-val" style="color:var(--text-muted)">β€”</span>`;
const CPct = 5/15*100, c = Math.max(-5,Math.min(10,ddg)), p = (c+5)/15*100;
const [l, w, col] = ddg < 0 ? [p, CPct-p, 'var(--green)'] : [CPct, p-CPct, 'var(--red)'];
const sign = ddg >= 0 ? '+' : '';
return `<div class="gauge-wrap"><div class="gauge-track">
<div class="gauge-fill" style="left:${l.toFixed(1)}%;width:${Math.max(0,w).toFixed(1)}%;background:${col}"></div>
<div class="gauge-center" style="left:${CPct.toFixed(1)}%"></div>
</div><span class="gauge-val" style="color:${col}">${sign}${ddg.toFixed(3)}</span></div>`;
}
const MECH = { Stability:{sym:'β–²',cls:'mech-stability'}, Interface:{sym:'⬑',cls:'mech-interface'}, Pockets:{sym:'β—‰',cls:'mech-pocket'}, Unassigned:{sym:'Β·',cls:'mech-unassigned'} };
function mechBadge(label, predicted=false) {
const m = MECH[label]||MECH.Unassigned;
if (predicted) return `<span class="mech-badge ${m.cls} predicted" title="ML prediction β€” approximated (${label})">${m.sym} ${label}<span class="pred-marker">~</span></span>`;
return `<span class="mech-badge ${m.cls}">${m.sym} ${label}</span>`;
}
// Use ML prediction when structural label is Unassigned and ml_preds parquet is available
function mechDisplay(row) {
if (row.mechLabel !== 'Unassigned') return mechBadge(row.mechLabel);
if (row.mlMechLabel) return mechBadge(row.mlMechLabel, true);
return mechBadge('Unassigned');
}
function consensusBadge(row) { const cls = row.pathCount>=2?'high':row.pathCount===1?'medium':'low'; return `<span class="consensus-badge consensus-${cls}">${row.consensus}</span>`; }
async function fetchMechPrediction(row) {
if (!API_MODE || row.mechLabel !== 'Unassigned') return null;
if (row._mechPred !== undefined) return row._mechPred; // cached
try {
const pos = parseInt(row.mutCode.match(/\d+/)?.[0] || '0');
const aa_from = row.mutCode[0];
const aa_to = row.mutCode[row.mutCode.length - 1];
const params = new URLSearchParams({
am_pathogenicity: row.amScore ?? 0,
esm1b_llr: row.esmLLR ?? 0,
pred_ddg: row.predDDG ?? 0,
position: pos,
aa_from, aa_to,
});
const res = await fetch(`${API_BASE}/api/predict-mechanism?${params}`,
{ signal: AbortSignal.timeout(45000) });
if (!res.ok) { row._mechPred = null; return null; }
row._mechPred = await res.json();
} catch { row._mechPred = null; }
return row._mechPred;
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// POSITION LANDSCAPE CHART
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
let chartPositions = []; // cached for hit detection
function renderPositionChart(rows, selectedPos, landscapeData) {
const el = document.getElementById('position-chart');
// Build per-position max AM score.
// Prefer full landscape data (all positions) over the 2000-row table slice.
const posMap = new Map();
if (landscapeData && landscapeData.length > 0) {
for (const d of landscapeData) {
if (d.position == null || d.score == null) continue;
const e = posMap.get(d.position);
if (!e || d.score > e.maxAM) posMap.set(d.position, { maxAM: d.score });
}
} else {
for (const r of rows) {
const m = r.mutCode.match(/^[A-Z](\d+)[A-Z]$/);
if (!m) continue;
const pos = parseInt(m[1]);
const e = posMap.get(pos);
if (!e || r.amScore > e.maxAM) posMap.set(pos, { maxAM: r.amScore, variantId: r.variantId });
}
}
if (posMap.size < 5) { el.style.display = 'none'; return; }
el.style.display = 'block';
const positions = [...posMap.entries()].sort((a,b) => a[0]-b[0]);
const minPos = positions[0][0], maxPos = positions[positions.length-1][0];
// Setup canvas
let canvas = el.querySelector('canvas');
if (!canvas) {
canvas = document.createElement('canvas');
el.insertBefore(canvas, el.querySelector('.chart-title'));
canvas.addEventListener('click', onChartClick);
canvas.addEventListener('mousemove', onChartHover);
canvas.addEventListener('mouseleave', () => { canvas._tooltip && (canvas._tooltip.style.display = 'none'); });
}
const dpr = window.devicePixelRatio || 1;
const W = el.clientWidth || 600;
const H = 72;
el.style.height = H + 'px';
canvas.width = W * dpr; canvas.height = H * dpr;
canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const ML = 30, MR = 8, MT = 8, MB = 18;
const CW = W - ML - MR, CH = H - MT - MB;
const posRange = Math.max(1, maxPos - minPos);
function xS(pos) { return ML + (pos - minPos) / posRange * CW; }
function yS(v) { return MT + CH - v * CH; }
// Background
ctx.fillStyle = '#f3f2ee'; ctx.fillRect(0, 0, W, H);
// Threshold lines
ctx.setLineDash([3,3]);
ctx.lineWidth = 0.8;
ctx.strokeStyle = 'rgba(192,57,43,0.35)';
ctx.beginPath(); ctx.moveTo(ML, yS(0.564)); ctx.lineTo(W-MR, yS(0.564)); ctx.stroke();
ctx.strokeStyle = 'rgba(166,92,0,0.3)';
ctx.beginPath(); ctx.moveTo(ML, yS(0.340)); ctx.lineTo(W-MR, yS(0.340)); ctx.stroke();
ctx.setLineDash([]);
// Baseline
ctx.strokeStyle = '#d8d5ce'; ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.moveTo(ML, yS(0)); ctx.lineTo(W-MR, yS(0)); ctx.stroke();
// Lollipops
chartPositions = [];
for (const [pos, data] of positions) {
const x = xS(pos), y = yS(data.maxAM), baseY = yS(0);
const col = data.maxAM > 0.564 ? '#c0392b' : data.maxAM < 0.340 ? '#1f6b40' : '#a65c00';
const isSel = pos === selectedPos;
// Stem
ctx.strokeStyle = col + (isSel ? 'ff' : '70');
ctx.lineWidth = isSel ? 1.5 : 0.8;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, baseY); ctx.stroke();
// Head
const r = isSel ? 3.5 : 2;
ctx.fillStyle = col + (isSel ? 'ff' : 'bb');
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI*2); ctx.fill();
if (isSel) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke(); }
chartPositions.push({ pos, x, y, maxAM: data.maxAM });
}
// Y axis labels
ctx.font = `9px "JetBrains Mono", monospace`;
ctx.textAlign = 'right'; ctx.fillStyle = '#7a776f';
ctx.fillText('1.0', ML-3, yS(1)+3);
ctx.fillStyle = 'rgba(192,57,43,0.8)';
ctx.fillText('0.56', ML-3, yS(0.564)+3);
ctx.fillStyle = 'rgba(166,92,0,0.8)';
ctx.fillText('0.34', ML-3, yS(0.340)+3);
ctx.fillStyle = '#7a776f'; ctx.textAlign = 'left';
ctx.fillText(minPos, ML, H-4);
ctx.textAlign = 'right';
ctx.fillText(maxPos, W-MR, H-4);
}
function findNearestChartPosition(mouseX) {
let nearest = null, minDist = Infinity;
for (const p of chartPositions) {
const d = Math.abs(p.x - mouseX);
if (d < minDist) { minDist = d; nearest = p; }
}
return minDist < 12 ? nearest : null;
}
function onChartClick(e) {
const rect = this.getBoundingClientRect();
const nearest = findNearestChartPosition(e.clientX - rect.left);
if (!nearest) return;
App.filterPosition = App.filterPosition === nearest.pos ? null : nearest.pos;
updatePositionFilterUI();
renderTable(App.currentRows);
renderPositionChart(App.filteredRows.length ? App.filteredRows : App.currentRows, App.filterPosition, App.landscapeData);
}
function onChartHover(e) {
const canvas = this;
const rect = canvas.getBoundingClientRect();
const nearest = findNearestChartPosition(e.clientX - rect.left);
canvas.style.cursor = nearest ? 'pointer' : 'crosshair';
// Simple tooltip via title attribute
if (nearest) {
const clsLabel = nearest.maxAM > 0.564 ? 'Pathogenic' : nearest.maxAM < 0.340 ? 'Benign' : 'Ambiguous';
canvas.title = `Position ${nearest.pos} β€” max AM: ${nearest.maxAM.toFixed(3)} (${clsLabel})`;
} else {
canvas.title = '';
}
}
function updatePositionFilterUI() {
const badge = document.getElementById('position-filter-badge');
if (App.filterPosition !== null) {
badge.style.display = 'flex';
document.getElementById('pf-pos-label').textContent = App.filterPosition;
} else {
badge.style.display = 'none';
}
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// VIRTUAL SCROLL TABLE
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const VS_H = 34; // row height px
const VS_BUF = 30; // rows above/below viewport
let vsRows = [];
function vsInit() {
const scroll = document.getElementById('table-scroll');
scroll.addEventListener('scroll', () => vsRender(scroll.scrollTop), { passive: true });
}
function vsUpdate(rows) {
vsRows = rows;
vsRender(document.getElementById('table-scroll').scrollTop);
document.getElementById('results-count').textContent = `${rows.length.toLocaleString()} variants`;
updateSortUI();
}
function vsRender(scrollTop) {
const scroll = document.getElementById('table-scroll');
const containerH = scroll.clientHeight || 500;
const total = vsRows.length;
if (!total) { document.getElementById('results-tbody').replaceChildren(); return; }
const firstVis = Math.floor(scrollTop / VS_H);
const lastVis = Math.ceil((scrollTop + containerH) / VS_H);
const first = Math.max(0, firstVis - VS_BUF);
const last = Math.min(total - 1, lastVis + VS_BUF);
const sel = App.selectedRow?.variantId;
const tbody = document.getElementById('results-tbody');
const frag = document.createDocumentFragment();
if (first > 0) {
const tr = document.createElement('tr');
tr.className = 'vs-spacer';
tr.innerHTML = `<td colspan="6" style="height:${first*VS_H}px"></td>`;
frag.appendChild(tr);
}
for (let i = first; i <= last; i++) {
const row = vsRows[i];
const tr = document.createElement('tr');
tr.dataset.vid = row.variantId;
if (row.variantId === sel) tr.classList.add('selected');
tr.innerHTML = `
<td class="cell-id"><span class="protein-id">${row.proteinId}&nbsp;/</span>&nbsp;${row.mutCode}</td>
<td>${amGauge(row.amScore)}</td>
<td>${esmGauge(row.esmLLR)}</td>
<td>${ddgGauge(row.predDDG)}</td>
<td>${mechDisplay(row)}</td>
<td>${consensusBadge(row)}</td>`;
tr.addEventListener('click', () => selectRow(row));
frag.appendChild(tr);
}
if (last < total - 1) {
const tr = document.createElement('tr');
tr.className = 'vs-spacer';
tr.innerHTML = `<td colspan="6" style="height:${(total-1-last)*VS_H}px"></td>`;
frag.appendChild(tr);
}
tbody.replaceChildren(frag);
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// TABLE FILTER + SORT
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function applyFilterAndSort(rows) {
let out = rows;
if (App.filterMech !== 'all') out = out.filter(r => r.mechLabel === App.filterMech);
if (App.filterPosition !== null) out = out.filter(r => {
const m = r.mutCode.match(/^[A-Z](\d+)/);
return m && parseInt(m[1]) === App.filterPosition;
});
if (App.filterText) {
const q = App.filterText.toUpperCase();
out = out.filter(r => r.mutCode.includes(q) || r.variantId.includes(q));
}
const col = App.sortCol, dir = App.sortDir === 'asc' ? 1 : -1;
out = [...out].sort((a,b) => {
const av = a[col], bv = b[col];
if (col === 'mechLabel') {
// Tier 0: real label Tier 1: ML-predicted Unassigned Tier 2: pure Unassigned
const tA = av !== 'Unassigned' ? 0 : (a.mlMechLabel ? 1 : 2);
const tB = bv !== 'Unassigned' ? 0 : (b.mlMechLabel ? 1 : 2);
if (tA !== tB) return tA - tB; // tier order fixed regardless of dir
const lA = tA === 0 ? av : (a.mlMechLabel || '');
const lB = tB === 0 ? bv : (b.mlMechLabel || '');
return dir * lA.localeCompare(lB);
}
return typeof av === 'string' ? dir * av.localeCompare(bv) : dir * (av - bv);
});
App.filteredRows = out;
return out;
}
function renderTable(rows) {
const visible = applyFilterAndSort(rows);
vsUpdate(visible);
}
function updateSortUI() {
document.querySelectorAll('#results-table th').forEach(th => {
th.classList.remove('sort-asc', 'sort-desc');
if (th.dataset.col === App.sortCol) th.classList.add(App.sortDir === 'asc' ? 'sort-asc' : 'sort-desc');
});
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// ROW SELECTION & DETAIL
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function selectRow(row) {
const old = document.querySelector('#results-tbody tr.selected');
if (old) old.classList.remove('selected');
App.selectedRow = row;
const tr = document.querySelector(`#results-tbody tr[data-vid="${row.variantId}"]`);
if (tr) { tr.classList.add('selected'); tr.scrollIntoView({ block: 'nearest' }); }
renderDetail(row);
drawScatter(App.currentRows, row);
loadAndHighlight(row.proteinId, row.mutCode);
document.getElementById('legend-pocket').style.display = row.pocketLabel ? 'flex' : 'none';
document.getElementById('legend-interface').style.display = row.interfaceLabel ? 'flex' : 'none';
}
function mutDescription(mutCode) {
const m = mutCode.match(/^([A-Z])(\d+)([A-Z])$/);
if (!m) return mutCode;
return `${AA1[m[1]]||m[1]} β†’ ${AA1[m[3]]||m[3]} at position ${m[2]}`;
}
function renderDetail(row) {
const panel = document.getElementById('detail-panel');
panel.classList.remove('empty');
const amVerdict = row.amLabel ? 'pathogenic' : (row.amClass === 'ambiguous' ? 'ambiguous' : 'benign');
const amText = row.amLabel ? 'Pathogenic' : (row.amClass === 'ambiguous' ? 'Ambiguous' : 'Benign');
const esmVerdict = row.esmLLR === null ? '' : (row.esmPathogenic ? 'pathogenic' : 'benign');
const esmText = row.esmLLR === null ? 'β€”' : (row.esmPathogenic ? 'Pathogenic' : 'Neutral');
const ddgVerdict = row.predDDG === null ? '' : (row.ddgLabel ? 'pathogenic' : 'benign');
const ddgText = row.predDDG === null ? 'β€”' : (row.ddgLabel ? 'Destabilizing' : 'Stabilizing');
const consIcons = { 'High Risk':'⬛', 'Medium':'β–ͺ', 'Low Risk':'β–‘' };
const consCls = row.pathCount >= 2 ? 'high' : row.pathCount === 1 ? 'medium' : 'low';
const consMsg = row.pathCount >= 2 ? `${row.pathCount} of 3 classifiers predict pathogenic effect.` : row.pathCount === 1 ? `1 of 3 classifiers predicts pathogenic effect. Evidence is inconclusive.` : `No classifier predicts pathogenic effect.`;
const pdockqText = row.interfacePdockq !== null ? ` (pDockQ ${row.interfacePdockq.toFixed(3)})` : '';
panel.innerHTML = `
<div class="detail-header">
<span class="detail-variant">${row.proteinId} / ${row.mutCode}</span>
<span class="detail-description">${mutDescription(row.mutCode)}</span>
</div>
<div class="detail-scores">
<div class="score-row"><span class="score-name">AlphaMissense</span>${amGauge(row.amScore)}<span class="score-verdict ${amVerdict}">${amText}</span></div>
<div class="score-row"><span class="score-name">ESM1b LLR</span>${esmGauge(row.esmLLR)}<span class="score-verdict ${esmVerdict}">${esmText}</span></div>
<div class="score-row"><span class="score-name">ΔΔG</span>${ddgGauge(row.predDDG)}<span class="score-verdict ${ddgVerdict}">${ddgText}</span></div>
</div>
<div class="detail-consensus ${consCls}">
<span class="consensus-icon">${consIcons[row.consensus]}</span>
<div>
<div class="consensus-title ${consCls}">${row.consensus}</div>
<div class="consensus-detail">${consMsg}<br/>
Mechanism: <span id="detail-mech-badge">${mechDisplay(row)}${row.mechLabel === 'Unassigned' && row.mlConfidence != null ? `<span style="font-size:10px;color:var(--text-muted);margin-left:4px;">${(row.mlConfidence*100).toFixed(0)}%</span>` : ''}</span>
${row.interfaceLabel ? ` Β· Interface${pdockqText}` : ''}
${row.pocketLabel ? ' Β· In predicted pocket' : ''}
</div>
</div>
</div>
<div class="detail-links">
<a class="detail-link" href="https://www.uniprot.org/uniprotkb/${row.proteinId}" target="_blank" rel="noopener">UniProt: ${row.proteinId} β†—</a>
<a class="detail-link" href="https://www.ebi.ac.uk/ProtVar/query?search=${row.proteinId}" target="_blank" rel="noopener">ProtVar β†—</a>
<a class="detail-link" href="landscape.html?protein=${row.proteinId}" target="_blank">Landscape β†—</a>
</div>`;
// ClinVar panel β€” only in API mode
const cvPanel = document.getElementById('clinvar-panel');
if (API_MODE) {
cvPanel.style.display = 'block';
loadClinVar(row.proteinId, row.mutCode, row.amClass);
} else {
cvPanel.style.display = 'none';
}
// ML predictions now come directly from the parquet JOIN β€” no async fetch needed
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// PROTEIN STATS
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function computeStats(rows) {
const s = { path: 0, amb: 0, ben: 0, mech: {}, total: rows.length };
for (const r of rows) {
if (r.amClass === 'pathogenic') s.path++;
else if (r.amClass === 'ambiguous') s.amb++;
else s.ben++;
s.mech[r.mechLabel] = (s.mech[r.mechLabel] || 0) + 1;
}
return s;
}
function statsHTML(stats) {
const n = stats.total;
if (!n) return '';
const pP = (stats.path/n*100).toFixed(0), pA = (stats.amb/n*100).toFixed(0), pB = (stats.ben/n*100).toFixed(0);
const mechOrder = ['Stability','Interface','Pockets','Unassigned'];
const mechChips = mechOrder.filter(k => stats.mech[k]).map(k => {
const m = MECH[k] || MECH.Unassigned;
return `<span class="mech-chip-mini">${m.sym} ${stats.mech[k].toLocaleString()}</span>`;
}).join('');
return `<div class="pinfo-stats">
<div class="stats-bar">
<div class="stats-seg" style="width:${pP}%;background:var(--red)"></div>
<div class="stats-seg" style="width:${pA}%;background:var(--amber)"></div>
<div class="stats-seg" style="width:${pB}%;background:var(--green)"></div>
</div>
<div class="stats-legend">
<span><span class="stats-dot" style="background:var(--red)"></span>${pP}% pathogenic</span>
<span><span class="stats-dot" style="background:var(--amber)"></span>${pA}% ambiguous</span>
<span><span class="stats-dot" style="background:var(--green)"></span>${pB}% benign</span>
</div>
<div class="mech-chips">${mechChips}</div>
</div>`;
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// UNIPROT API
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
async function fetchUniProt(pid) {
if (App.uniprotCache.has(pid)) { renderProteinInfo(pid, App.uniprotCache.get(pid)); return; }
try {
const resp = await fetch(`https://rest.uniprot.org/uniprotkb/${pid}.json`);
if (!resp.ok) throw new Error(resp.status);
const data = await resp.json();
const info = {
name: data.proteinDescription?.recommendedName?.fullName?.value || data.proteinDescription?.submissionNames?.[0]?.fullName?.value || pid,
gene: data.genes?.[0]?.geneName?.value || 'β€”',
length: data.sequence?.length || 'β€”',
fn: data.comments?.find(c => c.commentType === 'FUNCTION')?.texts?.[0]?.value?.slice(0, 200) || null,
url: `https://www.uniprot.org/uniprotkb/${pid}`,
};
App.uniprotCache.set(pid, info);
renderProteinInfo(pid, info);
} catch(e) {
renderProteinInfo(pid, { name: pid, gene:'β€”', length:'β€”', fn:null, url:`https://www.uniprot.org/uniprotkb/${pid}` });
}
}
function renderProteinInfo(pid, info) {
const allRows = App.byProtein.get(pid) || [];
const stats = computeStats(allRows);
document.getElementById('protein-info').innerHTML = `
<div class="pinfo-header">
<span class="pinfo-id">${pid}</span>
<span class="pinfo-name">${escHtml(info.name)}</span>
<a class="pinfo-link" href="${info.url}" target="_blank" rel="noopener">UniProt β†—</a>
</div>
<div class="pinfo-meta">
<span>Gene: <b>${escHtml(info.gene)}</b></span>
<span>Length: <b>${info.length} aa</b></span>
<span>${allRows.length.toLocaleString()} variants in dataset</span>
</div>
${info.fn ? `<div class="pinfo-function">${escHtml(info.fn)}…</div>` : ''}
${statsHTML(stats)}`;
}
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// SCATTER PLOT (AM pathogenicity Γ— ESM1b LLR)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
let scatterPoints = [];
function drawScatter(rows, selRow) {
const canvas = document.getElementById('scatter-canvas');
const section = document.getElementById('scatter-section');
if (!canvas || !rows || !rows.length) { if (section) section.style.display = 'none'; return; }
section.style.display = '';
const dpr = window.devicePixelRatio || 1;
const W = canvas.offsetWidth || 300, H = canvas.offsetHeight || 130;
canvas.width = W * dpr; canvas.height = H * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const ML = 34, MR = 8, MT = 16, MB = 22;
const pw = W - ML - MR, ph = H - MT - MB;
// ESM1b y-range β€” skip rows without ESM data
let yMin = Infinity, yMax = -Infinity;
for (const r of rows) { if (r.esmLLR === null) continue; if (r.esmLLR < yMin) yMin = r.esmLLR; if (r.esmLLR > yMax) yMax = r.esmLLR; }
if (!isFinite(yMin)) { yMin = -10; yMax = 5; } // fallback when no ESM data
const yPad = (yMax - yMin) * 0.1 || 1;
yMin -= yPad; yMax += yPad;
const toX = am => ML + am * pw;
const toY = esm => MT + (1 - (esm - yMin) / (yMax - yMin)) * ph;
// Background
ctx.fillStyle = '#fafaf8'; ctx.fillRect(0, 0, W, H);
// Axes
ctx.strokeStyle = '#d8d5ce'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(ML, MT); ctx.lineTo(ML, MT + ph); ctx.lineTo(ML + pw, MT + ph); ctx.stroke();
// y=0 dash
const y0 = toY(0);
if (y0 > MT && y0 < MT + ph) {
ctx.strokeStyle = '#c0bcb4'; ctx.lineWidth = 0.5; ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.moveTo(ML, y0); ctx.lineTo(ML + pw, y0); ctx.stroke();
ctx.setLineDash([]);
}
// AM threshold lines
for (const [thresh, color] of [[0.340, '#1f6b40'], [0.564, '#c0392b']]) {
const tx = toX(thresh);
ctx.strokeStyle = color; ctx.lineWidth = 0.8; ctx.globalAlpha = 0.35;
ctx.setLineDash([3, 3]);
ctx.beginPath(); ctx.moveTo(tx, MT); ctx.lineTo(tx, MT + ph); ctx.stroke();
ctx.setLineDash([]); ctx.globalAlpha = 1;
}
// Axis labels
ctx.fillStyle = '#7a776f'; ctx.font = '9px JetBrains Mono, monospace';
ctx.textAlign = 'center';
ctx.fillText('AlphaMissense', ML + pw / 2, H - 4);
ctx.textAlign = 'left'; ctx.fillText('0', ML, H - 10);
ctx.textAlign = 'right'; ctx.fillText('1', ML + pw, H - 10);
ctx.save(); ctx.translate(10, MT + ph / 2); ctx.rotate(-Math.PI / 2);
ctx.textAlign = 'center'; ctx.fillText('ESM1b LLR', 0, 0); ctx.restore();
// Title
ctx.fillStyle = '#4a4845'; ctx.font = '9px JetBrains Mono, monospace';
ctx.textAlign = 'left';
ctx.fillText(`Score correlation Β· n=${rows.length.toLocaleString()}`, ML, 11);
// Dots β€” sample if large
const MAX = 4000;
const step = rows.length > MAX ? Math.ceil(rows.length / MAX) : 1;
scatterPoints = [];
for (let i = 0; i < rows.length; i += step) {
const r = rows[i];
if (r.esmLLR === null) continue;
const x = toX(r.amScore), y = toY(r.esmLLR);
const isSel = selRow && r.variantId === selRow.variantId;
const color = r.pathCount >= 2 ? '#c0392b' : r.pathCount === 1 ? '#a65c00' : '#1f6b40';
ctx.globalAlpha = isSel ? 1 : 0.45;
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(x, y, isSel ? 4.5 : 2, 0, Math.PI * 2); ctx.fill();
if (isSel) {
ctx.globalAlpha = 1; ctx.strokeStyle = 'white'; ctx.lineWidth = 1.5;
ctx.stroke();
}
scatterPoints.push({ x, y, row: r });
}
ctx.globalAlpha = 1;
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// MOL* VIEWER
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
async function initMolstar() {
if (typeof molstar === 'undefined') return;
try {
App.molViewer = await molstar.Viewer.create('mol-container', {
layoutIsExpanded: false, layoutShowControls: false,
layoutShowRemoteState: false, layoutShowSequence: false,
layoutShowLog: false, layoutShowLeftPanel: false,
viewportShowExpand: false, viewportShowSelectionMode: false,
viewportShowAnimation: false,
});
} catch(e) { console.error('Molstar init failed:', e); }
}
function setViewerLoading(on) { document.getElementById('viewer-loading').classList.toggle('visible', on); }
function setViewerStatus(msg) { document.getElementById('viewer-status').textContent = msg; }
async function loadAndHighlight(proteinId, mutCode) {
if (!App.molViewer) return;
const plugin = App.molViewer.plugin || App.molViewer;
setViewerLoading(true); setViewerStatus('');
try {
if (App.molLoadedProtein !== proteinId) {
await plugin.clear();
// Try local first, fall back to AlphaFold EBI
let structureUrl = `structures/AF-${proteinId}-F1-model_v6.pdb`;
try {
const probe = await fetch(structureUrl, { method: 'HEAD' });
if (!probe.ok) {
const afApi = await fetch(`https://alphafold.ebi.ac.uk/api/prediction/${proteinId}`);
if (afApi.ok) {
const afData = await afApi.json();
structureUrl = afData[0]?.pdbUrl || structureUrl;
}
}
} catch(_) {}
await App.molViewer.loadStructureFromUrl(structureUrl, 'pdb', false);
App.molLoadedProtein = proteinId;
await delay(150);
}
if (mutCode) {
const m = mutCode.match(/^([A-Z])(\d+)([A-Z])$/i);
if (m) await highlightResidue(plugin, parseInt(m[2]));
}
setViewerStatus(mutCode ? `pos ${mutCode.match(/\d+/)?.[0]}` : '');
} catch(e) {
console.warn('Viewer update error:', e);
setViewerStatus('');
} finally { setViewerLoading(false); }
}
async function highlightResidue(plugin, resno) {
if (tryStateTransforms(plugin, resno)) return;
await trySelectionFocus(plugin, resno);
}
function tryStateTransforms(plugin, resno) {
try {
const ST = molstar.StateTransforms;
if (!ST?.Model?.StructureComponentFromExpression) return false;
const structures = plugin.managers.structure.hierarchy.current.structures;
if (!structures.length) return false;
const cellRef = structures[0].cell.transform.ref;
const expression = { '@version':2, expression: { name:'structure-query.generator.atom-groups', params: { 'residue-test': { name:'core.rel.eq', params: [{ name:'structure-query.atom.auth-seq-id' }, { name:'core.type.int', params:[resno] }] } } } };
const REF = 'mut-hl';
if (plugin.state.data.cells.has(REF)) plugin.build().delete(REF).commit();
plugin.build().to(cellRef)
.apply(ST.Model.StructureComponentFromExpression, { label:`Mut@${resno}`, expression }, { ref: REF })
.apply(ST.Representation.StructureRepresentation3D, {
type: { name:'ball-and-stick', params:{ sizeFactor:2.5 } },
colorTheme: { name:'uniform', params:{ value:0xE3002C } },
sizeTheme: { name:'uniform', params:{ value:1 } },
}).commit();
setTimeout(() => {
try {
const sphere = getSphereForResidue(plugin, resno);
if (sphere) plugin.managers.camera.focusSphere?.(sphere, { durationMs:500 });
} catch(e) {}
}, 300);
return true;
} catch(e) { return false; }
}
async function trySelectionFocus(plugin, resno) {
try {
const structures = plugin.managers.structure.hierarchy.current.structures;
if (!structures.length) return;
const structure = structures[0].cell.obj?.data;
if (!structure) return;
const loci = buildResidueLoci(structure, resno);
if (!loci) return;
plugin.managers.structure.selection.fromLoci('set', loci);
plugin.managers.camera.focusLoci(loci, { extraRadius:8, durationMs:500 });
} catch(e) {}
}
function buildResidueLoci(structure, resno) {
const els = [];
for (const unit of structure.units) {
const { elements } = unit;
const ri = unit.residueIndex;
const hier = unit.model.atomicHierarchy;
if (!hier?.residues?.auth_seq_id) continue;
const seqId = hier.residues.auth_seq_id;
const indices = [];
for (let j = 0; j < elements.length; j++) {
if (seqId.value(ri[elements[j]]) === resno) indices.push(j);
}
if (indices.length) els.push({ unit, indices: new Int32Array(indices.sort((a,b)=>a-b)) });
}
if (!els.length) return null;
return { kind:'element-loci', structure, elements: els };
}
function getSphereForResidue(plugin, resno) {
try {
const structure = plugin.managers.structure.hierarchy.current.structures[0]?.cell.obj?.data;
if (!structure) return null;
const loci = buildResidueLoci(structure, resno);
if (!loci) return null;
let cx=0,cy=0,cz=0,n=0;
for (const { unit, indices } of loci.elements) {
const { elements } = unit, { x,y,z } = unit.conformation;
for (const idx of indices) { const eI = elements[idx]; cx+=x(eI);cy+=y(eI);cz+=z(eI);n++; }
}
if (!n) return null;
return { center:[cx/n,cy/n,cz/n], radius:6 };
} catch(e) { return null; }
}
function setupViewerControls() {
document.getElementById('btn-reset-view').onclick = () => {
try { App.molViewer?.plugin.managers.camera.reset(); } catch(e) {}
};
document.getElementById('btn-toggle-spin').onclick = function() {
App.molSpinning = !App.molSpinning;
this.style.background = App.molSpinning ? 'rgba(255,255,255,.2)' : '';
try {
const plugin = App.molViewer.plugin;
const cur = plugin.canvas3d.props.trackball;
plugin.canvas3d.setProps({ trackball: { ...cur, animate: App.molSpinning ? {name:'spin',params:{speed:1}} : {name:'off',params:{}} } });
} catch(e) {}
};
document.getElementById('btn-toggle-surface').onclick = function() {
if (App.selectedRow) {
App.molLoadedProtein = null;
loadAndHighlight(App.selectedRow.proteinId, App.selectedRow.mutCode);
}
};
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// URL SHARING
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function encodeURL(query) {
// "Q7Z4H8 A126C\nP12235 A114D" β†’ "#q=Q7Z4H8+A126C,P12235+A114D"
return '#q=' + query.trim().split('\n').map(l => l.trim().replace(/\s+/,'+')).join(',');
}
function decodeURL(hash) {
if (!hash.startsWith('#q=')) return null;
return hash.slice(3).split(',').map(l => l.replace(/\+/,' ')).join('\n');
}
function shareResults(rawQuery) {
const url = location.href.split('#')[0] + encodeURL(rawQuery);
navigator.clipboard?.writeText(url).then(() => {
const el = document.getElementById('share-done');
el.style.display = 'inline';
setTimeout(() => { el.style.display = 'none'; }, 1800);
});
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// SEARCH FLOW
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
let lastQuery = '';
function isProteinWideQuery(query) {
const lines = query.trim().split('\n').map(l => l.trim()).filter(Boolean);
if (lines.length !== 1) return null;
const parsed = parseLine(lines[0]);
return (parsed && !parsed.error && parsed.mut === null) ? parsed.pid : null;
}
function showResults(rows, query) {
lastQuery = query;
const proteins = [...new Set(rows.map(r => r.proteinId))];
document.getElementById('results-title').textContent =
proteins.length === 1 ? `${proteins[0]} β€” ${rows.length.toLocaleString()} variants`
: `${rows.length.toLocaleString()} variants across ${proteins.length} proteins`;
document.getElementById('results-subtitle').textContent = `Query: ${query.trim().split('\n').join(', ')}`;
// Landscape banner β€” inject for single-protein queries
const existingBanner = document.getElementById('landscape-banner');
if (existingBanner) existingBanner.remove();
const singlePid = isProteinWideQuery(query);
if (singlePid) {
const banner = document.createElement('div');
banner.id = 'landscape-banner';
banner.className = 'landscape-banner';
banner.innerHTML = `<div class="landscape-banner-inner">
<div class="landscape-banner-text">
<span class="landscape-icon">⊞</span>
<div>
<strong>Mutational Landscape</strong>
<span>View all positions Γ— amino acid substitutions as a heatmap</span>
</div>
</div>
<a href="landscape.html?protein=${singlePid}&score=am_pathogenicity" class="landscape-btn" target="_blank">
Open landscape β†’
</a>
</div>`;
document.getElementById('results-header').after(banner);
}
App.currentRows = rows;
App.selectedRow = null;
App.filterMech = 'all';
App.filterText = '';
App.filterPosition = null;
App.landscapeData = null;
document.querySelectorAll('.chip').forEach(c => c.classList.toggle('active', c.dataset.mech === 'all'));
document.getElementById('mutation-filter').value = '';
updatePositionFilterUI();
document.body.dataset.view = 'results';
renderTable(rows);
// Position landscape + scatter
renderPositionChart(rows, null, null);
drawScatter(rows, null);
// For single-protein queries, fetch full landscape data to power the mini chart
// (the 2000-row result slice only covers part of the sequence).
if (singlePid) {
fetch(`${API_BASE}/api/protein/${singlePid}/landscape?score=am_pathogenicity`,
{ signal: AbortSignal.timeout(120000) })
.then(r => r.ok ? r.json() : null)
.then(json => {
if (json && json.data && json.data.length > 0) {
App.landscapeData = json.data;
renderPositionChart(
App.filteredRows.length ? App.filteredRows : App.currentRows,
App.filterPosition,
App.landscapeData
);
}
})
.catch(() => {}); // fail silently β€” chart stays with partial data
}
// Auto-select first row + fetch UniProt.
// Use requestAnimationFrame so the browser has painted the view and
// the virtual-scroll container has its final clientHeight before
// vsRender and selectRow try to measure/find DOM nodes.
if (rows.length > 0) {
requestAnimationFrame(() => {
vsRender(0);
selectRow(rows[0]);
});
proteins.forEach(pid => fetchUniProt(pid));
}
// Update URL hash for sharing
history.replaceState(null, '', encodeURL(query));
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// API SEARCH
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// Maps an API result object to the internal row format used by the frontend.
function apiResultToRow(r) {
const pathCount = (r.am_label ? 1 : 0) + (r.esm1b_is_pathogenic ? 1 : 0) + (r.pred_ddg_label ? 1 : 0);
return {
variantId: r.variant_id,
proteinId: r.protein_id,
mutCode: r.mutation_code,
amScore: r.am_pathogenicity ?? 0,
amClass: r.am_class,
amLabel: !!r.am_label,
esmLLR: r.esm1b_llr ?? null,
esmPathogenic: !!r.esm1b_is_pathogenic,
predDDG: r.pred_ddg ?? null,
ddgLabel: !!r.pred_ddg_label,
interfacePdockq: r.interface_pdockq ?? null,
interfaceLabel: !!r.interface_label,
pocketLabel: !!r.pocket_label,
mechLabel: r.mechanistic_label || 'Unassigned',
mlMechLabel: r.ml_mechLabel || null,
mlConfidence: r.ml_confidence ?? null,
pathCount,
consensus: pathCount >= 2 ? 'High Risk' : pathCount === 1 ? 'Medium' : 'Low Risk',
consensusScore: r.consensus_score ?? pathCount,
};
}
async function searchViaAPI(rawInput) {
const rows = [], errors = [];
const lines = rawInput.split('\n').map(l => l.trim()).filter(Boolean);
for (const line of lines) {
const parsed = parseLine(line);
if (!parsed) continue;
if (parsed.error) { errors.push(parsed.error); continue; }
try {
const url = parsed.mut
? `${API_BASE}/api/search?protein_id=${parsed.pid}&mutation=${parsed.mut}&limit=1`
: `${API_BASE}/api/search?protein_id=${parsed.pid}&limit=2000`;
const resp = await fetch(url);
if (!resp.ok) {
errors.push(`Protein "${parsed.pid}" not found or API error.`);
continue;
}
const data = await resp.json();
if (data.total === 0) {
errors.push(`${parsed.mut ? `Variant "${parsed.pid}/${parsed.mut}"` : `Protein "${parsed.pid}"`} not found in dataset.`);
continue;
}
const mapped = data.results.map(apiResultToRow);
// Populate local maps for stats / compat
for (const r of mapped) {
App.byVariant.set(r.variantId, r);
if (!App.byProtein.has(r.proteinId)) App.byProtein.set(r.proteinId, []);
if (!App.byProtein.get(r.proteinId).find(x => x.variantId === r.variantId))
App.byProtein.get(r.proteinId).push(r);
}
rows.push(...mapped);
} catch(e) {
errors.push(`Search failed for "${line}": ${e.message}`);
}
}
return { rows, errors };
}
async function handleAnalyze() {
const raw = document.getElementById('query-input').value;
if (!raw.trim()) return;
document.getElementById('btn-analyze').disabled = true;
try {
let rows, errors;
if (API_MODE) {
({ rows, errors } = await searchViaAPI(raw));
} else {
({ rows, errors } = executeSearch(raw));
}
renderErrors(errors);
if (rows.length === 0 && !errors.length) renderErrors(['No results found. Check the protein ID and mutation format.']);
if (rows.length > 0) showResults(rows, raw);
} finally {
document.getElementById('btn-analyze').disabled = false;
}
}
function renderErrors(errors) {
const el = document.getElementById('search-errors');
if (!errors.length) { el.style.display = 'none'; return; }
el.style.display = 'block';
el.innerHTML = errors.map(e => `<div class="error-item">${escHtml(e)}</div>`).join('');
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// EXPORT CSV
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function exportCSV() {
const rows = App.filteredRows.length ? App.filteredRows : App.currentRows;
const hdr = ['variant_id','protein_id','mutation','am_pathogenicity','am_class','ESM1b_LLR','pred_ddg','mechanistic_label','interface_label','pocket_label','consensus'];
const lines = [hdr.join(',')];
for (const r of rows) {
lines.push([r.variantId,r.proteinId,r.mutCode,r.amScore.toFixed(4),r.amClass,r.esmLLR!=null?r.esmLLR.toFixed(3):'',r.predDDG!=null?r.predDDG.toFixed(3):'',r.mechLabel,r.interfaceLabel,r.pocketLabel,`"${r.consensus}"`].join(','));
}
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([lines.join('\r\n')], { type:'text/csv;charset=utf-8;' }));
a.download = `mutvar_${Date.now()}.csv`;
a.click(); URL.revokeObjectURL(a.href);
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// CLINVAR INTEGRATION
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const CV_COLORS = {
'Pathogenic': '#dc2626',
'Likely pathogenic': '#ef4444',
'Uncertain significance': '#f59e0b',
'Likely benign': '#22c55e',
'Benign': '#16a34a',
};
async function loadClinVar(proteinId, mutCode, ourAmClass) {
const panel = document.getElementById('clinvar-panel');
panel.innerHTML = '<div class="cv-section-title">ClinVar</div><span class="cv-loading">Checking ClinVar…</span>';
try {
const resp = await fetch(`${API_BASE}/api/clinvar/${proteinId}/${mutCode}`);
const data = await resp.json();
if (!data.found) {
panel.innerHTML = `
<div class="cv-section-title">ClinVar</div>
<div class="cv-not-found">Not found in ClinVar${data.reason ? ` (${data.reason})` : ''}</div>`;
return;
}
const color = CV_COLORS[data.classification] || '#6b7280';
const cvClass = data.classification.toLowerCase();
const disagreement = (
(ourAmClass === 'pathogenic' && (cvClass.includes('benign') && !cvClass.includes('likely pathogenic'))) ||
(ourAmClass === 'benign' && cvClass.includes('pathogenic'))
);
panel.innerHTML = `
<div class="cv-section-title">ClinVar</div>
<div class="cv-found">
<div>
<span class="cv-badge" style="background:${color}">${escHtml(data.classification)}</span>
</div>
${data.review_status ? `<div class="cv-review">${escHtml(data.review_status)}</div>` : ''}
${data.title ? `<div class="cv-title">${escHtml(data.title)}</div>` : ''}
<a class="cv-link" href="${escHtml(data.clinvar_url)}" target="_blank" rel="noopener">View on ClinVar β†—</a>
${disagreement ? `
<div class="cv-alert">
<strong>Prediction disagreement</strong> β€” AlphaMissense: <b>${ourAmClass}</b> vs ClinVar: <b>${escHtml(data.classification)}</b>.
This variant warrants manual review.
</div>` : ''}
</div>`;
} catch(e) {
panel.innerHTML = '<div class="cv-section-title">ClinVar</div><span class="cv-not-found">Lookup unavailable</span>';
}
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// UTILS
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// BOOT
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
document.addEventListener('DOMContentLoaded', async () => {
// Example chips
document.querySelectorAll('.example-chip').forEach(chip => {
chip.onclick = () => {
document.getElementById('query-input').value = chip.dataset.query;
document.getElementById('search-errors').style.display = 'none';
};
});
// Analyze button (handleAnalyze is async in API mode)
document.getElementById('btn-analyze').onclick = () => handleAnalyze();
document.getElementById('query-input').addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleAnalyze();
});
// Navigation
document.getElementById('btn-back').onclick = () => { document.body.dataset.view = 'search'; };
// Sort headers
document.querySelectorAll('#results-table th[data-col]').forEach(th => {
th.onclick = () => {
const col = th.dataset.col;
App.sortDir = App.sortCol === col ? (App.sortDir === 'asc' ? 'desc' : 'asc') : (col === 'mutCode' || col === 'mechLabel' ? 'asc' : 'desc');
App.sortCol = col;
renderTable(App.currentRows);
};
});
// Filter chips
document.getElementById('filter-chips').onclick = e => {
const chip = e.target.closest('.chip'); if (!chip) return;
App.filterMech = chip.dataset.mech;
document.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
chip.classList.add('active');
renderTable(App.currentRows);
renderPositionChart(App.filteredRows.length ? App.filteredRows : App.currentRows, App.filterPosition, App.landscapeData);
};
// Inline text filter (debounced)
let filterTimer;
document.getElementById('mutation-filter').oninput = function() {
clearTimeout(filterTimer);
filterTimer = setTimeout(() => {
App.filterText = this.value.trim();
renderTable(App.currentRows);
}, 120);
};
// Position filter clear
document.getElementById('btn-clear-posfilter').onclick = () => {
App.filterPosition = null;
updatePositionFilterUI();
renderTable(App.currentRows);
renderPositionChart(App.currentRows, null, App.landscapeData);
};
// Export + Share
document.getElementById('btn-export').onclick = exportCSV;
document.getElementById('btn-share').onclick = () => { if (lastQuery) shareResults(lastQuery); };
// Keyboard navigation in table (↑/↓)
document.getElementById('table-scroll').addEventListener('keydown', e => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const rows = App.filteredRows;
if (!rows.length) return;
const currIdx = App.selectedRow ? rows.findIndex(r => r.variantId === App.selectedRow.variantId) : -1;
const nextIdx = e.key === 'ArrowDown' ? Math.min(rows.length-1, currIdx+1) : Math.max(0, currIdx-1);
if (nextIdx !== currIdx) selectRow(rows[nextIdx]);
});
// Resize: re-render chart when panel width changes
window.addEventListener('resize', () => {
if (document.body.dataset.view === 'results' && App.currentRows.length) {
renderPositionChart(App.filteredRows.length ? App.filteredRows : App.currentRows, App.filterPosition, App.landscapeData);
drawScatter(App.currentRows, App.selectedRow);
}
});
// Scatter click β€” select nearest dot
document.getElementById('scatter-canvas').addEventListener('click', e => {
const rect = e.target.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
let nearest = null, minDist = 14;
for (const p of scatterPoints) {
const d = Math.hypot(p.x - mx, p.y - my);
if (d < minDist) { minDist = d; nearest = p; }
}
if (nearest) selectRow(nearest.row);
});
// Viewer controls
setupViewerControls();
vsInit();
// ── Detect API, then fall back to TSV ────────────────────────
const statusEl = document.getElementById('tsv-status');
statusEl.textContent = 'Connecting…';
let dataReady = false;
// 1. Try API (generous timeout β€” first query scans Parquet metadata)
try {
const infoResp = await fetch(`${API_BASE}/api/info`, { signal: AbortSignal.timeout(60000) });
if (infoResp.ok) {
const info = await infoResp.json();
API_MODE = true;
statusEl.textContent = `${info.total_variants.toLocaleString()} variants Β· ${info.total_proteins.toLocaleString()} proteins`;
document.getElementById('btn-analyze').disabled = false;
dataReady = true;
}
} catch(_) {}
// 2. Fallback: TSV (works on HF Spaces before Python backend is built)
if (!dataReady) {
try {
await loadTSV();
statusEl.textContent = `${App.byVariant.size.toLocaleString()} variants ready`;
document.getElementById('btn-analyze').disabled = false;
dataReady = true;
} catch(e) {
statusEl.textContent = 'Dataset load failed';
console.error(e);
}
}
// Update landscape link with current protein context (if known from URL)
const fromURL = decodeURL(location.hash);
if (fromURL && dataReady) {
document.getElementById('query-input').value = fromURL;
await handleAnalyze();
}
// Init Mol*
if (typeof molstar !== 'undefined') initMolstar();
else window.addEventListener('load', () => { if (typeof molstar !== 'undefined') initMolstar(); });
});
</script>
</body>
</html>