| <!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 { |
| |
| --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); |
| |
| --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; |
| |
| --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; } |
| |
| |
| #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 { |
| 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 { 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; } |
| |
| |
| .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; } |
| |
| |
| .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 { |
| 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 { |
| 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; } |
| |
| |
| .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; |
| } |
| |
| |
| .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; } |
| |
| |
| .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-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; } |
| |
| |
| .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-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 { |
| 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 { |
| 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-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-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); } |
| |
| |
| .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); } |
| |
| |
| @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> |
|
|
| |
| <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> |
| |
|
|
| |
| <div id="pf-app" hidden> |
| <div class="app-layout"> |
| |
| <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 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"> |
| |
| <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 MKTIIALSYIFCLVFA... Paste your FASTA sequence above. 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> |
|
|
| |
| <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> |
|
|
| |
| <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> < 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> |
|
|
| |
| <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 (<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> |
| |
|
|
| <script src="/static/home.js"></script> |
| <script> |
| |
| const ta = document.getElementById('sequenceInput'); |
| const cc = document.getElementById('charCount'); |
| let allResults = []; |
| let currentPage = 1; |
| const PAGE_SIZE = 10; |
| let _seqMap = {}; |
| let _explainData = {}; |
| let _explainViewers = {}; |
| |
| |
| 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); |
| |
| |
| function toggleSidebar() { |
| document.getElementById('sidebar').classList.toggle('open'); |
| document.getElementById('sidebarOverlay').classList.toggle('visible'); |
| } |
| |
| |
| 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; |
| |
| |
| document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); |
| btn.classList.add('active'); |
| |
| |
| Object.keys(views).forEach(v => { |
| document.getElementById(views[v].el).style.display = v === view ? 'block' : 'none'; |
| }); |
| |
| |
| document.getElementById('pageTitle').textContent = views[view].title; |
| |
| |
| if (window.innerWidth <= 1024) toggleSidebar(); |
| }); |
| }); |
| |
| |
| 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(); }); |
| |
| |
| 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); } |
| |
| |
| 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; |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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; |
| |
| _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`; |
| } |
| } |
| |
| |
| 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' }); |
| } |
| |
| |
| 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}`; |
| |
| |
| 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; |
| |
| |
| 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(''); |
| |
| |
| const impliedHTML = impliedPreds.length === 0 ? '' : ` |
| <button class="lower-conf-toggle" aria-expanded="false" onclick="toggleLowerConf(this)"> |
| <span class="toggle-icon">▶</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">▶</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; |
| |
| |
| 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>`; |
| |
| |
| 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(); |
| } |
| |
| |
| 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); |
| } |
| |
| |
| 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(); |
| } |
| |
| |
| 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); |
| } |
| |
| |
| 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(); |
| } |
| |
| |
| 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 }); |
| } |
| |
| |
| 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>`; |
| } |
| } |
| |
| |
| 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) { |
| |
| 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>`; |
| } |
| |
| |
| function featureColor(v) { |
| |
| 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; |
| |
| |
| 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; |
| |
| |
| const descEl = document.getElementById(`explain-desc-${idx}`); |
| if (descEl) { |
| descEl.textContent = feat.desc; |
| descEl.style.color = feat.color; |
| } |
| |
| |
| 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(); |
| } |
| |
| |
| 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 |
| ? ` Β· <a href="${escHtml(d.structure.entry_url)}" target="_blank" rel="noopener" style="color:var(--accent-blue);">AlphaFold</a> |
| Β· <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>`; |
| |
| |
| if (topFeat) selectExplainFeature(idx, topFeat.key); |
| |
| |
| 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(); |
| |
| 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>`; |
| } |
| } |
| |
| |
| function extractUniProtId(name) { |
| |
| 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; |
| } |
| |
| |
| 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() === '') { |
| |
| 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(' Β· '); |
| |
| 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 ≥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><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'); |
| |
| viewer.setStyle({}, { |
| cartoon: { |
| colorfunc: atom => plddt_color(atom.b || 0), |
| opacity: 1.0, |
| } |
| }); |
| viewer.zoomTo(); |
| viewer.render(); |
| |
| 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>`; |
| } |
| } |
| } |
| |
| |
| 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>`; |
| } |
| |
| |
| 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>'; |
| }); |
| }); |
| } |
| }); |
| |
| |
| |
| 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> |
|
|