mutvar / vcf-upload.html
edohollou
Add VEP debug logging + robust position/matching fallbacks
7e31cdb
Raw
History Blame Contribute Delete
51 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>VCF Upload β€” Beltrao Lab Tools</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{
--bg-primary:#fafaf8;--bg-secondary:#f4f3ef;--bg-card:#ffffff;
--text-primary:#1a1a18;--text-secondary:#5a5a52;--text-muted:#9a9a90;
--accent:#2d5a8e;--accent-light:#e8f0f8;
--score-pathogenic:#c0392b;--score-ambiguous:#d68910;--score-benign:#1e8449;
--mech-stability:#922b21;--mech-interface:#1a5276;--mech-pockets:#1e8449;--mech-unassigned:#717d7e;
--border:#e0dfd8;--border-strong:#c8c7bf;
--font-body:'Source Serif 4',Georgia,serif;
--font-ui:'DM Sans',system-ui,sans-serif;
--font-mono:'JetBrains Mono','Fira Code',monospace;
}
body{font-family:var(--font-ui);background:var(--bg-primary);color:var(--text-primary);min-height:100vh}
/* ── Header ── */
.site-header{border-bottom:1px solid var(--border);background:var(--bg-card);position:sticky;top:0;z-index:100}
.header-inner{max-width:1100px;margin:0 auto;padding:0 2rem;height:52px;display:flex;align-items:center;justify-content:space-between}
.site-logo{font-family:var(--font-ui);font-size:.9rem;font-weight:500;color:var(--text-primary);text-decoration:none;display:flex;align-items:center;gap:.4rem}
.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}
/* Dropdown */
.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}
/* ── Footer ── */
.site-footer{border-top:1px solid var(--border);padding:2rem;margin-top:4rem}
.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)}
/* ── Layout ── */
.vcf-main{padding:2rem 0 4rem}
.vcf-container{max-width:900px;margin:0 auto;padding:0 2rem}
/* ── Page header ── */
.breadcrumb{font-family:var(--font-ui);font-size:.78rem;color:var(--text-muted);display:flex;gap:.5rem;align-items:center;margin-bottom:1rem}
.breadcrumb a{color:var(--accent);text-decoration:none}
.breadcrumb a:hover{text-decoration:underline}
.page-title{font-family:var(--font-body);font-size:2rem;font-weight:300;margin-bottom:.8rem}
.page-desc{font-family:var(--font-ui);font-size:.95rem;color:var(--text-secondary);line-height:1.7;max-width:640px;margin-bottom:2rem}
/* ── Pipeline steps ── */
.pipeline-steps{display:flex;align-items:center;gap:.6rem;margin-bottom:2rem;flex-wrap:wrap}
.pipeline-step{display:flex;align-items:center;gap:.5rem;background:var(--bg-secondary);border:1px solid var(--border);padding:.35rem .8rem;border-radius:4px}
.step-num{font-family:var(--font-mono);font-size:.72rem;color:var(--accent);font-weight:500}
.step-label{font-family:var(--font-ui);font-size:.78rem;color:var(--text-secondary)}
.pipeline-arrow{font-family:var(--font-ui);color:var(--text-muted);font-size:.85rem}
/* ── Upload section ── */
.upload-section{display:flex;flex-direction:column;gap:1.2rem}
.drop-zone{border:2px dashed var(--border-strong);border-radius:8px;padding:2.5rem;text-align:center;cursor:pointer;transition:border-color .2s,background .2s}
.drop-zone:hover,.drop-zone.drag-over{border-color:var(--accent);background:var(--accent-light)}
.drop-icon{font-size:2rem;color:var(--text-muted);margin-bottom:.8rem}
.drop-title{font-family:var(--font-ui);font-size:.95rem;font-weight:500;color:var(--text-primary);margin-bottom:.3rem}
.drop-subtitle{font-family:var(--font-ui);font-size:.82rem;color:var(--text-muted);margin-bottom:1rem}
.drop-browse-btn{font-family:var(--font-ui);font-size:.83rem;border:1px solid var(--border-strong);border-radius:4px;padding:.4rem 1rem;background:var(--bg-card);cursor:pointer;color:var(--text-secondary);transition:background .15s}
.drop-browse-btn:hover{background:var(--bg-secondary)}
.divider-or{text-align:center;position:relative;margin:.2rem 0}
.divider-or::before{content:'';position:absolute;top:50%;left:0;right:0;height:1px;background:var(--border)}
.divider-or span{font-family:var(--font-ui);font-size:.78rem;color:var(--text-muted);background:var(--bg-primary);padding:0 .8rem;position:relative}
.vcf-paste-area{width:100%;font-family:var(--font-mono);font-size:.75rem;border:1px solid var(--border);border-radius:6px;padding:.8rem 1rem;background:var(--bg-card);color:var(--text-primary);resize:vertical;line-height:1.5}
.vcf-paste-area:focus{outline:none;border-color:var(--accent)}
.vcf-paste-area::placeholder{color:var(--text-muted)}
.example-section{display:flex;align-items:center;gap:.8rem}
.example-label{font-family:var(--font-ui);font-size:.82rem;color:var(--text-muted)}
.example-btn{font-family:var(--font-ui);font-size:.82rem;color:var(--accent);border:none;background:none;cursor:pointer;text-decoration:underline;padding:0}
.options-row{display:flex;gap:1.5rem;align-items:center;flex-wrap:wrap}
.option-item{display:flex;align-items:center;gap:.5rem;font-family:var(--font-ui);font-size:.83rem;color:var(--text-secondary);cursor:pointer}
.option-select{font-family:var(--font-ui);font-size:.82rem;border:1px solid var(--border);border-radius:4px;padding:.2rem .5rem;background:var(--bg-card);color:var(--text-primary)}
.analyse-btn{font-family:var(--font-ui);font-size:.95rem;font-weight:500;color:#fff;background:var(--accent);border:none;padding:.75rem 2rem;border-radius:4px;cursor:pointer;transition:background .15s;align-self:flex-start}
.analyse-btn:hover:not(:disabled){background:#1e4070}
.analyse-btn:disabled{opacity:.4;cursor:not-allowed}
.privacy-note{font-family:var(--font-ui);font-size:.75rem;color:var(--text-muted);display:flex;align-items:flex-start;gap:.4rem;line-height:1.5}
.privacy-note::before{content:'πŸ”’';font-size:.8rem;flex-shrink:0;margin-top:.05rem}
/* ── Progress ── */
.progress-section{margin-top:2rem;border:1px solid var(--border);border-radius:6px;padding:1.2rem}
.progress-header{display:flex;justify-content:space-between;font-family:var(--font-ui);font-size:.85rem;color:var(--text-secondary);margin-bottom:.6rem}
.progress-bar-wrap{height:4px;background:var(--bg-secondary);border-radius:2px;overflow:hidden;margin-bottom:.8rem}
.progress-bar{height:100%;background:var(--accent);transition:width .3s ease;border-radius:2px}
.progress-log{font-family:var(--font-mono);font-size:.72rem;color:var(--text-secondary);max-height:140px;overflow-y:auto;line-height:1.65}
.log-line{padding:.05rem 0}
/* ── Results ── */
.results-stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:1rem;margin-top:2rem;margin-bottom:1.5rem}
@media(max-width:700px){.results-stats-grid{grid-template-columns:repeat(2,1fr)}}
.stat-card{border:1px solid var(--border);border-radius:6px;padding:1rem;background:var(--bg-card)}
.stat-card.highlight{border-color:var(--score-pathogenic);background:#fdf5f5}
.stat-val{display:block;font-family:var(--font-mono);font-size:1.6rem;font-weight:500;color:var(--text-primary)}
.stat-card.highlight .stat-val{color:var(--score-pathogenic)}
.stat-lbl{display:block;font-family:var(--font-ui);font-size:.78rem;color:var(--text-muted);margin-top:.2rem}
.mechanism-breakdown{display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:1.2rem;padding:.8rem 1.2rem;background:var(--bg-secondary);border-radius:6px;border:1px solid var(--border)}
.mech-stat{display:flex;align-items:center;gap:.5rem}
.mech-count{font-family:var(--font-mono);font-size:.9rem;font-weight:500;color:var(--text-primary)}
.results-actions{display:flex;gap:.8rem;margin-bottom:1.2rem;flex-wrap:wrap}
.btn-export{font-family:var(--font-ui);font-size:.83rem;font-weight:500;color:#fff;background:var(--accent);border:none;padding:.5rem 1rem;border-radius:4px;cursor:pointer;transition:background .15s}
.btn-export:hover{background:#1e4070}
.btn-secondary-sm{font-family:var(--font-ui);font-size:.83rem;color:var(--text-secondary);border:1px solid var(--border);background:var(--bg-card);padding:.5rem 1rem;border-radius:4px;cursor:pointer;transition:background .15s}
.btn-secondary-sm:hover{background:var(--bg-secondary)}
/* Table */
.vcf-results-table-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:6px}
.vcf-results-table{width:100%;border-collapse:collapse;font-family:var(--font-ui);font-size:.82rem}
.vcf-results-table th{text-align:left;padding:.6rem .8rem;background:var(--bg-secondary);border-bottom:1px solid var(--border);font-weight:500;color:var(--text-secondary);white-space:nowrap;font-size:.75rem;letter-spacing:.03em}
.result-row{border-bottom:1px solid var(--border);transition:background .1s}
.result-row:last-child{border-bottom:none}
.result-row:hover{background:var(--accent-light)}
.result-row td{padding:.6rem .8rem;vertical-align:middle}
.vcf-coord{font-family:var(--font-mono);font-size:.72rem;color:var(--text-secondary);display:block}
.vcf-allele{font-family:var(--font-mono);font-size:.78rem;color:var(--text-primary);display:block}
.vcf-rsid{font-family:var(--font-mono);font-size:.68rem;color:var(--text-muted);display:block}
.protein-id{font-family:var(--font-mono);font-size:.78rem;font-weight:500;color:var(--text-primary);display:block}
.gene-symbol{font-family:var(--font-ui);font-size:.72rem;color:var(--text-muted);display:block}
.mutation-code{font-family:var(--font-mono);font-size:.82rem;color:var(--text-primary);display:block}
.hgvsp{font-family:var(--font-mono);font-size:.68rem;color:var(--text-muted);display:block}
.score-bar-wrap{height:3px;background:var(--bg-secondary);border-radius:2px;width:55px;margin-bottom:.2rem}
.score-bar{height:100%;border-radius:2px}
.score-val{font-family:var(--font-mono);font-size:.78rem}
.score-mono{font-family:var(--font-mono);font-size:.78rem;color:var(--text-secondary)}
.score-mono.pathogenic{color:var(--score-pathogenic)}
.score-mono.benign{color:var(--score-benign)}
.mech-badge{font-family:var(--font-ui);font-size:.7rem;font-weight:500;padding:.15rem .5rem;border-radius:100px;white-space:nowrap}
.mech-badge.stability{background:#fdecea;color:var(--mech-stability)}
.mech-badge.interface{background:#e8f0f8;color:var(--mech-interface)}
.mech-badge.pockets{background:#e8f5ec;color:var(--mech-pockets)}
.mech-badge.unassigned{background:#f0f0ee;color:var(--mech-unassigned)}
.consensus-badge{font-family:var(--font-ui);font-size:.7rem;font-weight:500;padding:.15rem .5rem;border-radius:100px;white-space:nowrap}
.consensus-badge.high{background:#fdecea;color:var(--score-pathogenic)}
.consensus-badge.medium{background:#fef3e2;color:#b7770d}
.consensus-badge.low{background:#e8f5ec;color:var(--score-benign)}
.view-link{font-family:var(--font-ui);font-size:.78rem;color:var(--accent);text-decoration:none;white-space:nowrap}
.view-link:hover{text-decoration:underline}
/* Not found */
.not-found-section{margin-top:1.5rem;border:1px solid var(--border);border-radius:6px;padding:1.2rem;background:var(--bg-secondary)}
.not-found-section h3{font-family:var(--font-body);font-size:1rem;margin-bottom:.5rem}
.not-found-note{font-family:var(--font-ui);font-size:.82rem;color:var(--text-secondary);line-height:1.6;margin-bottom:1rem}
.not-found-item{display:flex;gap:1rem;align-items:center;padding:.4rem 0;border-bottom:1px solid var(--border);flex-wrap:wrap}
.not-found-item:last-child{border-bottom:none}
.not-found-gene{font-family:var(--font-ui);font-size:.82rem;color:var(--text-secondary)}
.not-found-reason{font-family:var(--font-ui);font-size:.75rem;color:var(--text-muted)}
.not-found-more{font-family:var(--font-ui);font-size:.78rem;color:var(--text-muted);margin-top:.5rem}
/* ── Risk distribution banner ── */
.risk-banner{display:flex;gap:.6rem;margin-bottom:1.4rem;flex-wrap:wrap;align-items:stretch}
.risk-banner-pill{flex:1;min-width:120px;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:.8rem 1rem;border-radius:6px;border:1px solid transparent;cursor:pointer;transition:filter .15s}
.risk-banner-pill:hover{filter:brightness(.95)}
.risk-banner-pill.active{outline:2px solid currentColor;outline-offset:-2px}
.risk-banner-pill.pill-high{background:#fdf0f0;border-color:#f5c6c6;color:var(--score-pathogenic)}
.risk-banner-pill.pill-medium{background:#fef8ec;border-color:#f5e0a0;color:#a06010}
.risk-banner-pill.pill-low{background:#f0faf3;border-color:#a8dbb8;color:var(--score-benign)}
.risk-banner-pill.pill-all{background:var(--bg-secondary);border-color:var(--border);color:var(--text-secondary)}
.risk-banner-count{font-family:var(--font-mono);font-size:1.6rem;font-weight:500;line-height:1}
.risk-banner-label{font-family:var(--font-ui);font-size:.72rem;margin-top:.25rem;opacity:.8}
/* ── Result row left-border risk indicator ── */
.result-row.risk-high td:first-child{border-left:3px solid var(--score-pathogenic)}
.result-row.risk-medium td:first-child{border-left:3px solid #d4a017}
.result-row.risk-low td:first-child{border-left:3px solid var(--score-benign)}
.result-row.risk-high{background:#fffafa}
.result-row.risk-high:hover{background:#fdf0f0}
</style>
</head>
<body>
<!-- HEADER -->
<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>
<main class="vcf-main">
<div class="vcf-container">
<div class="page-header" style="margin-bottom:2rem">
<div class="breadcrumb">
<a href="landing.html">Tools</a>
<span>β†’</span>
<span>VCF Upload</span>
</div>
<h1 class="page-title">Upload a VCF file</h1>
<p class="page-desc">
Upload a Variant Call Format (VCF) file from your sequencing pipeline.
MutVar maps genomic coordinates to protein variants via Ensembl VEP and retrieves
precomputed pathogenicity predictions for all missense variants found in our dataset.
</p>
</div>
<div class="pipeline-steps">
<div class="pipeline-step"><span class="step-num">1</span><span class="step-label">Parse VCF</span></div>
<div class="pipeline-arrow">β†’</div>
<div class="pipeline-step"><span class="step-num">2</span><span class="step-label">Map to protein (Ensembl VEP)</span></div>
<div class="pipeline-arrow">β†’</div>
<div class="pipeline-step"><span class="step-num">3</span><span class="step-label">Lookup MutVar predictions</span></div>
<div class="pipeline-arrow">β†’</div>
<div class="pipeline-step"><span class="step-num">4</span><span class="step-label">Results + export</span></div>
</div>
<div class="upload-section">
<div id="drop-zone" class="drop-zone">
<div class="drop-icon">⊞</div>
<div class="drop-title" id="drop-title">Drop your VCF file here</div>
<div class="drop-subtitle" id="drop-subtitle">or click to browse β€” .vcf and .vcf.gz supported</div>
<input type="file" id="vcf-file-input" accept=".vcf,.vcf.gz" style="display:none"/>
<button class="drop-browse-btn" onclick="document.getElementById('vcf-file-input').click()">Browse files</button>
</div>
<div class="divider-or"><span>or paste VCF content directly</span></div>
<textarea id="vcf-paste-area" class="vcf-paste-area" rows="8"
placeholder="##fileformat=VCFv4.2&#10;#CHROM&#9;POS&#9;ID&#9;REF&#9;ALT&#9;QUAL&#9;FILTER&#9;INFO&#10;chr11&#9;108490993&#9;.&#9;G&#9;T&#9;.&#9;PASS&#9;."></textarea>
<div class="example-section">
<span class="example-label">Don't have a VCF file?</span>
<button id="load-example-btn" class="example-btn">Load example VCF (7 canonical cancer driver mutations)</button>
</div>
<div class="options-row">
<label class="option-item">
<input type="checkbox" id="missense-only-cb" checked/>
<span>Missense variants only</span>
</label>
<div class="option-item">
<span>Genome build:</span>
<select id="genome-build-select" class="option-select">
<option value="GRCh38" selected>GRCh38 (hg38)</option>
<option value="GRCh37">GRCh37 (hg19)</option>
</select>
</div>
</div>
<button id="analyse-btn" class="analyse-btn" disabled>Analyse VCF</button>
<p class="privacy-note">
Your VCF file is processed entirely in your browser. Variant coordinates are sent only
to the public <a href="https://rest.ensembl.org" target="_blank" style="color:var(--accent)">Ensembl VEP REST API</a>
(EMBL-EBI) for genomic annotation. No data is stored on our servers.
</p>
</div>
<div id="progress-section" class="progress-section" style="display:none">
<div class="progress-header">
<span id="progress-title">Processing…</span>
<span id="progress-pct">0%</span>
</div>
<div class="progress-bar-wrap">
<div id="progress-bar" class="progress-bar" style="width:0%"></div>
</div>
<div id="progress-log" class="progress-log"></div>
</div>
<div id="results-section" style="display:none">
<div class="results-stats-grid">
<div class="stat-card"><span class="stat-val" id="stat-total">β€”</span><span class="stat-lbl">variants in VCF</span></div>
<div class="stat-card"><span class="stat-val" id="stat-missense">β€”</span><span class="stat-lbl">missense variants</span></div>
<div class="stat-card"><span class="stat-val" id="stat-found">β€”</span><span class="stat-lbl">found in MutVar</span></div>
<div class="stat-card highlight"><span class="stat-val" id="stat-high-risk">β€”</span><span class="stat-lbl">High Risk</span></div>
</div>
<!-- Risk distribution filter banner -->
<div class="risk-banner" id="risk-banner">
<div class="risk-banner-pill pill-all active" data-filter="all" onclick="setRiskFilter('all')">
<span class="risk-banner-count" id="banner-count-all">β€”</span>
<span class="risk-banner-label">All variants</span>
</div>
<div class="risk-banner-pill pill-high" data-filter="high" onclick="setRiskFilter('high')">
<span class="risk-banner-count" id="banner-count-high">β€”</span>
<span class="risk-banner-label">High Risk (β‰₯2/3)</span>
</div>
<div class="risk-banner-pill pill-medium" data-filter="medium" onclick="setRiskFilter('medium')">
<span class="risk-banner-count" id="banner-count-medium">β€”</span>
<span class="risk-banner-label">Medium (1/3)</span>
</div>
<div class="risk-banner-pill pill-low" data-filter="low" onclick="setRiskFilter('low')">
<span class="risk-banner-count" id="banner-count-low">β€”</span>
<span class="risk-banner-label">Low Risk (0/3)</span>
</div>
</div>
<div id="mechanism-breakdown" class="mechanism-breakdown"></div>
<div class="results-actions">
<button id="export-csv-btn" class="btn-export">Export CSV</button>
<button id="show-not-found-btn" class="btn-secondary-sm">
Show variants not in dataset (<span id="not-found-count">0</span>)
</button>
</div>
<div class="vcf-results-table-wrap">
<table class="vcf-results-table" id="vcf-results-table">
<thead>
<tr>
<th>VCF variant</th>
<th>Protein</th>
<th>Mutation</th>
<th>AlphaMissense</th>
<th>ESM1b LLR</th>
<th>ΔΔG</th>
<th>Mechanism</th>
<th>Consensus</th>
<th></th>
</tr>
</thead>
<tbody id="vcf-results-tbody"></tbody>
</table>
</div>
<div id="not-found-section" class="not-found-section" style="display:none">
<h3>Variants not in MutVar dataset</h3>
<p class="not-found-note">
These missense variants were identified by Ensembl VEP but are not in the MutVar dataset.
MutVar currently covers precomputed AlphaMissense, ESM1b, and ΔΔG predictions for
human proteins derived from the AlphaMissense proteome-wide release.
</p>
<div id="not-found-list"></div>
</div>
</div>
</div>
</main>
<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="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>
</div>
</div>
</footer>
<script>
'use strict';
// ═══════════════════════════════════════════════════════════════
// CONFIG
// ═══════════════════════════════════════════════════════════════
const API_BASE = (location.hostname === 'localhost' || location.hostname === '127.0.0.1')
? 'http://localhost:8000' : '';
const VEP_BATCH_SIZE = 50;
const VEP_RATE_DELAY = 600; // ms between batches β€” Ensembl rate limit
// Example VCF: canonical cancer driver mutations (GRCh38 coordinates).
// BRAF V600E, TP53 R175H/R248W, KRAS G12D/G12V, IDH1 R132H, PTEN R130Q
const EXAMPLE_VCF = `##fileformat=VCFv4.2
##reference=GRCh38
##INFO=<ID=GENE,Number=1,Type=String,Description="Gene symbol">
#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO
7\t140753336\t.\tA\tT\t100\tPASS\tGENE=BRAF
17\t7674220\t.\tG\tA\t100\tPASS\tGENE=TP53
17\t7673776\t.\tC\tT\t100\tPASS\tGENE=TP53
12\t25245350\t.\tC\tT\t100\tPASS\tGENE=KRAS
12\t25245350\t.\tC\tA\t100\tPASS\tGENE=KRAS
2\t208248388\t.\tG\tA\t100\tPASS\tGENE=IDH1
10\t89692905\t.\tG\tA\t100\tPASS\tGENE=PTEN`;
// ═══════════════════════════════════════════════════════════════
// VCF PARSING
// ═══════════════════════════════════════════════════════════════
/**
* Parse raw VCF text. Returns only SNVs (single nucleotide variants)
* β€” the only type that can produce missense substitutions.
*/
function parseVCF(vcfText) {
const variants = [];
for (const line of vcfText.split(/\r?\n/)) {
if (line.startsWith('#') || !line.trim()) continue;
const cols = line.split('\t');
if (cols.length < 5) continue;
const [chrom, pos, id, ref, alt] = cols;
// SNVs only: single-nucleotide REF and ALT
if (ref.length !== 1 || alt.length !== 1) continue;
if (!/^[ACGT]$/i.test(ref) || !/^[ACGT]$/i.test(alt)) continue;
variants.push({
chrom: chrom.replace(/^chr/i, ''), // Ensembl uses "11" not "chr11"
pos: parseInt(pos, 10),
id: id === '.' ? null : id,
ref: ref.toUpperCase(),
alt: alt.toUpperCase(),
originalChrom: chrom,
});
}
return variants;
}
// ═══════════════════════════════════════════════════════════════
// ENSEMBL VEP ANNOTATION
// ═══════════════════════════════════════════════════════════════
/**
* Annotate variants with Ensembl VEP REST API (batch mode).
* Returns Map: "CHROM:POS:REF:ALT" β†’ { uniprotId, geneSymbol, aaFrom, aaTo, position, hgvsp }
*
* Position extraction: VEP sometimes omits protein_position; we fall back
* to parsing the HGVS protein notation (e.g. "p.Ala126Asp" β†’ 126).
*/
async function annotateWithVEP(variants, genomeBuild) {
const results = new Map();
const server = genomeBuild === 'GRCh37'
? 'https://grch37.rest.ensembl.org'
: 'https://rest.ensembl.org';
const total = variants.length;
let done = 0;
for (let i = 0; i < total; i += VEP_BATCH_SIZE) {
const batch = variants.slice(i, i + VEP_BATCH_SIZE);
const batchNum = Math.floor(i / VEP_BATCH_SIZE) + 1;
const batchTotal = Math.ceil(total / VEP_BATCH_SIZE);
updateProgress(
`Annotating with Ensembl VEP (batch ${batchNum}/${batchTotal})…`,
10 + Math.round((i / total) * 55)
);
const vepVariants = batch.map(v => `${v.chrom} ${v.pos} ${v.id || '.'} ${v.ref} ${v.alt}`);
try {
const resp = await fetch(`${server}/vep/human/region`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ variants: vepVariants, uniprot: 1, hgvs: 1 })
});
if (!resp.ok) {
logProgress(`⚠️ VEP API error (batch ${batchNum}): HTTP ${resp.status}`);
done += batch.length;
continue;
}
const vepData = await resp.json();
// Debug: how many came back and what types
const nWithTC = vepData.filter(r => r.transcript_consequences?.length > 0).length;
const nMissense = vepData.filter(r =>
r.transcript_consequences?.some(tc => tc.consequence_terms?.includes('missense_variant'))
).length;
logProgress(` VEP batch ${batchNum}: ${vepData.length} results, ${nWithTC} with transcripts, ${nMissense} missense`);
if (nWithTC === 0 && vepData.length > 0) {
const sample = vepData[0];
const topConseq = sample.most_severe_consequence || '?';
logProgress(` Sample consequence: ${topConseq} (variant: ${sample.input})`);
}
let debugCount = 0;
for (const result of vepData) {
if (!result.transcript_consequences) continue;
// Find best missense consequence: prefer canonical + Swiss-Prot match
let best = null, bestScore = -1;
for (const tc of result.transcript_consequences) {
if (!tc.consequence_terms?.includes('missense_variant')) continue;
const aaParts = tc.amino_acids?.split('/');
if (!aaParts || aaParts.length !== 2) continue;
const hasSP = (tc.swissprot?.length ?? 0) > 0;
const score = (tc.canonical === 1 ? 10 : 0) + (hasSP ? 5 : 0) + (tc.biotype === 'protein_coding' ? 2 : 0);
if (score > bestScore) { bestScore = score; best = tc; }
}
if (!best) continue;
// Debug first 3 missense results
if (debugCount < 3) {
debugCount++;
logProgress(` [dbg] input="${result.input}" pos=${best.protein_position} aa=${best.amino_acids} hgvsp=${best.hgvsp} sp=${best.swissprot}`);
}
// Extract UniProt accession (strip isoform version: "Q7Z4H8.152" β†’ "Q7Z4H8")
const uniprotId = best.swissprot?.[0]?.split('.')[0] ?? null;
// Extract protein position: multiple fallbacks
let position = parseInt(best.protein_position, 10);
if (isNaN(position) && best.protein_position) {
// sometimes "175-175" range format
const m = String(best.protein_position).match(/^(\d+)/);
if (m) position = parseInt(m[1], 10);
}
if (isNaN(position) && best.hgvsp) {
// parse from HGVS: "ENST00000269305.9:p.Arg175His" β†’ 175
const m = best.hgvsp.match(/[p]\.[A-Za-z*]{1,3}(\d+)/);
if (m) position = parseInt(m[1], 10);
}
if (isNaN(position) || position <= 0) {
if (debugCount <= 3) logProgress(` [dbg] skipped: bad position (${best.protein_position})`);
continue;
}
const [aaFrom, aaTo] = best.amino_acids.split('/');
if (!aaFrom || !aaTo || aaFrom === '-' || aaTo === '-') {
if (debugCount <= 3) logProgress(` [dbg] skipped: bad amino acids (${best.amino_acids})`);
continue;
}
// Match result back to input variant using VEP's own parsed fields
// (more reliable than parsing result.input string)
const inChrom = String(result.seq_region_name || '').replace(/^chr/i, '');
const inPos = result.start;
const [inRef, inAlt] = (result.allele_string || '/').split('/');
const variant = batch.find(v =>
v.chrom === inChrom &&
v.pos === inPos &&
v.ref === (inRef || '').toUpperCase() &&
v.alt === (inAlt || '').toUpperCase()
);
if (!variant) {
if (debugCount <= 3) logProgress(` [dbg] skipped: no match for ${inChrom}:${inPos} ${inRef}>${inAlt} in batch`);
continue;
}
const key = `${variant.chrom}:${variant.pos}:${variant.ref}:${variant.alt}`;
results.set(key, {
geneSymbol: best.gene_symbol ?? 'β€”',
uniprotId,
aaFrom: aaFrom.toUpperCase(),
aaTo: aaTo.toUpperCase(),
position,
hgvsp: best.hgvsp ?? null,
isMissense: true,
});
}
} catch (err) {
logProgress(`⚠️ Network error (batch ${batchNum}): ${err.message}`);
}
done += batch.length;
if (i + VEP_BATCH_SIZE < total) await sleep(VEP_RATE_DELAY);
}
return results;
}
// ═══════════════════════════════════════════════════════════════
// MUTVAR API LOOKUP
// ═══════════════════════════════════════════════════════════════
/**
* Bulk-lookup variants against the MutVar backend API (/api/lookup).
* Takes a Map of VEP annotations (same Map returned by annotateWithVEP).
* Returns Map: "PROTEINID/MUTCODE" β†’ data row (null if not in dataset).
*/
async function bulkLookupMutVar(vepResults) {
const toQuery = [];
const seen = new Set();
for (const ann of vepResults.values()) {
if (!ann.uniprotId || !ann.position || !ann.aaFrom || !ann.aaTo) continue;
const mutation = `${ann.aaFrom}${ann.position}${ann.aaTo}`;
const key = `${ann.uniprotId}/${mutation}`;
if (!seen.has(key)) {
seen.add(key);
toQuery.push({ protein_id: ann.uniprotId, mutation });
}
}
if (toQuery.length === 0) return new Map();
logProgress(`Querying MutVar API for ${toQuery.length} variant${toQuery.length > 1 ? 's' : ''}…`);
const resp = await fetch(`${API_BASE}/api/lookup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ variants: toQuery }),
});
if (!resp.ok) throw new Error(`MutVar API error: HTTP ${resp.status}`);
const { results } = await resp.json();
const index = new Map();
for (const r of results) {
index.set(`${r.protein_id}/${r.mutation}`, r.found ? r.data : null);
}
const nFound = results.filter(r => r.found).length;
logProgress(`βœ“ ${nFound} / ${results.length} variant${results.length !== 1 ? 's' : ''} found in MutVar`);
return index;
}
// ═══════════════════════════════════════════════════════════════
// MAIN PIPELINE
// ═══════════════════════════════════════════════════════════════
async function runAnalysis(vcfText, genomeBuild, missenseOnly) {
showProgress();
try {
// Step 1: Parse VCF
updateProgress('Parsing VCF file…', 5);
const variants = parseVCF(vcfText);
logProgress(`βœ“ Parsed ${variants.length} SNV${variants.length !== 1 ? 's' : ''} from VCF`);
if (variants.length === 0) {
logProgress('⚠️ No valid single-nucleotide variants found. Only SNVs can produce missense substitutions.');
return;
}
if (variants.length > 2000) {
logProgress(`⚠️ Large VCF (${variants.length} SNVs). Processing in batches β€” this may take a few minutes.`);
}
// Step 2: VEP annotation
updateProgress('Annotating with Ensembl VEP…', 10);
const vepResults = await annotateWithVEP(variants, genomeBuild);
logProgress(`βœ“ VEP annotation done: ${vepResults.size} missense variant${vepResults.size !== 1 ? 's' : ''} identified`);
// Step 3: Bulk lookup against MutVar API
updateProgress('Querying MutVar predictions…', 70);
const mutvarIndex = await bulkLookupMutVar(vepResults);
// Step 4: Match
updateProgress('Matching results…', 90);
const matched = [];
const notFound = [];
for (const v of variants) {
const key = `${v.chrom}:${v.pos}:${v.ref}:${v.alt}`;
const ann = vepResults.get(key);
if (!ann) {
if (!missenseOnly) notFound.push({ v, ann: null, reason: 'Not a missense variant' });
continue;
}
const mutCode = `${ann.aaFrom}${ann.position}${ann.aaTo}`;
const row = mutvarIndex.get(`${ann.uniprotId}/${mutCode}`) ?? null;
if (row) {
// API returns booleans (true/false/null) β€” not "True"/"False" strings
const consensusScore =
(row.am_label ? 1 : 0) +
(row.esm1b_is_pathogenic ? 1 : 0) +
(row.pred_ddg_label ? 1 : 0);
// When ESM1b and ΔΔG are absent, derive risk from AM pathogenicity alone
const hasFull = row.esm1b_llr != null || row.pred_ddg != null;
const amScore = parseFloat(row.am_pathogenicity) || 0;
const effectiveRisk = hasFull
? consensusScore
: (amScore >= 0.8 ? 2 : amScore > 0.564 ? 1 : 0);
matched.push({ v, ann, row, mutCode, consensusScore, hasFull, effectiveRisk });
} else {
notFound.push({
v, ann,
reason: ann.uniprotId
? `${ann.uniprotId} not in MutVar dataset`
: 'No Swiss-Prot ID returned by VEP',
});
}
}
logProgress(`βœ“ Found ${matched.length} variant${matched.length !== 1 ? 's' : ''} in MutVar`);
if (notFound.filter(x => x.ann).length > 0)
logProgress(` ${notFound.filter(x => x.ann).length} missense variant(s) not covered in MutVar`);
updateProgress('Rendering results…', 95);
renderResults(variants, matched, notFound);
updateProgress('Done.', 100);
} catch (err) {
logProgress(`❌ Error: ${err.message}`);
console.error(err);
}
}
// ═══════════════════════════════════════════════════════════════
// RENDER
// ═══════════════════════════════════════════════════════════════
function renderResults(allVariants, matched, notFound) {
document.getElementById('results-section').style.display = 'block';
const missenseTotal = matched.length + notFound.filter(x => x.ann).length;
const highRiskCount = matched.filter(m => m.effectiveRisk >= 2).length;
const mediumCount = matched.filter(m => m.effectiveRisk === 1).length;
const lowCount = matched.filter(m => m.effectiveRisk === 0).length;
document.getElementById('stat-total').textContent = allVariants.length;
document.getElementById('stat-missense').textContent = missenseTotal;
document.getElementById('stat-found').textContent = matched.length;
document.getElementById('stat-high-risk').textContent = highRiskCount;
// Risk banner counts
document.getElementById('banner-count-all').textContent = matched.length;
document.getElementById('banner-count-high').textContent = highRiskCount;
document.getElementById('banner-count-medium').textContent = mediumCount;
document.getElementById('banner-count-low').textContent = lowCount;
// Mechanism breakdown
const mc = { Stability: 0, Interface: 0, Pockets: 0, Unassigned: 0 };
matched.forEach(m => { const mech = m.row.mechanistic_label || 'Unassigned'; if (mc[mech] !== undefined) mc[mech]++; });
document.getElementById('mechanism-breakdown').innerHTML =
Object.entries(mc).map(([mech, count]) =>
`<div class="mech-stat"><span class="mech-badge ${mech.toLowerCase()}">${mech}</span><span class="mech-count">${count}</span></div>`
).join('');
// Sort by consensus then AlphaMissense
matched.sort((a, b) =>
b.effectiveRisk - a.effectiveRisk ||
parseFloat(b.row.am_pathogenicity) - parseFloat(a.row.am_pathogenicity)
);
// Store globally for filtering, then render
window._vcfMatched = matched;
setRiskFilter('all');
// Not found list
const notFoundMissense = notFound.filter(x => x.ann);
// Prevent stale closure β€” update the export button with current matched
document.getElementById('export-csv-btn').onclick = () => exportCSV(matched);
document.getElementById('not-found-count').textContent = notFoundMissense.length;
document.getElementById('not-found-list').innerHTML =
notFoundMissense.slice(0, 50).map(({ v, ann, reason }) =>
`<div class="not-found-item">
<span class="vcf-coord" style="display:inline;font-size:.78rem">${v.originalChrom}:${v.pos} ${v.ref}β†’${v.alt}</span>
<span class="not-found-gene">${ann?.geneSymbol ?? 'β€”'}</span>
<span class="not-found-reason">${reason}</span>
</div>`
).join('')
+ (notFoundMissense.length > 50
? `<div class="not-found-more">…and ${notFoundMissense.length - 50} more</div>` : '');
}
// ═══════════════════════════════════════════════════════════════
// ROW BUILDER (top-level so setRiskFilter can call it at any time)
// ═══════════════════════════════════════════════════════════════
function buildRows(rows) {
return rows.map(({ v, ann, row, mutCode, consensusScore, hasFull, effectiveRisk }) => {
const am = parseFloat(row.am_pathogenicity);
const esm = parseFloat(row.esm1b_llr);
const ddg = parseFloat(row.pred_ddg);
const amClass = row.am_class || '';
const amColor = { pathogenic: 'var(--score-pathogenic)', ambiguous: 'var(--score-ambiguous)', benign: 'var(--score-benign)' }[amClass] || 'var(--text-secondary)';
const consLabel = effectiveRisk >= 2 ? 'High Risk'
: effectiveRisk === 1 ? 'Medium'
: 'Low Risk';
const consClass = effectiveRisk >= 2 ? 'high' : effectiveRisk === 1 ? 'medium' : 'low';
const riskRowClass = effectiveRisk >= 2 ? 'risk-high' : effectiveRisk === 1 ? 'risk-medium' : 'risk-low';
const mechClass = (row.mechanistic_label || 'Unassigned').toLowerCase();
const hgvspShort = ann.hgvsp ? (ann.hgvsp.split(':')[1] || '') : '';
const scoreDetail = `AM: ${row.am_label ? 'pathogenic' : 'benign'} Β· ESM1b: ${row.esm1b_is_pathogenic ? 'pathogenic' : 'β€”'} Β· ΔΔG: ${row.pred_ddg_label ? 'destabilizing' : 'β€”'}`;
return `<tr class="result-row ${riskRowClass}" data-risk="${effectiveRisk >= 2 ? 'high' : effectiveRisk === 1 ? 'medium' : 'low'}">
<td>
<span class="vcf-coord">${v.originalChrom}:${v.pos}</span>
<span class="vcf-allele">${v.ref}β†’${v.alt}</span>
${v.id ? `<span class="vcf-rsid">${v.id}</span>` : ''}
</td>
<td>
<span class="protein-id">${ann.uniprotId || 'β€”'}</span>
<span class="gene-symbol">${ann.geneSymbol}</span>
</td>
<td>
<span class="mutation-code">${row.mutation_code}</span>
${hgvspShort ? `<span class="hgvsp">${hgvspShort}</span>` : ''}
</td>
<td>
<div class="score-bar-wrap"><div class="score-bar" style="width:${isNaN(am)?0:am*100}%;background:${amColor}"></div></div>
<span class="score-val" style="color:${amColor}">${isNaN(am)?'β€”':am.toFixed(3)}</span>
</td>
<td><span class="score-mono ${isNaN(esm)?'':esm<-4?'pathogenic':'benign'}">${isNaN(esm)?'β€”':esm.toFixed(2)}</span></td>
<td><span class="score-mono ${isNaN(ddg)?'':ddg>1.5?'pathogenic':ddg<0?'benign':''}">${isNaN(ddg)?'β€”':(ddg>0?'+':'')+ddg.toFixed(2)}</span></td>
<td><span class="mech-badge ${mechClass}">${row.mechanistic_label || 'Unassigned'}</span></td>
<td title="${scoreDetail}"><span class="consensus-badge ${consClass}">${consLabel}</span></td>
<td><a href="index.html#q=${ann.uniprotId}+${mutCode}" target="_blank" class="view-link">View β†’</a></td>
</tr>`;
}).join('');
}
// ═══════════════════════════════════════════════════════════════
// RISK FILTER
// ═══════════════════════════════════════════════════════════════
function setRiskFilter(filter) {
const matched = window._vcfMatched;
if (!matched) return;
// Highlight active pill
document.querySelectorAll('.risk-banner-pill').forEach(p => {
p.classList.toggle('active', p.dataset.filter === filter);
});
const filtered = filter === 'all' ? matched
: filter === 'high' ? matched.filter(m => m.effectiveRisk >= 2)
: filter === 'medium' ? matched.filter(m => m.effectiveRisk === 1)
: matched.filter(m => m.effectiveRisk === 0);
const tbody = document.getElementById('vcf-results-tbody');
if (filtered.length === 0) {
const label = filter === 'high' ? 'High Risk' : filter === 'medium' ? 'Medium' : 'Low Risk';
tbody.innerHTML = `<tr><td colspan="9" style="text-align:center;padding:2rem;color:var(--text-muted);font-family:var(--font-ui)">
No ${label} variants in this VCF.
</td></tr>`;
} else {
tbody.innerHTML = buildRows(filtered);
}
}
// ═══════════════════════════════════════════════════════════════
// EXPORT CSV
// ═══════════════════════════════════════════════════════════════
function exportCSV(matched) {
const headers = ['vcf_chrom','vcf_pos','vcf_ref','vcf_alt','rsid','gene','uniprot_id','mutation','hgvsp','am_pathogenicity','am_class','esm1b_llr','pred_ddg','mechanistic_label','consensus_score','consensus_label'];
const esc = v => `"${String(v ?? '').replace(/"/g,'""')}"`;
const rows = matched.map(({ v, ann, row, mutCode, consensusScore, hasFull, effectiveRisk }) => [
v.originalChrom, v.pos, v.ref, v.alt, v.id ?? '',
ann.geneSymbol, ann.uniprotId ?? '', mutCode, ann.hgvsp ?? '',
row.am_pathogenicity, row.am_class,
row.esm1b_llr, row.pred_ddg,
row.mechanistic_label, consensusScore,
effectiveRisk >= 2 ? 'High Risk' : effectiveRisk === 1 ? 'Moderate/Pathogenic' : 'Low/Benign',
].map(esc).join(','));
const blob = new Blob([[headers.join(','), ...rows].join('\n')], { type: 'text/csv;charset=utf-8;' });
const a = Object.assign(document.createElement('a'), {
href: URL.createObjectURL(blob),
download: `mutvar_vcf_${new Date().toISOString().slice(0,10)}.csv`,
});
a.click();
URL.revokeObjectURL(a.href);
}
// ═══════════════════════════════════════════════════════════════
// UI HELPERS
// ═══════════════════════════════════════════════════════════════
function updateProgress(msg, pct) {
document.getElementById('progress-title').textContent = msg;
document.getElementById('progress-pct').textContent = `${pct}%`;
document.getElementById('progress-bar').style.width = `${pct}%`;
}
function logProgress(msg) {
const log = document.getElementById('progress-log');
const div = document.createElement('div');
div.className = 'log-line';
div.textContent = msg;
log.appendChild(div);
log.scrollTop = log.scrollHeight;
}
function showProgress() {
document.getElementById('progress-section').style.display = 'block';
document.getElementById('results-section').style.display = 'none';
document.getElementById('progress-log').innerHTML = '';
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
// ═══════════════════════════════════════════════════════════════
// EVENTS
// ═══════════════════════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('vcf-file-input');
const pasteArea = document.getElementById('vcf-paste-area');
const analyseBtn = document.getElementById('analyse-btn');
const checkContent = () => {
analyseBtn.disabled = pasteArea.value.trim().length < 10;
};
pasteArea.addEventListener('input', checkContent);
// Drag & drop
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault(); dropZone.classList.remove('drag-over');
if (e.dataTransfer.files[0]) loadFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', () => { if (fileInput.files[0]) loadFile(fileInput.files[0]); });
function loadFile(file) {
if (file.name.endsWith('.gz') && typeof DecompressionStream !== 'undefined') {
new Response(file.stream().pipeThrough(new DecompressionStream('gzip'))).text()
.then(text => { pasteArea.value = text; checkContent(); setDropLabel(file); })
.catch(() => logProgress('⚠️ Could not decompress .vcf.gz. Please convert to .vcf first.'));
} else {
const reader = new FileReader();
reader.onload = e => { pasteArea.value = e.target.result; checkContent(); setDropLabel(file); };
reader.readAsText(file);
}
}
function setDropLabel(file) {
document.getElementById('drop-title').textContent = `βœ“ ${file.name}`;
document.getElementById('drop-subtitle').textContent = `${(file.size / 1024).toFixed(1)} KB loaded`;
}
// Example VCF
document.getElementById('load-example-btn').addEventListener('click', () => {
pasteArea.value = EXAMPLE_VCF;
checkContent();
});
// Analyse
analyseBtn.addEventListener('click', () => {
if (!pasteArea.value.trim()) return;
runAnalysis(
pasteArea.value,
document.getElementById('genome-build-select').value,
document.getElementById('missense-only-cb').checked
);
});
// Show/hide not-found
document.getElementById('show-not-found-btn').addEventListener('click', () => {
const s = document.getElementById('not-found-section');
s.style.display = s.style.display === 'none' ? 'block' : 'none';
});
});
</script>
</body>
</html>