first commit
Browse files- Dockerfile +17 -0
- README.md +79 -11
- data/missense_human_subset.tsv +0 -0
- index.html +1434 -0
- nginx.conf +47 -0
- structures/AF-P12235-F1-model_v6.pdb +0 -0
- structures/AF-Q7Z4H8-F1-model_v6.pdb +0 -0
- structures/AF-Q8IUR5-F1-model_v6.pdb +0 -0
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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
--
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 P12235 A114D Q8IUR5"></textarea>
|
| 326 |
+
|
| 327 |
+
<p class="search-format-hint">
|
| 328 |
+
Format: <code>PROTEIN_ID MUTATION</code> ·
|
| 329 |
+
accepts <code>A126C</code> or <code>Ala126Cys</code> ·
|
| 330 |
+
protein-only returns all variants ·
|
| 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 P12235 A114D 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} /</span> ${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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
| 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
|
|
|