protfunc / static /interface.html
Sbhat2026's picture
fix: version display v5.0 badge and footer
b127623
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ProtFunc β€” Protein Function Prediction</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/home.css" />
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
/* Base palette β€” PRISM-consistent */
--bg: #f5f7fa;
--surface: #ffffff;
--surface-2: #f0f2f6;
--surface-3: #e8ebf2;
--border: #e2e5ec;
--text: #111827;
--text-primary: #111827;
--text-secondary:#6b7280;
--text-muted: #9ca3af;
--text-dim: #9ca3af;
--accent: #6366f1;
--accent-2: #8b5cf6;
--accent-light: rgba(99,102,241,0.08);
--accent-blue: #0891b2;
--accent-blue-dim: rgba(8,145,178,0.1);
/* Confidence palette */
--high-bg: rgba(22,163,74,0.08);
--high-border: rgba(22,163,74,0.25);
--high-text: #15803d;
--high-bar: #16a34a;
--med-bg: rgba(202,138,4,0.08);
--med-border: rgba(202,138,4,0.25);
--med-text: #a16207;
--med-bar: #ca8a04;
--low-bg: rgba(220,38,38,0.06);
--low-border: rgba(220,38,38,0.2);
--low-text: #b91c1c;
--low-bar: #dc2626;
/* Layout */
--sidebar-w: 228px;
--radius: 10px;
--radius-sm: 7px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--shadow: 0 4px 12px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.05);
--transition: 0.15s cubic-bezier(0.4,0,0.2,1);
}
body { background: var(--bg); color: var(--text); font-family: 'Inter', -apple-system, sans-serif; }
/* ── App layout ─────────────────────────────────────────────────────── */
#pf-app { display: flex; height: 100vh; overflow: hidden; }
#pf-app[hidden] { display: none !important; }
.app-layout { display: flex; width: 100%; height: 100%; overflow: hidden; }
/* ── Sidebar ────────────────────────────────────────────────────────── */
.sidebar {
width: var(--sidebar-w); flex-shrink: 0;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
height: 100%; overflow: hidden;
}
.sidebar-header {
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border);
}
.logo {
display: flex; align-items: center; gap: 8px;
text-decoration: none; color: var(--text);
}
.logo-icon {
width: 28px; height: 28px; border-radius: 7px;
background: var(--accent-light);
display: flex; align-items: center; justify-content: center;
color: var(--accent);
}
.logo-text { font-weight: 700; font-size: 0.95rem; letter-spacing: -0.02em; }
.logo-badge {
font-size: 0.6rem; font-weight: 600; letter-spacing: 0.05em;
background: var(--accent-light); color: var(--accent);
padding: 2px 7px; border-radius: 10px; border: 1px solid rgba(99,102,241,0.2);
}
.sidebar-nav { flex: 1; overflow-y: auto; padding: 12px 8px; }
.nav-section { margin-bottom: 20px; }
.nav-section-title {
font-size: 0.65rem; font-weight: 600; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--text-muted);
padding: 0 8px 6px;
}
.nav-item {
display: flex; align-items: center; gap: 10px;
width: 100%; padding: 8px 10px; border-radius: var(--radius-sm);
font-size: 0.82rem; font-weight: 500; color: var(--text-secondary);
background: none; border: none; cursor: pointer; text-align: left;
text-decoration: none; transition: background var(--transition), color var(--transition);
}
.nav-item:hover { background: var(--surface-2); color: var(--text); }
.nav-item.active { background: var(--accent-light); color: var(--accent); }
.nav-item svg { width: 15px; height: 15px; flex-shrink: 0; }
.history-section { margin-top: auto; }
.sidebar-footer {
padding: 12px 8px;
border-top: 1px solid var(--border);
}
.model-badge {
display: flex; align-items: center; gap: 6px;
font-size: 0.72rem; color: var(--text-secondary);
}
.dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--high-bar); flex-shrink: 0;
box-shadow: 0 0 0 2px rgba(22,163,74,0.2);
}
.sidebar-overlay { display: none; }
/* ── Main content ───────────────────────────────────────────────────── */
.main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.topbar {
height: 52px; flex-shrink: 0;
display: flex; align-items: center; justify-content: space-between;
padding: 0 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-sm);
}
.topbar-left { display: flex; align-items: center; gap: 12px; }
.topbar-right { display: flex; align-items: center; gap: 10px; }
.page-title { font-size: 0.95rem; font-weight: 600; color: var(--text); letter-spacing: -0.01em; }
.mobile-menu-btn { display: none; }
.icon-btn {
width: 32px; height: 32px; border-radius: var(--radius-sm);
background: none; border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center;
cursor: pointer; color: var(--text-secondary);
transition: background var(--transition), border-color var(--transition);
}
.icon-btn:hover { background: var(--surface-2); color: var(--text); }
.icon-btn svg { width: 15px; height: 15px; }
.page-content { flex: 1; overflow-y: auto; padding: 24px; }
/* ── Cards ──────────────────────────────────────────────────────────── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
margin-bottom: 16px;
}
.card-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px 12px;
border-bottom: 1px solid var(--border);
}
.card-title { font-size: 0.85rem; font-weight: 600; color: var(--text); letter-spacing: -0.01em; }
.card-body { padding: 16px 18px; }
/* ── Input area ─────────────────────────────────────────────────────── */
.demo-bar {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
margin-bottom: 12px;
}
.demo-label { font-size: 0.72rem; color: var(--text-muted); white-space: nowrap; }
.demo-btn {
font-size: 0.72rem; padding: 3px 10px; border-radius: 20px;
border: 1px solid var(--border); background: var(--surface-2);
color: var(--text-secondary); cursor: pointer;
transition: border-color var(--transition), color var(--transition);
}
.demo-btn:hover { border-color: var(--accent); color: var(--accent); }
.textarea-wrapper { position: relative; margin-bottom: 10px; }
.seq-textarea {
width: 100%; min-height: 120px; max-height: 320px;
padding: 12px 14px;
background: var(--surface-2); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius-sm);
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace; font-size: 0.82rem; line-height: 1.6;
resize: vertical; outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
}
.seq-textarea:focus { border-color: rgba(99,102,241,0.4); box-shadow: 0 0 0 3px rgba(99,102,241,0.08); }
.seq-textarea::placeholder { color: var(--text-muted); font-style: italic; }
.char-count { font-size: 0.72rem; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
.action-bar {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 10px;
}
.input-hints { display: flex; gap: 6px; flex-wrap: wrap; flex: 1; }
.hint-tag {
font-size: 0.65rem; font-weight: 600; letter-spacing: 0.04em;
padding: 3px 8px; border-radius: 20px;
background: var(--accent-light); color: var(--accent);
border: 1px solid rgba(99,102,241,0.2);
}
.taxon-select-wrap { display: flex; align-items: center; gap: 6px; }
.taxon-label { font-size: 0.75rem; color: var(--text-muted); white-space: nowrap; }
.taxon-select {
padding: 5px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border); background: var(--surface-2);
color: var(--text); font-size: 0.8rem; font-family: inherit;
outline: none; cursor: pointer;
}
.btn-primary {
display: flex; align-items: center; gap: 8px;
padding: 9px 18px; border-radius: var(--radius-sm);
background: var(--accent); color: #fff;
border: none; font-size: 0.85rem; font-weight: 600;
cursor: pointer; white-space: nowrap;
transition: background var(--transition), transform 0.1s;
}
.btn-primary:hover { background: var(--accent-2); }
.btn-primary:active { transform: scale(0.98); }
.btn-primary svg { width: 14px; height: 14px; }
.btn-primary:disabled { background: var(--border); color: var(--text-muted); cursor: not-allowed; }
/* ── Status bar ─────────────────────────────────────────────────────── */
.status-bar {
padding: 10px 14px; border-radius: var(--radius-sm);
font-size: 0.82rem; background: var(--surface-2);
border: 1px solid var(--border); color: var(--text-secondary);
margin-bottom: 12px; display: none;
}
.status-bar:not(:empty) { display: block; }
.status-bar.running { border-color: rgba(99,102,241,0.3); color: var(--accent); }
.status-bar.error { border-color: var(--low-border); color: var(--low-text); background: var(--low-bg); }
/* ── Results toolbar ────────────────────────────────────────────────── */
.results-toolbar {
display: none; align-items: center; justify-content: space-between;
gap: 12px; margin-bottom: 12px; flex-wrap: wrap;
}
.results-toolbar.visible { display: flex; }
.toolbar-left { flex: 1; }
.toolbar-right { display: flex; gap: 6px; }
.filter-input {
width: 100%; max-width: 300px;
padding: 7px 12px; border-radius: var(--radius-sm);
border: 1px solid var(--border); background: var(--surface);
color: var(--text); font-size: 0.82rem; font-family: inherit;
outline: none;
transition: border-color var(--transition);
}
.filter-input:focus { border-color: rgba(99,102,241,0.4); }
.export-btn {
display: flex; align-items: center; gap: 6px;
padding: 6px 12px; border-radius: var(--radius-sm);
border: 1px solid var(--border); background: var(--surface);
color: var(--text-secondary); font-size: 0.78rem; font-weight: 500;
cursor: pointer; transition: border-color var(--transition), color var(--transition);
}
.export-btn:hover { border-color: var(--accent); color: var(--accent); }
.export-btn svg { width: 13px; height: 13px; }
/* ── Result cards ───────────────────────────────────────────────────── */
.results-area { display: flex; flex-direction: column; gap: 12px; }
.result-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.result-card.error { border-color: var(--low-border); }
.result-card.error .result-header { background: var(--low-bg); }
.result-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; cursor: pointer;
background: var(--surface-2);
border-bottom: 1px solid var(--border);
transition: background var(--transition);
gap: 12px;
}
.result-header:hover { background: var(--surface-3); }
.result-header-left { display: flex; align-items: center; gap: 10px; min-width: 0; }
.result-name {
font-size: 0.82rem; font-weight: 600; color: var(--text);
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.result-stats {
display: flex; align-items: center; gap: 10px; flex-shrink: 0; flex-wrap: wrap;
}
/* Confidence badges */
.conf-high { background: var(--high-bg); color: var(--high-text); border: 1px solid var(--high-border); border-radius: 20px; padding: 2px 9px; font-size: 0.7rem; font-weight: 600; }
.conf-med { background: var(--med-bg); color: var(--med-text); border: 1px solid var(--med-border); border-radius: 20px; padding: 2px 9px; font-size: 0.7rem; font-weight: 600; }
.conf-low { background: var(--low-bg); color: var(--low-text); border: 1px solid var(--low-border); border-radius: 20px; padding: 2px 9px; font-size: 0.7rem; font-weight: 600; }
/* Confidence bars */
.conf-bar-wrap { height: 4px; border-radius: 2px; background: var(--surface-3); overflow: hidden; width: 60px; }
.conf-bar { height: 100%; border-radius: 2px; }
.conf-bar.high { background: var(--high-bar); }
.conf-bar.med { background: var(--med-bar); }
.conf-bar.low { background: var(--low-bar); }
/* Card tabs inside result cards */
.card-tab-strip {
display: flex; gap: 0; border-bottom: 1px solid var(--border);
background: var(--surface-2);
}
.card-tab {
padding: 8px 14px; font-size: 0.75rem; font-weight: 500;
color: var(--text-muted); cursor: pointer; border: none; background: none;
border-bottom: 2px solid transparent; transition: color var(--transition);
}
.card-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.card-tab:hover:not(.active) { color: var(--text-secondary); }
.card-tab-panel { padding: 14px 16px; }
.card-tab-panel[hidden] { display: none !important; }
/* Prediction rows inside result cards */
.pred-row {
display: flex; align-items: center; gap: 10px; padding: 6px 0;
border-bottom: 1px solid var(--border); font-size: 0.8rem;
}
.pred-row:last-child { border-bottom: none; }
.pred-go-id { font-family: 'JetBrains Mono', monospace; font-size: 0.72rem; color: var(--accent); min-width: 80px; }
.pred-name { flex: 1; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pred-prob { font-family: 'JetBrains Mono', monospace; font-size: 0.72rem; font-weight: 600; min-width: 44px; text-align: right; }
.no-preds { color: var(--text-muted); font-size: 0.82rem; padding: 12px 0; }
.suppressed-note { font-size: 0.72rem; color: var(--text-muted); padding-top: 6px; }
/* Saliency / structure */
.saliency-btn {
padding: 6px 14px; border-radius: var(--radius-sm);
border: 1px solid var(--border); background: var(--surface-2);
color: var(--text-secondary); font-size: 0.78rem; cursor: pointer;
transition: border-color var(--transition), color var(--transition);
}
.saliency-btn:hover { border-color: var(--accent); color: var(--accent); }
/* ── Batch upload ───────────────────────────────────────────────────── */
.batch-upload {
border: 2px dashed var(--border); border-radius: var(--radius);
padding: 40px 24px; text-align: center; cursor: pointer;
transition: border-color var(--transition), background var(--transition);
}
.batch-upload:hover { border-color: var(--accent); background: var(--accent-light); }
.batch-upload-icon { width: 36px; height: 36px; stroke: var(--text-muted); margin-bottom: 10px; }
.batch-upload-text { font-size: 0.88rem; color: var(--text-secondary); margin-bottom: 4px; }
.batch-upload-hint { font-size: 0.75rem; color: var(--text-muted); }
input[type="file"] { display: none; }
/* ── Pagination ─────────────────────────────────────────────────────── */
.pagination {
display: flex; align-items: center; justify-content: center; gap: 8px;
padding: 12px 0; font-size: 0.8rem;
}
.page-btn {
padding: 5px 12px; border-radius: var(--radius-sm);
border: 1px solid var(--border); background: var(--surface);
color: var(--text-secondary); cursor: pointer;
transition: border-color var(--transition), color var(--transition);
}
.page-btn:hover { border-color: var(--accent); color: var(--accent); }
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.page-info { color: var(--text-muted); font-size: 0.75rem; }
/* ── History list ───────────────────────────────────────────────────── */
.history-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; border-radius: var(--radius-sm);
font-size: 0.78rem; color: var(--text-secondary); cursor: pointer;
transition: background var(--transition);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.history-item:hover { background: var(--surface-2); color: var(--text); }
.history-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
/* ── Filter toggles ─────────────────────────────────────────────────── */
.filter-toggle {
padding: 4px 10px; font-size: 0.72rem; border-radius: 20px;
border: 1px solid var(--border); background: var(--surface-2);
color: var(--text-muted); cursor: pointer;
transition: border-color var(--transition), color var(--transition), background var(--transition);
}
.filter-toggle.active-med { border-color: var(--med-border); background: var(--med-bg); color: var(--med-text); }
.filter-toggle.active-low { border-color: var(--low-border); background: var(--low-bg); color: var(--low-text); }
/* ── Generalization table ───────────────────────────────────────────── */
.gen-table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
.gen-table th {
text-align: left; padding: 8px 12px; font-size: 0.7rem;
font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase;
color: var(--text-muted); background: var(--surface-2);
border-bottom: 1px solid var(--border);
}
.gen-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); }
.gen-table tr:last-child td { border-bottom: none; }
.gen-table tr:hover td { background: var(--surface-2); }
/* ── Mobile ─────────────────────────────────────────────────────────── */
@media (max-width: 700px) {
.sidebar { position: fixed; left: -100%; top: 0; bottom: 0; z-index: 50; box-shadow: var(--shadow); transition: left 0.25s; }
.sidebar.open { left: 0; }
.sidebar-overlay { display: block; position: fixed; inset: 0; z-index: 40; background: rgba(0,0,0,0.3); display: none; }
.sidebar-overlay.visible { display: block; }
.mobile-menu-btn { display: flex; }
.page-content { padding: 16px; }
}
</style>
</head>
<body>
<!-- ══ LANDING ════════════════════════════════════════════════════════════ -->
<div id="pf-landing">
<nav id="pf-nav">
<a class="pf-nav-logo" href="#">Prot<em>Func</em></a>
<span class="pf-nav-author">by Siddhant Bhat</span>
<div class="pf-nav-links">
<a href="https://github.com/SBhat2026/protfunc" target="_blank" rel="noopener">GitHub</a>
<a href="https://huggingface.co/Sbhat2026" target="_blank" rel="noopener">Models</a>
</div>
<button class="pf-nav-enter" data-pf-cta="predict">Open App</button>
</nav>
<section id="pf-hero">
<canvas id="pf-hero-canvas"></canvas>
<div class="pf-hero-content">
<div class="pf-hero-eyebrow pf-reveal">Metazoa-scale Β· CAFA5-compatible Β· v2</div>
<h1 class="pf-hero-h1 pf-reveal pf-reveal-d1">Predicting protein<br><em>molecular function</em><br>from sequence.</h1>
<p class="pf-hero-sub pf-reveal pf-reveal-d2">A multi-label classifier trained on insects and mammals that maps amino acid sequences to Gene Ontology Molecular Function terms β€” no structure required.</p>
<div class="pf-hero-ctas pf-reveal pf-reveal-d3">
<button class="pf-cta-primary" data-pf-cta="predict">Try a prediction β†’</button>
<button class="pf-cta-secondary" data-pf-cta="batch">Batch upload</button>
</div>
</div>
<div class="pf-scroll-hint">
<svg viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5v14M5 12l7 7 7-7"/>
</svg>
Scroll to explore
</div>
</section>
<div id="pf-stat-strip">
<div class="pf-stat-item pf-reveal">
<span class="pf-stat-val">0.939</span>
<span class="pf-stat-label">micro-Fmax (insects Β· CAFA5)</span>
</div>
<div class="pf-stat-item pf-reveal pf-reveal-d1">
<span class="pf-stat-val">~4,200</span>
<span class="pf-stat-label">GO molecular function terms</span>
</div>
<div class="pf-stat-item pf-reveal pf-reveal-d2">
<span class="pf-stat-val">35M</span>
<span class="pf-stat-label">ESM-2 parameter backbone</span>
</div>
<div class="pf-stat-item pf-reveal pf-reveal-d3">
<span class="pf-stat-val">Metazoa</span>
<span class="pf-stat-label">cross-taxon coverage (insects + mammals)</span>
</div>
</div>
<section class="pf-scrollytell" data-stage="1">
<div class="pf-scrollytell-inner">
<div class="pf-story-canvas-wrap">
<canvas id="pf-story-canvas" width="300" height="300"></canvas>
</div>
<div class="pf-story-text">
<div class="pf-stage-num pf-reveal">Step 1 β€” Representation</div>
<h2 class="pf-reveal pf-reveal-d1">ESM-2 encodes each sequence into a rich embedding.</h2>
<p class="pf-reveal pf-reveal-d2">Every amino acid sequence is passed through ESM-2 (35M parameters, 480-dimensional residue embeddings). Mean-pooling collapses per-residue representations into a single 480d protein vector, enriched with 11 physicochemical features.</p>
<div class="pf-story-stat pf-reveal pf-reveal-d3"><strong>480d</strong> ESM-2 + <strong>11d</strong> physicochemical</div>
</div>
</div>
</section>
<section class="pf-scrollytell" data-stage="2">
<div class="pf-scrollytell-inner reverse">
<div class="pf-story-canvas-wrap"><canvas id="pf-story-canvas-2" width="320" height="280"></canvas></div>
<div class="pf-story-text">
<div class="pf-stage-num pf-reveal">Step 2 β€” Multi-label classification</div>
<h2 class="pf-reveal pf-reveal-d1">A residual MLP predicts thousands of GO terms at once.</h2>
<p class="pf-reveal pf-reveal-d2">The 491d feature vector feeds an ImprovedResidualMLP (2048-hidden, 8 ResBlocks, dropout, LayerNorm). Sigmoid outputs one probability per GO:MF term. Per-label Platt scaling calibrates confidence, and the GO DAG propagates predictions to parent terms.</p>
<div class="pf-story-stat pf-reveal pf-reveal-d3"><strong>8</strong> ResBlocks Β· <strong>~4,200</strong> GO-MF outputs Β· Platt calibrated</div>
</div>
</div>
</section>
<section class="pf-scrollytell" data-stage="3">
<div class="pf-scrollytell-inner">
<div class="pf-story-canvas-wrap"><canvas id="pf-story-canvas-3" width="320" height="280"></canvas></div>
<div class="pf-story-text">
<div class="pf-stage-num pf-reveal">Step 3 β€” Cross-taxon generalization</div>
<h2 class="pf-reveal pf-reveal-d1">Trained jointly on insects and mammals β€” generalises across Metazoa.</h2>
<p class="pf-reveal pf-reveal-d2">An ESM-embedding taxon probe auto-detects whether the input sequence is insect, mammal, or other. The corresponding model variant and per-label thresholds are applied, giving calibrated predictions across taxonomic groups without manual selection.</p>
<div class="pf-story-stat pf-reveal pf-reveal-d3">Insects βœ“ Β· Mammals βœ“ Β· Fish / Birds β€” roadmap</div>
</div>
</div>
</section>
<section class="pf-scrollytell" data-stage="4">
<div class="pf-scrollytell-inner reverse">
<div class="pf-story-canvas-wrap"><canvas id="pf-story-canvas-4" width="320" height="280"></canvas></div>
<div class="pf-story-text">
<div class="pf-stage-num pf-reveal">Step 4 β€” Validated against CAFA5</div>
<h2 class="pf-reveal pf-reveal-d1">Benchmarked with the community standard.</h2>
<p class="pf-reveal pf-reveal-d2">Evaluation follows the CAFA5 protocol (protein-centric Fmax, AUPR, Smin, coverage). The unified model achieves micro-Fmax 0.939 (insect CAFA5) and 0.804 (mammal, enriched GOA labels). A 480-d ESM-2 taxon probe (96% accuracy) auto-selects per-taxon calibration.</p>
<div class="pf-story-stat pf-reveal pf-reveal-d3"><strong>0.939</strong> insect Β· <strong>0.804</strong> mammal Β· CAFA5 protein-centric</div>
</div>
</div>
</section>
<section id="pf-footer-cta">
<h2 class="pf-reveal">Ready to predict?</h2>
<p class="pf-reveal pf-reveal-d1">Paste any amino acid sequence β€” plain AA or FASTA β€” and get GO molecular function predictions in seconds.</p>
<div class="pf-hero-ctas pf-reveal pf-reveal-d2">
<button class="pf-cta-primary" data-pf-cta="predict">Try a prediction β†’</button>
<button class="pf-cta-secondary" data-pf-cta="batch">Batch upload</button>
</div>
</section>
<section id="pf-cite" style="padding:48px 40px;max-width:760px;margin:0 auto;">
<h3 style="font-size:1rem;font-weight:700;color:var(--text);margin-bottom:8px;">How to cite</h3>
<p style="font-size:0.8rem;color:var(--text-secondary);margin-bottom:14px;">If you use ProtFunc in your research, please cite:</p>
<div style="position:relative;">
<pre id="pf-bibtex" style="background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:16px;font-family:'JetBrains Mono',ui-monospace,'SF Mono',Menlo,monospace;font-size:0.72rem;line-height:1.6;color:var(--text-secondary);overflow-x:auto;white-space:pre;">@software{bhat_protfunc_2026,
author = {Bhat, Siddhant},
title = {{ProtFunc}: Protein Molecular Function Prediction across Metazoa},
year = {2026},
url = {https://protfunc.prismlab.workers.dev},
note = {ESM-2 35M + ImprovedResidualMLP, unified insect/mammal model}
}</pre>
<button onclick="(function(){navigator.clipboard.writeText(document.getElementById('pf-bibtex').textContent).then(()=>{this.textContent='Copied!';setTimeout(()=>{this.textContent='Copy'},1500)}).catch(()=>{})}).call(this)" style="position:absolute;top:10px;right:10px;padding:4px 10px;border-radius:5px;border:1px solid var(--border);background:var(--surface);color:var(--text-secondary);font-size:0.72rem;cursor:pointer;">Copy</button>
</div>
</section>
<footer class="pf-home-footer">
<span>ProtFunc Β· Protein Molecular Function Prediction Β· v5.0</span>
<span>Created by <strong>Siddhant Bhat</strong> Β· Sole author of model, training pipeline, and webapp</span>
<span>ESM-2 Β· PyTorch Β· FastAPI Β· <a href="https://huggingface.co/Sbhat2026" target="_blank" rel="noopener">HuggingFace Models</a></span>
</footer>
</div>
<!-- ══ END LANDING ════════════════════════════════════════════════════════ -->
<!-- ══ APP ════════════════════════════════════════════════════════════════ -->
<div id="pf-app" hidden>
<div class="app-layout">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<a href="#" class="logo" onclick="document.getElementById('pf-app').hidden=true;document.getElementById('pf-landing').hidden=false;document.getElementById('pf-landing').style.opacity='1';history.pushState({},'','#');return false;">
<div class="logo-icon">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M8 12 C8 8 10 6 12 6 C14 6 16 8 16 12 C16 16 14 18 12 18"/>
<circle cx="12" cy="12" r="2" fill="currentColor"/>
</svg>
</div>
<span class="logo-text">ProtFunc</span>
<span class="logo-badge" id="modelBadge">v2</span>
</a>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-section-title">Predict</div>
<button class="nav-item active" data-view="predict">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
Single Prediction
</button>
<button class="nav-item" data-view="batch">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
Batch Upload
</button>
</div>
<div class="nav-section">
<div class="nav-section-title">Info</div>
<button class="nav-item" data-view="generalization">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
Generalization
</button>
<button class="nav-item" data-view="about">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
About
</button>
<a class="nav-item" href="https://www.uniprot.org" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
UniProt Database
</a>
</div>
<div class="nav-section history-section">
<div class="nav-section-title">Recent</div>
<div id="historyList"></div>
</div>
</nav>
<div class="sidebar-footer">
<div class="model-badge" style="flex-direction:column;gap:3px;padding:6px 8px;">
<div style="display:flex;align-items:center;gap:6px;">
<span class="dot"></span>
<span id="modelFooterLabel">ESM-2 35M + GO:MF</span>
</div>
<div style="font-size:0.65rem;color:var(--text-muted);">Metazoa Β· CAFA5-compatible</div>
</div>
</div>
</aside>
<div class="sidebar-overlay" id="sidebarOverlay" onclick="toggleSidebar()"></div>
<!-- Main content -->
<main class="main-content">
<header class="topbar">
<div class="topbar-left">
<button class="icon-btn mobile-menu-btn" onclick="toggleSidebar()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<h1 class="page-title" id="pageTitle">Molecular Function Prediction</h1>
</div>
<div class="topbar-right">
<div class="model-badge">
<span class="dot"></span>
Model Ready
</div>
</div>
</header>
<div class="page-content">
<!-- Predict View -->
<div id="predictView">
<div class="card">
<div class="card-header">
<span class="card-title">Input Sequence</span>
<span class="char-count" id="charCount">0 aa</span>
</div>
<div class="card-body">
<div class="demo-bar">
<span class="demo-label">Try example:</span>
<button class="demo-btn" onclick="loadDemo('hippo')">Hippo Kinase</button>
<button class="demo-btn" onclick="loadDemo('opsin')">Opsin Rh6</button>
<button class="demo-btn" onclick="loadDemo('human')">Human P53</button>
<button class="demo-btn" onclick="loadDemo('mouse_tp53')">Mouse Trp53</button>
<button class="demo-btn" onclick="loadDemo('zebrafish_tp53')">Zebrafish tp53</button>
</div>
<div class="textarea-wrapper">
<textarea id="sequenceInput" class="seq-textarea"
placeholder=">MyProtein&#10;MKTIIALSYIFCLVFA...&#10;&#10;Paste your FASTA sequence above.&#10;Add a UniProt accession below to enable 3D structure + saliency visualization."
spellcheck="false" autocomplete="off"></textarea>
</div>
<div style="display:flex;align-items:center;gap:10px;margin:8px 0 4px 0;">
<label style="font-size:0.78rem;color:var(--text-secondary);white-space:nowrap;min-width:120px;">UniProt Accession <span style="opacity:0.6">(optional)</span></label>
<input id="uniprotAccession" type="text"
placeholder="e.g. P04637"
style="flex:1;max-width:180px;padding:6px 10px;border-radius:6px;border:1px solid var(--border);background:var(--surface-2);color:var(--text-primary);font-size:0.85rem;font-family:inherit;"
spellcheck="false" autocomplete="off" />
<span style="font-size:0.72rem;color:var(--text-muted);">Enables 3D saliency map</span>
</div>
<div class="action-bar">
<div class="input-hints">
<span class="hint-tag">FASTA</span>
<span class="hint-tag">Plain AA</span>
<span class="hint-tag">Multi-sequence</span>
</div>
<div class="taxon-select-wrap">
<label class="taxon-label" for="taxonSelect">Organism</label>
<select id="taxonSelect" class="taxon-select">
<option value="auto">Auto-detect</option>
<option value="insect">Insect</option>
<option value="mammal">Mammal</option>
</select>
</div>
<button class="btn-primary" id="predictBtn" onclick="predict()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Predict Functions
</button>
</div>
</div>
</div>
<div id="status" class="status-bar"></div>
<div id="resultsToolbar" class="results-toolbar">
<div class="toolbar-left">
<input class="filter-input" id="termFilter" placeholder="Filter by GO term..." oninput="applyFilters()" />
</div>
<div class="toolbar-right">
<button class="export-btn" onclick="downloadTSV()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
TSV
</button>
<button class="export-btn" onclick="downloadJSON()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
JSON
</button>
</div>
</div>
<div id="results" class="results-area"></div>
<div id="pagination" class="pagination"></div>
</div>
<!-- Batch View -->
<div id="batchView" style="display:none;">
<div class="card">
<div class="card-header">
<span class="card-title">Batch Upload</span>
</div>
<div class="card-body">
<p style="color:var(--text-secondary);margin-bottom:16px;font-size:0.9rem;">
Upload a FASTA file with multiple sequences for batch prediction. Maximum 100 sequences per batch.
</p>
<div class="batch-upload" id="dropZone" onclick="document.getElementById('fileInput').click()">
<svg class="batch-upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<div class="batch-upload-text">Drop FASTA file here or click to upload</div>
<div class="batch-upload-hint">.fasta, .fa, .txt files supported</div>
</div>
<input type="file" id="fileInput" accept=".fasta,.fa,.txt" onchange="handleFileUpload(event)" />
<div id="batchStatus" class="status-bar" style="margin-top:20px;"></div>
<div id="batchResults" class="results-area" style="margin-top:20px;"></div>
</div>
</div>
</div>
<!-- Generalization View -->
<div id="generalizationView" style="display:none;">
<div class="card">
<div class="card-header">
<span class="card-title">Cross-Taxon Generalization</span>
</div>
<div class="card-body">
<p style="color:var(--text-secondary);margin-bottom:16px;font-size:0.875rem;">
Cross-taxon generalization: how well the model transfers to each taxonomic group.
<strong style="color:var(--text-primary);">gen_ratio</strong> = taxon micro-Fmax Γ· insect test micro-Fmax (insect baseline β‰ˆ 0.953).
Target: β‰₯ 0.90 (strong), β‰₯ 0.85 (acceptable). CAFA5-protocol metrics shown where available.
</p>
<div style="display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap;">
<div style="font-size:0.75rem;display:flex;align-items:center;gap:4px;"><span style="width:10px;height:10px;border-radius:50%;background:var(--high-bar);display:inline-block;"></span> gen_ratio β‰₯ 0.90</div>
<div style="font-size:0.75rem;display:flex;align-items:center;gap:4px;"><span style="width:10px;height:10px;border-radius:50%;background:var(--med-bar);display:inline-block;"></span> 0.85–0.90</div>
<div style="font-size:0.75rem;display:flex;align-items:center;gap:4px;"><span style="width:10px;height:10px;border-radius:50%;background:var(--low-bar);display:inline-block;"></span> &lt; 0.85</div>
</div>
<div id="genTableContainer" style="overflow-x:auto;">
<p style="color:var(--text-secondary);font-size:0.875rem;">Loading generalization data…</p>
</div>
</div>
</div>
</div>
<!-- About View -->
<div id="aboutView" style="display:none;">
<div class="card">
<div class="card-header">
<span class="card-title">About ProtFunc</span>
</div>
<div class="card-body" style="line-height:1.8;color:var(--text-secondary);">
<h3 style="color:var(--text-primary);margin-bottom:12px;font-size:1rem;">Author</h3>
<p style="margin-bottom:20px;">
ProtFunc was designed, trained, and deployed by <strong style="color:var(--text-primary);">Siddhant Bhat</strong> as sole author. Model architecture, training data curation, evaluation against CAFA5 benchmarks, and webapp implementation are all original work.
</p>
<h3 style="color:var(--text-primary);margin-bottom:12px;font-size:1rem;">What is ProtFunc?</h3>
<p style="margin-bottom:20px;">
ProtFunc predicts Gene Ontology Molecular Function (GO:MF) terms for protein sequences. It uses <strong style="color:var(--text-primary);">ESM-2</strong>, a protein language model trained on hundreds of millions of sequences, to generate embeddings that are passed through a trained classifier with attention pooling.
</p>
<h3 style="color:var(--text-primary);margin-bottom:12px;font-size:1rem;">How to Use</h3>
<ul style="padding-left:20px;margin-bottom:20px;">
<li>Paste your sequence in plain amino acid or FASTA format in the input box</li>
<li>Multiple sequences can be submitted at once</li>
<li>Maximum sequence length: 2500 amino acids</li>
<li>Click <strong>Predict Functions</strong> or press <strong>Cmd/Ctrl + Enter</strong></li>
<li>For <strong>3D structure + saliency</strong>: enter a UniProt accession (e.g. <code style="background:var(--surface-3);padding:1px 5px;border-radius:3px;">P04637</code>), then click <strong>View Saliency</strong></li>
</ul>
<h3 style="color:var(--text-primary);margin-bottom:12px;font-size:1rem;">Confidence Levels</h3>
<ul style="padding-left:20px;margin-bottom:20px;">
<li><span style="color:var(--high-text);">High (75%+)</span> β€” Strong prediction, reliable</li>
<li><span style="color:var(--med-text);">Medium (55–75%)</span> β€” Moderate confidence, hidden by default</li>
<li><span style="color:var(--low-text);">Low (&lt;55%)</span> β€” Uncertain, use as supplementary signal only</li>
</ul>
<h3 style="color:var(--text-primary);margin-bottom:12px;font-size:1rem;">Model Information</h3>
<p style="margin-bottom:20px;">
ProtFunc uses <strong style="color:var(--text-primary);">ESM-2</strong> (esm2_t12_35M, 480d embeddings) + 11 physicochemical features feeding an <strong style="color:var(--text-primary);">ImprovedResidualMLP</strong> (2048-hidden, 8 ResBlocks, ~4,200 GO-MF outputs). The model is fine-tuned jointly on insect and mammal proteins to support cross-taxon generalization.
</p>
<h3 style="color:var(--text-primary);margin-bottom:12px;font-size:1rem;">CAFA Benchmark Compliance</h3>
<p style="margin-bottom:12px;">
Evaluations follow the <strong style="color:var(--text-primary);">CAFA5</strong> protocol (protein-centric Fmax, AUPR, Smin, coverage). The unified model achieves micro-Fmax <strong style="color:var(--high-text);">0.939</strong> (insect) and <strong style="color:var(--high-text);">0.804</strong> (mammal, enriched GOA labels).
</p>
<h3 style="color:var(--text-primary);margin-bottom:12px;font-size:1rem;">Taxon Coverage</h3>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:20px;">
<span style="font-size:0.75rem;padding:3px 10px;border-radius:12px;background:var(--high-bg);color:var(--high-text);border:1px solid var(--high-border);">Insects βœ“ trained</span>
<span style="font-size:0.75rem;padding:3px 10px;border-radius:12px;background:var(--accent-light);color:var(--accent);border:1px solid rgba(99,102,241,0.2);">Mammals βœ“ fine-tuned</span>
<span style="font-size:0.75rem;padding:3px 10px;border-radius:12px;background:var(--surface-3);color:var(--text-muted);border:1px solid var(--border);">Fish β€” planned</span>
<span style="font-size:0.75rem;padding:3px 10px;border-radius:12px;background:var(--surface-3);color:var(--text-muted);border:1px solid var(--border);">Birds β€” planned</span>
<span style="font-size:0.75rem;padding:3px 10px;border-radius:12px;background:var(--surface-3);color:var(--text-muted);border:1px solid var(--border);">All Metazoa β€” roadmap</span>
</div>
<h3 style="color:var(--text-primary);margin-bottom:12px;font-size:1rem;">Research Roadmap</h3>
<ul style="padding-left:20px;margin-bottom:20px;">
<li><strong>AlphaFold embeddings</strong> β€” Augment ESM-2 with structural features from AF2 pLDDT + contact maps for better specificity on rare MF terms</li>
<li><strong>Fish and bird fine-tuning</strong> β€” Extend the multi-taxon corpus with fish (Danio, Oreochromis) and avian (Gallus) reviewed SwissProt entries</li>
<li><strong>Transformer pooling</strong> β€” Replace mean-pool with a learnable attention pooler over ESM-2 residue tokens</li>
<li><strong>CAFA5 macro-Fmax</strong> β€” Target macro-Fmax improvement via threshold-free methods (AUROC ranking) for rare GO leaf terms</li>
<li><strong>All Metazoa</strong> β€” Scale to full TrEMBL Metazoa with semi-supervised label propagation via GO DAG</li>
</ul>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- ══ END APP ════════════════════════════════════════════════════════════ -->
<script src="/static/home.js"></script>
<script>
// ── State ─────────────────────────────────────────────────────────────────
const ta = document.getElementById('sequenceInput');
const cc = document.getElementById('charCount');
let allResults = [];
let currentPage = 1;
const PAGE_SIZE = 10;
let _seqMap = {}; // globalIdx β†’ raw sequence string for saliency
let _explainData = {}; // globalIdx β†’ last /api/explainability response
let _explainViewers = {}; // globalIdx β†’ 3Dmol viewer for explain panel
// ── Theme ─────────────────────────────────────────────────────────────────
function applyTheme(light) {
document.documentElement.classList.toggle('light', light);
localStorage.setItem('theme', light ? 'light' : 'dark');
}
function toggleTheme() {
applyTheme(!document.documentElement.classList.contains('light'));
}
applyTheme(true); // fixed light theme to match PRISM style
// ── Sidebar ───────────────────────────────────────────────────────────────
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
document.getElementById('sidebarOverlay').classList.toggle('visible');
}
// ── View Navigation ───────────────────────────────────────────────────────
const views = {
predict: { el: 'predictView', title: 'Molecular Function Prediction' },
batch: { el: 'batchView', title: 'Batch Upload' },
generalization: { el: 'generalizationView', title: 'Cross-Taxon Generalization' },
about: { el: 'aboutView', title: 'About ProtFunc' }
};
document.querySelectorAll('.nav-item[data-view]').forEach(btn => {
btn.addEventListener('click', () => {
const view = btn.dataset.view;
if (!views[view]) return;
// Update active nav
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
btn.classList.add('active');
// Show view
Object.keys(views).forEach(v => {
document.getElementById(views[v].el).style.display = v === view ? 'block' : 'none';
});
// Update title
document.getElementById('pageTitle').textContent = views[view].title;
// Close mobile sidebar
if (window.innerWidth <= 1024) toggleSidebar();
});
});
// ── Char Counter ──────────────────────────────────────────────────────────
ta.addEventListener('input', () => {
const seq = ta.value.split('\n').filter(l => !l.trimStart().startsWith('>')).join('').replace(/[^A-Za-z]/g, '');
cc.textContent = seq.length ? `${seq.length.toLocaleString()} aa` : '0 aa';
});
ta.addEventListener('keydown', e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) predict(); });
// ── Helpers ───────────────────────────────────────────────────────────────
function confClass(p) { return p >= 0.75 ? 'conf-high' : p >= 0.55 ? 'conf-med' : 'conf-low'; }
function confLabel(p) { return p >= 0.75 ? 'High' : p >= 0.55 ? 'Medium' : 'Low'; }
function confBarClass(p) { return p >= 0.75 ? 'high' : p >= 0.55 ? 'med' : 'low'; }
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// ── Validation ────────────────────────────────────────────────────────────
const MIN_SEQ_LENGTH = 30;
const MIN_ENTROPY_BITS = 2.5;
const MAX_DOMINANT_FRAC = 0.60;
const MIN_DISTINCT_AA = 5;
const INVALID_AA_RE = /[BJOUXZ]/gi;
function sequenceEntropy(seq) {
const upper = seq.toUpperCase();
const counts = {};
for (const aa of upper) counts[aa] = (counts[aa] || 0) + 1;
const n = upper.length;
return -Object.values(counts).reduce((h, c) => h + (c / n) * Math.log2(c / n), 0);
}
function parseSequences(raw) {
const lines = raw.split('\n');
const seqs = [];
let current = null;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('>')) {
if (current) seqs.push(current);
current = { name: trimmed.slice(1).trim() || `Sequence ${seqs.length + 1}`, residues: '' };
} else if (trimmed) {
if (!current) current = { name: `Sequence ${seqs.length + 1}`, residues: '' };
current.residues += trimmed.replace(/\s+/g, '');
}
}
if (current) seqs.push(current);
return seqs;
}
function validateSequences(seqs) {
const errors = [];
for (const seq of seqs) {
const label = `"${escHtml(seq.name)}"`;
const res = seq.residues;
if (res.length === 0) { errors.push(`${label}: sequence is empty.`); continue; }
if (res.length < MIN_SEQ_LENGTH) {
errors.push(`${label}: too short (${res.length} aa, minimum ${MIN_SEQ_LENGTH}).`);
continue;
}
// Non-letter characters (digits, symbols)
const nonLetter = [...new Set([...res].filter(c => !/[A-Za-z]/.test(c)))];
if (nonLetter.length > 0) {
const display = nonLetter.slice(0, 5).map(c => `'${c}'`).join(', ');
errors.push(`${label}: contains non-amino-acid characters: ${display}. Only single-letter codes are accepted.`);
continue;
}
// DNA/RNA detection
const upper2 = res.toUpperCase();
const nuclCount = [...upper2].filter(c => 'ATCGU'.includes(c)).length;
const distinctChars = new Set(upper2).size;
if (nuclCount / res.length > 0.85 && distinctChars <= 6) {
errors.push(`${label}: looks like a nucleotide (DNA/RNA) sequence, not a protein. Enter an amino acid sequence.`);
continue;
}
const badChars = [...new Set((res.match(INVALID_AA_RE) || []).map(c => c.toUpperCase()))];
if (badChars.length > 0) {
errors.push(`${label}: invalid characters: ${badChars.join(', ')}.`);
}
const upper = res.toUpperCase();
const counts = {};
for (const aa of upper) counts[aa] = (counts[aa] || 0) + 1;
if (Object.keys(counts).length < MIN_DISTINCT_AA) {
errors.push(`${label}: only ${Object.keys(counts).length} distinct residues (minimum ${MIN_DISTINCT_AA}).`);
continue;
}
const maxCount = Math.max(...Object.values(counts));
const domFrac = maxCount / res.length;
if (domFrac > MAX_DOMINANT_FRAC) {
errors.push(`${label}: low complexity (${Math.round(domFrac * 100)}% single residue).`);
continue;
}
const H = sequenceEntropy(res);
if (H < MIN_ENTROPY_BITS) {
errors.push(`${label}: low entropy (${H.toFixed(2)} bits).`);
}
}
return errors;
}
// ── Predict ───────────────────────────────────────────────────────────────
async function predict() {
const raw = ta.value.trim();
if (!raw) { shake(); return; }
const btn = document.getElementById('predictBtn');
const status = document.getElementById('status');
const seqs = parseSequences(raw);
if (seqs.length === 0) { shake(); return; }
const validationErrors = validateSequences(seqs);
if (validationErrors.length > 0) {
status.className = 'status-bar visible';
status.innerHTML = `<span class="status-error">${validationErrors.join('<br>')}</span>`;
document.getElementById('results').innerHTML = '';
shake();
return;
}
btn.disabled = true;
btn.innerHTML = '<div class="spinner"></div>Running...';
status.className = 'status-bar visible';
status.innerHTML = '<div class="spinner"></div><span class="status-text">Running ESM-2 embeddings...</span>';
document.getElementById('results').innerHTML = '';
document.getElementById('pagination').className = 'pagination';
document.getElementById('resultsToolbar').className = 'results-toolbar';
try {
const taxon = (document.getElementById('taxonSelect')?.value) || 'auto';
const res = await fetch('/predict', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sequence: raw, taxon }),
});
if (!res.ok) throw new Error(`Server error ${res.status}`);
const data = await res.json();
if (data.error) throw new Error(data.error);
status.className = 'status-bar';
allResults = data.results || [];
currentPage = 1;
// Store sequences for saliency lookup (by global index)
_seqMap = {};
seqs.forEach((seq, i) => { _seqMap[i] = seq.residues; });
renderPage();
if (allResults.length > 0) {
document.getElementById('resultsToolbar').className = 'results-toolbar visible';
addToHistory(allResults, raw);
}
} catch(e) {
status.className = 'status-bar visible';
status.innerHTML = `<span class="status-error">${escHtml(e.message)}</span>`;
document.getElementById('results').innerHTML = `
<div style="border:1px solid var(--low-border);background:var(--low-bg);border-radius:10px;padding:20px 24px;display:flex;gap:14px;align-items:flex-start;margin-top:8px;">
<svg style="flex-shrink:0;width:20px;height:20px;color:var(--low-text);" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<div>
<div style="font-size:0.85rem;font-weight:600;color:var(--low-text);margin-bottom:4px;">Prediction failed</div>
<div style="font-size:0.8rem;color:var(--text-secondary);margin-bottom:12px;">${escHtml(e.message)}</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;">
<button onclick="predict()" style="padding:6px 14px;border-radius:6px;border:1px solid var(--low-border);background:var(--surface);color:var(--low-text);font-size:0.8rem;cursor:pointer;font-weight:600;">Retry</button>
<a href="https://github.com/SBhat2026/protfunc/issues/new" target="_blank" rel="noopener" style="padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:var(--surface);color:var(--text-secondary);font-size:0.8rem;text-decoration:none;display:inline-flex;align-items:center;">Report issue</a>
</div>
</div>
</div>`;
} finally {
btn.disabled = false;
btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>Predict Functions`;
}
}
// ── Pagination ────────────────────────────────────────────────────────────
function renderPage() {
const start = (currentPage - 1) * PAGE_SIZE;
const pageData = allResults.slice(start, start + PAGE_SIZE);
renderResults(pageData, start);
renderPagination();
}
function renderPagination() {
const total = Math.ceil(allResults.length / PAGE_SIZE);
const pag = document.getElementById('pagination');
if (total <= 1) { pag.className = 'pagination'; return; }
pag.className = 'pagination visible';
let html = `<button class="page-btn" onclick="goPage(${currentPage-1})" ${currentPage===1?'disabled':''}>Prev</button>`;
for (let i = 1; i <= total; i++) {
html += `<button class="page-btn ${i===currentPage?'active':''}" onclick="goPage(${i})">${i}</button>`;
}
html += `<button class="page-btn" onclick="goPage(${currentPage+1})" ${currentPage===total?'disabled':''}>Next</button>`;
html += `<span class="page-info">${allResults.length} sequences</span>`;
pag.innerHTML = html;
}
function goPage(p) {
const total = Math.ceil(allResults.length / PAGE_SIZE);
if (p < 1 || p > total) return;
currentPage = p;
renderPage();
window.scrollTo({ top: document.getElementById('results').offsetTop - 80, behavior: 'smooth' });
}
// ── Render Results ────────────────────────────────────────────────────────
function predRowHTML(p, extraClass = '') {
const cc2 = confClass(p.prob);
const barClass = confBarClass(p.prob);
return `<div class="pred-row ${cc2} ${extraClass}" data-name="${escHtml((p.name||'').toLowerCase())}" data-goid="${escHtml(p.go_id||'')}">
<div class="pred-main">
<span class="pred-name">${escHtml(p.name)}</span>
<span class="pred-goid"><a href="https://amigo.geneontology.org/amigo/term/${p.go_id}" target="_blank" rel="noopener">${escHtml(p.go_id)}</a></span>
${extraClass === 'suppressed' ? '<span class="suppressed-reason">Parent term not predicted</span>' : ''}
</div>
<div class="pred-right">
<div class="pred-conf">
<span class="pred-conf-label">${confLabel(p.prob)}</span>
<span class="pred-prob">${(p.prob*100).toFixed(1)}%</span>
</div>
<div class="prob-bar"><div class="prob-bar-fill ${barClass}" style="width:${Math.round(p.prob*100)}%"></div></div>
</div>
</div>`;
}
function renderResults(results, offset) {
const container = document.getElementById('results');
if (!results || results.length === 0) {
container.innerHTML = '<p class="no-preds">No results returned.</p>';
return;
}
container.innerHTML = results.map((r, idx) => {
const globalIdx = (offset || 0) + idx;
if (r.error) {
return `<div class="result-card error">
<div class="result-header">
<div class="result-header-left">
<span class="seq-name">${escHtml(r.name || `Sequence ${globalIdx+1}`)}</span>
</div>
</div>
<div class="error-message">${escHtml(r.error)}</div>
</div>`;
}
const preds = r.predictions || [];
const suppressed = r.suppressed || [];
const seqName = r.name || `Sequence ${globalIdx+1}`;
// Sort: confidence band (highβ‰₯0.75, medβ‰₯0.55, low<0.55) then alphabetical within band
const _band = p => p.prob >= 0.75 ? 0 : p.prob >= 0.55 ? 1 : 2;
const _confSort = (a, b) => {
const bd = _band(a) - _band(b);
return bd !== 0 ? bd : a.name.localeCompare(b.name);
};
const directPreds = preds.filter(p => !p.implied).sort(_confSort);
const impliedPreds = preds.filter(p => p.implied).sort(_confSort);
const highCount = directPreds.filter(p => p.prob >= 0.75).length;
const medCount = directPreds.filter(p => p.prob >= 0.55 && p.prob < 0.75).length;
const lowCount = directPreds.filter(p => p.prob < 0.55).length;
// Main display: direct predictions sorted by specificity (most specific first)
const mainHTML = directPreds.length === 0
? '<p class="no-preds">No molecular function predicted β€” lower the threshold or see broader terms below.</p>'
: directPreds.map(p => predRowHTML(p)).join('');
// Implied ancestors collapsed (broader MF context)
const impliedHTML = impliedPreds.length === 0 ? '' : `
<button class="lower-conf-toggle" aria-expanded="false" onclick="toggleLowerConf(this)">
<span class="toggle-icon">&#9654;</span>
${impliedPreds.length} broader MF term${impliedPreds.length !== 1 ? 's' : ''}
<span class="lower-conf-detail">implied ancestors</span>
</button>
<div class="lower-conf-list">
${impliedPreds.map(p => predRowHTML(p)).join('')}
</div>`;
const suppressedHTML = suppressed.length === 0 ? '' : `
<button class="suppressed-toggle" aria-expanded="false" onclick="toggleSuppressed(this)">
<span class="toggle-icon">&#9654;</span>
${suppressed.length} suppressed prediction${suppressed.length > 1 ? 's' : ''}
<span class="suppressed-tooltip">parent not predicted</span>
</button>
<div class="suppressed-list">
${suppressed.map(p => predRowHTML(p, 'suppressed')).join('')}
</div>`;
const uid = (r.uniprot?.accession) || extractUniProtId(seqName) || null;
const totalPreds = highCount + medCount + lowCount;
// ── Taxon badge ──────────────────────────────────────────────────────
const taxon = r.taxon_applied || 'auto';
const txSrc = r.taxon_source || 'manual';
const txConf = r.taxon_confidence || 1.0;
const txIcon = taxon === 'insect' ? 'πŸͺ²' : '🐭';
const txLabel = taxon === 'insect' ? 'Insect' : 'Mammal';
const txSrcLabel = txSrc === 'uniprot' ? 'UniProt taxonomy'
: txSrc === 'probe' ? `probe ${(txConf*100).toFixed(0)}%`
: txSrc === 'composition' ? `composition ${(txConf*100).toFixed(0)}%`
: 'manual';
const txBadge = `<span class="taxon-badge taxon-${taxon}" title="Detected via ${txSrcLabel}">${txIcon} ${txLabel}</span>`;
// ── UniProt annotation comparison ────────────────────────────────────
let uniprotCompareHTML = '';
if (r.uniprot && r.uniprot.go_mf_known && r.uniprot.go_mf_known.length > 0) {
const knownIds = new Set(r.uniprot.go_mf_known.map(x => x.go_id));
const predIds = new Set(preds.map(x => x.go_id));
const tp = r.uniprot.go_mf_known.filter(x => predIds.has(x.go_id));
const fn = r.uniprot.go_mf_known.filter(x => !predIds.has(x.go_id));
const ev_icons = {"experimental":"βœ“","computational":"~","electronic":"⚑","other":"?"};
const knownRows = r.uniprot.go_mf_known.map(x => {
const hit = predIds.has(x.go_id);
const cls = hit ? 'uniprot-tp' : 'uniprot-fn';
const lbl = hit ? 'predicted' : 'missed';
return `<div class="uniprot-row ${cls}">
<span class="uniprot-ev" title="${x.ev_tier}">${ev_icons[x.ev_tier]||'?'}</span>
<span class="uniprot-name">${escHtml(x.name)}</span>
<span class="uniprot-go">${escHtml(x.go_id)}</span>
<span class="uniprot-lbl">${lbl}</span>
</div>`;
}).join('');
const org = r.uniprot.organism ? `<span style="color:var(--text-muted);font-size:0.68rem;"> β€” ${escHtml(r.uniprot.organism)}</span>` : '';
uniprotCompareHTML = `
<div class="uniprot-compare">
<div class="uniprot-compare-header">
UniProt known GO-MF${org}
<span class="uniprot-stats">
<span class="tp-count">${tp.length}/${r.uniprot.go_mf_known.length} predicted</span>
${fn.length ? `<span class="fn-count">${fn.length} missed</span>` : ''}
</span>
</div>
<div class="uniprot-rows">${knownRows}</div>
<div class="uniprot-legend">
<span>βœ“ experimental</span><span>~ computational</span><span>⚑ electronic</span>
</div>
</div>`;
}
return `
<div class="result-card" style="animation-delay:${idx*60}ms" data-card-idx="${globalIdx}">
<div class="result-header">
<div class="result-header-left">
<span class="seq-name">${escHtml(seqName)}</span>
<span class="seq-len">${r.sequence_length || '?'} aa</span>
${txBadge}
</div>
<div class="result-stats">
${highCount ? `<span class="stat-chip high">${highCount} high</span>` : ''}
${medCount ? `<span class="stat-chip med">${medCount} med</span>` : ''}
${lowCount ? `<span class="stat-chip low">${lowCount} low</span>` : ''}
</div>
</div>
<div class="card-tabs" role="tablist">
<button class="card-tab active" data-tab="predictions" onclick="switchCardTab(${globalIdx}, 'predictions')">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
Predictions <span class="tab-badge">${totalPreds}</span>
</button>
<button class="card-tab" data-tab="explain" onclick="switchCardTab(${globalIdx}, 'explain', '${uid || ''}')">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>
Saliency
</button>
<button class="card-tab" data-tab="structure" onclick="switchCardTab(${globalIdx}, 'structure')">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
3D Structure
</button>
<button class="card-tab" data-tab="why" onclick="switchCardTab(${globalIdx}, 'why')">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
Why?
</button>
</div>
<div class="tab-content active" data-panel="predictions">
${uniprotCompareHTML}
<div class="pred-list">${mainHTML}</div>
${impliedHTML}
${suppressedHTML}
</div>
<div class="tab-content" data-panel="explain">
<div class="structure-panel open" id="saliency-panel-${globalIdx}"></div>
</div>
<div class="tab-content" data-panel="structure">
<div class="structure-panel open" id="structure-panel-${globalIdx}"></div>
</div>
<div class="tab-content" data-panel="why">
<div class="structure-panel open" id="why-panel-${globalIdx}"></div>
</div>
</div>`;
}).join('');
applyFilters();
}
// Card tab switching β€” lazy-loads saliency/structure on first view
function switchCardTab(idx, tab, uid) {
const card = document.querySelector(`.result-card[data-card-idx="${idx}"]`);
if (!card) return;
card.querySelectorAll('.card-tab').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tab);
});
card.querySelectorAll('.tab-content').forEach(c => {
c.classList.toggle('active', c.dataset.panel === tab);
});
if (tab === 'explain') {
const panel = document.getElementById(`saliency-panel-${idx}`);
if (panel && panel.innerHTML.trim() === '') loadSaliencyPanel(idx, uid || '');
} else if (tab === 'structure') {
const panel = document.getElementById(`structure-panel-${idx}`);
if (panel && panel.innerHTML.trim() === '') loadStructurePanel(idx);
} else if (tab === 'why') {
const panel = document.getElementById(`why-panel-${idx}`);
if (panel && panel.innerHTML.trim() === '') loadWhyPanel(idx);
}
}
function toggleSuppressed(btn) {
const expanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!expanded));
btn.nextElementSibling.classList.toggle('open', !expanded);
}
function toggleLowerConf(btn) {
const expanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!expanded));
btn.nextElementSibling.classList.toggle('open', !expanded);
}
// ── Filters ───────────────────────────────────────────────────────────────
let showMed = true;
let showLow = true;
function applyFilters() {
const query = (document.getElementById('termFilter').value || '').toLowerCase().trim();
document.querySelectorAll('.pred-row').forEach(row => {
const name = row.dataset.name || '';
const goid = row.dataset.goid || '';
const isMed = row.classList.contains('conf-med');
const isLow = row.classList.contains('conf-low');
const hiddenByConf = (isMed && !showMed) || (isLow && !showLow);
const hiddenByFilter = query && !name.includes(query) && !goid.includes(query);
row.classList.toggle('hidden-med', isMed && !showMed);
row.classList.toggle('hidden-low', isLow && !showLow);
row.classList.toggle('filtered-out', !hiddenByConf && hiddenByFilter);
});
}
function toggleMed() {
showMed = !showMed;
const btn = document.getElementById('toggleMedBtn');
btn.textContent = showMed ? 'Hide Medium' : 'Show Medium';
btn.classList.toggle('active', !showMed);
applyFilters();
}
function toggleLow() {
showLow = !showLow;
const btn = document.getElementById('toggleLowBtn');
btn.textContent = showLow ? 'Hide Low' : 'Show Low';
btn.classList.toggle('active', showLow);
applyFilters();
}
// ── Downloads ─────────────────────────────────────────────────────────────
function downloadTSV() {
if (!allResults.length) return;
const rows = ['Protein\tLength\tGO_ID\tFunction\tConfidence'];
for (const r of allResults) {
if (r.error) continue;
for (const p of (r.predictions || [])) {
rows.push([r.name, r.sequence_length, p.go_id, p.name, (p.prob*100).toFixed(1)+'%'].join('\t'));
}
}
triggerDownload(rows.join('\n'), 'protfunc_results.tsv', 'text/tab-separated-values');
}
function downloadJSON() {
if (!allResults.length) return;
triggerDownload(JSON.stringify(allResults, null, 2), 'protfunc_results.json', 'application/json');
}
function triggerDownload(content, filename, mime) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
// ── History ───────────────────────────────────────────────────────────────
const HISTORY_KEY = 'protfunc_history';
const HISTORY_MAX = 10;
function loadHistory() {
try { return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; }
catch { return []; }
}
function saveHistory(h) { localStorage.setItem(HISTORY_KEY, JSON.stringify(h.slice(0, HISTORY_MAX))); }
function addToHistory(results, inputSeq) {
const h = loadHistory();
const name = results[0]?.name || 'Unknown';
h.unshift({ name, ts: Date.now(), results, inputSeq });
saveHistory(h);
renderHistory();
}
function renderHistory() {
const h = loadHistory();
const list = document.getElementById('historyList');
if (h.length === 0) {
list.innerHTML = '<div class="history-empty">No recent predictions</div>';
return;
}
list.innerHTML = h.slice(0, 5).map((entry, i) => {
const time = new Date(entry.ts).toLocaleString(undefined, { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' });
return `<div class="history-item" onclick="restoreHistory(${i})">
<span class="history-item-name">${escHtml(entry.name)}</span>
<span class="history-item-time">${time}</span>
</div>`;
}).join('');
}
function restoreHistory(i) {
const h = loadHistory();
if (!h[i]) return;
allResults = h[i].results;
currentPage = 1;
ta.value = h[i].inputSeq || '';
const seq = ta.value.split('\n').filter(l => !l.trimStart().startsWith('>')).join('').replace(/[^A-Za-z]/g, '');
cc.textContent = seq.length ? `${seq.length.toLocaleString()} aa` : '0 aa';
document.getElementById('resultsToolbar').className = 'results-toolbar visible';
renderPage();
}
// ── Demo sequences ────────────────────────────────────────────────────────
const DEMOS = {
hippo: `>Drosophila_Hippo_kinase
MSEPEVTSVVDMKSPNISSSCSFFKLKKLSEESLLQPPEKVFDIMYKLGEGSYGSVYKAVHKESSSIVAIKLVPVESDLHEIIKEISIMQQCDSPYVVRYYGSYFKQYDLWICMEYCGAGSVSDIMRLRKKTLTEDEIATILSDTLQGLVYLHLRRKIHRDIKAANILLNTEGYAKLADFGVAGQLTDTMAKRNTVIGTPFWMAPEVIEEIGYDCVADIWSLGITALEMAEGKPPYGEIHPMRAIFMIPQKPPPSFREPDRWSTEFIDFVSKCLVKEPDDRATATELLEHEFIRNAKHRSILKPMLEETCAIREQQRANRSFGGVLAASQAKSLATQENGMQQHITDNAFMEDPGTLVPEKFGEYQQSSASDATMIAHAEQGVDEGTLGPGGLRNLSKAAAPAAASSAASPLDMPAVDSGTMVELESNLGTMVINSDSDDSTTAKNNDDQKPRNRYRPQFLEHFDRKNAGDGRGDEKPIATEYSPAAAEQQQQQQQQQQQQDEQHLASGANDLNNWEHNMEMQFQQISAINQYGLQQHQQQQQVLMAYPLMNEQLIALNNQPNLLLSNAAPMGQQGIPAAAPAQPPPAYQNQHMHTQSHAYVEGEFEFLKFLTFDDLNQRLCNIDHEMELEIEQLNKKYNAKRQPIVDAMNAKRKRQQNINNNLIKI`,
opsin: `>Drosophila_Opsin_Rh6
MASLHPPSFAYMRDGRNLSLAESVPAEIMHMVDPYWYQWPPLEPMWFGIIGFVIAILGTMSLAGNFIVMYIFTSSKGLRTPSNMFVVNLAFSDFMMMFTMFPPVVLNGFYGTWIMGPFLCELYGMFGSLFGCVSIWSMTLIAYDRYCVIVKGMARKPLTATAAVLRLMVVWTICGAWALMPLFGWNRYVPEGNMTACGTDYFAKDWWNRSYIIVYSLWVYLTPLLTIIFSYWHIMKAVAAHEKAMREQAKKMNVASLRNSEADKSKAIEIKLAKVALTTISLWFFAWTPYTIINYAGIFESMHLSPLSTICGSVFAKANAVCNPIVYGLSHPKYKQVLREKMPCLACGKDDLTSDSRTQATAEISESQA`,
human: `>Human_P53_Tumor_Suppressor
MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD`,
mouse_tp53: `>Mouse_P53_Trp53
MTAMEESQSDISLELPLSQETFSGLWKLLPPEDILPSPHCMDDLLLPQDVEEFFEGPSEALRVSGAPAAQDPVTETPGPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYPQGLUGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSEGSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGKLLGRDSFEVRVCACPGRDRRTEEENHRKKGQVLKEIREGQRLSQQHYREVAAAKDREAEDSREKPEPGEPPGEGGEDPDGHRMFPEHLLGDVPSSNTMAIFQSMEDSSGSLLNDSSPTQVPASQKTYQGLSPYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAK`,
zebrafish_tp53: `>Danio_rerio_tp53
MVVRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGKLLGRNSFEVRVCACPGRDKRTEEENLRKKGEPVHGQWLDSPRTFQQNLNKFPQPPKTCPDLIRWNPEEDPGPDEAPRMPEAAPPVAPASAPTPAAPVPAPSWPLSSSVPSQKTYPQGLDGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGKLL`
};
function loadDemo(key) {
const seq = DEMOS[key];
if (!seq) return;
ta.value = seq;
const aa = seq.split('\n').filter(l => !l.trimStart().startsWith('>')).join('').replace(/[^A-Za-z]/g, '');
cc.textContent = aa.length ? `${aa.length.toLocaleString()} aa` : '0 aa';
ta.focus();
}
function shake() {
const card = document.querySelector('.card');
card.classList.add('shake');
card.addEventListener('animationend', () => card.classList.remove('shake'), { once: true });
}
// ── Batch Upload ──────────────────────────────────────────────────────────
const dropZone = document.getElementById('dropZone');
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file) processFile(file);
});
function handleFileUpload(event) {
const file = event.target.files[0];
if (file) processFile(file);
}
async function processFile(file) {
const status = document.getElementById('batchStatus');
const results = document.getElementById('batchResults');
status.className = 'status-bar visible';
status.innerHTML = '<div class="spinner"></div><span class="status-text">Reading file...</span>';
results.innerHTML = '';
try {
const text = await file.text();
const seqs = parseSequences(text);
if (seqs.length === 0) {
status.innerHTML = '<span class="status-error">No valid sequences found in file.</span>';
return;
}
if (seqs.length > 100) {
status.innerHTML = '<span class="status-error">Maximum 100 sequences per batch. Found ' + seqs.length + '.</span>';
return;
}
status.innerHTML = `<div class="spinner"></div><span class="status-text">Processing ${seqs.length} sequences...</span>`;
const res = await fetch('/predict', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sequence: text }),
});
if (!res.ok) throw new Error(`Server error ${res.status}`);
const data = await res.json();
status.className = 'status-bar';
const batchResults = data.results || [];
renderResults(batchResults, 0);
} catch(e) {
status.innerHTML = `<span class="status-error">${escHtml(e.message)}</span>`;
}
}
// ── 3Dmol.js Saliency Viewer ─────────────────────────────────────────────
let tdmolLoaded = false;
function load3Dmol() {
if (tdmolLoaded) return Promise.resolve();
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/3dmol@2.0.3/build/3Dmol-min.js';
s.onload = () => { tdmolLoaded = true; resolve(); };
s.onerror = reject;
document.head.appendChild(s);
});
}
function saliencyColor(score) {
// score in [0,1]; blue (low) β†’ white (mid) β†’ red (high)
const r = score > 0.5 ? 1.0 : 2 * score;
const b = score < 0.5 ? 1.0 : 2 * (1 - score);
const g = score > 0.5 ? 2 * (1 - score) : 2 * score;
const toHex = v => Math.round(v * 255).toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
function saliencyLegendHTML() {
const steps = 10;
let grad = '';
for (let i = 0; i <= steps; i++) {
grad += `<span style="display:inline-block;width:${100/(steps+1)}%;height:10px;background:${saliencyColor(i/steps)};"></span>`;
}
return `<div style="margin-top:8px; font-size:0.72rem; color:var(--text-muted);">
<div style="display:flex; width:100%; border-radius:3px; overflow:hidden;">${grad}</div>
<div style="display:flex; justify-content:space-between; margin-top:2px;"><span>Low importance</span><span>High importance</span></div>
</div>`;
}
// Map [0,1] feature value to blue-white-red hex color
function featureColor(v) {
// 0 = blue (#3b82f6), 0.5 = white (#e5e7eb), 1 = red (#ef4444)
const r = v < 0.5 ? Math.round(59 + (229 - 59) * (v * 2)) : Math.round(229 + (239 - 229) * ((v - 0.5) * 2));
const g = v < 0.5 ? Math.round(130 + (231 - 130) * (v * 2)) : Math.round(231 + (68 - 231) * ((v - 0.5) * 2));
const b = v < 0.5 ? Math.round(246 + (235 - 246) * (v * 2)) : Math.round(235 + (68 - 235) * ((v - 0.5) * 2));
return `rgb(${r},${g},${b})`;
}
function renderExplainBars(idx, features, selectedKey) {
const maxAbs = Math.max(...features.map(f => f.abs_importance), 1e-9);
return features.map(f => {
const pct = Math.round((f.abs_importance / maxAbs) * 100);
const sign = f.importance >= 0 ? '+' : 'βˆ’';
const col = f.importance >= 0 ? '#4ade80' : '#f87171';
const sel = f.key === selectedKey ? ' selected' : '';
return `<div class="feat-bar-row${sel}" onclick="selectExplainFeature(${idx},'${f.key}')" title="${escHtml(f.desc)}">
<span class="feat-bar-label">${escHtml(f.label)}</span>
<div class="feat-bar-track">
<div class="feat-bar-fill" style="width:${pct}%;background:${col};"></div>
</div>
<span class="feat-bar-val" style="color:${col}">${sign}${f.abs_importance.toFixed(3)}</span>
</div>`;
}).join('');
}
function selectExplainFeature(idx, featureKey) {
const data = _explainData[idx];
if (!data) return;
// Update bar selection highlight
const panel = document.getElementById(`saliency-panel-${idx}`);
if (panel) {
panel.querySelectorAll('.feat-bar-row').forEach(el => el.classList.remove('selected'));
const sel = panel.querySelector(`.feat-bar-row[onclick*="'${featureKey}'"]`);
if (sel) sel.classList.add('selected');
}
const feat = data.features.find(f => f.key === featureKey);
if (!feat) return;
// Update feature description text
const descEl = document.getElementById(`explain-desc-${idx}`);
if (descEl) {
descEl.textContent = feat.desc;
descEl.style.color = feat.color;
}
// Recolor 3D viewer
const viewer = _explainViewers[idx];
if (viewer && feat.per_residue) {
feat.per_residue.forEach((v, i) => {
viewer.setStyle({ resi: i + 1 }, { cartoon: { color: featureColor(v), opacity: 0.95 } });
});
viewer.render();
}
// Update sequence heatmap
const seqBar = document.getElementById(`explain-seqbar-${idx}`);
const seq = _seqMap[idx] || '';
if (seqBar && feat.per_residue) {
const L = feat.per_residue.length;
seqBar.innerHTML = feat.per_residue.map((v, i) => {
const aa = seq[i] || '?';
return `<span title="${aa}${i+1}: ${v.toFixed(3)}" style="display:inline-block;width:${Math.max(4,Math.min(16,560/L))}px;height:16px;background:${featureColor(v)};margin:0 0.5px;border-radius:1px;"></span>`;
}).join('');
}
}
async function loadWhyPanel(idx) {
const panel = document.getElementById(`why-panel-${idx}`);
if (!panel) return;
panel.innerHTML = '<div style="padding:12px;color:var(--label2);font-size:0.82rem">Loading definitions…</div>';
const card = document.querySelector(`.result-card[data-card-idx="${idx}"]`);
if (!card) { panel.innerHTML = ''; return; }
const rows = card.querySelectorAll('.pred-row');
const ids = [...new Set([...rows].map(r => r.dataset.goid).filter(Boolean))].slice(0, 30);
if (!ids.length) { panel.innerHTML = '<div style="padding:12px;color:var(--label2);font-size:0.82rem">No terms to explain.</div>'; return; }
try {
const resp = await fetch(`/api/explain_terms?ids=${ids.join(',')}`);
const data = await resp.json();
const terms = (data.terms || []).filter(t => t.definition);
if (!terms.length) {
panel.innerHTML = '<div style="padding:12px;color:var(--label2);font-size:0.82rem">No definitions available in the ontology.</div>';
return;
}
panel.innerHTML = `
<div style="padding:10px 14px">
<p style="font-size:0.75rem;color:var(--label2);margin:0 0 10px">GO-MF term definitions from go-basic.obo. Showing ${terms.length} of ${ids.length} predicted terms.</p>
${terms.map(t => `
<div style="margin-bottom:10px;padding:8px 10px;border-radius:6px;background:var(--surface-2)">
<div style="font-size:0.78rem;font-weight:600;color:var(--accent);margin-bottom:3px">${escHtml(t.name)}</div>
<div style="font-size:0.72rem;color:var(--label2);line-height:1.4">${escHtml(t.definition)}</div>
<div style="font-size:0.68rem;color:var(--label3);margin-top:3px">${escHtml(t.id)}</div>
</div>`).join('')}
</div>`;
} catch(e) {
panel.innerHTML = `<div style="padding:12px;color:var(--label2);font-size:0.82rem">Could not load definitions.</div>`;
}
}
async function loadSaliencyPanel(idx, uid) {
const panel = document.getElementById(`saliency-panel-${idx}`);
if (!panel) return;
if (panel.innerHTML.trim() !== '') return;
const seq = _seqMap[idx] || '';
if (!seq) {
panel.innerHTML = '<p class="structure-not-found">Sequence not available.</p>';
return;
}
const taxon = document.getElementById('taxonSelect')?.value || 'auto';
panel.innerHTML = '<p class="structure-loading"><span style="opacity:0.6">Computing feature importance… (may take 3–10s)</span></p>';
try {
const resp = await fetch('/api/explainability', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sequence: seq, uniprot_id: uid || '', taxon }),
});
const d = await resp.json();
if (d.error) {
panel.innerHTML = `<p class="structure-not-found">Error: ${escHtml(d.error)}</p>`;
return;
}
_explainData[idx] = d;
const topFeat = d.features[0];
const hasStruct = d.structure && d.structure.found && d.structure.cif_url;
const structLinks = hasStruct
? `&nbsp;Β·&nbsp;<a href="${escHtml(d.structure.entry_url)}" target="_blank" rel="noopener" style="color:var(--accent-blue);">AlphaFold</a>
&nbsp;Β·&nbsp;<a href="${escHtml(d.structure.uniprot_url)}" target="_blank" rel="noopener" style="color:var(--accent-blue);">UniProt</a>`
: (uid ? `<span style="color:var(--text-muted); font-size:0.7rem;"> (no AF structure for ${escHtml(uid)})</span>` : '');
const viewerHtml = hasStruct
? `<div id="explain-viewer-${idx}" style="width:100%;height:340px;border-radius:8px;background:#0d0d1a;position:relative;"></div>`
: `<div style="padding:24px;text-align:center;color:var(--text-muted);font-size:0.8rem;">
Enter a UniProt accession above to see feature importance on the 3D structure.
</div>`;
panel.innerHTML = `
<div class="explain-wrap">
<div class="explain-left">
<div class="explain-title">Feature Importance</div>
<div style="font-size:0.68rem;color:var(--text-muted);margin-bottom:8px;">Gradient Γ— input attribution<br>Click to color structure</div>
${renderExplainBars(idx, d.features, topFeat?.key)}
</div>
<div class="explain-right">
<div style="font-size:0.72rem;color:var(--text-secondary);margin-bottom:6px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
<span id="explain-desc-${idx}" style="color:${escHtml(topFeat?.color||'#888')}">${escHtml(topFeat?.desc||'')}</span>
${structLinks}
</div>
<div id="explain-seqbar-${idx}" style="overflow-x:auto;padding:2px 0;margin-bottom:8px;"></div>
<div style="display:flex;gap:8px;font-size:0.65rem;color:var(--text-muted);margin-bottom:8px;align-items:center;">
<span style="display:inline-block;width:32px;height:7px;border-radius:3px;background:linear-gradient(to right,#3b82f6,#e5e7eb,#ef4444);"></span>
<span>Low β†’ neutral β†’ high</span>
</div>
${viewerHtml}
</div>
</div>`;
// Render initial sequence bar for top feature
if (topFeat) selectExplainFeature(idx, topFeat.key);
// Load 3Dmol viewer and color by top feature
if (hasStruct) {
await load3Dmol();
const el = document.getElementById(`explain-viewer-${idx}`);
if (el && window.$3Dmol) {
const viewer = window.$3Dmol.createViewer(el, { backgroundColor: '#0d0d1a' });
_explainViewers[idx] = viewer;
fetch(d.structure.cif_url)
.then(r => r.text())
.then(cifText => {
viewer.addModel(cifText, 'mmcif');
viewer.setStyle({}, { cartoon: { color: '#888888', opacity: 0.9 } });
viewer.zoomTo();
viewer.render();
// Color by top feature
if (topFeat) selectExplainFeature(idx, topFeat.key);
el.title = 'Double-click to spin / stop';
let spinning = false;
el.addEventListener('dblclick', () => {
spinning = !spinning;
if (spinning) viewer.spin('y'); else viewer.spin(false);
});
})
.catch(() => {
el.innerHTML = '<p style="color:#aaa;padding:16px;text-align:center;">CIF fetch failed.</p>';
});
}
}
} catch(e) {
panel.innerHTML = `<p class="structure-not-found">Failed: ${escHtml(e.message)}</p>`;
}
}
// ── AlphaFold Structure Viewer ────────────────────────────────────────────
function extractUniProtId(name) {
// Matches: sp|P04637|..., tr|Q9Y5Z9|..., bare P04637, or >P04637 style
const m = name.match(/(?:sp|tr)\|([A-Z0-9]{4,10})\|/i)
|| name.match(/\b([OPQ][0-9][A-Z][A-Z0-9]{2}[0-9](?:[A-Z][A-Z0-9]{2}[0-9])?)\b/)
|| name.match(/\b([A-NR-Z][0-9][A-Z][A-Z0-9]{2}[0-9](?:[A-Z][A-Z0-9]{2}[0-9])?)\b/);
return m ? m[1].toUpperCase() : null;
}
// pLDDT β†’ color: blue (>90), cyan (70-90), yellow (50-70), orange (<50) β€” AlphaFold convention
function plddt_color(plddt) {
if (plddt >= 90) return '#0053D6';
if (plddt >= 70) return '#65CBF3';
if (plddt >= 50) return '#FFDB13';
return '#FF7D45';
}
async function loadStructurePanel(idx) {
const panel = document.getElementById(`structure-panel-${idx}`);
if (!panel) return;
if (panel.innerHTML.trim() === '') {
// Read accession from field or FASTA header (seqName not easily available here, use field)
const uid = document.getElementById('uniprotAccession').value.trim().toUpperCase();
if (!uid) {
panel.innerHTML = `<p class="structure-not-found">Enter a UniProt accession in the field above (e.g. <strong>P04637</strong>) then click Structure.</p>`;
return;
}
panel.innerHTML = `<p class="structure-loading"><span style="opacity:0.6">Loading AlphaFold structure for ${escHtml(uid)}…</span></p>`;
try {
const resp = await fetch(`/api/structure?uniprot_id=${encodeURIComponent(uid)}`);
const d = await resp.json();
if (!d.found) {
panel.innerHTML = `<p class="structure-not-found">No AlphaFold structure found for <strong>${escHtml(uid)}</strong>.`
+ (d.uniprot_url ? ` <a href="${d.uniprot_url}" target="_blank" rel="noopener">View on UniProt</a>` : '')
+ `</p>`;
return;
}
const meta = [
d.gene ? `<strong>Gene:</strong> ${escHtml(d.gene)}` : '',
d.organism ? `<strong>Organism:</strong> <em>${escHtml(d.organism)}</em>` : '',
`<a href="${d.entry_url}" target="_blank" rel="noopener" style="color:var(--accent-blue)">AlphaFold</a>`,
`<a href="${d.uniprot_url}" target="_blank" rel="noopener" style="color:var(--accent-blue)">UniProt</a>`,
].filter(Boolean).join(' &nbsp;Β·&nbsp; ');
panel.innerHTML = `
<div class="structure-meta">${meta}</div>
<div style="font-size:0.72rem;color:var(--text-muted);margin-bottom:4px;display:flex;gap:12px;flex-wrap:wrap;">
<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#0053D6;margin-right:3px;"></span>pLDDT &ge;90 (very high)</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#65CBF3;margin-right:3px;"></span>70–90 (confident)</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#FFDB13;margin-right:3px;"></span>50–70 (low)</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:#FF7D45;margin-right:3px;"></span>&lt;50 (very low)</span>
</div>
<div id="struct3d-${idx}" style="width:100%;height:400px;border-radius:8px;background:#0d0d1a;position:relative;"></div>`;
await load3Dmol();
const el = document.getElementById(`struct3d-${idx}`);
if (!el || !window.$3Dmol) return;
const viewer = window.$3Dmol.createViewer(el, { backgroundColor: '#0d0d1a' });
fetch(d.cif_url)
.then(r => r.text())
.then(cifText => {
viewer.addModel(cifText, 'mmcif');
// Color cartoon by B-factor (AlphaFold stores pLDDT in B-factor column)
viewer.setStyle({}, {
cartoon: {
colorfunc: atom => plddt_color(atom.b || 0),
opacity: 1.0,
}
});
viewer.zoomTo();
viewer.render();
// Add mouse spin toggle on double-click
el.title = 'Double-click to spin / stop';
let spinning = false;
el.addEventListener('dblclick', () => {
spinning = !spinning;
if (spinning) viewer.spin('y'); else viewer.spin(false);
});
})
.catch(() => {
el.innerHTML = '<p style="color:#aaa;padding:16px;text-align:center;">CIF fetch failed β€” try again or check network.</p>';
});
} catch(e) {
panel.innerHTML = `<p class="structure-not-found">Failed: ${escHtml(e.message)}</p>`;
}
}
}
// ── Generalization Panel ──────────────────────────────────────────────────
function renderGenTable(data) {
const container = document.getElementById('genTableContainer');
if (!data.available || data.taxa.length === 0) {
container.innerHTML = `<p style="color: var(--text-secondary); font-size: 0.875rem;">
No generalization data yet β€” run eval_generalization.py after model training completes.
</p>`;
return;
}
const cols = ['taxon', 'model_checkpoint', 'n_labeled', 'micro_fmax', 'cafa_fmax',
'precision', 'recall', 'macro_f1', 'micro_auprc', 'label_coverage', 'generalization_ratio'];
const labels = ['Taxon', 'Checkpoint', 'n_labeled', 'micro_Fmax', 'CAFA_Fmax',
'Prec', 'Recall', 'macro_F1', 'AUPRC', 'label_cov', 'gen_ratio'];
const genRatioColor = (v) => {
if (v == null || isNaN(v)) return 'var(--text-secondary)';
if (v >= 0.90) return 'var(--high-text, #4ade80)';
if (v >= 0.85) return 'var(--med-text, #facc15)';
return 'var(--low-text, #f87171)';
};
let rows = data.taxa.map(taxon => {
const r = data.results[taxon];
return `<tr>
${cols.map((c, i) => {
const v = c === 'taxon' ? taxon : r[c];
const fmt = (typeof v === 'number') ? v.toFixed(4) : (v ?? 'β€”');
const style = c === 'generalization_ratio'
? `style="font-weight:600; color:${genRatioColor(v)}"` : '';
return `<td ${style}>${fmt}</td>`;
}).join('')}
</tr>`;
}).join('');
container.innerHTML = `
<table style="width:100%; border-collapse:collapse; font-size:0.8rem;">
<thead>
<tr style="border-bottom: 1px solid var(--border);">
${labels.map(l => `<th style="text-align:left; padding:8px 10px; color:var(--text-secondary); white-space:nowrap;">${l}</th>`).join('')}
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
<p style="color:var(--text-secondary); font-size:0.75rem; margin-top:12px;">
Last evaluated: ${data.taxa.map(t => data.results[t].evaluated_at).filter(Boolean).sort().pop() ?? 'β€”'}
</p>`;
}
// Load generalization data whenever the tab is opened
document.querySelectorAll('.nav-item[data-view]').forEach(btn => {
if (btn.dataset.view === 'generalization') {
btn.addEventListener('click', () => {
fetch('/api/generalization').then(r => r.json()).then(renderGenTable).catch(() => {
document.getElementById('genTableContainer').innerHTML =
'<p style="color:var(--text-secondary);">Failed to load generalization data.</p>';
});
});
}
});
// ── Init ──────────────────────────────────────────────────────────────────
// Fetch model version and update badge
fetch('/api/model/info').then(r => r.json()).then(d => {
const badge = document.getElementById('modelBadge');
const footerLabel = document.getElementById('modelFooterLabel');
if (!badge) return;
if (d.model === 'unified_35M_v1' || d.model === 'unified_v1' || d.model?.includes('unified')) {
badge.textContent = 'v5.0';
badge.style.background = 'rgba(99,102,241,0.12)';
badge.style.color = 'var(--accent)';
if (footerLabel) footerLabel.textContent = 'ESM-2 35M Β· Multi-taxon';
} else if (d.model === 'mammal_enriched' || d.model?.includes('multitaxon')) {
badge.textContent = 'v4.0';
badge.style.background = 'rgba(59,130,246,0.15)';
badge.style.color = 'var(--accent-blue)';
if (footerLabel) footerLabel.textContent = 'ESM-2 8M Β· Multi-taxon';
} else if (d.model === 'protfunc_v3_fixed') {
badge.textContent = 'v3.1';
} else if (d.model === 'protfunc_v3') {
badge.textContent = 'v3';
} else if (d.model === 'improved') {
badge.textContent = 'v2+';
} else {
badge.textContent = 'v1';
}
}).catch(() => {});
renderHistory();
</script>
</body>
</html>