edohollou commited on
Commit
06045f9
·
1 Parent(s): 65046c1

first commit

Browse files
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM nginx:alpine
2
+
3
+ # Copy application files
4
+ COPY index.html /usr/share/nginx/html/index.html
5
+ COPY nginx.conf /etc/nginx/conf.d/default.conf
6
+ COPY data/ /usr/share/nginx/html/data/
7
+ COPY structures/ /usr/share/nginx/html/structures/
8
+
9
+ # Ensure nginx can read all assets
10
+ RUN chmod -R 644 /usr/share/nginx/html/data/ \
11
+ && chmod -R 644 /usr/share/nginx/html/structures/ \
12
+ && chmod 755 /usr/share/nginx/html/data/ \
13
+ && chmod 755 /usr/share/nginx/html/structures/
14
+
15
+ EXPOSE 80
16
+
17
+ CMD ["nginx", "-g", "daemon off;"]
README.md CHANGED
@@ -1,11 +1,79 @@
1
- ---
2
- title: Mutvar
3
- emoji: 😻
4
- colorFrom: yellow
5
- colorTo: green
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MutVar — Protein Mutation Impact Viewer
2
+
3
+ A single-page scientific tool for exploring predicted pathogenicity of missense variants across three human proteins. Integrates AlphaMissense, ESM1b, and structure-based ΔΔG scores with an interactive 3D molecular viewer.
4
+
5
+ ## Quick start
6
+
7
+ ```bash
8
+ docker build -t mutation-impact .
9
+ docker run -p 8080:80 mutation-impact
10
+ ```
11
+
12
+ Open [http://localhost:8080](http://localhost:8080). The dataset (32 k variants) loads client-side in ~1 s over a local Docker network.
13
+
14
+ ## Features
15
+
16
+ | Feature | Details |
17
+ |---|---|
18
+ | **Multi-score consensus** | AlphaMissense (Cheng et al., *Science* 2023), ESM1b LLR, and structural ΔΔG are each classified independently; ≥ 2/3 pathogenic → *High Risk*, 1/3 → *Medium*, 0/3 → *Low Risk* |
19
+ | **Score gauges** | Inline bar visualizations with AM thresholds at 0.340 / 0.564, bidirectional bars for ESM1b LLR and ΔΔG |
20
+ | **AM landscape chart** | Canvas lollipop chart of per-position max AM score along the full sequence — click any position to filter the table |
21
+ | **Score correlation** | AM vs ESM1b LLR scatter plot; dots coloured by consensus risk; click any dot to select the corresponding variant |
22
+ | **3D structure viewer** | Mol\* (RCSB) embedded from CDN; AlphaFold2 structure loaded per protein; mutation residue highlighted in ball-and-stick red |
23
+ | **Mechanistic labels** | Stability / Interface / Pockets / Unassigned — filter chips + colour badges |
24
+ | **Protein stats** | Per-protein pathogenicity distribution bar + mechanism breakdown |
25
+ | **UniProt integration** | Gene name, protein function, and sequence length fetched live from UniProt REST API |
26
+ | **Virtual scroll** | O(1) DOM size regardless of result set (handles 16 k-row Q8IUR5 at 60 fps) |
27
+ | **Keyboard navigation** | ↑ / ↓ arrows in the table; Ctrl+Enter to search |
28
+ | **CSV export** | Exports the current filtered / sorted view |
29
+ | **URL sharing** | `#q=Q7Z4H8+A126C,P12235+A114D` — copy shareable link to clipboard |
30
+
31
+ ## Querying
32
+
33
+ ```
34
+ # Single variant (one-letter or three-letter AA codes accepted)
35
+ Q7Z4H8 A126C
36
+ P12235 Ala114Asp
37
+
38
+ # Multiple variants (one per line)
39
+ Q7Z4H8 A126C
40
+ Q8IUR5 A322K
41
+ P12235 A114D
42
+
43
+ # All variants for a protein
44
+ Q8IUR5
45
+ ```
46
+
47
+ Available proteins: `P12235` (SLC25A4/ANT1), `Q7Z4H8` (LRRC32/GARP), `Q8IUR5` (LRRC15).
48
+
49
+ ## Dataset
50
+
51
+ `data/missense_human_subset.tsv` — 32,054 missense variants with columns:
52
+
53
+ | Column | Description |
54
+ |---|---|
55
+ | `am_pathogenicity` | AlphaMissense score [0, 1] |
56
+ | `am_class` | `benign` / `ambiguous` / `pathogenic` |
57
+ | `ESM1b_LLR` | Log-likelihood ratio from ESM1b language model |
58
+ | `ESM1b_is_pathogenic` | `pathogenic` / `benign` |
59
+ | `pred_ddg` | Predicted ΔΔG (kcal/mol) from structure-based model |
60
+ | `pred_ddg_label` | `True` = destabilising (> threshold) |
61
+ | `mechanistic_label` | Stability / Interface / Pockets / Unassigned |
62
+ | `interface_pdockq` | pDockQ score for interface variants |
63
+
64
+ ## Architecture
65
+
66
+ Pure vanilla HTML/CSS/JS — no build step, no framework. All assets are bundled into a single `index.html` served by nginx:alpine. The TSV is parsed once at startup into two `Map` objects (`byVariant`, `byProtein`) for O(1) lookups. External dependencies loaded from CDN:
67
+
68
+ - **Mol\*** (`molstar@4`) — 3D molecular viewer
69
+ - **Google Fonts** — IBM Plex Mono, Source Serif 4
70
+
71
+ ## Scoring thresholds
72
+
73
+ | Score | Benign | Ambiguous | Pathogenic |
74
+ |---|---|---|---|
75
+ | AlphaMissense | < 0.340 | 0.340–0.564 | > 0.564 |
76
+ | ESM1b LLR | ≥ 0 | — | < 0 |
77
+ | ΔΔG | ≤ threshold | — | > threshold |
78
+
79
+ References: Cheng et al. (2023) *Science* 381, eadg7492; Meier et al. (2021) *bioRxiv*.
data/missense_human_subset.tsv ADDED
The diff for this file is too large to render. See raw diff
 
index.html ADDED
@@ -0,0 +1,1434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>MutVar — Protein Mutation Impact</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com"/>
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
9
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&family=Inter:wght@400;500;600&display=swap" rel="stylesheet"/>
10
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/molstar@4/build/viewer/molstar.css"/>
11
+ <style>
12
+ /* ── Reset ────────────────────────────────────────────────────── */
13
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
14
+ html { font-size: 14px; -webkit-font-smoothing: antialiased; }
15
+
16
+ :root {
17
+ --font-mono: 'IBM Plex Mono', 'Fira Mono', monospace;
18
+ --font-serif: 'Source Serif 4', Georgia, serif;
19
+ --font-sans: 'Inter', system-ui, sans-serif;
20
+ --bg: #fafaf8;
21
+ --bg-surface: #f3f2ee;
22
+ --bg-inset: #eceae5;
23
+ --border: #d8d5ce;
24
+ --border-light: #e8e5df;
25
+ --text: #1a1917;
26
+ --text-2: #4a4845;
27
+ --text-3: #7a776f;
28
+ --red: #c0392b;
29
+ --red-bg: #fdf0ee;
30
+ --red-border: #f0c5c0;
31
+ --green: #1f6b40;
32
+ --green-bg: #edf6f1;
33
+ --green-border: #b4dcc7;
34
+ --amber: #a65c00;
35
+ --amber-bg: #fdf3e8;
36
+ --amber-border: #f0d5a8;
37
+ --mech-stability: #7b3f00;
38
+ --mech-interface: #1a4a6b;
39
+ --mech-pocket: #4a1a6b;
40
+ --mech-unassigned: #5a6475;
41
+ --accent: #2563eb;
42
+ --gauge-bg: #e8e5df;
43
+ --shadow-sm: 0 1px 3px rgba(0,0,0,.07);
44
+ }
45
+ body { font-family: var(--font-sans); background: var(--bg); color: var(--text); min-height: 100vh; }
46
+
47
+ /* ── Views ────────────────────────────────────────────────────── */
48
+ #view-search { display: none; }
49
+ #view-results { display: none; }
50
+ body[data-view="search"] #view-search { display: block; }
51
+ body[data-view="results"] #view-results { display: flex; flex-direction: column; height: calc(100vh - 48px); overflow: hidden; }
52
+
53
+ /* ── Header ───────────────────────────────────────────────────── */
54
+ #app-header {
55
+ height: 48px; border-bottom: 1px solid var(--border);
56
+ background: var(--bg); display: flex; align-items: center;
57
+ padding: 0 24px; gap: 20px; position: sticky; top: 0; z-index: 100;
58
+ }
59
+ .header-logo { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--text); }
60
+ .header-logo-mark {
61
+ width: 22px; height: 22px; background: var(--text);
62
+ clip-path: polygon(50% 0%,61% 35%,98% 35%,68% 57%,79% 91%,50% 70%,21% 91%,32% 57%,2% 35%,39% 35%);
63
+ }
64
+ .header-logo-name { font-family: var(--font-mono); font-size: 15px; font-weight: 500; letter-spacing: -0.02em; }
65
+ .header-logo-name span { color: var(--text-3); font-weight: 400; }
66
+ .header-nav { display: flex; gap: 2px; margin-left: auto; }
67
+ .header-nav a {
68
+ font-size: 13px; color: var(--text-2); text-decoration: none;
69
+ padding: 4px 10px; border-radius: 4px; transition: color .15s, background .15s;
70
+ }
71
+ .header-nav a:hover { color: var(--text); background: var(--bg-surface); }
72
+ .header-back {
73
+ font-size: 12px; font-family: var(--font-mono);
74
+ color: var(--text-3); background: none; border: none;
75
+ cursor: pointer; padding: 4px 8px; border-radius: 4px; display: none; align-items: center; gap: 4px;
76
+ }
77
+ .header-back:hover { background: var(--bg-surface); color: var(--text-2); }
78
+ body[data-view="results"] .header-back { display: flex; }
79
+
80
+ /* ── Search View ──────────────────────────────────────────────── */
81
+ #view-search { max-width: 640px; margin: 0 auto; padding: 64px 24px 48px; }
82
+ .search-hero h1 { font-family: var(--font-serif); font-size: 30px; font-weight: 600; line-height: 1.25; margin-bottom: 8px; }
83
+ .search-hero p { font-size: 14px; color: var(--text-3); line-height: 1.6; margin-bottom: 32px; }
84
+ .search-hero p a { color: var(--accent); text-decoration: none; }
85
+ .search-hero p a:hover { text-decoration: underline; }
86
+ .search-form-label { display: block; font-size: 12px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--text-2); margin-bottom: 8px; }
87
+ #query-input {
88
+ width: 100%; min-height: 120px; padding: 12px 14px;
89
+ font-family: var(--font-mono); font-size: 13px; line-height: 1.7;
90
+ background: white; border: 1px solid var(--border); border-radius: 6px;
91
+ resize: vertical; color: var(--text); outline: none;
92
+ transition: border-color .15s, box-shadow .15s;
93
+ }
94
+ #query-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
95
+ #query-input::placeholder { color: var(--text-3); }
96
+ .search-format-hint { margin-top: 8px; font-size: 12px; color: var(--text-3); line-height: 1.6; }
97
+ .search-format-hint code { font-family: var(--font-mono); background: var(--bg-inset); padding: 1px 5px; border-radius: 3px; color: var(--text-2); }
98
+ .search-examples { margin-top: 16px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
99
+ .search-examples-label { font-size: 11px; color: var(--text-3); font-weight: 600; text-transform: uppercase; letter-spacing: .05em; margin-right: 2px; }
100
+ .example-chip { font-family: var(--font-mono); font-size: 11.5px; padding: 4px 10px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 100px; cursor: pointer; color: var(--text-2); transition: all .15s; }
101
+ .example-chip:hover { background: var(--text); border-color: var(--text); color: white; }
102
+ .search-actions { margin-top: 20px; display: flex; align-items: center; gap: 12px; }
103
+ #btn-analyze { padding: 9px 20px; background: var(--text); color: white; border: none; border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; transition: opacity .15s; }
104
+ #btn-analyze:hover { opacity: .85; }
105
+ #btn-analyze:disabled { opacity: .4; cursor: not-allowed; }
106
+ #tsv-status { font-size: 12px; font-family: var(--font-mono); color: var(--text-3); }
107
+ #search-errors { margin-top: 16px; display: none; }
108
+ .error-item { font-size: 12.5px; font-family: var(--font-mono); color: var(--red); padding: 6px 10px; background: var(--red-bg); border: 1px solid var(--red-border); border-radius: 4px; margin-bottom: 4px; }
109
+
110
+ /* ── Results Layout ───────────────────────────────────────────── */
111
+ #results-body { display: grid; grid-template-columns: 1fr 400px; flex: 1; overflow: hidden; }
112
+ #panel-left { display: flex; flex-direction: column; overflow: hidden; border-right: 1px solid var(--border); }
113
+ #panel-right { display: flex; flex-direction: column; overflow-y: auto; background: var(--bg); }
114
+
115
+ /* ── Position Landscape Chart ─────────────────────────────────── */
116
+ #position-chart {
117
+ flex-shrink: 0;
118
+ display: none;
119
+ background: var(--bg-surface);
120
+ border-bottom: 1px solid var(--border);
121
+ cursor: crosshair;
122
+ position: relative;
123
+ overflow: hidden;
124
+ }
125
+ #position-chart canvas { display: block; }
126
+ #position-chart .chart-title {
127
+ position: absolute; top: 5px; right: 8px;
128
+ font-family: var(--font-mono); font-size: 9px;
129
+ color: var(--text-3); pointer-events: none;
130
+ }
131
+ #position-filter-badge {
132
+ display: none;
133
+ padding: 3px 12px;
134
+ background: #eff6ff;
135
+ border-bottom: 1px solid #bfdbfe;
136
+ font-size: 11px; font-family: var(--font-mono);
137
+ color: var(--accent);
138
+ flex-shrink: 0;
139
+ }
140
+ #position-filter-badge button {
141
+ background: none; border: none; color: var(--accent);
142
+ cursor: pointer; font-size: 11px; font-family: inherit;
143
+ margin-left: 6px; text-decoration: underline; padding: 0;
144
+ }
145
+
146
+ /* ── Table Toolbar ────────────────────────────────────────────── */
147
+ #table-toolbar {
148
+ padding: 8px 12px;
149
+ border-bottom: 1px solid var(--border);
150
+ display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
151
+ background: var(--bg); flex-shrink: 0;
152
+ }
153
+ .filter-chips { display: flex; gap: 4px; flex-wrap: wrap; }
154
+ .chip {
155
+ font-size: 11px; font-weight: 500; padding: 3px 9px;
156
+ border-radius: 100px; border: 1px solid var(--border);
157
+ background: var(--bg); color: var(--text-2); cursor: pointer;
158
+ transition: all .12s; white-space: nowrap;
159
+ }
160
+ .chip:hover { background: var(--bg-surface); }
161
+ .chip.active { background: var(--text); color: white; border-color: var(--text); }
162
+ .toolbar-right { margin-left: auto; display: flex; align-items: center; gap: 6px; }
163
+ #mutation-filter {
164
+ font-family: var(--font-mono); font-size: 11px;
165
+ padding: 3px 8px; border: 1px solid var(--border);
166
+ border-radius: 4px; background: var(--bg); color: var(--text);
167
+ outline: none; width: 130px; transition: border-color .15s, box-shadow .15s;
168
+ }
169
+ #mutation-filter:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(37,99,235,.08); }
170
+ #mutation-filter::placeholder { color: var(--text-3); }
171
+ #results-count { font-family: var(--font-mono); font-size: 11px; color: var(--text-3); white-space: nowrap; }
172
+ #btn-share, #btn-export {
173
+ font-size: 11.5px; font-weight: 500; padding: 3px 9px;
174
+ background: var(--bg-surface); border: 1px solid var(--border);
175
+ border-radius: 4px; cursor: pointer; color: var(--text-2);
176
+ transition: all .12s; white-space: nowrap;
177
+ }
178
+ #btn-share:hover, #btn-export:hover { background: var(--bg-inset); color: var(--text); }
179
+ #share-done { display: none; font-size: 10px; font-family: var(--font-mono); color: var(--green); }
180
+
181
+ /* ── Results table ─────────────────────────────────────────��──── */
182
+ #results-header { padding: 10px 16px 0; flex-shrink: 0; }
183
+ .results-title { font-family: var(--font-serif); font-size: 17px; font-weight: 600; color: var(--text); margin-bottom: 2px; }
184
+ .results-subtitle { font-size: 11px; color: var(--text-3); font-family: var(--font-mono); }
185
+ #table-scroll { overflow: auto; flex: 1; outline: none; }
186
+ table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
187
+ thead th {
188
+ font-size: 10.5px; font-weight: 600; letter-spacing: .06em;
189
+ text-transform: uppercase; color: var(--text-3); background: var(--bg-inset);
190
+ padding: 7px 8px; border-bottom: 1px solid var(--border);
191
+ position: sticky; top: 0; cursor: pointer; user-select: none;
192
+ white-space: nowrap; z-index: 10;
193
+ }
194
+ thead th:hover { color: var(--text-2); }
195
+ thead th.sort-asc::after { content: ' ↑'; }
196
+ thead th.sort-desc::after { content: ' ↓'; }
197
+ thead th::after { opacity: 0.3; }
198
+ tbody tr { cursor: pointer; height: 34px; border-bottom: 1px solid var(--border-light); }
199
+ tbody tr:hover td { background: var(--bg-surface); }
200
+ tbody tr.selected td { background: #eff6ff; border-bottom-color: #bfdbfe; }
201
+ tbody tr.selected td:first-child { border-left: 2px solid var(--accent); }
202
+ tbody tr.vs-spacer td { pointer-events: none; border: none; padding: 0; }
203
+ td { padding: 4px 8px; vertical-align: middle; height: 34px; }
204
+ .cell-id { font-family: var(--font-mono); font-size: 12px; font-weight: 500; color: var(--text); white-space: nowrap; }
205
+ .cell-id .protein-id { color: var(--text-3); }
206
+
207
+ /* ── Score Gauges ─────────────────────────────────────────────── */
208
+ .gauge-wrap { display: flex; align-items: center; gap: 6px; min-width: 100px; }
209
+ .gauge-track { position: relative; flex: 1; height: 5px; background: var(--gauge-bg); border-radius: 3px; overflow: visible; }
210
+ .gauge-fill { position: absolute; left: 0; top: 0; height: 100%; border-radius: 3px; }
211
+ .gauge-tick { position: absolute; top: -3px; width: 1px; height: 11px; background: var(--text-3); opacity: 0.5; }
212
+ .gauge-center { position: absolute; top: -3px; width: 1.5px; height: 11px; background: var(--text-2); opacity: 0.5; transform: translateX(-50%); }
213
+ .gauge-val { font-family: var(--font-mono); font-size: 10.5px; color: var(--text-3); min-width: 36px; text-align: right; white-space: nowrap; }
214
+
215
+ /* ── Badges ───────────────────────────────────────────────────── */
216
+ .mech-badge { display: inline-flex; align-items: center; gap: 4px; font-family: var(--font-mono); font-size: 10.5px; padding: 2px 7px; border-radius: 3px; white-space: nowrap; }
217
+ .mech-stability { background: #f7ede0; color: var(--mech-stability); border: 1px solid #e8c9a0; }
218
+ .mech-interface { background: #e0eef7; color: var(--mech-interface); border: 1px solid #a0c9e8; }
219
+ .mech-pocket { background: #ede0f7; color: var(--mech-pocket); border: 1px solid #c9a0e8; }
220
+ .mech-unassigned { background: var(--bg-surface); color: var(--mech-unassigned); border: 1px solid var(--border); }
221
+ .consensus-badge { font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; padding: 2px 8px; border-radius: 100px; white-space: nowrap; }
222
+ .consensus-high { background: var(--red-bg); color: var(--red); border: 1px solid var(--red-border); }
223
+ .consensus-medium { background: var(--amber-bg); color: var(--amber); border: 1px solid var(--amber-border); }
224
+ .consensus-low { background: var(--green-bg); color: var(--green); border: 1px solid var(--green-border); }
225
+
226
+ /* ── Right Panel: Protein Info ────────────────────────────────── */
227
+ #protein-info { padding: 14px 16px; border-bottom: 1px solid var(--border); background: var(--bg); flex-shrink: 0; }
228
+ .pinfo-header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 5px; flex-wrap: wrap; }
229
+ .pinfo-id { font-family: var(--font-mono); font-size: 13px; font-weight: 500; }
230
+ .pinfo-name { font-family: var(--font-serif); font-size: 13px; color: var(--text-2); flex: 1; min-width: 0; }
231
+ .pinfo-link { font-size: 11px; color: var(--accent); text-decoration: none; white-space: nowrap; margin-left: auto; }
232
+ .pinfo-link:hover { text-decoration: underline; }
233
+ .pinfo-meta { display: flex; gap: 14px; font-size: 11.5px; color: var(--text-3); margin-bottom: 6px; flex-wrap: wrap; }
234
+ .pinfo-meta b { color: var(--text-2); font-weight: 500; }
235
+ .pinfo-function { font-size: 11.5px; color: var(--text-3); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: 8px; }
236
+ .pinfo-loading { font-size: 11.5px; color: var(--text-3); font-family: var(--font-mono); }
237
+ /* Stats bar */
238
+ .stats-bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; margin-bottom: 4px; background: var(--gauge-bg); }
239
+ .stats-seg { height: 100%; transition: width .3s; }
240
+ .stats-legend { display: flex; gap: 10px; font-size: 10px; color: var(--text-3); font-family: var(--font-mono); flex-wrap: wrap; }
241
+ .stats-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 3px; vertical-align: middle; }
242
+ .mech-chips { margin-top: 6px; display: flex; gap: 4px; flex-wrap: wrap; }
243
+ .mech-chip-mini { font-size: 10px; font-family: var(--font-mono); padding: 1px 6px; border-radius: 3px; color: var(--text-3); background: var(--bg-inset); }
244
+
245
+ /* ── Right Panel: Scatter Plot ───────────────────────────────── */
246
+ #scatter-section { border-bottom: 1px solid var(--border); flex-shrink: 0; display: none; }
247
+ #scatter-canvas { display: block; width: 100%; height: 130px; cursor: crosshair; }
248
+
249
+ /* ── Right Panel: Mol* Viewer ─────────────────────────────────── */
250
+ #viewer-section { flex-shrink: 0; position: relative; }
251
+ #mol-container { width: 100%; height: 320px; background: #111; position: relative; }
252
+ #viewer-controls { position: absolute; top: 8px; right: 8px; display: flex; flex-direction: column; gap: 4px; z-index: 20; }
253
+ .viewer-btn {
254
+ width: 28px; height: 28px; background: rgba(0,0,0,.45);
255
+ backdrop-filter: blur(4px); border: 1px solid rgba(255,255,255,.15);
256
+ border-radius: 4px; color: rgba(255,255,255,.8); font-size: 13px;
257
+ cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .12s;
258
+ }
259
+ .viewer-btn:hover { background: rgba(0,0,0,.7); color: white; }
260
+ #viewer-legend { position: absolute; bottom: 8px; left: 8px; background: rgba(0,0,0,.55); backdrop-filter: blur(4px); border: 1px solid rgba(255,255,255,.1); border-radius: 4px; padding: 6px 8px; z-index: 20; }
261
+ .legend-item { display: flex; align-items: center; gap: 5px; font-size: 10px; color: rgba(255,255,255,.75); margin-bottom: 3px; }
262
+ .legend-item:last-child { margin-bottom: 0; }
263
+ .legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
264
+ #viewer-status { position: absolute; bottom: 8px; right: 8px; font-family: var(--font-mono); font-size: 10px; color: rgba(255,255,255,.5); z-index: 20; pointer-events: none; }
265
+ #viewer-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(17,17,17,.85); z-index: 30; pointer-events: none; opacity: 0; transition: opacity .2s; }
266
+ #viewer-loading.visible { opacity: 1; }
267
+ .spinner { width: 24px; height: 24px; border: 2px solid rgba(255,255,255,.2); border-top-color: rgba(255,255,255,.7); border-radius: 50%; animation: spin .7s linear infinite; }
268
+ @keyframes spin { to { transform: rotate(360deg); } }
269
+
270
+ /* ── Right Panel: Mutation Detail ─────────────────────────────── */
271
+ #detail-panel { padding: 14px 16px; border-top: 1px solid var(--border); flex: 1; background: var(--bg); }
272
+ #detail-panel.empty { display: flex; align-items: center; justify-content: center; }
273
+ .detail-empty-msg { font-size: 12px; color: var(--text-3); text-align: center; font-family: var(--font-mono); }
274
+ .detail-header { margin-bottom: 10px; }
275
+ .detail-variant { font-family: var(--font-mono); font-size: 14px; font-weight: 500; display: block; margin-bottom: 2px; }
276
+ .detail-description { font-family: var(--font-serif); font-style: italic; font-size: 13px; color: var(--text-2); }
277
+ .detail-scores { margin-bottom: 12px; }
278
+ .score-row { display: grid; grid-template-columns: 86px 1fr auto; align-items: center; gap: 8px; padding: 4px 0; border-bottom: 1px solid var(--border-light); }
279
+ .score-row:last-child { border-bottom: none; }
280
+ .score-name { font-size: 10.5px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: .04em; }
281
+ .score-verdict { font-family: var(--font-mono); font-size: 10.5px; font-weight: 500; white-space: nowrap; }
282
+ .score-verdict.pathogenic { color: var(--red); }
283
+ .score-verdict.benign { color: var(--green); }
284
+ .score-verdict.ambiguous { color: var(--amber); }
285
+ .detail-consensus { display: flex; align-items: flex-start; gap: 10px; padding: 9px 11px; border-radius: 5px; margin-bottom: 11px; }
286
+ .detail-consensus.high { background: var(--red-bg); border: 1px solid var(--red-border); }
287
+ .detail-consensus.medium { background: var(--amber-bg); border: 1px solid var(--amber-border); }
288
+ .detail-consensus.low { background: var(--green-bg); border: 1px solid var(--green-border); }
289
+ .consensus-icon { font-size: 18px; line-height: 1; flex-shrink: 0; }
290
+ .consensus-title { font-family: var(--font-mono); font-size: 12px; font-weight: 500; margin-bottom: 2px; }
291
+ .consensus-title.high { color: var(--red); }
292
+ .consensus-title.medium { color: var(--amber); }
293
+ .consensus-title.low { color: var(--green); }
294
+ .consensus-detail { font-size: 11.5px; color: var(--text-2); line-height: 1.5; }
295
+ .detail-links { display: flex; gap: 8px; flex-wrap: wrap; }
296
+ .detail-link { font-size: 11.5px; color: var(--accent); text-decoration: none; padding: 4px 10px; border: 1px solid #bfdbfe; background: #eff6ff; border-radius: 4px; transition: all .12s; }
297
+ .detail-link:hover { background: #dbeafe; }
298
+ </style>
299
+ </head>
300
+ <body data-view="search">
301
+
302
+ <!-- HEADER -->
303
+ <header id="app-header">
304
+ <a class="header-logo" href="#" id="logo-link">
305
+ <div class="header-logo-mark"></div>
306
+ <span class="header-logo-name">Mut<span>Var</span></span>
307
+ </a>
308
+ <button class="header-back" id="btn-back">← New search</button>
309
+ <nav class="header-nav">
310
+ <a href="https://www.biorxiv.org/content/10.1101/2024.05.29.596373v1" target="_blank" rel="noopener">Preprint</a>
311
+ <a href="https://www.ebi.ac.uk/ProtVar/" target="_blank" rel="noopener">ProtVar</a>
312
+ </nav>
313
+ </header>
314
+
315
+ <!-- ════════════════════════ SEARCH VIEW ════════════════════════ -->
316
+ <div id="view-search">
317
+ <div class="search-hero">
318
+ <h1>Protein Mutation<br/>Impact Explorer</h1>
319
+ <p>Query predicted pathogenicity for missense variants across three human proteins.
320
+ Scores from <a href="https://alphafold.ebi.ac.uk/" target="_blank">AlphaMissense</a>,
321
+ ESM1b, and structural ΔΔG are integrated into a mechanistic assessment.</p>
322
+ </div>
323
+
324
+ <label class="search-form-label" for="query-input">Mutations — one per line</label>
325
+ <textarea id="query-input" placeholder="Q7Z4H8 A126C&#10;P12235 A114D&#10;Q8IUR5"></textarea>
326
+
327
+ <p class="search-format-hint">
328
+ Format: <code>PROTEIN_ID MUTATION</code> &nbsp;·&nbsp;
329
+ accepts <code>A126C</code> or <code>Ala126Cys</code> &nbsp;·&nbsp;
330
+ protein-only returns all variants &nbsp;·&nbsp;
331
+ available: <code>P12235</code> <code>Q7Z4H8</code> <code>Q8IUR5</code>
332
+ </p>
333
+ <div class="search-examples">
334
+ <span class="search-examples-label">Try:</span>
335
+ <button class="example-chip" data-query="Q7Z4H8 A126C">Q7Z4H8 A126C</button>
336
+ <button class="example-chip" data-query="P12235 A114D">P12235 A114D</button>
337
+ <button class="example-chip" data-query="Q8IUR5 A322K">Q8IUR5 A322K</button>
338
+ <button class="example-chip" data-query="Q7Z4H8 A126C&#10;P12235 A114D&#10;Q8IUR5 A322K">All three</button>
339
+ <button class="example-chip" data-query="Q7Z4H8">All Q7Z4H8</button>
340
+ </div>
341
+ <div class="search-actions">
342
+ <button id="btn-analyze" disabled>Search</button>
343
+ <span id="tsv-status">Loading dataset…</span>
344
+ </div>
345
+ <div id="search-errors"></div>
346
+ </div>
347
+
348
+ <!-- ════════════════════════ RESULTS VIEW ════════════════════════ -->
349
+ <div id="view-results">
350
+ <div id="results-body">
351
+
352
+ <!-- LEFT panel -->
353
+ <div id="panel-left">
354
+ <div id="results-header">
355
+ <div class="results-title" id="results-title">Results</div>
356
+ <div class="results-subtitle" id="results-subtitle"></div>
357
+ </div>
358
+
359
+ <!-- Position landscape chart -->
360
+ <div id="position-chart">
361
+ <span class="chart-title">AlphaMissense landscape — click position to filter</span>
362
+ </div>
363
+ <!-- Position filter badge -->
364
+ <div id="position-filter-badge">
365
+ Filtered to position <strong id="pf-pos-label"></strong>
366
+ <button id="btn-clear-posfilter">clear ×</button>
367
+ </div>
368
+
369
+ <div id="table-toolbar">
370
+ <div class="filter-chips" id="filter-chips">
371
+ <button class="chip active" data-mech="all">All</button>
372
+ <button class="chip" data-mech="Stability">▲ Stability</button>
373
+ <button class="chip" data-mech="Interface">⬡ Interface</button>
374
+ <button class="chip" data-mech="Pockets">◉ Pockets</button>
375
+ <button class="chip" data-mech="Unassigned">· Unassigned</button>
376
+ </div>
377
+ <div class="toolbar-right">
378
+ <input id="mutation-filter" type="text" placeholder="Filter: A126…" autocomplete="off"/>
379
+ <span id="results-count"></span>
380
+ <button id="btn-share">⬡ Share</button>
381
+ <span id="share-done">Copied!</span>
382
+ <button id="btn-export">↓ CSV</button>
383
+ </div>
384
+ </div>
385
+
386
+ <div id="table-scroll" tabindex="0">
387
+ <table id="results-table">
388
+ <thead>
389
+ <tr>
390
+ <th data-col="mutCode">Variant</th>
391
+ <th data-col="amScore">AlphaMissense</th>
392
+ <th data-col="esmLLR">ESM1b LLR</th>
393
+ <th data-col="predDDG">ΔΔG</th>
394
+ <th data-col="mechLabel">Mechanism</th>
395
+ <th data-col="consensusScore">Consensus</th>
396
+ </tr>
397
+ </thead>
398
+ <tbody id="results-tbody"></tbody>
399
+ </table>
400
+ </div>
401
+ </div>
402
+
403
+ <!-- RIGHT panel -->
404
+ <div id="panel-right">
405
+ <div id="protein-info"><p class="pinfo-loading">Fetching protein information…</p></div>
406
+ <div id="scatter-section">
407
+ <canvas id="scatter-canvas"></canvas>
408
+ </div>
409
+ <div id="viewer-section">
410
+ <div id="mol-container"></div>
411
+ <div id="viewer-controls">
412
+ <button class="viewer-btn" id="btn-reset-view" title="Reset view">⌂</button>
413
+ <button class="viewer-btn" id="btn-toggle-spin" title="Toggle spin">↻</button>
414
+ <button class="viewer-btn" id="btn-toggle-surface" title="Toggle surface">◈</button>
415
+ </div>
416
+ <div id="viewer-legend">
417
+ <div class="legend-item"><div class="legend-dot" style="background:#e3002c"></div>Mutation site</div>
418
+ <div class="legend-item" id="legend-pocket" style="display:none"><div class="legend-dot" style="background:#00bcd4"></div>Pocket</div>
419
+ <div class="legend-item" id="legend-interface" style="display:none"><div class="legend-dot" style="background:#ffc107"></div>Interface</div>
420
+ </div>
421
+ <div id="viewer-status"></div>
422
+ <div id="viewer-loading"><div class="spinner"></div></div>
423
+ </div>
424
+ <div id="detail-panel" class="empty">
425
+ <p class="detail-empty-msg">← Select a variant to explore</p>
426
+ </div>
427
+ </div>
428
+ </div>
429
+ </div>
430
+
431
+ <script src="https://cdn.jsdelivr.net/npm/molstar@4/build/viewer/molstar.js"></script>
432
+ <script>
433
+ 'use strict';
434
+
435
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
436
+ // STATE
437
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
438
+ const App = {
439
+ byVariant: new Map(),
440
+ byProtein: new Map(),
441
+ currentRows: [],
442
+ filteredRows: [],
443
+ sortCol: 'amScore',
444
+ sortDir: 'desc',
445
+ filterMech: 'all',
446
+ filterText: '',
447
+ filterPosition: null,
448
+ selectedRow: null,
449
+ molViewer: null,
450
+ molLoadedProtein: null,
451
+ molSpinning: false,
452
+ uniprotCache: new Map(),
453
+ };
454
+
455
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
456
+ // AMINO ACIDS
457
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
458
+ const AA1 = { A:'Alanine',C:'Cysteine',D:'Aspartate',E:'Glutamate',F:'Phenylalanine',G:'Glycine',H:'Histidine',I:'Isoleucine',K:'Lysine',L:'Leucine',M:'Methionine',N:'Asparagine',P:'Proline',Q:'Glutamine',R:'Arginine',S:'Serine',T:'Threonine',V:'Valine',W:'Tryptophan',Y:'Tyrosine' };
459
+ const AA3 = { ALA:'A',ARG:'R',ASN:'N',ASP:'D',CYS:'C',GLN:'Q',GLU:'E',GLY:'G',HIS:'H',ILE:'I',LEU:'L',LYS:'K',MET:'M',PHE:'F',PRO:'P',SER:'S',THR:'T',TRP:'W',TYR:'Y',VAL:'V' };
460
+
461
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
462
+ // TSV LOADING
463
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
464
+ async function loadTSV() {
465
+ const resp = await fetch('data/missense_human_subset.tsv');
466
+ if (!resp.ok) throw new Error(`TSV ${resp.status}`);
467
+ const text = await resp.text();
468
+ const lines = text.split('\n');
469
+ const hdrs = lines[0].split('\t');
470
+ const C = {};
471
+ hdrs.forEach((h,i) => { C[h.trim()] = i; });
472
+
473
+ for (let i = 1; i < lines.length; i++) {
474
+ const l = lines[i]; if (!l.trim()) continue;
475
+ const f = l.split('\t');
476
+ const vid = f[C['variant_id']]; if (!vid) continue;
477
+ const slash = vid.indexOf('/');
478
+ const pid = vid.slice(0, slash);
479
+ const mut = vid.slice(slash + 1);
480
+ const amScore = parseFloat(f[C['am_pathogenicity']]) || 0;
481
+ const esmLLR = parseFloat(f[C['ESM1b_LLR']]) || 0;
482
+ const predDDG = parseFloat(f[C['pred_ddg']]) || 0;
483
+ const amLabel = f[C['am_label']] === 'True';
484
+ const esmPath = f[C['ESM1b_is_pathogenic']] === 'pathogenic';
485
+ const ddgLbl = f[C['pred_ddg_label']] === 'True';
486
+ const pathCount = (amLabel?1:0) + (esmPath?1:0) + (ddgLbl?1:0);
487
+ const row = {
488
+ variantId: vid, proteinId: pid, mutCode: mut, amScore,
489
+ amClass: f[C['am_class']], amLabel, esmLLR, esmPathogenic: esmPath,
490
+ predDDG, ddgLabel: ddgLbl,
491
+ interfacePdockq: f[C['interface_pdockq']] ? parseFloat(f[C['interface_pdockq']]) : null,
492
+ interfaceLabel: f[C['interface_label']] === 'True',
493
+ pocketLabel: f[C['pocket_label']] === 'True',
494
+ mechLabel: f[C['mechanistic_label']] || 'Unassigned',
495
+ pathCount, consensus: pathCount >= 2 ? 'High Risk' : pathCount === 1 ? 'Medium' : 'Low Risk',
496
+ };
497
+ App.byVariant.set(vid, row);
498
+ if (!App.byProtein.has(pid)) App.byProtein.set(pid, []);
499
+ App.byProtein.get(pid).push(row);
500
+ }
501
+ }
502
+
503
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
504
+ // INPUT PARSING
505
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━���━━━━━━━━━━━━━━━━━━━━━━━━
506
+ function parseLine(raw) {
507
+ const line = raw.trim(); if (!line) return null;
508
+ const parts = line.split(/\s+/);
509
+ const pid = parts[0].toUpperCase();
510
+ if (parts.length === 1) return { pid, mut: null };
511
+ let m = parts[1].toUpperCase().match(/^([A-Z])(\d+)([A-Z])$/);
512
+ if (m) return { pid, mut: `${m[1]}${m[2]}${m[3]}` };
513
+ m = parts[1].match(/^([A-Za-z]{3})(\d+)([A-Za-z]{3})$/);
514
+ if (m) {
515
+ const from = AA3[m[1].toUpperCase()], to = AA3[m[3].toUpperCase()];
516
+ if (from && to) return { pid, mut: `${from}${m[2]}${to}` };
517
+ }
518
+ return { error: `Invalid format: "${raw}" — expected e.g. Q7Z4H8 A126C` };
519
+ }
520
+
521
+ function executeSearch(rawInput) {
522
+ const rows = [], errors = [], seen = new Set();
523
+ for (const line of rawInput.split('\n')) {
524
+ if (!line.trim()) continue;
525
+ const parsed = parseLine(line);
526
+ if (!parsed) continue;
527
+ if (parsed.error) { errors.push(parsed.error); continue; }
528
+ if (parsed.mut === null) {
529
+ const protein = App.byProtein.get(parsed.pid);
530
+ if (!protein) errors.push(`Protein "${parsed.pid}" not found. Available: P12235, Q7Z4H8, Q8IUR5`);
531
+ else for (const r of protein) { if (!seen.has(r.variantId)) { seen.add(r.variantId); rows.push(r); } }
532
+ } else {
533
+ const key = `${parsed.pid}/${parsed.mut}`;
534
+ const row = App.byVariant.get(key);
535
+ if (!row) {
536
+ let hint = '';
537
+ const mutM = parsed.mut.match(/^([A-Z])(\d+)([A-Z])$/);
538
+ if (mutM) {
539
+ const protein = App.byProtein.get(parsed.pid);
540
+ if (protein) {
541
+ const atPos = protein.filter(r => r.mutCode.match(/^[A-Z](\d+)/)?.[1] === mutM[2]);
542
+ if (atPos.length > 0) hint = ` Position ${mutM[2]} reference is "${atPos[0].mutCode[0]}", not "${mutM[1]}". Try: ${parsed.pid} ${atPos[0].mutCode[0]}${mutM[2]}${mutM[3]}`;
543
+ else hint = ` Position ${mutM[2]} not in dataset for ${parsed.pid}.`;
544
+ }
545
+ }
546
+ errors.push(`Variant "${key}" not found.${hint}`);
547
+ } else if (!seen.has(row.variantId)) { seen.add(row.variantId); rows.push(row); }
548
+ }
549
+ }
550
+ return { rows, errors };
551
+ }
552
+
553
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
554
+ // GAUGE RENDERERS
555
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
556
+ function amColor(s) { return s > 0.564 ? 'var(--red)' : s < 0.340 ? 'var(--green)' : 'var(--amber)'; }
557
+
558
+ function amGauge(score) {
559
+ const col = amColor(score);
560
+ return `<div class="gauge-wrap"><div class="gauge-track">
561
+ <div class="gauge-fill" style="width:${(score*100).toFixed(1)}%;background:${col}"></div>
562
+ <div class="gauge-tick" style="left:34%"></div>
563
+ <div class="gauge-tick" style="left:56.4%"></div>
564
+ </div><span class="gauge-val">${score.toFixed(3)}</span></div>`;
565
+ }
566
+
567
+ function esmGauge(llr) {
568
+ const CPct = 25/35*100, c = Math.max(-25,Math.min(10,llr)), p = (c+25)/35*100;
569
+ const [l, w, col] = llr < 0 ? [p, CPct-p, 'var(--red)'] : [CPct, p-CPct, 'var(--green)'];
570
+ const sign = llr >= 0 ? '+' : '';
571
+ return `<div class="gauge-wrap"><div class="gauge-track">
572
+ <div class="gauge-fill" style="left:${l.toFixed(1)}%;width:${Math.max(0,w).toFixed(1)}%;background:${col}"></div>
573
+ <div class="gauge-center" style="left:${CPct.toFixed(1)}%"></div>
574
+ </div><span class="gauge-val" style="color:${col}">${sign}${llr.toFixed(2)}</span></div>`;
575
+ }
576
+
577
+ function ddgGauge(ddg) {
578
+ const CPct = 5/15*100, c = Math.max(-5,Math.min(10,ddg)), p = (c+5)/15*100;
579
+ const [l, w, col] = ddg < 0 ? [p, CPct-p, 'var(--green)'] : [CPct, p-CPct, 'var(--red)'];
580
+ const sign = ddg >= 0 ? '+' : '';
581
+ return `<div class="gauge-wrap"><div class="gauge-track">
582
+ <div class="gauge-fill" style="left:${l.toFixed(1)}%;width:${Math.max(0,w).toFixed(1)}%;background:${col}"></div>
583
+ <div class="gauge-center" style="left:${CPct.toFixed(1)}%"></div>
584
+ </div><span class="gauge-val" style="color:${col}">${sign}${ddg.toFixed(3)}</span></div>`;
585
+ }
586
+
587
+ const MECH = { Stability:{sym:'▲',cls:'mech-stability'}, Interface:{sym:'⬡',cls:'mech-interface'}, Pockets:{sym:'◉',cls:'mech-pocket'}, Unassigned:{sym:'·',cls:'mech-unassigned'} };
588
+ function mechBadge(label) { const m = MECH[label]||MECH.Unassigned; return `<span class="mech-badge ${m.cls}">${m.sym} ${label}</span>`; }
589
+ function consensusBadge(row) { const cls = row.pathCount>=2?'high':row.pathCount===1?'medium':'low'; return `<span class="consensus-badge consensus-${cls}">${row.consensus}</span>`; }
590
+
591
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
592
+ // POSITION LANDSCAPE CHART
593
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━��━━━━━━━━━━━━━━━━━━━━━
594
+ let chartPositions = []; // cached for hit detection
595
+
596
+ function renderPositionChart(rows, selectedPos) {
597
+ const el = document.getElementById('position-chart');
598
+
599
+ // Build per-position max AM score
600
+ const posMap = new Map();
601
+ for (const r of rows) {
602
+ const m = r.mutCode.match(/^[A-Z](\d+)[A-Z]$/);
603
+ if (!m) continue;
604
+ const pos = parseInt(m[1]);
605
+ const e = posMap.get(pos);
606
+ if (!e || r.amScore > e.maxAM) posMap.set(pos, { maxAM: r.amScore, variantId: r.variantId });
607
+ }
608
+
609
+ if (posMap.size < 5) { el.style.display = 'none'; return; }
610
+ el.style.display = 'block';
611
+
612
+ const positions = [...posMap.entries()].sort((a,b) => a[0]-b[0]);
613
+ const minPos = positions[0][0], maxPos = positions[positions.length-1][0];
614
+
615
+ // Setup canvas
616
+ let canvas = el.querySelector('canvas');
617
+ if (!canvas) {
618
+ canvas = document.createElement('canvas');
619
+ el.insertBefore(canvas, el.querySelector('.chart-title'));
620
+ canvas.addEventListener('click', onChartClick);
621
+ canvas.addEventListener('mousemove', onChartHover);
622
+ canvas.addEventListener('mouseleave', () => { canvas._tooltip && (canvas._tooltip.style.display = 'none'); });
623
+ }
624
+
625
+ const dpr = window.devicePixelRatio || 1;
626
+ const W = el.clientWidth || 600;
627
+ const H = 72;
628
+ el.style.height = H + 'px';
629
+ canvas.width = W * dpr; canvas.height = H * dpr;
630
+ canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
631
+
632
+ const ctx = canvas.getContext('2d');
633
+ ctx.scale(dpr, dpr);
634
+
635
+ const ML = 30, MR = 8, MT = 8, MB = 18;
636
+ const CW = W - ML - MR, CH = H - MT - MB;
637
+ const posRange = Math.max(1, maxPos - minPos);
638
+
639
+ function xS(pos) { return ML + (pos - minPos) / posRange * CW; }
640
+ function yS(v) { return MT + CH - v * CH; }
641
+
642
+ // Background
643
+ ctx.fillStyle = '#f3f2ee'; ctx.fillRect(0, 0, W, H);
644
+
645
+ // Threshold lines
646
+ ctx.setLineDash([3,3]);
647
+ ctx.lineWidth = 0.8;
648
+ ctx.strokeStyle = 'rgba(192,57,43,0.35)';
649
+ ctx.beginPath(); ctx.moveTo(ML, yS(0.564)); ctx.lineTo(W-MR, yS(0.564)); ctx.stroke();
650
+ ctx.strokeStyle = 'rgba(166,92,0,0.3)';
651
+ ctx.beginPath(); ctx.moveTo(ML, yS(0.340)); ctx.lineTo(W-MR, yS(0.340)); ctx.stroke();
652
+ ctx.setLineDash([]);
653
+
654
+ // Baseline
655
+ ctx.strokeStyle = '#d8d5ce'; ctx.lineWidth = 0.5;
656
+ ctx.beginPath(); ctx.moveTo(ML, yS(0)); ctx.lineTo(W-MR, yS(0)); ctx.stroke();
657
+
658
+ // Lollipops
659
+ chartPositions = [];
660
+ for (const [pos, data] of positions) {
661
+ const x = xS(pos), y = yS(data.maxAM), baseY = yS(0);
662
+ const col = data.maxAM > 0.564 ? '#c0392b' : data.maxAM < 0.340 ? '#1f6b40' : '#a65c00';
663
+ const isSel = pos === selectedPos;
664
+
665
+ // Stem
666
+ ctx.strokeStyle = col + (isSel ? 'ff' : '70');
667
+ ctx.lineWidth = isSel ? 1.5 : 0.8;
668
+ ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, baseY); ctx.stroke();
669
+
670
+ // Head
671
+ const r = isSel ? 3.5 : 2;
672
+ ctx.fillStyle = col + (isSel ? 'ff' : 'bb');
673
+ ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI*2); ctx.fill();
674
+ if (isSel) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke(); }
675
+
676
+ chartPositions.push({ pos, x, y, maxAM: data.maxAM });
677
+ }
678
+
679
+ // Y axis labels
680
+ ctx.font = `9px "IBM Plex Mono", monospace`;
681
+ ctx.textAlign = 'right'; ctx.fillStyle = '#7a776f';
682
+ ctx.fillText('1.0', ML-3, yS(1)+3);
683
+ ctx.fillStyle = 'rgba(192,57,43,0.8)';
684
+ ctx.fillText('0.56', ML-3, yS(0.564)+3);
685
+ ctx.fillStyle = 'rgba(166,92,0,0.8)';
686
+ ctx.fillText('0.34', ML-3, yS(0.340)+3);
687
+ ctx.fillStyle = '#7a776f'; ctx.textAlign = 'left';
688
+ ctx.fillText(minPos, ML, H-4);
689
+ ctx.textAlign = 'right';
690
+ ctx.fillText(maxPos, W-MR, H-4);
691
+ }
692
+
693
+ function findNearestChartPosition(mouseX) {
694
+ let nearest = null, minDist = Infinity;
695
+ for (const p of chartPositions) {
696
+ const d = Math.abs(p.x - mouseX);
697
+ if (d < minDist) { minDist = d; nearest = p; }
698
+ }
699
+ return minDist < 12 ? nearest : null;
700
+ }
701
+
702
+ function onChartClick(e) {
703
+ const rect = this.getBoundingClientRect();
704
+ const nearest = findNearestChartPosition(e.clientX - rect.left);
705
+ if (!nearest) return;
706
+ App.filterPosition = App.filterPosition === nearest.pos ? null : nearest.pos;
707
+ updatePositionFilterUI();
708
+ renderTable(App.currentRows);
709
+ renderPositionChart(App.filteredRows.length ? App.filteredRows : App.currentRows, App.filterPosition);
710
+ }
711
+
712
+ function onChartHover(e) {
713
+ const canvas = this;
714
+ const rect = canvas.getBoundingClientRect();
715
+ const nearest = findNearestChartPosition(e.clientX - rect.left);
716
+ canvas.style.cursor = nearest ? 'pointer' : 'crosshair';
717
+
718
+ // Simple tooltip via title attribute
719
+ if (nearest) {
720
+ const clsLabel = nearest.maxAM > 0.564 ? 'Pathogenic' : nearest.maxAM < 0.340 ? 'Benign' : 'Ambiguous';
721
+ canvas.title = `Position ${nearest.pos} — max AM: ${nearest.maxAM.toFixed(3)} (${clsLabel})`;
722
+ } else {
723
+ canvas.title = '';
724
+ }
725
+ }
726
+
727
+ function updatePositionFilterUI() {
728
+ const badge = document.getElementById('position-filter-badge');
729
+ if (App.filterPosition !== null) {
730
+ badge.style.display = 'flex';
731
+ document.getElementById('pf-pos-label').textContent = App.filterPosition;
732
+ } else {
733
+ badge.style.display = 'none';
734
+ }
735
+ }
736
+
737
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
738
+ // VIRTUAL SCROLL TABLE
739
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
740
+ const VS_H = 34; // row height px
741
+ const VS_BUF = 30; // rows above/below viewport
742
+
743
+ let vsRows = [];
744
+
745
+ function vsInit() {
746
+ const scroll = document.getElementById('table-scroll');
747
+ scroll.addEventListener('scroll', () => vsRender(scroll.scrollTop), { passive: true });
748
+ }
749
+
750
+ function vsUpdate(rows) {
751
+ vsRows = rows;
752
+ vsRender(document.getElementById('table-scroll').scrollTop);
753
+ document.getElementById('results-count').textContent = `${rows.length.toLocaleString()} variants`;
754
+ updateSortUI();
755
+ }
756
+
757
+ function vsRender(scrollTop) {
758
+ const scroll = document.getElementById('table-scroll');
759
+ const containerH = scroll.clientHeight || 500;
760
+ const total = vsRows.length;
761
+
762
+ if (!total) { document.getElementById('results-tbody').replaceChildren(); return; }
763
+
764
+ const firstVis = Math.floor(scrollTop / VS_H);
765
+ const lastVis = Math.ceil((scrollTop + containerH) / VS_H);
766
+ const first = Math.max(0, firstVis - VS_BUF);
767
+ const last = Math.min(total - 1, lastVis + VS_BUF);
768
+ const sel = App.selectedRow?.variantId;
769
+
770
+ const tbody = document.getElementById('results-tbody');
771
+ const frag = document.createDocumentFragment();
772
+
773
+ if (first > 0) {
774
+ const tr = document.createElement('tr');
775
+ tr.className = 'vs-spacer';
776
+ tr.innerHTML = `<td colspan="6" style="height:${first*VS_H}px"></td>`;
777
+ frag.appendChild(tr);
778
+ }
779
+
780
+ for (let i = first; i <= last; i++) {
781
+ const row = vsRows[i];
782
+ const tr = document.createElement('tr');
783
+ tr.dataset.vid = row.variantId;
784
+ if (row.variantId === sel) tr.classList.add('selected');
785
+ tr.innerHTML = `
786
+ <td class="cell-id"><span class="protein-id">${row.proteinId}&nbsp;/</span>&nbsp;${row.mutCode}</td>
787
+ <td>${amGauge(row.amScore)}</td>
788
+ <td>${esmGauge(row.esmLLR)}</td>
789
+ <td>${ddgGauge(row.predDDG)}</td>
790
+ <td>${mechBadge(row.mechLabel)}</td>
791
+ <td>${consensusBadge(row)}</td>`;
792
+ tr.addEventListener('click', () => selectRow(row));
793
+ frag.appendChild(tr);
794
+ }
795
+
796
+ if (last < total - 1) {
797
+ const tr = document.createElement('tr');
798
+ tr.className = 'vs-spacer';
799
+ tr.innerHTML = `<td colspan="6" style="height:${(total-1-last)*VS_H}px"></td>`;
800
+ frag.appendChild(tr);
801
+ }
802
+
803
+ tbody.replaceChildren(frag);
804
+ }
805
+
806
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
807
+ // TABLE FILTER + SORT
808
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
809
+ function applyFilterAndSort(rows) {
810
+ let out = rows;
811
+ if (App.filterMech !== 'all') out = out.filter(r => r.mechLabel === App.filterMech);
812
+ if (App.filterPosition !== null) out = out.filter(r => {
813
+ const m = r.mutCode.match(/^[A-Z](\d+)/);
814
+ return m && parseInt(m[1]) === App.filterPosition;
815
+ });
816
+ if (App.filterText) {
817
+ const q = App.filterText.toUpperCase();
818
+ out = out.filter(r => r.mutCode.includes(q) || r.variantId.includes(q));
819
+ }
820
+ const col = App.sortCol, dir = App.sortDir === 'asc' ? 1 : -1;
821
+ out = [...out].sort((a,b) => {
822
+ const av = a[col], bv = b[col];
823
+ return typeof av === 'string' ? dir * av.localeCompare(bv) : dir * (av - bv);
824
+ });
825
+ App.filteredRows = out;
826
+ return out;
827
+ }
828
+
829
+ function renderTable(rows) {
830
+ const visible = applyFilterAndSort(rows);
831
+ vsUpdate(visible);
832
+ }
833
+
834
+ function updateSortUI() {
835
+ document.querySelectorAll('#results-table th').forEach(th => {
836
+ th.classList.remove('sort-asc', 'sort-desc');
837
+ if (th.dataset.col === App.sortCol) th.classList.add(App.sortDir === 'asc' ? 'sort-asc' : 'sort-desc');
838
+ });
839
+ }
840
+
841
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
842
+ // ROW SELECTION & DETAIL
843
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
844
+ function selectRow(row) {
845
+ const old = document.querySelector('#results-tbody tr.selected');
846
+ if (old) old.classList.remove('selected');
847
+ App.selectedRow = row;
848
+ const tr = document.querySelector(`#results-tbody tr[data-vid="${row.variantId}"]`);
849
+ if (tr) { tr.classList.add('selected'); tr.scrollIntoView({ block: 'nearest' }); }
850
+ renderDetail(row);
851
+ drawScatter(App.currentRows, row);
852
+ loadAndHighlight(row.proteinId, row.mutCode);
853
+ document.getElementById('legend-pocket').style.display = row.pocketLabel ? 'flex' : 'none';
854
+ document.getElementById('legend-interface').style.display = row.interfaceLabel ? 'flex' : 'none';
855
+ }
856
+
857
+ function mutDescription(mutCode) {
858
+ const m = mutCode.match(/^([A-Z])(\d+)([A-Z])$/);
859
+ if (!m) return mutCode;
860
+ return `${AA1[m[1]]||m[1]} → ${AA1[m[3]]||m[3]} at position ${m[2]}`;
861
+ }
862
+
863
+ function renderDetail(row) {
864
+ const panel = document.getElementById('detail-panel');
865
+ panel.classList.remove('empty');
866
+ const amVerdict = row.amLabel ? 'pathogenic' : (row.amClass === 'ambiguous' ? 'ambiguous' : 'benign');
867
+ const amText = row.amLabel ? 'Pathogenic' : (row.amClass === 'ambiguous' ? 'Ambiguous' : 'Benign');
868
+ const esmVerdict = row.esmPathogenic ? 'pathogenic' : 'benign';
869
+ const esmText = row.esmPathogenic ? 'Pathogenic' : 'Neutral';
870
+ const ddgVerdict = row.ddgLabel ? 'pathogenic' : 'benign';
871
+ const ddgText = row.ddgLabel ? 'Destabilizing' : 'Stabilizing';
872
+ const consIcons = { 'High Risk':'⬛', 'Medium':'▪', 'Low Risk':'□' };
873
+ const consCls = row.pathCount >= 2 ? 'high' : row.pathCount === 1 ? 'medium' : 'low';
874
+ const consMsg = row.pathCount >= 2 ? `${row.pathCount} of 3 classifiers predict pathogenic effect.` : row.pathCount === 1 ? `1 of 3 classifiers predicts pathogenic effect. Evidence is inconclusive.` : `No classifier predicts pathogenic effect.`;
875
+ const pdockqText = row.interfacePdockq !== null ? ` (pDockQ ${row.interfacePdockq.toFixed(3)})` : '';
876
+
877
+ panel.innerHTML = `
878
+ <div class="detail-header">
879
+ <span class="detail-variant">${row.proteinId} / ${row.mutCode}</span>
880
+ <span class="detail-description">${mutDescription(row.mutCode)}</span>
881
+ </div>
882
+ <div class="detail-scores">
883
+ <div class="score-row"><span class="score-name">AlphaMissense</span>${amGauge(row.amScore)}<span class="score-verdict ${amVerdict}">${amText}</span></div>
884
+ <div class="score-row"><span class="score-name">ESM1b LLR</span>${esmGauge(row.esmLLR)}<span class="score-verdict ${esmVerdict}">${esmText}</span></div>
885
+ <div class="score-row"><span class="score-name">ΔΔG</span>${ddgGauge(row.predDDG)}<span class="score-verdict ${ddgVerdict}">${ddgText}</span></div>
886
+ </div>
887
+ <div class="detail-consensus ${consCls}">
888
+ <span class="consensus-icon">${consIcons[row.consensus]}</span>
889
+ <div>
890
+ <div class="consensus-title ${consCls}">${row.consensus}</div>
891
+ <div class="consensus-detail">${consMsg}<br/>
892
+ Mechanism: ${mechBadge(row.mechLabel)}
893
+ ${row.interfaceLabel ? ` · Interface${pdockqText}` : ''}
894
+ ${row.pocketLabel ? ' · In predicted pocket' : ''}
895
+ </div>
896
+ </div>
897
+ </div>
898
+ <div class="detail-links">
899
+ <a class="detail-link" href="https://www.uniprot.org/uniprotkb/${row.proteinId}" target="_blank" rel="noopener">UniProt: ${row.proteinId} ↗</a>
900
+ <a class="detail-link" href="https://www.ebi.ac.uk/ProtVar/query?search=${row.proteinId}" target="_blank" rel="noopener">ProtVar ↗</a>
901
+ </div>`;
902
+ }
903
+
904
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
905
+ // PROTEIN STATS
906
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
907
+ function computeStats(rows) {
908
+ const s = { path: 0, amb: 0, ben: 0, mech: {}, total: rows.length };
909
+ for (const r of rows) {
910
+ if (r.amClass === 'pathogenic') s.path++;
911
+ else if (r.amClass === 'ambiguous') s.amb++;
912
+ else s.ben++;
913
+ s.mech[r.mechLabel] = (s.mech[r.mechLabel] || 0) + 1;
914
+ }
915
+ return s;
916
+ }
917
+
918
+ function statsHTML(stats) {
919
+ const n = stats.total;
920
+ if (!n) return '';
921
+ const pP = (stats.path/n*100).toFixed(0), pA = (stats.amb/n*100).toFixed(0), pB = (stats.ben/n*100).toFixed(0);
922
+ const mechOrder = ['Stability','Interface','Pockets','Unassigned'];
923
+ const mechChips = mechOrder.filter(k => stats.mech[k]).map(k => {
924
+ const m = MECH[k] || MECH.Unassigned;
925
+ return `<span class="mech-chip-mini">${m.sym} ${stats.mech[k].toLocaleString()}</span>`;
926
+ }).join('');
927
+ return `<div class="pinfo-stats">
928
+ <div class="stats-bar">
929
+ <div class="stats-seg" style="width:${pP}%;background:var(--red)"></div>
930
+ <div class="stats-seg" style="width:${pA}%;background:var(--amber)"></div>
931
+ <div class="stats-seg" style="width:${pB}%;background:var(--green)"></div>
932
+ </div>
933
+ <div class="stats-legend">
934
+ <span><span class="stats-dot" style="background:var(--red)"></span>${pP}% pathogenic</span>
935
+ <span><span class="stats-dot" style="background:var(--amber)"></span>${pA}% ambiguous</span>
936
+ <span><span class="stats-dot" style="background:var(--green)"></span>${pB}% benign</span>
937
+ </div>
938
+ <div class="mech-chips">${mechChips}</div>
939
+ </div>`;
940
+ }
941
+
942
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
943
+ // UNIPROT API
944
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
945
+ async function fetchUniProt(pid) {
946
+ if (App.uniprotCache.has(pid)) { renderProteinInfo(pid, App.uniprotCache.get(pid)); return; }
947
+ try {
948
+ const resp = await fetch(`https://rest.uniprot.org/uniprotkb/${pid}.json`);
949
+ if (!resp.ok) throw new Error(resp.status);
950
+ const data = await resp.json();
951
+ const info = {
952
+ name: data.proteinDescription?.recommendedName?.fullName?.value || data.proteinDescription?.submissionNames?.[0]?.fullName?.value || pid,
953
+ gene: data.genes?.[0]?.geneName?.value || '—',
954
+ length: data.sequence?.length || '—',
955
+ fn: data.comments?.find(c => c.commentType === 'FUNCTION')?.texts?.[0]?.value?.slice(0, 200) || null,
956
+ url: `https://www.uniprot.org/uniprotkb/${pid}`,
957
+ };
958
+ App.uniprotCache.set(pid, info);
959
+ renderProteinInfo(pid, info);
960
+ } catch(e) {
961
+ renderProteinInfo(pid, { name: pid, gene:'—', length:'—', fn:null, url:`https://www.uniprot.org/uniprotkb/${pid}` });
962
+ }
963
+ }
964
+
965
+ function renderProteinInfo(pid, info) {
966
+ const allRows = App.byProtein.get(pid) || [];
967
+ const stats = computeStats(allRows);
968
+ document.getElementById('protein-info').innerHTML = `
969
+ <div class="pinfo-header">
970
+ <span class="pinfo-id">${pid}</span>
971
+ <span class="pinfo-name">${escHtml(info.name)}</span>
972
+ <a class="pinfo-link" href="${info.url}" target="_blank" rel="noopener">UniProt ↗</a>
973
+ </div>
974
+ <div class="pinfo-meta">
975
+ <span>Gene: <b>${escHtml(info.gene)}</b></span>
976
+ <span>Length: <b>${info.length} aa</b></span>
977
+ <span>${allRows.length.toLocaleString()} variants in dataset</span>
978
+ </div>
979
+ ${info.fn ? `<div class="pinfo-function">${escHtml(info.fn)}…</div>` : ''}
980
+ ${statsHTML(stats)}`;
981
+ }
982
+
983
+ function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
984
+
985
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
986
+ // SCATTER PLOT (AM pathogenicity × ESM1b LLR)
987
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
988
+ let scatterPoints = [];
989
+
990
+ function drawScatter(rows, selRow) {
991
+ const canvas = document.getElementById('scatter-canvas');
992
+ const section = document.getElementById('scatter-section');
993
+ if (!canvas || !rows || !rows.length) { if (section) section.style.display = 'none'; return; }
994
+ section.style.display = '';
995
+
996
+ const dpr = window.devicePixelRatio || 1;
997
+ const W = canvas.offsetWidth || 300, H = canvas.offsetHeight || 130;
998
+ canvas.width = W * dpr; canvas.height = H * dpr;
999
+ const ctx = canvas.getContext('2d');
1000
+ ctx.scale(dpr, dpr);
1001
+
1002
+ const ML = 34, MR = 8, MT = 16, MB = 22;
1003
+ const pw = W - ML - MR, ph = H - MT - MB;
1004
+
1005
+ // ESM1b y-range
1006
+ let yMin = Infinity, yMax = -Infinity;
1007
+ for (const r of rows) { if (r.esmLLR < yMin) yMin = r.esmLLR; if (r.esmLLR > yMax) yMax = r.esmLLR; }
1008
+ const yPad = (yMax - yMin) * 0.1 || 1;
1009
+ yMin -= yPad; yMax += yPad;
1010
+
1011
+ const toX = am => ML + am * pw;
1012
+ const toY = esm => MT + (1 - (esm - yMin) / (yMax - yMin)) * ph;
1013
+
1014
+ // Background
1015
+ ctx.fillStyle = '#fafaf8'; ctx.fillRect(0, 0, W, H);
1016
+
1017
+ // Axes
1018
+ ctx.strokeStyle = '#d8d5ce'; ctx.lineWidth = 1;
1019
+ ctx.beginPath(); ctx.moveTo(ML, MT); ctx.lineTo(ML, MT + ph); ctx.lineTo(ML + pw, MT + ph); ctx.stroke();
1020
+
1021
+ // y=0 dash
1022
+ const y0 = toY(0);
1023
+ if (y0 > MT && y0 < MT + ph) {
1024
+ ctx.strokeStyle = '#c0bcb4'; ctx.lineWidth = 0.5; ctx.setLineDash([3, 3]);
1025
+ ctx.beginPath(); ctx.moveTo(ML, y0); ctx.lineTo(ML + pw, y0); ctx.stroke();
1026
+ ctx.setLineDash([]);
1027
+ }
1028
+
1029
+ // AM threshold lines
1030
+ for (const [thresh, color] of [[0.340, '#1f6b40'], [0.564, '#c0392b']]) {
1031
+ const tx = toX(thresh);
1032
+ ctx.strokeStyle = color; ctx.lineWidth = 0.8; ctx.globalAlpha = 0.35;
1033
+ ctx.setLineDash([3, 3]);
1034
+ ctx.beginPath(); ctx.moveTo(tx, MT); ctx.lineTo(tx, MT + ph); ctx.stroke();
1035
+ ctx.setLineDash([]); ctx.globalAlpha = 1;
1036
+ }
1037
+
1038
+ // Axis labels
1039
+ ctx.fillStyle = '#7a776f'; ctx.font = '9px IBM Plex Mono, monospace';
1040
+ ctx.textAlign = 'center';
1041
+ ctx.fillText('AlphaMissense', ML + pw / 2, H - 4);
1042
+ ctx.textAlign = 'left'; ctx.fillText('0', ML, H - 10);
1043
+ ctx.textAlign = 'right'; ctx.fillText('1', ML + pw, H - 10);
1044
+ ctx.save(); ctx.translate(10, MT + ph / 2); ctx.rotate(-Math.PI / 2);
1045
+ ctx.textAlign = 'center'; ctx.fillText('ESM1b LLR', 0, 0); ctx.restore();
1046
+
1047
+ // Title
1048
+ ctx.fillStyle = '#4a4845'; ctx.font = '9px IBM Plex Mono, monospace';
1049
+ ctx.textAlign = 'left';
1050
+ ctx.fillText(`Score correlation · n=${rows.length.toLocaleString()}`, ML, 11);
1051
+
1052
+ // Dots — sample if large
1053
+ const MAX = 4000;
1054
+ const step = rows.length > MAX ? Math.ceil(rows.length / MAX) : 1;
1055
+ scatterPoints = [];
1056
+ for (let i = 0; i < rows.length; i += step) {
1057
+ const r = rows[i];
1058
+ const x = toX(r.amScore), y = toY(r.esmLLR);
1059
+ const isSel = selRow && r.variantId === selRow.variantId;
1060
+ const color = r.pathCount >= 2 ? '#c0392b' : r.pathCount === 1 ? '#a65c00' : '#1f6b40';
1061
+ ctx.globalAlpha = isSel ? 1 : 0.45;
1062
+ ctx.fillStyle = color;
1063
+ ctx.beginPath(); ctx.arc(x, y, isSel ? 4.5 : 2, 0, Math.PI * 2); ctx.fill();
1064
+ if (isSel) {
1065
+ ctx.globalAlpha = 1; ctx.strokeStyle = 'white'; ctx.lineWidth = 1.5;
1066
+ ctx.stroke();
1067
+ }
1068
+ scatterPoints.push({ x, y, row: r });
1069
+ }
1070
+ ctx.globalAlpha = 1;
1071
+ }
1072
+
1073
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1074
+ // MOL* VIEWER
1075
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1076
+ async function initMolstar() {
1077
+ if (typeof molstar === 'undefined') return;
1078
+ try {
1079
+ App.molViewer = await molstar.Viewer.create('mol-container', {
1080
+ layoutIsExpanded: false, layoutShowControls: false,
1081
+ layoutShowRemoteState: false, layoutShowSequence: false,
1082
+ layoutShowLog: false, layoutShowLeftPanel: false,
1083
+ viewportShowExpand: false, viewportShowSelectionMode: false,
1084
+ viewportShowAnimation: false,
1085
+ });
1086
+ } catch(e) { console.error('Molstar init failed:', e); }
1087
+ }
1088
+
1089
+ function setViewerLoading(on) { document.getElementById('viewer-loading').classList.toggle('visible', on); }
1090
+ function setViewerStatus(msg) { document.getElementById('viewer-status').textContent = msg; }
1091
+
1092
+ async function loadAndHighlight(proteinId, mutCode) {
1093
+ if (!App.molViewer) return;
1094
+ const plugin = App.molViewer.plugin || App.molViewer;
1095
+ setViewerLoading(true); setViewerStatus('');
1096
+ try {
1097
+ if (App.molLoadedProtein !== proteinId) {
1098
+ await plugin.clear();
1099
+ await App.molViewer.loadStructureFromUrl(`structures/AF-${proteinId}-F1-model_v6.pdb`, 'pdb', false);
1100
+ App.molLoadedProtein = proteinId;
1101
+ await delay(150);
1102
+ }
1103
+ if (mutCode) {
1104
+ const m = mutCode.match(/^([A-Z])(\d+)([A-Z])$/i);
1105
+ if (m) await highlightResidue(plugin, parseInt(m[2]));
1106
+ }
1107
+ setViewerStatus(mutCode ? `pos ${mutCode.match(/\d+/)?.[0]}` : '');
1108
+ } catch(e) {
1109
+ console.warn('Viewer update error:', e);
1110
+ setViewerStatus('');
1111
+ } finally { setViewerLoading(false); }
1112
+ }
1113
+
1114
+ async function highlightResidue(plugin, resno) {
1115
+ if (tryStateTransforms(plugin, resno)) return;
1116
+ await trySelectionFocus(plugin, resno);
1117
+ }
1118
+
1119
+ function tryStateTransforms(plugin, resno) {
1120
+ try {
1121
+ const ST = molstar.StateTransforms;
1122
+ if (!ST?.Model?.StructureComponentFromExpression) return false;
1123
+ const structures = plugin.managers.structure.hierarchy.current.structures;
1124
+ if (!structures.length) return false;
1125
+ const cellRef = structures[0].cell.transform.ref;
1126
+ const expression = { '@version':2, expression: { name:'structure-query.generator.atom-groups', params: { 'residue-test': { name:'core.rel.eq', params: [{ name:'structure-query.atom.auth-seq-id' }, { name:'core.type.int', params:[resno] }] } } } };
1127
+ const REF = 'mut-hl';
1128
+ if (plugin.state.data.cells.has(REF)) plugin.build().delete(REF).commit();
1129
+ plugin.build().to(cellRef)
1130
+ .apply(ST.Model.StructureComponentFromExpression, { label:`Mut@${resno}`, expression }, { ref: REF })
1131
+ .apply(ST.Representation.StructureRepresentation3D, {
1132
+ type: { name:'ball-and-stick', params:{ sizeFactor:2.5 } },
1133
+ colorTheme: { name:'uniform', params:{ value:0xE3002C } },
1134
+ sizeTheme: { name:'uniform', params:{ value:1 } },
1135
+ }).commit();
1136
+ setTimeout(() => {
1137
+ try {
1138
+ const sphere = getSphereForResidue(plugin, resno);
1139
+ if (sphere) plugin.managers.camera.focusSphere?.(sphere, { durationMs:500 });
1140
+ } catch(e) {}
1141
+ }, 300);
1142
+ return true;
1143
+ } catch(e) { return false; }
1144
+ }
1145
+
1146
+ async function trySelectionFocus(plugin, resno) {
1147
+ try {
1148
+ const structures = plugin.managers.structure.hierarchy.current.structures;
1149
+ if (!structures.length) return;
1150
+ const structure = structures[0].cell.obj?.data;
1151
+ if (!structure) return;
1152
+ const loci = buildResidueLoci(structure, resno);
1153
+ if (!loci) return;
1154
+ plugin.managers.structure.selection.fromLoci('set', loci);
1155
+ plugin.managers.camera.focusLoci(loci, { extraRadius:8, durationMs:500 });
1156
+ } catch(e) {}
1157
+ }
1158
+
1159
+ function buildResidueLoci(structure, resno) {
1160
+ const els = [];
1161
+ for (const unit of structure.units) {
1162
+ const { elements } = unit;
1163
+ const ri = unit.residueIndex;
1164
+ const hier = unit.model.atomicHierarchy;
1165
+ if (!hier?.residues?.auth_seq_id) continue;
1166
+ const seqId = hier.residues.auth_seq_id;
1167
+ const indices = [];
1168
+ for (let j = 0; j < elements.length; j++) {
1169
+ if (seqId.value(ri[elements[j]]) === resno) indices.push(j);
1170
+ }
1171
+ if (indices.length) els.push({ unit, indices: new Int32Array(indices.sort((a,b)=>a-b)) });
1172
+ }
1173
+ if (!els.length) return null;
1174
+ return { kind:'element-loci', structure, elements: els };
1175
+ }
1176
+
1177
+ function getSphereForResidue(plugin, resno) {
1178
+ try {
1179
+ const structure = plugin.managers.structure.hierarchy.current.structures[0]?.cell.obj?.data;
1180
+ if (!structure) return null;
1181
+ const loci = buildResidueLoci(structure, resno);
1182
+ if (!loci) return null;
1183
+ let cx=0,cy=0,cz=0,n=0;
1184
+ for (const { unit, indices } of loci.elements) {
1185
+ const { elements } = unit, { x,y,z } = unit.conformation;
1186
+ for (const idx of indices) { const eI = elements[idx]; cx+=x(eI);cy+=y(eI);cz+=z(eI);n++; }
1187
+ }
1188
+ if (!n) return null;
1189
+ return { center:[cx/n,cy/n,cz/n], radius:6 };
1190
+ } catch(e) { return null; }
1191
+ }
1192
+
1193
+ function setupViewerControls() {
1194
+ document.getElementById('btn-reset-view').onclick = () => {
1195
+ try { App.molViewer?.plugin.managers.camera.reset(); } catch(e) {}
1196
+ };
1197
+ document.getElementById('btn-toggle-spin').onclick = function() {
1198
+ App.molSpinning = !App.molSpinning;
1199
+ this.style.background = App.molSpinning ? 'rgba(255,255,255,.2)' : '';
1200
+ try {
1201
+ const plugin = App.molViewer.plugin;
1202
+ const cur = plugin.canvas3d.props.trackball;
1203
+ plugin.canvas3d.setProps({ trackball: { ...cur, animate: App.molSpinning ? {name:'spin',params:{speed:1}} : {name:'off',params:{}} } });
1204
+ } catch(e) {}
1205
+ };
1206
+ document.getElementById('btn-toggle-surface').onclick = function() {
1207
+ if (App.selectedRow) {
1208
+ App.molLoadedProtein = null;
1209
+ loadAndHighlight(App.selectedRow.proteinId, App.selectedRow.mutCode);
1210
+ }
1211
+ };
1212
+ }
1213
+
1214
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1215
+ // URL SHARING
1216
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1217
+ function encodeURL(query) {
1218
+ // "Q7Z4H8 A126C\nP12235 A114D" → "#q=Q7Z4H8+A126C,P12235+A114D"
1219
+ return '#q=' + query.trim().split('\n').map(l => l.trim().replace(/\s+/,'+')).join(',');
1220
+ }
1221
+ function decodeURL(hash) {
1222
+ if (!hash.startsWith('#q=')) return null;
1223
+ return hash.slice(3).split(',').map(l => l.replace(/\+/,' ')).join('\n');
1224
+ }
1225
+
1226
+ function shareResults(rawQuery) {
1227
+ const url = location.href.split('#')[0] + encodeURL(rawQuery);
1228
+ navigator.clipboard?.writeText(url).then(() => {
1229
+ const el = document.getElementById('share-done');
1230
+ el.style.display = 'inline';
1231
+ setTimeout(() => { el.style.display = 'none'; }, 1800);
1232
+ });
1233
+ }
1234
+
1235
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1236
+ // SEARCH FLOW
1237
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1238
+ let lastQuery = '';
1239
+
1240
+ function showResults(rows, query) {
1241
+ lastQuery = query;
1242
+ const proteins = [...new Set(rows.map(r => r.proteinId))];
1243
+ document.getElementById('results-title').textContent =
1244
+ proteins.length === 1 ? `${proteins[0]} — ${rows.length.toLocaleString()} variants`
1245
+ : `${rows.length.toLocaleString()} variants across ${proteins.length} proteins`;
1246
+ document.getElementById('results-subtitle').textContent = `Query: ${query.trim().split('\n').join(', ')}`;
1247
+
1248
+ App.currentRows = rows;
1249
+ App.selectedRow = null;
1250
+ App.filterMech = 'all';
1251
+ App.filterText = '';
1252
+ App.filterPosition = null;
1253
+ document.querySelectorAll('.chip').forEach(c => c.classList.toggle('active', c.dataset.mech === 'all'));
1254
+ document.getElementById('mutation-filter').value = '';
1255
+ updatePositionFilterUI();
1256
+
1257
+ document.body.dataset.view = 'results';
1258
+ renderTable(rows);
1259
+
1260
+ // Position landscape + scatter
1261
+ renderPositionChart(rows, null);
1262
+ drawScatter(rows, null);
1263
+
1264
+ // Auto-select first row + fetch UniProt
1265
+ if (rows.length > 0) {
1266
+ selectRow(rows[0]);
1267
+ proteins.forEach(pid => fetchUniProt(pid));
1268
+ }
1269
+
1270
+ // Update URL hash for sharing
1271
+ history.replaceState(null, '', encodeURL(query));
1272
+ }
1273
+
1274
+ function handleAnalyze() {
1275
+ const raw = document.getElementById('query-input').value;
1276
+ if (!raw.trim()) return;
1277
+ const { rows, errors } = executeSearch(raw);
1278
+ renderErrors(errors);
1279
+ if (rows.length === 0 && !errors.length) renderErrors(['No results found. Check the protein ID and mutation format.']);
1280
+ if (rows.length > 0) showResults(rows, raw);
1281
+ }
1282
+
1283
+ function renderErrors(errors) {
1284
+ const el = document.getElementById('search-errors');
1285
+ if (!errors.length) { el.style.display = 'none'; return; }
1286
+ el.style.display = 'block';
1287
+ el.innerHTML = errors.map(e => `<div class="error-item">${escHtml(e)}</div>`).join('');
1288
+ }
1289
+
1290
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1291
+ // EXPORT CSV
1292
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1293
+ function exportCSV() {
1294
+ const rows = App.filteredRows.length ? App.filteredRows : App.currentRows;
1295
+ const hdr = ['variant_id','protein_id','mutation','am_pathogenicity','am_class','ESM1b_LLR','pred_ddg','mechanistic_label','interface_label','pocket_label','consensus'];
1296
+ const lines = [hdr.join(',')];
1297
+ for (const r of rows) {
1298
+ lines.push([r.variantId,r.proteinId,r.mutCode,r.amScore.toFixed(4),r.amClass,r.esmLLR.toFixed(3),r.predDDG.toFixed(3),r.mechLabel,r.interfaceLabel,r.pocketLabel,`"${r.consensus}"`].join(','));
1299
+ }
1300
+ const a = document.createElement('a');
1301
+ a.href = URL.createObjectURL(new Blob([lines.join('\r\n')], { type:'text/csv;charset=utf-8;' }));
1302
+ a.download = `mutvar_${Date.now()}.csv`;
1303
+ a.click(); URL.revokeObjectURL(a.href);
1304
+ }
1305
+
1306
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1307
+ // UTILS
1308
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1309
+ function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
1310
+
1311
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1312
+ // BOOT
1313
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1314
+ document.addEventListener('DOMContentLoaded', async () => {
1315
+
1316
+ // Example chips
1317
+ document.querySelectorAll('.example-chip').forEach(chip => {
1318
+ chip.onclick = () => {
1319
+ document.getElementById('query-input').value = chip.dataset.query;
1320
+ document.getElementById('search-errors').style.display = 'none';
1321
+ };
1322
+ });
1323
+
1324
+ // Analyze button
1325
+ document.getElementById('btn-analyze').onclick = handleAnalyze;
1326
+ document.getElementById('query-input').addEventListener('keydown', e => {
1327
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleAnalyze();
1328
+ });
1329
+
1330
+ // Navigation
1331
+ document.getElementById('btn-back').onclick = () => { document.body.dataset.view = 'search'; };
1332
+ document.getElementById('logo-link').onclick = e => { e.preventDefault(); document.body.dataset.view = 'search'; };
1333
+
1334
+ // Sort headers
1335
+ document.querySelectorAll('#results-table th[data-col]').forEach(th => {
1336
+ th.onclick = () => {
1337
+ const col = th.dataset.col;
1338
+ App.sortDir = App.sortCol === col ? (App.sortDir === 'asc' ? 'desc' : 'asc') : (col === 'mutCode' || col === 'mechLabel' ? 'asc' : 'desc');
1339
+ App.sortCol = col;
1340
+ renderTable(App.currentRows);
1341
+ };
1342
+ });
1343
+
1344
+ // Filter chips
1345
+ document.getElementById('filter-chips').onclick = e => {
1346
+ const chip = e.target.closest('.chip'); if (!chip) return;
1347
+ App.filterMech = chip.dataset.mech;
1348
+ document.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
1349
+ chip.classList.add('active');
1350
+ renderTable(App.currentRows);
1351
+ renderPositionChart(App.filteredRows.length ? App.filteredRows : App.currentRows, App.filterPosition);
1352
+ };
1353
+
1354
+ // Inline text filter (debounced)
1355
+ let filterTimer;
1356
+ document.getElementById('mutation-filter').oninput = function() {
1357
+ clearTimeout(filterTimer);
1358
+ filterTimer = setTimeout(() => {
1359
+ App.filterText = this.value.trim();
1360
+ renderTable(App.currentRows);
1361
+ }, 120);
1362
+ };
1363
+
1364
+ // Position filter clear
1365
+ document.getElementById('btn-clear-posfilter').onclick = () => {
1366
+ App.filterPosition = null;
1367
+ updatePositionFilterUI();
1368
+ renderTable(App.currentRows);
1369
+ renderPositionChart(App.currentRows, null);
1370
+ };
1371
+
1372
+ // Export + Share
1373
+ document.getElementById('btn-export').onclick = exportCSV;
1374
+ document.getElementById('btn-share').onclick = () => { if (lastQuery) shareResults(lastQuery); };
1375
+
1376
+ // Keyboard navigation in table (↑/↓)
1377
+ document.getElementById('table-scroll').addEventListener('keydown', e => {
1378
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
1379
+ e.preventDefault();
1380
+ const rows = App.filteredRows;
1381
+ if (!rows.length) return;
1382
+ const currIdx = App.selectedRow ? rows.findIndex(r => r.variantId === App.selectedRow.variantId) : -1;
1383
+ const nextIdx = e.key === 'ArrowDown' ? Math.min(rows.length-1, currIdx+1) : Math.max(0, currIdx-1);
1384
+ if (nextIdx !== currIdx) selectRow(rows[nextIdx]);
1385
+ });
1386
+
1387
+ // Resize: re-render chart when panel width changes
1388
+ window.addEventListener('resize', () => {
1389
+ if (document.body.dataset.view === 'results' && App.currentRows.length) {
1390
+ renderPositionChart(App.filteredRows.length ? App.filteredRows : App.currentRows, App.filterPosition);
1391
+ drawScatter(App.currentRows, App.selectedRow);
1392
+ }
1393
+ });
1394
+
1395
+ // Scatter click — select nearest dot
1396
+ document.getElementById('scatter-canvas').addEventListener('click', e => {
1397
+ const rect = e.target.getBoundingClientRect();
1398
+ const mx = e.clientX - rect.left, my = e.clientY - rect.top;
1399
+ let nearest = null, minDist = 14;
1400
+ for (const p of scatterPoints) {
1401
+ const d = Math.hypot(p.x - mx, p.y - my);
1402
+ if (d < minDist) { minDist = d; nearest = p; }
1403
+ }
1404
+ if (nearest) selectRow(nearest.row);
1405
+ });
1406
+
1407
+ // Viewer controls
1408
+ setupViewerControls();
1409
+ vsInit();
1410
+
1411
+ // Load TSV
1412
+ try {
1413
+ await loadTSV();
1414
+ document.getElementById('tsv-status').textContent = `${App.byVariant.size.toLocaleString()} variants ready`;
1415
+ document.getElementById('btn-analyze').disabled = false;
1416
+
1417
+ // Check URL hash for shared query
1418
+ const fromURL = decodeURL(location.hash);
1419
+ if (fromURL) {
1420
+ document.getElementById('query-input').value = fromURL;
1421
+ handleAnalyze();
1422
+ }
1423
+ } catch(e) {
1424
+ document.getElementById('tsv-status').textContent = 'Dataset load failed';
1425
+ console.error(e);
1426
+ }
1427
+
1428
+ // Init Mol*
1429
+ if (typeof molstar !== 'undefined') initMolstar();
1430
+ else window.addEventListener('load', () => { if (typeof molstar !== 'undefined') initMolstar(); });
1431
+ });
1432
+ </script>
1433
+ </body>
1434
+ </html>
nginx.conf ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ server {
2
+ listen 80;
3
+ server_name localhost;
4
+ root /usr/share/nginx/html;
5
+ index index.html;
6
+
7
+ charset utf-8;
8
+
9
+ # TSV data
10
+ location /data/ {
11
+ default_type text/plain;
12
+ add_header Content-Type "text/tab-separated-values; charset=utf-8";
13
+ add_header Access-Control-Allow-Origin "*";
14
+ }
15
+
16
+ # PDB structures
17
+ location /structures/ {
18
+ default_type application/octet-stream;
19
+ add_header Content-Type "chemical/x-pdb";
20
+ add_header Access-Control-Allow-Origin "*";
21
+ }
22
+
23
+ # Everything else
24
+ location / {
25
+ try_files $uri $uri/ /index.html;
26
+ }
27
+
28
+ # Gzip for TSV (~2MB → ~400KB) and JS
29
+ gzip on;
30
+ gzip_vary on;
31
+ gzip_min_length 1024;
32
+ gzip_proxied any;
33
+ gzip_types
34
+ text/plain
35
+ text/html
36
+ text/css
37
+ text/tab-separated-values
38
+ application/javascript
39
+ application/json
40
+ application/octet-stream;
41
+
42
+ # Cache static assets
43
+ location ~* \.(js|css|woff2)$ {
44
+ expires 7d;
45
+ add_header Cache-Control "public, immutable";
46
+ }
47
+ }
structures/AF-P12235-F1-model_v6.pdb ADDED
The diff for this file is too large to render. See raw diff
 
structures/AF-Q7Z4H8-F1-model_v6.pdb ADDED
The diff for this file is too large to render. See raw diff
 
structures/AF-Q8IUR5-F1-model_v6.pdb ADDED
The diff for this file is too large to render. See raw diff