Commit ·
dd079af
1
Parent(s): 8959aee
Redesign the MuSProt website for the new database release
Browse filesNew frontend (home with live hierarchical knowledge graph, browse, sequence,
chain, statistics and docs pages) and explorer API backed by the
MuSProt-index.db sidecar; supports the new schema (tm_aln, coverage,
Pfam/ECOD, observed order/disorder labels).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This view is limited to 50 files because it contains too many changes. See raw diff
- README.md +11 -5
- backend/app/protein/api/routes/explore.py +143 -0
- backend/app/protein/catalog.py +467 -0
- backend/app/protein/config.py +9 -9
- backend/app/protein/data_loader.py +15 -8
- backend/app/protein/models.py +144 -144
- backend/app/protein/records.py +270 -0
- backend/app/protein/tsv_loader.py +11 -10
- backend/assets/MuSProt_documentation.md +189 -92
- backend/assets/musprot_summary.json +25 -25
- backend/scripts/build_index.py +656 -0
- backend/scripts/generate_node_lookup.py +0 -68
- backend/space_app.py +4 -0
- frontend/index.html +21 -16
- frontend/package-lock.json +50 -0
- frontend/package.json +2 -0
- frontend/public/favicon.svg +1 -0
- frontend/public/sitemap.xml +10 -0
- frontend/scripts/prerender.mjs +14 -4
- frontend/src/App.tsx +52 -10
- frontend/src/api/protein.ts +0 -122
- frontend/src/components/Domains.tsx +151 -0
- frontend/src/components/ForceGraph.tsx +357 -0
- frontend/src/components/HeroArt.tsx +62 -0
- frontend/src/components/KnowledgeGraph.tsx +322 -0
- frontend/src/components/Layout.tsx +134 -0
- frontend/src/components/PageMeta.tsx +58 -0
- frontend/src/components/Panels.tsx +142 -0
- frontend/src/components/SearchBox.tsx +189 -0
- frontend/src/components/SequenceTable.tsx +113 -0
- frontend/src/components/StateGlyph.tsx +56 -0
- frontend/src/components/StateViews.tsx +229 -0
- frontend/src/components/StructureViewer.tsx +136 -0
- frontend/src/components/Transitions.tsx +489 -0
- frontend/src/components/charts.tsx +349 -0
- frontend/src/components/datasets/protein/BindersPanel.tsx +0 -108
- frontend/src/components/datasets/protein/ChainBasicInfoCards.tsx +0 -146
- frontend/src/components/datasets/protein/ChainSearchInput.tsx +0 -204
- frontend/src/components/datasets/protein/DataTable.css +0 -177
- frontend/src/components/datasets/protein/DataTable.tsx +0 -214
- frontend/src/components/datasets/protein/ErrorBoundary.css +0 -93
- frontend/src/components/datasets/protein/ErrorBoundary.tsx +0 -105
- frontend/src/components/datasets/protein/ExperimentalConditionsPanel.tsx +0 -117
- frontend/src/components/datasets/protein/FilterPanel.tsx +0 -75
- frontend/src/components/datasets/protein/FunctionPanel.tsx +0 -80
- frontend/src/components/datasets/protein/ProteinDashboard.tsx +0 -391
- frontend/src/components/datasets/protein/SequencePanel.tsx +0 -137
- frontend/src/components/datasets/protein/StaticDistributionPlots.tsx +0 -34
- frontend/src/components/datasets/protein/StatsPanel.css +0 -52
- frontend/src/components/datasets/protein/StatsPanel.tsx +0 -38
README.md
CHANGED
|
@@ -37,7 +37,7 @@ Recommended Dataset repository files:
|
|
| 37 |
|
| 38 |
```text
|
| 39 |
MuSProt.db
|
| 40 |
-
MuSProt-
|
| 41 |
musprot_summary.json
|
| 42 |
MuSProt_documentation.md
|
| 43 |
plots/
|
|
@@ -47,7 +47,7 @@ Optional Space variables:
|
|
| 47 |
|
| 48 |
```text
|
| 49 |
MUSPROT_DB_PATH=/data/MuSProt.db
|
| 50 |
-
|
| 51 |
MUSPROT_SUMMARY_PATH=/data/musprot_summary.json
|
| 52 |
MUSPROT_DOCS_PATH=/data/MuSProt_documentation.md
|
| 53 |
MUSPROT_PLOTS_DIR=/data/plots
|
|
@@ -61,13 +61,18 @@ For a private Dataset repository, add `HF_TOKEN` as a Space secret.
|
|
| 61 |
|
| 62 |
## Updating the database
|
| 63 |
|
| 64 |
-
When publishing a new `MuSProt.db`,
|
|
|
|
|
|
|
| 65 |
|
| 66 |
```bash
|
| 67 |
-
|
| 68 |
-
|
| 69 |
```
|
| 70 |
|
|
|
|
|
|
|
|
|
|
| 71 |
The deployed application does not create indexes or modify the SQLite file.
|
| 72 |
Create any required indexes before uploading the database to the Dataset
|
| 73 |
repository.
|
|
@@ -81,6 +86,7 @@ npm run build
|
|
| 81 |
|
| 82 |
cd ../backend
|
| 83 |
MUSPROT_DB_PATH=/path/to/MuSProt.db \
|
|
|
|
| 84 |
MUSPROT_FRONTEND_DIR=../frontend/build \
|
| 85 |
uvicorn space_app:app --host 0.0.0.0 --port 8501
|
| 86 |
```
|
|
|
|
| 37 |
|
| 38 |
```text
|
| 39 |
MuSProt.db
|
| 40 |
+
MuSProt-index.db
|
| 41 |
musprot_summary.json
|
| 42 |
MuSProt_documentation.md
|
| 43 |
plots/
|
|
|
|
| 47 |
|
| 48 |
```text
|
| 49 |
MUSPROT_DB_PATH=/data/MuSProt.db
|
| 50 |
+
MUSPROT_INDEX_DB_PATH=/data/MuSProt-index.db
|
| 51 |
MUSPROT_SUMMARY_PATH=/data/musprot_summary.json
|
| 52 |
MUSPROT_DOCS_PATH=/data/MuSProt_documentation.md
|
| 53 |
MUSPROT_PLOTS_DIR=/data/plots
|
|
|
|
| 61 |
|
| 62 |
## Updating the database
|
| 63 |
|
| 64 |
+
When publishing a new `MuSProt.db`, rebuild the website index sidecar
|
| 65 |
+
(sequence catalog, search, state pairs, ECOD groups and dataset statistics).
|
| 66 |
+
It reads the DB once and takes about 6 minutes:
|
| 67 |
|
| 68 |
```bash
|
| 69 |
+
python backend/scripts/build_index.py /path/to/MuSProt.db --out-dir <staging dir> \
|
| 70 |
+
--ecod /path/to/ecod.latest.domains.txt
|
| 71 |
```
|
| 72 |
|
| 73 |
+
Upload `MuSProt.db`, `MuSProt-index.db` and `MuSProt_documentation.md` to the
|
| 74 |
+
storage bucket mounted at `/data`, then restart the Space.
|
| 75 |
+
|
| 76 |
The deployed application does not create indexes or modify the SQLite file.
|
| 77 |
Create any required indexes before uploading the database to the Dataset
|
| 78 |
repository.
|
|
|
|
| 86 |
|
| 87 |
cd ../backend
|
| 88 |
MUSPROT_DB_PATH=/path/to/MuSProt.db \
|
| 89 |
+
MUSPROT_INDEX_DB_PATH=/path/to/MuSProt-index.db \
|
| 90 |
MUSPROT_FRONTEND_DIR=../frontend/build \
|
| 91 |
uvicorn space_app:app --host 0.0.0.0 --port 8501
|
| 92 |
```
|
backend/app/protein/api/routes/explore.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Explorer API: overview, search, sequence catalog, sequence and chain detail."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 8 |
+
from starlette.concurrency import run_in_threadpool
|
| 9 |
+
|
| 10 |
+
from app.protein import catalog, records
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _index_required(value):
|
| 17 |
+
if value is None:
|
| 18 |
+
raise HTTPException(status_code=503, detail="The MuSProt index is not available yet")
|
| 19 |
+
return value
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@router.get("/overview")
|
| 23 |
+
async def get_overview():
|
| 24 |
+
"""Dataset-level counts and distributions (precomputed)."""
|
| 25 |
+
return _index_required(await run_in_threadpool(catalog.overview))
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.get("/search")
|
| 29 |
+
async def search(q: str = Query(..., min_length=1, max_length=200), limit: int = Query(12, ge=1, le=50)):
|
| 30 |
+
"""Search by chain (3vgk.A), PDB entry, sequence ID, UniProt accession or function text."""
|
| 31 |
+
return await run_in_threadpool(catalog.search, q, limit)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@router.get("/sequences")
|
| 35 |
+
async def list_sequences(
|
| 36 |
+
sort: str = Query("states"),
|
| 37 |
+
order: str = Query("desc", pattern="^(asc|desc)$"),
|
| 38 |
+
q: Optional[str] = Query(None, max_length=200),
|
| 39 |
+
min_states: Optional[int] = Query(None, ge=1),
|
| 40 |
+
min_obs: Optional[int] = Query(None, ge=1),
|
| 41 |
+
method: Optional[str] = Query(None, max_length=64),
|
| 42 |
+
ligand: Optional[str] = Query(None, pattern="^(both|holo|apo)$"),
|
| 43 |
+
cath: Optional[str] = Query(None, max_length=32),
|
| 44 |
+
offset: int = Query(0, ge=0),
|
| 45 |
+
limit: int = Query(25, ge=1, le=100),
|
| 46 |
+
ids: Optional[str] = Query(None, max_length=1000, description="Comma-separated sequence IDs"),
|
| 47 |
+
ecod: Optional[str] = Query(None, max_length=200, description="ECOD H-group (e.g. 11.1) or exact H-set (11.1;2003.1)"),
|
| 48 |
+
pfam: Optional[str] = Query(None, max_length=20),
|
| 49 |
+
):
|
| 50 |
+
"""Browse the sequence catalog with filters, sorting and pagination."""
|
| 51 |
+
id_list = [i.strip().upper() for i in ids.split(",") if i.strip()][:100] if ids else None
|
| 52 |
+
result = await run_in_threadpool(
|
| 53 |
+
catalog.list_sequences, sort, order, q, min_states, min_obs, method, ligand, cath, offset, limit, id_list,
|
| 54 |
+
ecod, pfam,
|
| 55 |
+
)
|
| 56 |
+
return _index_required(result)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@router.get("/sequence/{sequence_id}")
|
| 60 |
+
async def get_sequence(sequence_id: str):
|
| 61 |
+
"""One sequence: states, member chains and the state-state similarity matrix."""
|
| 62 |
+
seq = _index_required(await run_in_threadpool(catalog.get_sequence, sequence_id))
|
| 63 |
+
if not seq:
|
| 64 |
+
raise HTTPException(status_code=404, detail=f"Sequence {sequence_id} not found")
|
| 65 |
+
rep = await run_in_threadpool(records.get_node, seq["rep_pdb"], seq["rep_chain"])
|
| 66 |
+
if rep:
|
| 67 |
+
seq["sequence"] = rep["sequence"]
|
| 68 |
+
seq["functions"] = rep["functions"]
|
| 69 |
+
seq["cath_superfamilies"] = rep["cath_superfamilies"]
|
| 70 |
+
return seq
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@router.get("/chain/{pdb_id}/{chain_id}")
|
| 74 |
+
async def get_chain(pdb_id: str, chain_id: str):
|
| 75 |
+
"""Full record for one chain plus a summary of its sequence family."""
|
| 76 |
+
node = await run_in_threadpool(records.get_node, pdb_id, chain_id)
|
| 77 |
+
if node is None:
|
| 78 |
+
raise HTTPException(status_code=404, detail=f"{pdb_id.upper()}.{chain_id} is not in MuSProt")
|
| 79 |
+
node["family"] = await run_in_threadpool(catalog.sequence_summary, node["sequence_id"])
|
| 80 |
+
node["ecod_names"] = await run_in_threadpool(catalog.ecod_names, None, node["ecod_fids"])
|
| 81 |
+
return node
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@router.get("/chain/{pdb_id}/{chain_id}/transitions")
|
| 85 |
+
async def get_chain_transitions(pdb_id: str, chain_id: str):
|
| 86 |
+
"""Every comparison from this chain to other observations of the same sequence."""
|
| 87 |
+
node = await run_in_threadpool(records.get_node, pdb_id, chain_id)
|
| 88 |
+
if node is None:
|
| 89 |
+
raise HTTPException(status_code=404, detail=f"{pdb_id.upper()}.{chain_id} is not in MuSProt")
|
| 90 |
+
edges = await run_in_threadpool(records.get_transitions, node["pdb_id"], node["auth_asym_id"])
|
| 91 |
+
members = await run_in_threadpool(catalog.sequence_members, node["sequence_id"])
|
| 92 |
+
for e in edges:
|
| 93 |
+
m = members.get((e["pdb_id"], e["auth_asym_id"]))
|
| 94 |
+
if m:
|
| 95 |
+
e.update(
|
| 96 |
+
experimental_method=m["experimental_method"],
|
| 97 |
+
resolution=m["resolution"],
|
| 98 |
+
binding_status=m["binding_status"],
|
| 99 |
+
chain_composition=m["chain_composition"],
|
| 100 |
+
binders=m["binders"],
|
| 101 |
+
release_date=m["release_date"],
|
| 102 |
+
)
|
| 103 |
+
return {
|
| 104 |
+
"pdb_id": node["pdb_id"],
|
| 105 |
+
"auth_asym_id": node["auth_asym_id"],
|
| 106 |
+
"state_id": node["state_id"],
|
| 107 |
+
"sequence_id": node["sequence_id"],
|
| 108 |
+
"transitions": edges,
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@router.get("/graph/outer")
|
| 113 |
+
async def get_outer_graph(
|
| 114 |
+
seq: str = Query(..., max_length=400, description="Comma-separated sequence IDs whose ECOD groups to show"),
|
| 115 |
+
per_group: int = Query(14, ge=2, le=40),
|
| 116 |
+
):
|
| 117 |
+
"""Outer layer of the knowledge graph around the given sequences (one clique per ECOD H-set)."""
|
| 118 |
+
ids = [i.strip().upper() for i in seq.split(",") if i.strip()][:12]
|
| 119 |
+
listed = _index_required(await run_in_threadpool(catalog.list_sequences, "sequence", "asc", None, None, None,
|
| 120 |
+
None, None, None, 0, len(ids), ids))
|
| 121 |
+
hsets = []
|
| 122 |
+
for s_ in sorted(listed["items"], key=lambda x: ids.index(x["sequence_id"])):
|
| 123 |
+
hs = ";".join(s_["ecod_hset"])
|
| 124 |
+
if hs and hs not in hsets:
|
| 125 |
+
hsets.append(hs)
|
| 126 |
+
return await run_in_threadpool(catalog.outer_graph, hsets, per_group, ids)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@router.get("/graph/inner/{sequence_id}")
|
| 130 |
+
async def get_inner_graph(
|
| 131 |
+
sequence_id: str,
|
| 132 |
+
max_nodes: int = Query(60, ge=2, le=150),
|
| 133 |
+
max_states: int = Query(7, ge=1, le=40),
|
| 134 |
+
):
|
| 135 |
+
"""Inner layer for one sequence: observations of its largest states and their pairwise similarities."""
|
| 136 |
+
data = _index_required(
|
| 137 |
+
await run_in_threadpool(catalog.inner_graph_nodes, sequence_id.upper(), max_nodes, max_states)
|
| 138 |
+
)
|
| 139 |
+
if not data:
|
| 140 |
+
raise HTTPException(status_code=404, detail=f"Sequence {sequence_id} not found")
|
| 141 |
+
chains = [(n["pdb_id"], n["auth_asym_id"]) for n in data["nodes"]]
|
| 142 |
+
data["edges"] = await run_in_threadpool(records.pair_similarities, chains)
|
| 143 |
+
return data
|
backend/app/protein/catalog.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Queries against the website index sidecar (MuSProt-index.db).
|
| 3 |
+
|
| 4 |
+
The sidecar is built by scripts/build_index.py and holds the sequence catalog,
|
| 5 |
+
per-chain membership, state sizes, state-pair similarities and an FTS index of
|
| 6 |
+
function text. Every function degrades to None / [] when the sidecar is absent,
|
| 7 |
+
so chain-level pages keep working off the main DB alone.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import re
|
| 13 |
+
import sqlite3
|
| 14 |
+
from functools import lru_cache
|
| 15 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 16 |
+
|
| 17 |
+
from app.protein.config import get_index_database_path
|
| 18 |
+
from app.protein.database import connect_readonly
|
| 19 |
+
|
| 20 |
+
MAX_HEATMAP_STATES = 40
|
| 21 |
+
|
| 22 |
+
SORTABLE = {
|
| 23 |
+
"states": "n_states",
|
| 24 |
+
"observations": "n_obs",
|
| 25 |
+
"entries": "n_entries",
|
| 26 |
+
"length": "length",
|
| 27 |
+
"released": "last_release",
|
| 28 |
+
"sequence": "sequence_id",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
CHAIN_RE = re.compile(r"^([0-9][A-Za-z0-9]{3})\s*[.:_\s/-]\s*([A-Za-z0-9]{1,4})$")
|
| 32 |
+
PDB_RE = re.compile(r"^[0-9][A-Za-z0-9]{3}$")
|
| 33 |
+
SEQ_RE = re.compile(r"^SEQ\d{1,6}$", re.IGNORECASE)
|
| 34 |
+
UNIPROT_RE = re.compile(
|
| 35 |
+
r"^([OPQ][0-9][A-Z0-9]{3}[0-9]|[A-NR-Z][0-9]([A-Z][A-Z0-9]{2}[0-9]){1,2})(-\d+)?$",
|
| 36 |
+
re.IGNORECASE,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def available() -> bool:
|
| 41 |
+
return get_index_database_path() is not None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _connect() -> Optional[sqlite3.Connection]:
|
| 45 |
+
path = get_index_database_path()
|
| 46 |
+
if path is None:
|
| 47 |
+
return None
|
| 48 |
+
conn = connect_readonly(path)
|
| 49 |
+
conn.row_factory = sqlite3.Row
|
| 50 |
+
return conn
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@lru_cache(maxsize=1)
|
| 54 |
+
def overview() -> Optional[Dict[str, Any]]:
|
| 55 |
+
conn = _connect()
|
| 56 |
+
if conn is None:
|
| 57 |
+
return None
|
| 58 |
+
try:
|
| 59 |
+
row = conn.execute("SELECT value FROM meta WHERE key = 'overview'").fetchone()
|
| 60 |
+
return json.loads(row[0]) if row else None
|
| 61 |
+
finally:
|
| 62 |
+
conn.close()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _sequence_dict(r: sqlite3.Row) -> Dict[str, Any]:
|
| 66 |
+
return {
|
| 67 |
+
"sequence_id": r["sequence_id"],
|
| 68 |
+
"uniprot_id": r["uniprot_id"],
|
| 69 |
+
"length": r["length"],
|
| 70 |
+
"n_obs": r["n_obs"],
|
| 71 |
+
"n_states": r["n_states"],
|
| 72 |
+
"n_entries": r["n_entries"],
|
| 73 |
+
"n_holo": r["n_holo"],
|
| 74 |
+
"n_apo": r["n_apo"],
|
| 75 |
+
"n_cross_pairs": r["n_cross_pairs"],
|
| 76 |
+
"largest_state": r["largest_state"],
|
| 77 |
+
"rep_pdb": r["rep_pdb"],
|
| 78 |
+
"rep_chain": r["rep_chain"],
|
| 79 |
+
"cath_superfamily": r["cath_superfamily"],
|
| 80 |
+
"methods": [m for m in (r["methods"] or "").split(";") if m],
|
| 81 |
+
"first_release": r["first_release"],
|
| 82 |
+
"last_release": r["last_release"],
|
| 83 |
+
"top_function": r["top_function"],
|
| 84 |
+
"ecod_hset": [h for h in (r["ecod_hset"] or "").split(";") if h],
|
| 85 |
+
"pfam_ids": [h for h in (r["pfam_ids"] or "").split(";") if h],
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _member_dict(r: sqlite3.Row) -> Dict[str, Any]:
|
| 90 |
+
return {
|
| 91 |
+
"pdb_id": r["pdb_id"].lower(),
|
| 92 |
+
"auth_asym_id": r["auth_asym_id"],
|
| 93 |
+
"state_id": r["state_id"],
|
| 94 |
+
"uniprot_id": r["uniprot_id"],
|
| 95 |
+
"experimental_method": r["experimental_method"],
|
| 96 |
+
"resolution": r["resolution"],
|
| 97 |
+
"binding_status": r["binding_status"],
|
| 98 |
+
"chain_composition": r["chain_composition"],
|
| 99 |
+
"binders": [b for b in (r["binders"] or "").split(";") if b],
|
| 100 |
+
"release_date": r["release_date"],
|
| 101 |
+
"resolved_coverage": r["resolved_coverage"],
|
| 102 |
+
"cath_id": r["cath_id"],
|
| 103 |
+
"pfam_id": r["pfam_id"],
|
| 104 |
+
"ecod_id": r["ecod_id"],
|
| 105 |
+
"ecod_fid": r["ecod_fid"],
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def ecod_names(conn: Optional[sqlite3.Connection], f_ids: List[str] = (), h_ids: List[str] = ()) -> Dict[str, Any]:
|
| 110 |
+
"""Names for ECOD family ids (x.h.t.f) and homology ids (x.h)."""
|
| 111 |
+
own = conn is None
|
| 112 |
+
conn = conn or _connect()
|
| 113 |
+
if conn is None:
|
| 114 |
+
return {"families": {}, "homologies": {}}
|
| 115 |
+
try:
|
| 116 |
+
h_all = set(h_ids) | {".".join(f.split(".")[:2]) for f in f_ids}
|
| 117 |
+
fams = {}
|
| 118 |
+
for f in f_ids:
|
| 119 |
+
r = conn.execute("SELECT t_name, f_name FROM ecod_f WHERE f_id = ?", (f,)).fetchone()
|
| 120 |
+
if r:
|
| 121 |
+
fams[f] = {"t_name": r[0], "f_name": r[1]}
|
| 122 |
+
homs = {}
|
| 123 |
+
for h in h_all:
|
| 124 |
+
r = conn.execute("SELECT architecture, x_name, h_name FROM ecod_h WHERE h_id = ?", (h,)).fetchone()
|
| 125 |
+
if r:
|
| 126 |
+
homs[h] = {"architecture": r[0], "x_name": r[1], "h_name": r[2]}
|
| 127 |
+
return {"families": fams, "homologies": homs}
|
| 128 |
+
finally:
|
| 129 |
+
if own:
|
| 130 |
+
conn.close()
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def group_info(conn: sqlite3.Connection, hset: List[str]) -> Optional[Dict[str, Any]]:
|
| 134 |
+
if not hset:
|
| 135 |
+
return None
|
| 136 |
+
r = conn.execute("SELECT * FROM ecod_group WHERE hset = ?", (";".join(hset),)).fetchone()
|
| 137 |
+
if r is None:
|
| 138 |
+
return None
|
| 139 |
+
return {
|
| 140 |
+
"hset": r["hset"].split(";"), "label": r["label"], "n_sequences": r["n_sequences"],
|
| 141 |
+
"n_obs": r["n_obs"], "n_multistate": r["n_multistate"], "n_states": r["n_states"],
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _fts_query(q: str) -> str:
|
| 146 |
+
"""Turn free text into a safe FTS5 prefix query (every term must match)."""
|
| 147 |
+
terms = re.findall(r"[A-Za-z0-9]+", q)
|
| 148 |
+
return " ".join(f'"{t}"*' for t in terms[:8])
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# ─────────────────────────────────────────────────────────── browse
|
| 152 |
+
def list_sequences(
|
| 153 |
+
sort: str = "states",
|
| 154 |
+
order: str = "desc",
|
| 155 |
+
q: Optional[str] = None,
|
| 156 |
+
min_states: Optional[int] = None,
|
| 157 |
+
min_obs: Optional[int] = None,
|
| 158 |
+
method: Optional[str] = None,
|
| 159 |
+
ligand: Optional[str] = None,
|
| 160 |
+
cath: Optional[str] = None,
|
| 161 |
+
offset: int = 0,
|
| 162 |
+
limit: int = 25,
|
| 163 |
+
ids: Optional[List[str]] = None,
|
| 164 |
+
ecod: Optional[str] = None,
|
| 165 |
+
pfam: Optional[str] = None,
|
| 166 |
+
) -> Optional[Dict[str, Any]]:
|
| 167 |
+
conn = _connect()
|
| 168 |
+
if conn is None:
|
| 169 |
+
return None
|
| 170 |
+
where: List[str] = []
|
| 171 |
+
params: List[Any] = []
|
| 172 |
+
if ids:
|
| 173 |
+
where.append(f"s.sequence_id IN ({','.join('?' * len(ids))})")
|
| 174 |
+
params.extend(ids)
|
| 175 |
+
if q and _fts_query(q):
|
| 176 |
+
where.append("s.sequence_id IN (SELECT sequence_id FROM function_fts WHERE function_fts MATCH ?)")
|
| 177 |
+
params.append(_fts_query(q))
|
| 178 |
+
if min_states:
|
| 179 |
+
where.append("s.n_states >= ?")
|
| 180 |
+
params.append(min_states)
|
| 181 |
+
if min_obs:
|
| 182 |
+
where.append("s.n_obs >= ?")
|
| 183 |
+
params.append(min_obs)
|
| 184 |
+
if method:
|
| 185 |
+
where.append("(';' || s.methods || ';') LIKE ?")
|
| 186 |
+
params.append(f"%;{method};%")
|
| 187 |
+
if ligand == "both":
|
| 188 |
+
where.append("s.n_holo > 0 AND s.n_apo > 0")
|
| 189 |
+
elif ligand == "holo":
|
| 190 |
+
where.append("s.n_holo > 0")
|
| 191 |
+
elif ligand == "apo":
|
| 192 |
+
where.append("s.n_apo > 0")
|
| 193 |
+
if cath:
|
| 194 |
+
where.append("s.cath_superfamily LIKE ?")
|
| 195 |
+
params.append(f"{cath}%")
|
| 196 |
+
if ecod:
|
| 197 |
+
# an exact H-set (outer-graph group, e.g. "11.1;2003.1") or one H-group contained in it
|
| 198 |
+
if ";" in ecod:
|
| 199 |
+
where.append("s.ecod_hset = ?")
|
| 200 |
+
params.append(ecod)
|
| 201 |
+
else:
|
| 202 |
+
where.append("(';' || s.ecod_hset || ';') LIKE ?")
|
| 203 |
+
params.append(f"%;{ecod};%")
|
| 204 |
+
if pfam:
|
| 205 |
+
where.append("(';' || s.pfam_ids || ';') LIKE ?")
|
| 206 |
+
params.append(f"%;{pfam.upper()};%")
|
| 207 |
+
where_sql = f"WHERE {' AND '.join(where)}" if where else ""
|
| 208 |
+
col = SORTABLE.get(sort, "n_states")
|
| 209 |
+
direction = "ASC" if order == "asc" else "DESC"
|
| 210 |
+
try:
|
| 211 |
+
total = conn.execute(f"SELECT COUNT(*) FROM sequence s {where_sql}", params).fetchone()[0]
|
| 212 |
+
rows = conn.execute(
|
| 213 |
+
f"SELECT s.* FROM sequence s {where_sql} "
|
| 214 |
+
f"ORDER BY s.{col} {direction} NULLS LAST, s.n_obs DESC, s.sequence_id "
|
| 215 |
+
f"LIMIT ? OFFSET ?",
|
| 216 |
+
[*params, max(1, min(limit, 100)), max(0, offset)],
|
| 217 |
+
).fetchall()
|
| 218 |
+
finally:
|
| 219 |
+
conn.close()
|
| 220 |
+
return {"total": total, "offset": offset, "items": [_sequence_dict(r) for r in rows]}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
# ─────────────────────────────────────────────────────────── sequence detail
|
| 224 |
+
def get_sequence(sequence_id: str) -> Optional[Dict[str, Any]]:
|
| 225 |
+
conn = _connect()
|
| 226 |
+
if conn is None:
|
| 227 |
+
return None
|
| 228 |
+
try:
|
| 229 |
+
row = conn.execute(
|
| 230 |
+
"SELECT * FROM sequence WHERE sequence_id = ? COLLATE NOCASE", (sequence_id,)
|
| 231 |
+
).fetchone()
|
| 232 |
+
if row is None:
|
| 233 |
+
return {}
|
| 234 |
+
seq = _sequence_dict(row)
|
| 235 |
+
sid = seq["sequence_id"]
|
| 236 |
+
members = [
|
| 237 |
+
_member_dict(r) for r in conn.execute(
|
| 238 |
+
"SELECT * FROM member WHERE sequence_id = ? ORDER BY state_id, resolution IS NULL, resolution",
|
| 239 |
+
(sid,),
|
| 240 |
+
)
|
| 241 |
+
]
|
| 242 |
+
states = [
|
| 243 |
+
{"state_id": r["state_id"], "n_members": r["n_members"]}
|
| 244 |
+
for r in conn.execute(
|
| 245 |
+
"SELECT state_id, n_members FROM state WHERE sequence_id = ? "
|
| 246 |
+
"ORDER BY n_members DESC, state_id",
|
| 247 |
+
(sid,),
|
| 248 |
+
)
|
| 249 |
+
]
|
| 250 |
+
shown = {s["state_id"] for s in states[:MAX_HEATMAP_STATES]}
|
| 251 |
+
pairs = [
|
| 252 |
+
{
|
| 253 |
+
"a": r["state_a"], "b": r["state_b"], "similarity": r["similarity"],
|
| 254 |
+
"fidelity": r["fidelity"], "n_pairs": r["n_pairs"],
|
| 255 |
+
}
|
| 256 |
+
for r in conn.execute(
|
| 257 |
+
"SELECT state_a, state_b, similarity, fidelity, n_pairs FROM state_pair "
|
| 258 |
+
"WHERE sequence_id = ?",
|
| 259 |
+
(sid,),
|
| 260 |
+
)
|
| 261 |
+
if r["state_a"] in shown and r["state_b"] in shown
|
| 262 |
+
]
|
| 263 |
+
seq["ecod_group"] = group_info(conn, seq["ecod_hset"])
|
| 264 |
+
seq["ecod_names"] = ecod_names(conn, h_ids=seq["ecod_hset"])
|
| 265 |
+
finally:
|
| 266 |
+
conn.close()
|
| 267 |
+
seq.update(states=states, members=members, state_pairs=pairs,
|
| 268 |
+
heatmap_truncated=len(states) > MAX_HEATMAP_STATES)
|
| 269 |
+
return seq
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
def sequence_members(sequence_id: str) -> Dict[Tuple[str, str], Dict[str, Any]]:
|
| 273 |
+
"""Map (pdb_id, chain) -> member metadata for one sequence."""
|
| 274 |
+
conn = _connect()
|
| 275 |
+
if conn is None:
|
| 276 |
+
return {}
|
| 277 |
+
try:
|
| 278 |
+
return {
|
| 279 |
+
(r["pdb_id"].lower(), r["auth_asym_id"]): _member_dict(r)
|
| 280 |
+
for r in conn.execute("SELECT * FROM member WHERE sequence_id = ?", (sequence_id,))
|
| 281 |
+
}
|
| 282 |
+
finally:
|
| 283 |
+
conn.close()
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def sequence_summary(sequence_id: str) -> Optional[Dict[str, Any]]:
|
| 287 |
+
conn = _connect()
|
| 288 |
+
if conn is None:
|
| 289 |
+
return None
|
| 290 |
+
try:
|
| 291 |
+
row = conn.execute("SELECT * FROM sequence WHERE sequence_id = ?", (sequence_id,)).fetchone()
|
| 292 |
+
states = [
|
| 293 |
+
{"state_id": r["state_id"], "n_members": r["n_members"]}
|
| 294 |
+
for r in conn.execute(
|
| 295 |
+
"SELECT state_id, n_members FROM state WHERE sequence_id = ? ORDER BY n_members DESC, state_id",
|
| 296 |
+
(sequence_id,),
|
| 297 |
+
)
|
| 298 |
+
]
|
| 299 |
+
finally:
|
| 300 |
+
conn.close()
|
| 301 |
+
if row is None:
|
| 302 |
+
return None
|
| 303 |
+
out = _sequence_dict(row)
|
| 304 |
+
out["states"] = states
|
| 305 |
+
return out
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
# ─────────────────────────────────────────────────────────── search
|
| 309 |
+
def search(q: str, limit: int = 12) -> Dict[str, Any]:
|
| 310 |
+
"""Classify a free-text query and return typed hits."""
|
| 311 |
+
q = q.strip()
|
| 312 |
+
if not q:
|
| 313 |
+
return {"query": q, "kind": "empty", "chains": [], "sequences": []}
|
| 314 |
+
|
| 315 |
+
m = CHAIN_RE.match(q)
|
| 316 |
+
if m:
|
| 317 |
+
from app.protein import records
|
| 318 |
+
pdb, chain = m.group(1).lower(), m.group(2)
|
| 319 |
+
node_chains = [c for c in records.list_entry_chains(pdb)]
|
| 320 |
+
exact = [c for c in node_chains if c["auth_asym_id"] == chain] or [
|
| 321 |
+
c for c in node_chains if c["auth_asym_id"].lower() == chain.lower()
|
| 322 |
+
]
|
| 323 |
+
return {"query": q, "kind": "chain", "chains": exact, "sequences": [], "entry_chains": node_chains}
|
| 324 |
+
|
| 325 |
+
if PDB_RE.match(q) and not SEQ_RE.match(q):
|
| 326 |
+
from app.protein import records
|
| 327 |
+
chains = records.list_entry_chains(q.lower())
|
| 328 |
+
if chains:
|
| 329 |
+
return {"query": q, "kind": "entry", "chains": chains, "sequences": []}
|
| 330 |
+
|
| 331 |
+
conn = _connect()
|
| 332 |
+
if conn is None:
|
| 333 |
+
return {"query": q, "kind": "unavailable", "chains": [], "sequences": []}
|
| 334 |
+
try:
|
| 335 |
+
if SEQ_RE.match(q):
|
| 336 |
+
digits = re.sub(r"\D", "", q)
|
| 337 |
+
rows = conn.execute(
|
| 338 |
+
"SELECT * FROM sequence WHERE sequence_id = ?", (f"SEQ{int(digits):06d}",)
|
| 339 |
+
).fetchall()
|
| 340 |
+
return {"query": q, "kind": "sequence", "chains": [], "sequences": [_sequence_dict(r) for r in rows]}
|
| 341 |
+
|
| 342 |
+
if UNIPROT_RE.match(q):
|
| 343 |
+
rows = conn.execute(
|
| 344 |
+
"SELECT * FROM sequence WHERE sequence_id IN "
|
| 345 |
+
"(SELECT DISTINCT sequence_id FROM member WHERE uniprot_id = ? COLLATE NOCASE) "
|
| 346 |
+
"ORDER BY n_obs DESC LIMIT ?",
|
| 347 |
+
(q, limit),
|
| 348 |
+
).fetchall()
|
| 349 |
+
if not rows:
|
| 350 |
+
# multi-accession sequences store e.g. "O43318;Q15750"; the FTS column tokenises them
|
| 351 |
+
rows = conn.execute(
|
| 352 |
+
"SELECT s.* FROM function_fts f JOIN sequence s ON s.sequence_id = f.sequence_id "
|
| 353 |
+
"WHERE function_fts MATCH ? ORDER BY s.n_obs DESC LIMIT ?",
|
| 354 |
+
(f'uniprot_id : "{q}"', limit),
|
| 355 |
+
).fetchall()
|
| 356 |
+
if rows:
|
| 357 |
+
return {"query": q, "kind": "uniprot", "chains": [], "sequences": [_sequence_dict(r) for r in rows]}
|
| 358 |
+
|
| 359 |
+
fts = _fts_query(q)
|
| 360 |
+
if not fts:
|
| 361 |
+
return {"query": q, "kind": "text", "chains": [], "sequences": []}
|
| 362 |
+
rows = conn.execute(
|
| 363 |
+
"SELECT s.* FROM function_fts f JOIN sequence s ON s.sequence_id = f.sequence_id "
|
| 364 |
+
"WHERE function_fts MATCH ? ORDER BY bm25(function_fts), s.n_obs DESC LIMIT ?",
|
| 365 |
+
(fts, limit),
|
| 366 |
+
).fetchall()
|
| 367 |
+
total = conn.execute(
|
| 368 |
+
"SELECT COUNT(*) FROM function_fts WHERE function_fts MATCH ?", (fts,)
|
| 369 |
+
).fetchone()[0]
|
| 370 |
+
return {
|
| 371 |
+
"query": q, "kind": "text", "chains": [], "total": total,
|
| 372 |
+
"sequences": [_sequence_dict(r) for r in rows],
|
| 373 |
+
}
|
| 374 |
+
finally:
|
| 375 |
+
conn.close()
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
# ─────────────────────────────────────────────────────────── knowledge graph
|
| 379 |
+
def outer_graph(hsets: List[str], per_group: int = 14, include: List[str] = ()) -> Optional[Dict[str, Any]]:
|
| 380 |
+
"""Outer layer: sequences grouped by identical ECOD H-set (each group is a clique)."""
|
| 381 |
+
conn = _connect()
|
| 382 |
+
if conn is None:
|
| 383 |
+
return None
|
| 384 |
+
try:
|
| 385 |
+
groups = []
|
| 386 |
+
for hs in hsets:
|
| 387 |
+
info = group_info(conn, hs.split(";"))
|
| 388 |
+
if info is None:
|
| 389 |
+
continue
|
| 390 |
+
must = [
|
| 391 |
+
_sequence_dict(r) for r in conn.execute(
|
| 392 |
+
f"SELECT * FROM sequence WHERE ecod_hset = ? AND sequence_id IN ({','.join('?' * len(include))})",
|
| 393 |
+
(hs, *include),
|
| 394 |
+
)
|
| 395 |
+
] if include else []
|
| 396 |
+
rest = [
|
| 397 |
+
_sequence_dict(r) for r in conn.execute(
|
| 398 |
+
"SELECT * FROM sequence WHERE ecod_hset = ? ORDER BY n_states DESC, n_obs DESC LIMIT ?",
|
| 399 |
+
(hs, per_group),
|
| 400 |
+
)
|
| 401 |
+
]
|
| 402 |
+
seen = {m["sequence_id"] for m in must}
|
| 403 |
+
members = (must + [r for r in rest if r["sequence_id"] not in seen])[:per_group]
|
| 404 |
+
groups.append({**info, "sequences": members})
|
| 405 |
+
names = ecod_names(conn, h_ids=[h for g in groups for h in g["hset"]])
|
| 406 |
+
finally:
|
| 407 |
+
conn.close()
|
| 408 |
+
return {"groups": groups, "names": names["homologies"]}
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
def inner_graph_nodes(sequence_id: str, max_nodes: int = 60, max_states: int = 7) -> Optional[Dict[str, Any]]:
|
| 412 |
+
"""Pick up to max_nodes observations from the sequence's max_states largest states."""
|
| 413 |
+
conn = _connect()
|
| 414 |
+
if conn is None:
|
| 415 |
+
return None
|
| 416 |
+
try:
|
| 417 |
+
row = conn.execute("SELECT * FROM sequence WHERE sequence_id = ?", (sequence_id,)).fetchone()
|
| 418 |
+
if row is None:
|
| 419 |
+
return {}
|
| 420 |
+
members = [_member_dict(r) for r in conn.execute(
|
| 421 |
+
"SELECT * FROM member WHERE sequence_id = ? ORDER BY state_id, resolution IS NULL, resolution",
|
| 422 |
+
(sequence_id,),
|
| 423 |
+
)]
|
| 424 |
+
states = [
|
| 425 |
+
{"state_id": r[0], "n_members": r[1]} for r in conn.execute(
|
| 426 |
+
"SELECT state_id, n_members FROM state WHERE sequence_id = ? ORDER BY n_members DESC, state_id",
|
| 427 |
+
(sequence_id,),
|
| 428 |
+
)
|
| 429 |
+
]
|
| 430 |
+
pairs = [
|
| 431 |
+
{"a": r[0], "b": r[1], "similarity": r[2], "fidelity": r[3], "n_pairs": r[4]}
|
| 432 |
+
for r in conn.execute(
|
| 433 |
+
"SELECT state_a, state_b, similarity, fidelity, n_pairs FROM state_pair WHERE sequence_id = ?",
|
| 434 |
+
(sequence_id,),
|
| 435 |
+
)
|
| 436 |
+
]
|
| 437 |
+
finally:
|
| 438 |
+
conn.close()
|
| 439 |
+
|
| 440 |
+
order = [s["state_id"] for s in states[:max_states]]
|
| 441 |
+
pool = [m for m in members if m["state_id"] in set(order)]
|
| 442 |
+
if len(pool) <= max_nodes:
|
| 443 |
+
chosen = pool
|
| 444 |
+
else:
|
| 445 |
+
# round-robin across the kept states (largest first) so smaller states still appear
|
| 446 |
+
by_state: Dict[int, List[Dict[str, Any]]] = {}
|
| 447 |
+
for m in pool:
|
| 448 |
+
by_state.setdefault(m["state_id"], []).append(m)
|
| 449 |
+
chosen, depth = [], 0
|
| 450 |
+
while len(chosen) < max_nodes:
|
| 451 |
+
added = False
|
| 452 |
+
for st in order:
|
| 453 |
+
bucket = by_state.get(st, [])
|
| 454 |
+
if depth < len(bucket) and len(chosen) < max_nodes:
|
| 455 |
+
chosen.append(bucket[depth])
|
| 456 |
+
added = True
|
| 457 |
+
if not added:
|
| 458 |
+
break
|
| 459 |
+
depth += 1
|
| 460 |
+
shown_states = {s["state_id"] for s in states[:MAX_HEATMAP_STATES]}
|
| 461 |
+
return {
|
| 462 |
+
"sequence": _sequence_dict(row),
|
| 463 |
+
"nodes": chosen,
|
| 464 |
+
"n_total": len(members),
|
| 465 |
+
"states": states,
|
| 466 |
+
"state_pairs": [p for p in pairs if p["a"] in shown_states and p["b"] in shown_states],
|
| 467 |
+
}
|
backend/app/protein/config.py
CHANGED
|
@@ -66,18 +66,18 @@ def get_summary_path() -> Path | None:
|
|
| 66 |
)
|
| 67 |
|
| 68 |
|
| 69 |
-
def
|
| 70 |
-
"""Return
|
| 71 |
-
explicit_path = os.getenv("
|
| 72 |
if explicit_path:
|
| 73 |
path = Path(explicit_path).expanduser().resolve()
|
| 74 |
-
if path.exists()
|
| 75 |
-
return path
|
| 76 |
|
| 77 |
-
return (
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
|
|
|
| 81 |
|
| 82 |
|
| 83 |
def get_docs_path() -> Path | None:
|
|
|
|
| 66 |
)
|
| 67 |
|
| 68 |
|
| 69 |
+
def get_index_database_path() -> Path | None:
|
| 70 |
+
"""Return the website index sidecar (built by scripts/build_index.py), if present."""
|
| 71 |
+
explicit_path = os.getenv("MUSPROT_INDEX_DB_PATH")
|
| 72 |
if explicit_path:
|
| 73 |
path = Path(explicit_path).expanduser().resolve()
|
| 74 |
+
return path if path.exists() else None
|
|
|
|
| 75 |
|
| 76 |
+
return _existing_path(
|
| 77 |
+
DEFAULT_VOLUME_DIR / "MuSProt-index.db",
|
| 78 |
+
ASSETS_DIR / "MuSProt-index.db",
|
| 79 |
+
LOCAL_DATA_DIR / "MuSProt-index.db",
|
| 80 |
+
)
|
| 81 |
|
| 82 |
|
| 83 |
def get_docs_path() -> Path | None:
|
backend/app/protein/data_loader.py
CHANGED
|
@@ -44,6 +44,13 @@ class DataManager:
|
|
| 44 |
if summary_path:
|
| 45 |
self._summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
| 46 |
self._total_records = int(self._summary.get("total_records", 0))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
conn = connect_readonly(self.db_path)
|
| 49 |
try:
|
|
@@ -100,7 +107,7 @@ class DataManager:
|
|
| 100 |
try:
|
| 101 |
cur = conn.cursor()
|
| 102 |
cur.execute(
|
| 103 |
-
"SELECT MIN(CAST(
|
| 104 |
" MIN(CAST(RMSD AS REAL)), MAX(CAST(RMSD AS REAL))"
|
| 105 |
" FROM edge"
|
| 106 |
)
|
|
@@ -161,16 +168,16 @@ class DataManager:
|
|
| 161 |
where_clauses.append("CAST(RMSD AS REAL) <= ?")
|
| 162 |
params.append(rmsd_max)
|
| 163 |
if tm_min is not None:
|
| 164 |
-
where_clauses.append("CAST(
|
| 165 |
params.append(tm_min)
|
| 166 |
if tm_max is not None:
|
| 167 |
-
where_clauses.append("CAST(
|
| 168 |
params.append(tm_max)
|
| 169 |
|
| 170 |
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
|
| 171 |
sql = f"""
|
| 172 |
SELECT e.pdb_id_A, e.auth_asym_id_A, e.pdb_id_B, e.auth_asym_id_B,
|
| 173 |
-
CAST(e.
|
| 174 |
CAST(e.RMSD AS REAL) AS RMSD,
|
| 175 |
CAST(e.pair_similarity AS REAL) AS structure_sim,
|
| 176 |
e."delta_Rosetta", e."delta_FoldX", e."delta_EvoEF2",
|
|
@@ -249,10 +256,10 @@ class DataManager:
|
|
| 249 |
where_clauses.append("CAST(RMSD AS REAL) <= ?")
|
| 250 |
params.append(rmsd_max)
|
| 251 |
if tm_min is not None:
|
| 252 |
-
where_clauses.append("CAST(
|
| 253 |
params.append(tm_min)
|
| 254 |
if tm_max is not None:
|
| 255 |
-
where_clauses.append("CAST(
|
| 256 |
params.append(tm_max)
|
| 257 |
|
| 258 |
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
|
|
@@ -261,7 +268,7 @@ class DataManager:
|
|
| 261 |
try:
|
| 262 |
cur = conn.cursor()
|
| 263 |
cur.execute(
|
| 264 |
-
f"SELECT COUNT(*), AVG(CAST(RMSD AS REAL)), AVG(CAST(
|
| 265 |
f" FROM edge {where_sql}",
|
| 266 |
params,
|
| 267 |
)
|
|
@@ -292,7 +299,7 @@ class DataManager:
|
|
| 292 |
["0-0.5", "0.5-1.0", "1.0-1.5", "1.5-2.0", "2.0-5.0", ">5.0"],
|
| 293 |
)
|
| 294 |
tm_dist = _dist(
|
| 295 |
-
"
|
| 296 |
[0, 0.5, 0.7, 0.85, 0.95, 1.01],
|
| 297 |
["0-0.5", "0.5-0.7", "0.7-0.85", "0.85-0.95", "0.95-1.0"],
|
| 298 |
)
|
|
|
|
| 44 |
if summary_path:
|
| 45 |
self._summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
| 46 |
self._total_records = int(self._summary.get("total_records", 0))
|
| 47 |
+
from app.protein import catalog
|
| 48 |
+
overview = catalog.overview()
|
| 49 |
+
if overview:
|
| 50 |
+
# the index is rebuilt with every DB release, so its count wins over a stale summary
|
| 51 |
+
self._total_records = int(overview["counts"]["transitions"])
|
| 52 |
+
if self._summary:
|
| 53 |
+
self._summary["total_records"] = self._total_records
|
| 54 |
|
| 55 |
conn = connect_readonly(self.db_path)
|
| 56 |
try:
|
|
|
|
| 107 |
try:
|
| 108 |
cur = conn.cursor()
|
| 109 |
cur.execute(
|
| 110 |
+
"SELECT MIN(CAST(tm_aln AS REAL)), MAX(CAST(tm_aln AS REAL)),"
|
| 111 |
" MIN(CAST(RMSD AS REAL)), MAX(CAST(RMSD AS REAL))"
|
| 112 |
" FROM edge"
|
| 113 |
)
|
|
|
|
| 168 |
where_clauses.append("CAST(RMSD AS REAL) <= ?")
|
| 169 |
params.append(rmsd_max)
|
| 170 |
if tm_min is not None:
|
| 171 |
+
where_clauses.append("CAST(tm_aln AS REAL) >= ?")
|
| 172 |
params.append(tm_min)
|
| 173 |
if tm_max is not None:
|
| 174 |
+
where_clauses.append("CAST(tm_aln AS REAL) <= ?")
|
| 175 |
params.append(tm_max)
|
| 176 |
|
| 177 |
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
|
| 178 |
sql = f"""
|
| 179 |
SELECT e.pdb_id_A, e.auth_asym_id_A, e.pdb_id_B, e.auth_asym_id_B,
|
| 180 |
+
CAST(e.tm_aln AS REAL) AS TM1,
|
| 181 |
CAST(e.RMSD AS REAL) AS RMSD,
|
| 182 |
CAST(e.pair_similarity AS REAL) AS structure_sim,
|
| 183 |
e."delta_Rosetta", e."delta_FoldX", e."delta_EvoEF2",
|
|
|
|
| 256 |
where_clauses.append("CAST(RMSD AS REAL) <= ?")
|
| 257 |
params.append(rmsd_max)
|
| 258 |
if tm_min is not None:
|
| 259 |
+
where_clauses.append("CAST(tm_aln AS REAL) >= ?")
|
| 260 |
params.append(tm_min)
|
| 261 |
if tm_max is not None:
|
| 262 |
+
where_clauses.append("CAST(tm_aln AS REAL) <= ?")
|
| 263 |
params.append(tm_max)
|
| 264 |
|
| 265 |
where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
|
|
|
|
| 268 |
try:
|
| 269 |
cur = conn.cursor()
|
| 270 |
cur.execute(
|
| 271 |
+
f"SELECT COUNT(*), AVG(CAST(RMSD AS REAL)), AVG(CAST(tm_aln AS REAL))"
|
| 272 |
f" FROM edge {where_sql}",
|
| 273 |
params,
|
| 274 |
)
|
|
|
|
| 299 |
["0-0.5", "0.5-1.0", "1.0-1.5", "1.5-2.0", "2.0-5.0", ">5.0"],
|
| 300 |
)
|
| 301 |
tm_dist = _dist(
|
| 302 |
+
"tm_aln",
|
| 303 |
[0, 0.5, 0.7, 0.85, 0.95, 1.01],
|
| 304 |
["0-0.5", "0.5-0.7", "0.7-0.85", "0.85-0.95", "0.95-1.0"],
|
| 305 |
)
|
backend/app/protein/models.py
CHANGED
|
@@ -1,144 +1,144 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Data models for the protein visualization API.
|
| 3 |
-
"""
|
| 4 |
-
from typing import Optional, List, Dict, Any
|
| 5 |
-
from pydantic import BaseModel, Field
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
class CrystalGrow(BaseModel):
|
| 9 |
-
"""Crystal growth conditions."""
|
| 10 |
-
pH: Optional[float] = None
|
| 11 |
-
pH_structured: Optional[float] = None
|
| 12 |
-
pH_from_details: Optional[float] = None
|
| 13 |
-
temp: Optional[float] = None
|
| 14 |
-
pdbx_details: Optional[str] = None
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
class EntryInfo(BaseModel):
|
| 18 |
-
"""Entry-level information."""
|
| 19 |
-
rcsb_id: Optional[str] = None
|
| 20 |
-
title: Optional[str] = None
|
| 21 |
-
initial_release_date: Optional[str] = None
|
| 22 |
-
exptl_method: Optional[str] = None
|
| 23 |
-
resolution_combined: Optional[List[float]] = None
|
| 24 |
-
crystal_grow: Optional[CrystalGrow] = None
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
class PolymerEntity(BaseModel):
|
| 28 |
-
"""Polymer entity (sequence-level) information."""
|
| 29 |
-
rcsb_id: Optional[str] = None
|
| 30 |
-
entity_id: Optional[str] = None
|
| 31 |
-
sequence_length: Optional[int] = None
|
| 32 |
-
seq_can: Optional[str] = None
|
| 33 |
-
reference_sequences: Optional[List[Dict[str, str]]] = None
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
class PolymerEntityInstance(BaseModel):
|
| 37 |
-
"""Polymer entity instance (chain-level) information."""
|
| 38 |
-
rcsb_id: Optional[str] = None
|
| 39 |
-
asym_id: Optional[str] = None
|
| 40 |
-
auth_asym_id: Optional[str] = None
|
| 41 |
-
binding_affinity: Optional[Any] = None
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
class NonpolymerEntity(BaseModel):
|
| 45 |
-
"""Nonpolymer entity (ligand/binder) information."""
|
| 46 |
-
rcsb_id: Optional[str] = None
|
| 47 |
-
name: Optional[str] = None
|
| 48 |
-
formula_weight: Optional[float] = None
|
| 49 |
-
smiles: Optional[str] = None
|
| 50 |
-
inchi: Optional[str] = None
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
class ChainMetadata(BaseModel):
|
| 54 |
-
"""Complete chain metadata from RCSB."""
|
| 55 |
-
pdb_id: str
|
| 56 |
-
chain_id: str
|
| 57 |
-
entry: EntryInfo
|
| 58 |
-
polymer_entity: Optional[PolymerEntity] = None
|
| 59 |
-
polymer_entity_instance: Optional[PolymerEntityInstance] = None
|
| 60 |
-
nonpolymer_entities: Optional[List[NonpolymerEntity]] = None
|
| 61 |
-
binding_status: Optional[str] = None
|
| 62 |
-
chain_composition: Optional[str] = None
|
| 63 |
-
cath_id: Optional[str] = None
|
| 64 |
-
cath_superfamily: Optional[str] = None
|
| 65 |
-
state_id: Optional[str] = None
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
class FilterParams(BaseModel):
|
| 69 |
-
"""Request model for filtering protein data."""
|
| 70 |
-
chain_ids: Optional[List[str]] = Field(default=None, description="List of chain IDs to filter")
|
| 71 |
-
pdb_id: Optional[str] = Field(default=None, description="PDB ID for single chain search")
|
| 72 |
-
auth_asym_id: Optional[str] = Field(default=None, description="Chain ID for single chain search")
|
| 73 |
-
rmsd_min: Optional[float] = Field(default=None, ge=0, description="Minimum RMSD value")
|
| 74 |
-
rmsd_max: Optional[float] = Field(default=None, ge=0, description="Maximum RMSD value")
|
| 75 |
-
tm_score_min: Optional[float] = Field(default=None, ge=0, le=1, description="Minimum TM-score")
|
| 76 |
-
tm_score_max: Optional[float] = Field(default=None, ge=0, le=1, description="Maximum TM-score")
|
| 77 |
-
length_min: Optional[int] = Field(default=None, ge=0, description="Minimum sequence length")
|
| 78 |
-
length_max: Optional[int] = Field(default=None, ge=0, description="Maximum sequence length")
|
| 79 |
-
include_duplicates: Optional[bool] = Field(default=None, description="Include duplicate structures")
|
| 80 |
-
cluster_id: Optional[str] = Field(default=None, description="Structural similarity cluster ID")
|
| 81 |
-
limit: Optional[int] = Field(default=1000, ge=1, le=10000, description="Maximum results to return")
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
class FilterOptions(BaseModel):
|
| 85 |
-
"""Available filter options and ranges."""
|
| 86 |
-
chain_ids: List[str] = Field(description="Available chain IDs")
|
| 87 |
-
rmsd_range: Dict[str, float] = Field(description="Min and max RMSD values")
|
| 88 |
-
tm_score_range: Dict[str, float] = Field(description="Min and max TM-score values")
|
| 89 |
-
length_range: Dict[str, int] = Field(description="Min and max sequence lengths")
|
| 90 |
-
clusters: List[str] = Field(description="Available similarity clusters")
|
| 91 |
-
total_records: int = Field(description="Total number of records")
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
class DataRecord(BaseModel):
|
| 95 |
-
"""Single protein comparison record."""
|
| 96 |
-
pdb_id_a: str
|
| 97 |
-
auth_asym_id_a: str
|
| 98 |
-
pdb_id_b: str
|
| 99 |
-
auth_asym_id_b: str
|
| 100 |
-
tm_score: float
|
| 101 |
-
rmsd: float
|
| 102 |
-
structure_sim: Optional[float] = None
|
| 103 |
-
sequence_identity: Optional[float] = None
|
| 104 |
-
length_a: Optional[int] = None
|
| 105 |
-
length_b: Optional[int] = None
|
| 106 |
-
aligned_length: Optional[int] = None
|
| 107 |
-
status: Optional[str] = None
|
| 108 |
-
cluster_id: Optional[str] = None
|
| 109 |
-
exptl_method: Optional[str] = None
|
| 110 |
-
temp: Optional[float] = None
|
| 111 |
-
pH: Optional[float] = None
|
| 112 |
-
# Matched-chain (B) annotations from the node table
|
| 113 |
-
binding_status: Optional[str] = None
|
| 114 |
-
chain_composition: Optional[str] = None
|
| 115 |
-
# Energy score deltas fetched directly from the edge table
|
| 116 |
-
delta_rosetta: Optional[float] = None
|
| 117 |
-
delta_foldx: Optional[float] = None
|
| 118 |
-
delta_evoef2: Optional[float] = None
|
| 119 |
-
delta_rw: Optional[float] = None
|
| 120 |
-
delta_rw_plus: Optional[float] = None
|
| 121 |
-
# Alternative state similarity fields from edge table
|
| 122 |
-
state_id_b: Optional[str] = None
|
| 123 |
-
state_fidelity: Optional[str] = None
|
| 124 |
-
avg_sim: Optional[str] = None
|
| 125 |
-
observation_fidelity: Optional[str] = None
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
class DataResponse(BaseModel):
|
| 129 |
-
"""Response model for filtered data."""
|
| 130 |
-
data: List[DataRecord]
|
| 131 |
-
total: int
|
| 132 |
-
filtered: int
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
class SummaryStats(BaseModel):
|
| 136 |
-
"""Aggregate statistics for the dataset."""
|
| 137 |
-
total_records: int
|
| 138 |
-
unique_chains: int
|
| 139 |
-
avg_rmsd: float
|
| 140 |
-
avg_tm_score: float
|
| 141 |
-
avg_sequence_length: float
|
| 142 |
-
rmsd_distribution: Dict[str, int]
|
| 143 |
-
tm_score_distribution: Dict[str, int]
|
| 144 |
-
length_distribution: Dict[str, int]
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Data models for the protein visualization API.
|
| 3 |
+
"""
|
| 4 |
+
from typing import Optional, List, Dict, Any
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class CrystalGrow(BaseModel):
|
| 9 |
+
"""Crystal growth conditions."""
|
| 10 |
+
pH: Optional[float] = None
|
| 11 |
+
pH_structured: Optional[float] = None
|
| 12 |
+
pH_from_details: Optional[float] = None
|
| 13 |
+
temp: Optional[float] = None
|
| 14 |
+
pdbx_details: Optional[str] = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class EntryInfo(BaseModel):
|
| 18 |
+
"""Entry-level information."""
|
| 19 |
+
rcsb_id: Optional[str] = None
|
| 20 |
+
title: Optional[str] = None
|
| 21 |
+
initial_release_date: Optional[str] = None
|
| 22 |
+
exptl_method: Optional[str] = None
|
| 23 |
+
resolution_combined: Optional[List[float]] = None
|
| 24 |
+
crystal_grow: Optional[CrystalGrow] = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class PolymerEntity(BaseModel):
|
| 28 |
+
"""Polymer entity (sequence-level) information."""
|
| 29 |
+
rcsb_id: Optional[str] = None
|
| 30 |
+
entity_id: Optional[str] = None
|
| 31 |
+
sequence_length: Optional[int] = None
|
| 32 |
+
seq_can: Optional[str] = None
|
| 33 |
+
reference_sequences: Optional[List[Dict[str, str]]] = None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class PolymerEntityInstance(BaseModel):
|
| 37 |
+
"""Polymer entity instance (chain-level) information."""
|
| 38 |
+
rcsb_id: Optional[str] = None
|
| 39 |
+
asym_id: Optional[str] = None
|
| 40 |
+
auth_asym_id: Optional[str] = None
|
| 41 |
+
binding_affinity: Optional[Any] = None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class NonpolymerEntity(BaseModel):
|
| 45 |
+
"""Nonpolymer entity (ligand/binder) information."""
|
| 46 |
+
rcsb_id: Optional[str] = None
|
| 47 |
+
name: Optional[str] = None
|
| 48 |
+
formula_weight: Optional[float] = None
|
| 49 |
+
smiles: Optional[str] = None
|
| 50 |
+
inchi: Optional[str] = None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class ChainMetadata(BaseModel):
|
| 54 |
+
"""Complete chain metadata from RCSB."""
|
| 55 |
+
pdb_id: str
|
| 56 |
+
chain_id: str
|
| 57 |
+
entry: EntryInfo
|
| 58 |
+
polymer_entity: Optional[PolymerEntity] = None
|
| 59 |
+
polymer_entity_instance: Optional[PolymerEntityInstance] = None
|
| 60 |
+
nonpolymer_entities: Optional[List[NonpolymerEntity]] = None
|
| 61 |
+
binding_status: Optional[str] = None
|
| 62 |
+
chain_composition: Optional[str] = None
|
| 63 |
+
cath_id: Optional[str] = None
|
| 64 |
+
cath_superfamily: Optional[str] = None
|
| 65 |
+
state_id: Optional[str] = None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class FilterParams(BaseModel):
|
| 69 |
+
"""Request model for filtering protein data."""
|
| 70 |
+
chain_ids: Optional[List[str]] = Field(default=None, description="List of chain IDs to filter")
|
| 71 |
+
pdb_id: Optional[str] = Field(default=None, description="PDB ID for single chain search")
|
| 72 |
+
auth_asym_id: Optional[str] = Field(default=None, description="Chain ID for single chain search")
|
| 73 |
+
rmsd_min: Optional[float] = Field(default=None, ge=0, description="Minimum RMSD value")
|
| 74 |
+
rmsd_max: Optional[float] = Field(default=None, ge=0, description="Maximum RMSD value")
|
| 75 |
+
tm_score_min: Optional[float] = Field(default=None, ge=0, le=1, description="Minimum TM-score")
|
| 76 |
+
tm_score_max: Optional[float] = Field(default=None, ge=0, le=1, description="Maximum TM-score")
|
| 77 |
+
length_min: Optional[int] = Field(default=None, ge=0, description="Minimum sequence length")
|
| 78 |
+
length_max: Optional[int] = Field(default=None, ge=0, description="Maximum sequence length")
|
| 79 |
+
include_duplicates: Optional[bool] = Field(default=None, description="Include duplicate structures")
|
| 80 |
+
cluster_id: Optional[str] = Field(default=None, description="Structural similarity cluster ID")
|
| 81 |
+
limit: Optional[int] = Field(default=1000, ge=1, le=10000, description="Maximum results to return")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class FilterOptions(BaseModel):
|
| 85 |
+
"""Available filter options and ranges."""
|
| 86 |
+
chain_ids: List[str] = Field(description="Available chain IDs")
|
| 87 |
+
rmsd_range: Dict[str, float] = Field(description="Min and max RMSD values")
|
| 88 |
+
tm_score_range: Dict[str, float] = Field(description="Min and max TM-score values")
|
| 89 |
+
length_range: Dict[str, int] = Field(description="Min and max sequence lengths")
|
| 90 |
+
clusters: List[str] = Field(description="Available similarity clusters")
|
| 91 |
+
total_records: int = Field(description="Total number of records")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class DataRecord(BaseModel):
|
| 95 |
+
"""Single protein comparison record."""
|
| 96 |
+
pdb_id_a: str
|
| 97 |
+
auth_asym_id_a: str
|
| 98 |
+
pdb_id_b: str
|
| 99 |
+
auth_asym_id_b: str
|
| 100 |
+
tm_score: float
|
| 101 |
+
rmsd: float
|
| 102 |
+
structure_sim: Optional[float] = None
|
| 103 |
+
sequence_identity: Optional[float] = None
|
| 104 |
+
length_a: Optional[int] = None
|
| 105 |
+
length_b: Optional[int] = None
|
| 106 |
+
aligned_length: Optional[int] = None
|
| 107 |
+
status: Optional[str] = None
|
| 108 |
+
cluster_id: Optional[str] = None
|
| 109 |
+
exptl_method: Optional[str] = None
|
| 110 |
+
temp: Optional[float] = None
|
| 111 |
+
pH: Optional[float] = None
|
| 112 |
+
# Matched-chain (B) annotations from the node table
|
| 113 |
+
binding_status: Optional[str] = None
|
| 114 |
+
chain_composition: Optional[str] = None
|
| 115 |
+
# Energy score deltas fetched directly from the edge table
|
| 116 |
+
delta_rosetta: Optional[float] = None
|
| 117 |
+
delta_foldx: Optional[float] = None
|
| 118 |
+
delta_evoef2: Optional[float] = None
|
| 119 |
+
delta_rw: Optional[float] = None
|
| 120 |
+
delta_rw_plus: Optional[float] = None
|
| 121 |
+
# Alternative state similarity fields from edge table
|
| 122 |
+
state_id_b: Optional[str] = None
|
| 123 |
+
state_fidelity: Optional[str] = None
|
| 124 |
+
avg_sim: Optional[str] = None
|
| 125 |
+
observation_fidelity: Optional[str] = None
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class DataResponse(BaseModel):
|
| 129 |
+
"""Response model for filtered data."""
|
| 130 |
+
data: List[DataRecord]
|
| 131 |
+
total: int
|
| 132 |
+
filtered: int
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class SummaryStats(BaseModel):
|
| 136 |
+
"""Aggregate statistics for the dataset."""
|
| 137 |
+
total_records: int
|
| 138 |
+
unique_chains: int
|
| 139 |
+
avg_rmsd: float
|
| 140 |
+
avg_tm_score: float
|
| 141 |
+
avg_sequence_length: float
|
| 142 |
+
rmsd_distribution: Dict[str, int]
|
| 143 |
+
tm_score_distribution: Dict[str, int]
|
| 144 |
+
length_distribution: Dict[str, int]
|
backend/app/protein/records.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Per-chain reads from the main MuSProt.db.
|
| 3 |
+
|
| 4 |
+
Both tables carry a (pdb_id COLLATE NOCASE, chain) index, so every query here
|
| 5 |
+
is a single index seek. pdb_id is case-insensitive; chain ids are case-sensitive
|
| 6 |
+
(`d` and `D` are different chains), with a case-insensitive fallback for
|
| 7 |
+
convenience when the exact chain does not exist.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import math
|
| 13 |
+
import sqlite3
|
| 14 |
+
from typing import Any, Dict, List, Optional
|
| 15 |
+
|
| 16 |
+
from app.protein.config import get_database_path
|
| 17 |
+
from app.protein.database import connect_readonly
|
| 18 |
+
|
| 19 |
+
ENERGY_TERMS = ["Rosetta", "FoldX", "EvoEF2", "RW", "RW+"]
|
| 20 |
+
|
| 21 |
+
EDGE_COLUMNS = [
|
| 22 |
+
"pdb_id_B", "auth_asym_id_B", "tm_aln", "RMSD", "coverage", "pair_similarity",
|
| 23 |
+
"delta_Rosetta", "delta_FoldX", "delta_EvoEF2", "delta_RW", "delta_RW+",
|
| 24 |
+
"state_id_B", "state_similarity", "state_fidelity", "pair_fidelity",
|
| 25 |
+
"n_shared_resolved", "delta_order_valid", "order_invalid_reason", "n_ordered", "n_disordered",
|
| 26 |
+
"ordered_ranges", "disordered_ranges", "longest_ordered_segment", "longest_disordered_segment",
|
| 27 |
+
"n_terminal_ordered", "n_internal_ordered", "order_evidence", "ordering_with_ligand_change",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _connect() -> sqlite3.Connection:
|
| 32 |
+
conn = connect_readonly(get_database_path())
|
| 33 |
+
conn.row_factory = sqlite3.Row
|
| 34 |
+
return conn
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def num(value: Any) -> Optional[float]:
|
| 38 |
+
if value is None or value == "":
|
| 39 |
+
return None
|
| 40 |
+
try:
|
| 41 |
+
f = float(value)
|
| 42 |
+
except (TypeError, ValueError):
|
| 43 |
+
return None
|
| 44 |
+
return None if math.isnan(f) or math.isinf(f) else f
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def integer(value: Any) -> Optional[int]:
|
| 48 |
+
f = num(value)
|
| 49 |
+
return None if f is None else int(f)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def text(value: Any) -> Optional[str]:
|
| 53 |
+
if value is None:
|
| 54 |
+
return None
|
| 55 |
+
s = str(value).strip()
|
| 56 |
+
return None if s in ("", "None", "nan", "<NA>") else s
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _parse_list(raw: Any) -> List[Any]:
|
| 60 |
+
raw = (raw or "").strip() if isinstance(raw, str) else raw
|
| 61 |
+
if not raw:
|
| 62 |
+
return []
|
| 63 |
+
try:
|
| 64 |
+
val = json.loads(raw)
|
| 65 |
+
except ValueError:
|
| 66 |
+
import ast
|
| 67 |
+
try:
|
| 68 |
+
val = ast.literal_eval(raw)
|
| 69 |
+
except (ValueError, SyntaxError):
|
| 70 |
+
return [s.strip() for s in str(raw).split(";") if s.strip()]
|
| 71 |
+
return val if isinstance(val, list) else []
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _parse_ranges(raw: Optional[str]) -> List[List[int]]:
|
| 75 |
+
"""'5-173,187-353,400' -> [[5, 173], [187, 353], [400, 400]] (1-based, inclusive)."""
|
| 76 |
+
out = []
|
| 77 |
+
for part in (raw or "").split(","):
|
| 78 |
+
bounds = part.strip().split("-")
|
| 79 |
+
if len(bounds) == 2 and bounds[0].isdigit() and bounds[1].isdigit():
|
| 80 |
+
out.append([int(bounds[0]), int(bounds[1])])
|
| 81 |
+
elif len(bounds) == 1 and bounds[0].isdigit():
|
| 82 |
+
out.append([int(bounds[0]), int(bounds[0])])
|
| 83 |
+
return out
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _split(raw: Any) -> List[str]:
|
| 87 |
+
return [x for x in (text(raw) or "").split(";") if x]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _flag(raw: Any) -> Optional[bool]:
|
| 91 |
+
t = text(raw)
|
| 92 |
+
return None if t is None else t == "True"
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def resolve_chain_key(conn: sqlite3.Connection, pdb_id: str, chain: str) -> Optional[str]:
|
| 96 |
+
"""Return the stored chain id for (pdb_id, chain), preferring an exact match."""
|
| 97 |
+
row = conn.execute(
|
| 98 |
+
"SELECT auth_asym_id FROM node WHERE pdb_id = ? COLLATE NOCASE AND auth_asym_id = ? LIMIT 1",
|
| 99 |
+
(pdb_id, chain),
|
| 100 |
+
).fetchone()
|
| 101 |
+
if row:
|
| 102 |
+
return row[0]
|
| 103 |
+
candidates = [
|
| 104 |
+
r[0] for r in conn.execute(
|
| 105 |
+
"SELECT auth_asym_id FROM node WHERE pdb_id = ? COLLATE NOCASE", (pdb_id,)
|
| 106 |
+
)
|
| 107 |
+
]
|
| 108 |
+
for c in candidates:
|
| 109 |
+
if c.lower() == chain.lower():
|
| 110 |
+
return c
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def get_node(pdb_id: str, chain: str) -> Optional[Dict[str, Any]]:
|
| 115 |
+
"""Full, parsed node record for one chain, or None when absent."""
|
| 116 |
+
conn = _connect()
|
| 117 |
+
try:
|
| 118 |
+
stored = resolve_chain_key(conn, pdb_id, chain)
|
| 119 |
+
if stored is None:
|
| 120 |
+
return None
|
| 121 |
+
r = conn.execute(
|
| 122 |
+
"SELECT * FROM node WHERE pdb_id = ? COLLATE NOCASE AND auth_asym_id = ? LIMIT 1",
|
| 123 |
+
(pdb_id, stored),
|
| 124 |
+
).fetchone()
|
| 125 |
+
finally:
|
| 126 |
+
conn.close()
|
| 127 |
+
|
| 128 |
+
functions = [str(f).strip() for f in _parse_list(r["ranked_functions"])]
|
| 129 |
+
scores = _parse_list(r["matching_scores"])
|
| 130 |
+
ranked = []
|
| 131 |
+
for i, name in enumerate(functions):
|
| 132 |
+
if len(name) <= 10:
|
| 133 |
+
continue
|
| 134 |
+
ranked.append({"function": name, "score": num(scores[i]) if i < len(scores) else None})
|
| 135 |
+
|
| 136 |
+
return {
|
| 137 |
+
"pdb_id": r["pdb_id"].lower(),
|
| 138 |
+
"auth_asym_id": r["auth_asym_id"],
|
| 139 |
+
"sequence_id": r["sequence_id"],
|
| 140 |
+
"uniprot_id": text(r["uniprot_id"]),
|
| 141 |
+
"state_id": integer(r["state_id"]),
|
| 142 |
+
"sequence": text(r["sequence"]) or "",
|
| 143 |
+
"sequence_length": integer(r["sequence_length"]),
|
| 144 |
+
"modeled_sequence_length": integer(r["modeled_sequence_length"]),
|
| 145 |
+
"resolved_ranges": _parse_ranges(r["resolved_label_seq"]),
|
| 146 |
+
"n_resolved_aa": integer(r["n_resolved_aa"]),
|
| 147 |
+
"resolved_coverage": num(r["resolved_coverage"]),
|
| 148 |
+
"binders": [b for b in (text(r["binders"]) or "").split(";") if b],
|
| 149 |
+
"binding_status": text(r["binding_status"]),
|
| 150 |
+
"experimental_method": text(r["experimental_method"]),
|
| 151 |
+
"resolution": num(r["resolution"]),
|
| 152 |
+
"pH": num(r["pH"]),
|
| 153 |
+
"temp_K": num(r["temp_K"]),
|
| 154 |
+
"experimental_details": text(r["experimental_details"]),
|
| 155 |
+
"chain_composition": text(r["chain_composition"]),
|
| 156 |
+
"nucleic_acid_binding": text(r["non_protein_polymer_binding"]),
|
| 157 |
+
"release_date": text(r["initial_release_date"]),
|
| 158 |
+
"cath_domains": _split(r["cath_id"]),
|
| 159 |
+
"cath_superfamilies": _split(r["cath_superfamily"]),
|
| 160 |
+
"pfam_ids": _split(r["pfam_id"]),
|
| 161 |
+
"ecod_domains": _split(r["ecod_id"]),
|
| 162 |
+
"ecod_fids": _split(r["ecod_fid"]),
|
| 163 |
+
"energies": {term: num(r[term]) for term in ENERGY_TERMS},
|
| 164 |
+
"scoring": {
|
| 165 |
+
"n_seqres": integer(r["n_seqres"]),
|
| 166 |
+
"n_scored": integer(r["n_scored"]),
|
| 167 |
+
"scored_coverage": num(r["scored_coverage"]),
|
| 168 |
+
"foldx_n_scored": integer(r["foldx_n_scored"]),
|
| 169 |
+
"foldx_coverage": num(r["foldx_coverage"]),
|
| 170 |
+
},
|
| 171 |
+
"functions": ranked,
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def get_transitions(pdb_id: str, chain: str, limit: int = 20000) -> List[Dict[str, Any]]:
|
| 176 |
+
"""All directed edges leaving one chain (A = the given chain)."""
|
| 177 |
+
cols = ", ".join(f'"{c}"' for c in EDGE_COLUMNS)
|
| 178 |
+
conn = _connect()
|
| 179 |
+
try:
|
| 180 |
+
stored = resolve_chain_key(conn, pdb_id, chain) or chain
|
| 181 |
+
rows = conn.execute(
|
| 182 |
+
f"SELECT {cols} FROM edge WHERE pdb_id_A = ? COLLATE NOCASE AND auth_asym_id_A = ? LIMIT ?",
|
| 183 |
+
(pdb_id, stored, limit),
|
| 184 |
+
).fetchall()
|
| 185 |
+
finally:
|
| 186 |
+
conn.close()
|
| 187 |
+
|
| 188 |
+
out = []
|
| 189 |
+
for r in rows:
|
| 190 |
+
sim = num(r["pair_similarity"])
|
| 191 |
+
out.append({
|
| 192 |
+
"pdb_id": r["pdb_id_B"].lower(),
|
| 193 |
+
"auth_asym_id": r["auth_asym_id_B"],
|
| 194 |
+
"state_id": integer(r["state_id_B"]),
|
| 195 |
+
"tm_aln": num(r["tm_aln"]),
|
| 196 |
+
# no shared residues: RMSD/coverage are stored as 0.0 but are really undefined
|
| 197 |
+
"rmsd": num(r["RMSD"]) if sim is not None else None,
|
| 198 |
+
"coverage": num(r["coverage"]) if sim is not None else None,
|
| 199 |
+
"similarity": sim,
|
| 200 |
+
"fidelity": text(r["pair_fidelity"]),
|
| 201 |
+
"state_similarity": num(r["state_similarity"]),
|
| 202 |
+
"state_fidelity": text(r["state_fidelity"]),
|
| 203 |
+
"delta": {
|
| 204 |
+
"Rosetta": num(r["delta_Rosetta"]),
|
| 205 |
+
"FoldX": num(r["delta_FoldX"]),
|
| 206 |
+
"EvoEF2": num(r["delta_EvoEF2"]),
|
| 207 |
+
"RW": num(r["delta_RW"]),
|
| 208 |
+
"RW+": num(r["delta_RW+"]),
|
| 209 |
+
},
|
| 210 |
+
"order": {
|
| 211 |
+
"valid": _flag(r["delta_order_valid"]),
|
| 212 |
+
"reason": text(r["order_invalid_reason"]),
|
| 213 |
+
"n_shared_resolved": integer(r["n_shared_resolved"]),
|
| 214 |
+
"n_ordered": integer(r["n_ordered"]),
|
| 215 |
+
"n_disordered": integer(r["n_disordered"]),
|
| 216 |
+
"ordered_ranges": _parse_ranges(r["ordered_ranges"]),
|
| 217 |
+
"disordered_ranges": _parse_ranges(r["disordered_ranges"]),
|
| 218 |
+
"longest_ordered": integer(r["longest_ordered_segment"]),
|
| 219 |
+
"longest_disordered": integer(r["longest_disordered_segment"]),
|
| 220 |
+
"n_terminal_ordered": integer(r["n_terminal_ordered"]),
|
| 221 |
+
"n_internal_ordered": integer(r["n_internal_ordered"]),
|
| 222 |
+
"evidence": text(r["order_evidence"]),
|
| 223 |
+
"with_ligand_change": _flag(r["ordering_with_ligand_change"]),
|
| 224 |
+
},
|
| 225 |
+
})
|
| 226 |
+
return out
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def pair_similarities(chains: List[tuple]) -> List[Dict[str, Any]]:
|
| 230 |
+
"""Undirected similarity edges among a set of (pdb_id, chain) observations of one sequence."""
|
| 231 |
+
keep = {(p.lower(), c): i for i, (p, c) in enumerate(chains)}
|
| 232 |
+
out = []
|
| 233 |
+
conn = _connect()
|
| 234 |
+
try:
|
| 235 |
+
for i, (pdb, chain) in enumerate(chains):
|
| 236 |
+
for r in conn.execute(
|
| 237 |
+
"SELECT pdb_id_B, auth_asym_id_B, pair_similarity, pair_fidelity FROM edge "
|
| 238 |
+
"WHERE pdb_id_A = ? COLLATE NOCASE AND auth_asym_id_A = ?",
|
| 239 |
+
(pdb, chain),
|
| 240 |
+
):
|
| 241 |
+
j = keep.get((r[0].lower(), r[1]))
|
| 242 |
+
if j is not None and j > i:
|
| 243 |
+
out.append({"s": i, "t": j, "sim": num(r[2]), "fid": text(r[3])})
|
| 244 |
+
finally:
|
| 245 |
+
conn.close()
|
| 246 |
+
return out
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def list_entry_chains(pdb_id: str) -> List[Dict[str, Any]]:
|
| 250 |
+
"""Chains of one PDB entry present in MuSProt (lightweight columns only)."""
|
| 251 |
+
conn = _connect()
|
| 252 |
+
try:
|
| 253 |
+
rows = conn.execute(
|
| 254 |
+
"SELECT pdb_id, auth_asym_id, sequence_id, uniprot_id, state_id, sequence_length "
|
| 255 |
+
"FROM node WHERE pdb_id = ? COLLATE NOCASE",
|
| 256 |
+
(pdb_id,),
|
| 257 |
+
).fetchall()
|
| 258 |
+
finally:
|
| 259 |
+
conn.close()
|
| 260 |
+
return [
|
| 261 |
+
{
|
| 262 |
+
"pdb_id": r["pdb_id"].lower(),
|
| 263 |
+
"auth_asym_id": r["auth_asym_id"],
|
| 264 |
+
"sequence_id": r["sequence_id"],
|
| 265 |
+
"uniprot_id": text(r["uniprot_id"]),
|
| 266 |
+
"state_id": integer(r["state_id"]),
|
| 267 |
+
"sequence_length": integer(r["sequence_length"]),
|
| 268 |
+
}
|
| 269 |
+
for r in rows
|
| 270 |
+
]
|
backend/app/protein/tsv_loader.py
CHANGED
|
@@ -8,11 +8,10 @@ key the first occurrence is kept.
|
|
| 8 |
import ast
|
| 9 |
import sqlite3
|
| 10 |
import logging
|
| 11 |
-
import zlib
|
| 12 |
from functools import lru_cache
|
| 13 |
from typing import Dict, List, Optional, Tuple, Any
|
| 14 |
|
| 15 |
-
from app.protein.config import get_database_path
|
| 16 |
from app.protein.database import connect_readonly
|
| 17 |
|
| 18 |
logger = logging.getLogger(__name__)
|
|
@@ -83,23 +82,25 @@ def reset_index() -> None:
|
|
| 83 |
|
| 84 |
@lru_cache(maxsize=4096)
|
| 85 |
def _lookup_node_row(pdb_id: str, auth_asym_id: str) -> Optional[_Row]:
|
| 86 |
-
"""Read one node row
|
| 87 |
-
|
|
|
|
|
|
|
| 88 |
try:
|
| 89 |
conn.row_factory = sqlite3.Row
|
|
|
|
|
|
|
|
|
|
| 90 |
row = conn.execute(
|
| 91 |
'SELECT binding_status, sequence, sequence_length, cath_id, cath_superfamily,'
|
| 92 |
' Rosetta, FoldX, EvoEF2, RW, "RW+", ranked_functions,'
|
| 93 |
' state_id, experimental_method, pH, temp_K, chain_composition, matching_scores'
|
| 94 |
-
' FROM node WHERE
|
| 95 |
-
(pdb_id
|
| 96 |
).fetchone()
|
| 97 |
if row is None:
|
| 98 |
return None
|
| 99 |
-
return {
|
| 100 |
-
key: zlib.decompress(value).decode("utf-8") if isinstance(value, bytes) else value or ""
|
| 101 |
-
for key, value in ((key, row[key]) for key in row.keys())
|
| 102 |
-
}
|
| 103 |
finally:
|
| 104 |
conn.close()
|
| 105 |
|
|
|
|
| 8 |
import ast
|
| 9 |
import sqlite3
|
| 10 |
import logging
|
|
|
|
| 11 |
from functools import lru_cache
|
| 12 |
from typing import Dict, List, Optional, Tuple, Any
|
| 13 |
|
| 14 |
+
from app.protein.config import get_database_path
|
| 15 |
from app.protein.database import connect_readonly
|
| 16 |
|
| 17 |
logger = logging.getLogger(__name__)
|
|
|
|
| 82 |
|
| 83 |
@lru_cache(maxsize=4096)
|
| 84 |
def _lookup_node_row(pdb_id: str, auth_asym_id: str) -> Optional[_Row]:
|
| 85 |
+
"""Read one node row via the (pdb_id COLLATE NOCASE, auth_asym_id) index."""
|
| 86 |
+
from app.protein.records import resolve_chain_key
|
| 87 |
+
|
| 88 |
+
conn = connect_readonly(get_database_path())
|
| 89 |
try:
|
| 90 |
conn.row_factory = sqlite3.Row
|
| 91 |
+
stored = resolve_chain_key(conn, pdb_id, auth_asym_id)
|
| 92 |
+
if stored is None:
|
| 93 |
+
return None
|
| 94 |
row = conn.execute(
|
| 95 |
'SELECT binding_status, sequence, sequence_length, cath_id, cath_superfamily,'
|
| 96 |
' Rosetta, FoldX, EvoEF2, RW, "RW+", ranked_functions,'
|
| 97 |
' state_id, experimental_method, pH, temp_K, chain_composition, matching_scores'
|
| 98 |
+
' FROM node WHERE pdb_id = ? COLLATE NOCASE AND auth_asym_id = ? LIMIT 1',
|
| 99 |
+
(pdb_id, stored),
|
| 100 |
).fetchone()
|
| 101 |
if row is None:
|
| 102 |
return None
|
| 103 |
+
return {key: row[key] or "" for key in row.keys()}
|
|
|
|
|
|
|
|
|
|
| 104 |
finally:
|
| 105 |
conn.close()
|
| 106 |
|
backend/assets/MuSProt_documentation.md
CHANGED
|
@@ -1,91 +1,192 @@
|
|
| 1 |
# MuSProt Dataset Documentation
|
| 2 |
|
| 3 |
-
**MuSProt** (Multistate Protein Database) is a million-scale multimodal database for multistate proteins, designed to support programmable protein design and AI model development. It links experimentally observed conformational states of identical protein sequences from the PDB, organizes them into state clusters and transition relationships, and enriches each record with structural similarity, experimental context, state-specific function rankings and transition fidelity labels.
|
| 4 |
|
| 5 |
---
|
| 6 |
|
| 7 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
---
|
| 14 |
|
| 15 |
-
##
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
|
| 19 |
---
|
| 20 |
|
|
|
|
|
|
|
| 21 |
### Table: `node`
|
| 22 |
|
| 23 |
-
|
| 24 |
|
| 25 |
| Column | Type | Description |
|
| 26 |
|---|---|---|
|
| 27 |
-
| `sequence_id` | TEXT |
|
| 28 |
-
| `uniprot_id` | TEXT | UniProt accession
|
| 29 |
| `pdb_id` | TEXT | 4-character PDB entry ID (lowercase) |
|
| 30 |
-
| `auth_asym_id` | TEXT | Author chain identifier (
|
| 31 |
-
| `sequence` | TEXT |
|
| 32 |
-
| `sequence_length` | INT |
|
| 33 |
-
| `modeled_sequence` | TEXT |
|
| 34 |
-
| `modeled_sequence_length` | INT |
|
| 35 |
-
| `
|
| 36 |
-
| `
|
| 37 |
-
| `
|
| 38 |
-
| `
|
| 39 |
-
| `
|
| 40 |
-
| `
|
| 41 |
-
| `
|
| 42 |
-
| `
|
| 43 |
-
| `
|
| 44 |
-
| `
|
| 45 |
-
| `
|
| 46 |
-
| `
|
| 47 |
-
| `
|
| 48 |
-
| `
|
| 49 |
-
| `
|
| 50 |
-
| `
|
| 51 |
-
| `
|
| 52 |
-
| `
|
| 53 |
-
| `
|
| 54 |
-
| `
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
### Table: `edge`
|
| 60 |
|
| 61 |
-
|
| 62 |
|
| 63 |
| Column | Type | Description |
|
| 64 |
|---|---|---|
|
| 65 |
-
| `pdb_id_A` | TEXT | PDB ID of
|
| 66 |
-
| `auth_asym_id_A` | TEXT | Chain
|
| 67 |
-
| `pdb_id_B` | TEXT | PDB ID of
|
| 68 |
-
| `auth_asym_id_B` | TEXT | Chain
|
| 69 |
-
| `
|
| 70 |
-
| `RMSD` | FLOAT |
|
| 71 |
-
| `
|
| 72 |
-
| `
|
| 73 |
-
| `
|
| 74 |
-
| `
|
| 75 |
-
| `
|
| 76 |
-
| `delta_RW
|
| 77 |
-
| `
|
| 78 |
-
| `
|
| 79 |
-
| `
|
| 80 |
-
| `
|
| 81 |
-
| `
|
| 82 |
-
| `
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
---
|
| 85 |
|
| 86 |
## Usage Examples
|
| 87 |
|
| 88 |
-
### Python (sqlite3)
|
| 89 |
|
| 90 |
```python
|
| 91 |
import sqlite3
|
|
@@ -93,59 +194,55 @@ import pandas as pd
|
|
| 93 |
|
| 94 |
conn = sqlite3.connect("MuSProt.db")
|
| 95 |
|
| 96 |
-
#
|
| 97 |
-
|
| 98 |
-
"SELECT * FROM node WHERE
|
| 99 |
-
conn
|
| 100 |
)
|
| 101 |
|
| 102 |
-
#
|
| 103 |
-
|
| 104 |
-
"SELECT * FROM edge WHERE pdb_id_A =
|
| 105 |
-
conn
|
| 106 |
)
|
| 107 |
-
|
| 108 |
-
conn.close()
|
| 109 |
```
|
| 110 |
|
| 111 |
-
###
|
| 112 |
|
| 113 |
```python
|
| 114 |
-
# Retrieve pairs with high TM-score and low RMSD
|
| 115 |
df = pd.read_sql("""
|
| 116 |
-
SELECT
|
|
|
|
|
|
|
|
|
|
| 117 |
FROM edge
|
| 118 |
-
WHERE
|
| 119 |
-
AND
|
| 120 |
-
|
| 121 |
-
|
|
|
|
| 122 |
```
|
| 123 |
|
| 124 |
-
###
|
| 125 |
|
| 126 |
```python
|
| 127 |
-
# Get full info for both chains in each pair
|
| 128 |
df = pd.read_sql("""
|
| 129 |
-
SELECT
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
FROM edge e
|
| 137 |
-
JOIN node nA ON e.pdb_id_A = nA.pdb_id AND e.auth_asym_id_A = nA.auth_asym_id
|
| 138 |
-
JOIN node nB ON e.pdb_id_B = nB.pdb_id AND e.auth_asym_id_B = nB.auth_asym_id
|
| 139 |
-
WHERE nA.uniprot_id = 'P00533'
|
| 140 |
-
LIMIT 500
|
| 141 |
""", conn)
|
| 142 |
```
|
| 143 |
|
|
|
|
|
|
|
| 144 |
---
|
| 145 |
|
| 146 |
## Notes
|
| 147 |
|
| 148 |
-
- All
|
| 149 |
-
- `ranked_functions`
|
| 150 |
-
-
|
| 151 |
-
- Energy delta values represent B − A; a negative delta means chain B is lower energy than chain A.
|
|
|
|
| 1 |
# MuSProt Dataset Documentation
|
| 2 |
|
| 3 |
+
**MuSProt** (Multistate Protein Database) is a million-scale multimodal database for multistate proteins, designed to support programmable protein design and AI model development. It links experimentally observed conformational states of identical protein sequences from the PDB, organizes them into state clusters and transition relationships, and enriches each record with structural similarity, experimental context, state-specific function rankings and transition fidelity labels.
|
| 4 |
|
| 5 |
---
|
| 6 |
|
| 7 |
+
## At a glance
|
| 8 |
+
|
| 9 |
+
| | Count |
|
| 10 |
+
|---|---|
|
| 11 |
+
| Conformational observations (`node` rows, one per PDB chain) | 863,643 |
|
| 12 |
+
| Sequences (identical SEQRES observed ≥ 2 times) | 102,790 |
|
| 13 |
+
| Sequences with ≥ 2 conformational states | 24,673 |
|
| 14 |
+
| State clusters | 162,601 |
|
| 15 |
+
| Transition triplets (`edge` rows, ordered observation pairs) | 113,663,858 |
|
| 16 |
+
| Transitions between distinct states | 25,568,258 |
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## Concepts
|
| 21 |
+
|
| 22 |
+
### Observation
|
| 23 |
+
|
| 24 |
+
One protein chain (`pdb_id`, `auth_asym_id`) in one PDB entry. Observations whose full deposited sequence (SEQRES) is 100% identical share a `sequence_id`. Only sequences observed at least twice are included.
|
| 25 |
+
|
| 26 |
+
### State cluster
|
| 27 |
+
|
| 28 |
+
Within each sequence, two observations are linked when their **pair similarity is ≥ 0.95**. The connected components of that graph are the conformational states, numbered `0, 1, 2, …` per sequence (`state_id`).
|
| 29 |
+
|
| 30 |
+
### Transition
|
| 31 |
+
|
| 32 |
+
Every ordered pair (A → B) of observations of the same sequence is an `edge` row. Both directions are stored; they share all structural fields and differ only in the sign of the energy differences (B − A).
|
| 33 |
+
|
| 34 |
+
### Structural similarity
|
| 35 |
+
|
| 36 |
+
Pairs are superposed with US-align on a fixed residue correspondence (identical SEQRES numbering), and similarity is computed over the aligned core so that disordered or unmodelled termini do not penalise otherwise identical conformations:
|
| 37 |
+
|
| 38 |
+
```
|
| 39 |
+
tm_aln = (TM_A · L_A + TM_B · L_B) / (2 · aligned_length)
|
| 40 |
+
pair_similarity = 0.8 · tm_aln + 0.2 · exp(−(RMSD / 5 Å)²)
|
| 41 |
+
coverage = aligned_length / max(L_A, L_B)
|
| 42 |
+
```
|
| 43 |
|
| 44 |
+
`coverage` is reported separately as the completeness axis.
|
| 45 |
|
| 46 |
+
### Fidelity labels
|
| 47 |
+
|
| 48 |
+
`pair_fidelity` (from `pair_similarity`) and `state_fidelity` (from `state_similarity`) bin how far a transition moves:
|
| 49 |
+
|
| 50 |
+
| Label | Similarity | Meaning |
|
| 51 |
+
|---|---|---|
|
| 52 |
+
| `identical` | ≥ 0.95 | Same conformation |
|
| 53 |
+
| `low` | 0.70 – 0.95 | Subtle rearrangement |
|
| 54 |
+
| `medium` | 0.50 – 0.70 | Clear conformational change |
|
| 55 |
+
| `high` | ≤ 0.50 | Large-scale transition |
|
| 56 |
+
| empty | — | Not computable (the two chains share no resolved residues) |
|
| 57 |
|
| 58 |
---
|
| 59 |
|
| 60 |
+
## Download
|
| 61 |
+
|
| 62 |
+
The full dataset is a single SQLite database file (`MuSProt.db`, ~37 GB) with two tables, **`node`** and **`edge`**. Download it with the **Download** button at the top of the site.
|
| 63 |
|
| 64 |
+
Atomic coordinates are not included: fetch mmCIF files from the [RCSB PDB](https://www.rcsb.org) or [PDBe](https://www.ebi.ac.uk/pdbe/) using `pdb_id` and `auth_asym_id`.
|
| 65 |
|
| 66 |
---
|
| 67 |
|
| 68 |
+
## Database Schema
|
| 69 |
+
|
| 70 |
### Table: `node`
|
| 71 |
|
| 72 |
+
One row per observation (PDB chain).
|
| 73 |
|
| 74 |
| Column | Type | Description |
|
| 75 |
|---|---|---|
|
| 76 |
+
| `sequence_id` | TEXT | Sequence identifier (e.g. `SEQ049914`), shared by all chains with an identical SEQRES |
|
| 77 |
+
| `uniprot_id` | TEXT | UniProt accession(s) from SIFTS; `;`-joined for chimeric chains; empty if none |
|
| 78 |
| `pdb_id` | TEXT | 4-character PDB entry ID (lowercase) |
|
| 79 |
+
| `auth_asym_id` | TEXT | Author chain identifier (case-sensitive: `d` and `D` are different chains) |
|
| 80 |
+
| `sequence` | TEXT | Full deposited (canonical) sequence, including unmodelled residues |
|
| 81 |
+
| `sequence_length` | INT | Length of `sequence` |
|
| 82 |
+
| `modeled_sequence` | TEXT | Residues present in the coordinates, concatenated (gaps closed up) |
|
| 83 |
+
| `modeled_sequence_length` | INT | Length of `modeled_sequence` |
|
| 84 |
+
| `resolved_label_seq` | TEXT | Resolved SEQRES positions as ranges (`label_seq_id`, 1-based), e.g. `5-173,187-353` |
|
| 85 |
+
| `n_resolved_aa` | INT | Number of resolved standard amino acids |
|
| 86 |
+
| `resolved_coverage` | FLOAT | `n_resolved_aa / sequence_length` |
|
| 87 |
+
| `binders` | TEXT | Ligands, metals and cofactors on this chain (component IDs, `;`-joined; water and common buffers excluded) |
|
| 88 |
+
| `binding_status` | TEXT | `holo` if the chain has any binder, otherwise `apo` |
|
| 89 |
+
| `experimental_method` | TEXT | e.g. `X-RAY DIFFRACTION`, `ELECTRON MICROSCOPY`, `SOLUTION NMR` |
|
| 90 |
+
| `resolution` | FLOAT | Resolution in Å; empty when not applicable (e.g. NMR) |
|
| 91 |
+
| `pH` | FLOAT | pH of the crystallisation / sample condition |
|
| 92 |
+
| `temp_K` | FLOAT | Temperature in Kelvin |
|
| 93 |
+
| `experimental_details` | TEXT | Free-text crystallisation / sample-preparation details |
|
| 94 |
+
| `chain_composition` | TEXT | Entry composition: `monomeric`, `homomeric` or `heteromeric` |
|
| 95 |
+
| `non_protein_polymer_binding` | TEXT | Bound nucleic acid: `DNA`, `RNA`, `DNA/RNA`, `Other polymers` or `None` |
|
| 96 |
+
| `initial_release_date` | TEXT | Initial PDB release date (`YYYY-MM-DD`) |
|
| 97 |
+
| `cath_id` | TEXT | CATH domain IDs for the chain (e.g. `3vgkA01;3vgkA02`); empty if unassigned |
|
| 98 |
+
| `cath_superfamily` | TEXT | CATH superfamily code(s) (e.g. `3.30.420.40`) |
|
| 99 |
+
| `pfam_id` | TEXT | Pfam family accession(s), `;`-joined (e.g. `PF00069`); empty if none |
|
| 100 |
+
| `ecod_id` | TEXT | ECOD domain ID(s) for the chain, `;`-joined (e.g. `e3vgkA1;e3vgkA2`) |
|
| 101 |
+
| `ecod_fid` | TEXT | ECOD family ID(s) `x.h.t.f` (e.g. `2484.1.1.8`); the first two levels (`x.h`) are the homology group |
|
| 102 |
+
| `state_id` | TEXT | Conformational state of this observation within its sequence (`0`, `1`, …) |
|
| 103 |
+
| `Rosetta` | FLOAT | Rosetta total score |
|
| 104 |
+
| `FoldX` | FLOAT | FoldX total energy |
|
| 105 |
+
| `EvoEF2` | FLOAT | EvoEF2 total energy |
|
| 106 |
+
| `RW` | FLOAT | RW statistical potential |
|
| 107 |
+
| `RW+` | FLOAT | RW+ statistical potential |
|
| 108 |
+
| `n_seqres` | INT | SEQRES length used for energy scoring |
|
| 109 |
+
| `n_resolved` | INT | Resolved residues used for scoring |
|
| 110 |
+
| `n_scored` | INT | Residues in the consensus set scored by Rosetta / EvoEF2 / RW / RW+ |
|
| 111 |
+
| `scored_coverage` | FLOAT | `n_scored / n_seqres` |
|
| 112 |
+
| `foldx_n_scored` | INT | Residues scored by FoldX |
|
| 113 |
+
| `foldx_coverage` | FLOAT | `foldx_n_scored / n_seqres` |
|
| 114 |
+
| `ranked_functions` | TEXT | JSON list of functional annotations, ranked for this observation |
|
| 115 |
+
| `matching_scores` | TEXT | JSON list of matching scores, index-aligned with `ranked_functions` |
|
| 116 |
+
|
| 117 |
+
Energies are computed on a consensus residue set shared by all observations of a sequence, so they are comparable within a sequence. Lower is more favourable.
|
| 118 |
|
| 119 |
### Table: `edge`
|
| 120 |
|
| 121 |
+
One row per ordered pair of observations (A → B) of the same sequence.
|
| 122 |
|
| 123 |
| Column | Type | Description |
|
| 124 |
|---|---|---|
|
| 125 |
+
| `pdb_id_A` | TEXT | PDB ID of observation A |
|
| 126 |
+
| `auth_asym_id_A` | TEXT | Chain of observation A |
|
| 127 |
+
| `pdb_id_B` | TEXT | PDB ID of observation B |
|
| 128 |
+
| `auth_asym_id_B` | TEXT | Chain of observation B |
|
| 129 |
+
| `tm_aln` | FLOAT | TM-score normalised by the aligned length (conformation only) |
|
| 130 |
+
| `RMSD` | FLOAT | Cα RMSD over the aligned residues (Å) |
|
| 131 |
+
| `coverage` | FLOAT | `aligned_length / max(L_A, L_B)` |
|
| 132 |
+
| `pair_similarity` | FLOAT | `0.8 · tm_aln + 0.2 · exp(−(RMSD/5)²)` |
|
| 133 |
+
| `delta_Rosetta` | FLOAT | Rosetta(B) − Rosetta(A) |
|
| 134 |
+
| `delta_FoldX` | FLOAT | FoldX(B) − FoldX(A) |
|
| 135 |
+
| `delta_EvoEF2` | FLOAT | EvoEF2(B) − EvoEF2(A) |
|
| 136 |
+
| `delta_RW` | FLOAT | RW(B) − RW(A) |
|
| 137 |
+
| `delta_RW+` | FLOAT | RW+(B) − RW+(A) |
|
| 138 |
+
| `state_id_A` | TEXT | State of A |
|
| 139 |
+
| `state_id_B` | TEXT | State of B |
|
| 140 |
+
| `state_similarity` | FLOAT | Mean `pair_similarity` over all observation pairs between the two states (within-state when `state_id_A = state_id_B`) |
|
| 141 |
+
| `state_fidelity` | TEXT | Fidelity label of `state_similarity` |
|
| 142 |
+
| `pair_fidelity` | TEXT | Fidelity label of `pair_similarity` |
|
| 143 |
+
| `n_shared_resolved` | INT | Residues resolved in both A and B |
|
| 144 |
+
| `delta_order_valid` | TEXT | `True` when resolved-residue changes are comparable (same method, not both NMR, overlapping models) |
|
| 145 |
+
| `order_invalid_reason` | TEXT | `valid`, `cross_method`, `nmr`, `no_shared_residues` or `missing_data` |
|
| 146 |
+
| `n_ordered` | INT | Residues resolved in B but not in A (ordered on going A → B) |
|
| 147 |
+
| `n_disordered` | INT | Residues resolved in A but not in B |
|
| 148 |
+
| `ordered_ranges` | TEXT | SEQRES positions of the ordered residues, e.g. `115-123,187-189` |
|
| 149 |
+
| `disordered_ranges` | TEXT | SEQRES positions of the disordered residues |
|
| 150 |
+
| `longest_ordered_segment` | INT | Longest contiguous ordered stretch |
|
| 151 |
+
| `longest_disordered_segment` | INT | Longest contiguous disordered stretch |
|
| 152 |
+
| `n_terminal_ordered` | INT | Ordered residues in segments touching either terminus |
|
| 153 |
+
| `n_internal_ordered` | INT | Ordered residues in internal segments |
|
| 154 |
+
| `order_evidence` | TEXT | Evidence grade for the ordering: `high`, `medium`, `low` or `not_applicable` (see below) |
|
| 155 |
+
| `ordering_with_ligand_change` | TEXT | `True` when the ordering co-occurs with a change in bound ligands |
|
| 156 |
+
| `sequence_id` | TEXT | Sequence shared by A and B |
|
| 157 |
+
|
| 158 |
+
When A and B share no resolved residues, `tm_aln`, `pair_similarity` and `pair_fidelity` are empty and `RMSD` / `coverage` are stored as `0.0`.
|
| 159 |
+
|
| 160 |
+
### Order / disorder labels
|
| 161 |
+
|
| 162 |
+
These columns describe the *observed* resolved state (residues with or without modelled coordinates), not intrinsic disorder: a residue can lack density because it is flexible, or because of crystal contacts, occupancy or modelling choices. They are only computed when both structures use the same experimental method and at least one is not NMR (NMR models build every residue); otherwise `delta_order_valid` is `False` and the counts are empty.
|
| 163 |
+
|
| 164 |
+
`order_evidence` grades an ordering event from its length, position and the resolution of both structures:
|
| 165 |
+
|
| 166 |
+
| Grade | Rule |
|
| 167 |
+
|---|---|
|
| 168 |
+
| `high` | an internal ordered segment of ≥ 8 residues, both structures better than 2.5 Å |
|
| 169 |
+
| `medium` | otherwise, if not `low` |
|
| 170 |
+
| `low` | longest ordered segment ≤ 2 residues, purely terminal, or either structure worse than 3.5 Å |
|
| 171 |
+
| `not_applicable` | nothing was ordered |
|
| 172 |
+
|
| 173 |
+
### Knowledge graph
|
| 174 |
+
|
| 175 |
+
MuSProt is organised as a hierarchical graph. In the **outer layer**, each sequence is a node, and two sequences are linked when their chains carry the same set of ECOD homology groups (`x.h` of `ecod_fid`; 88.1% of sequences have an assignment). In the **inner layer**, every observation of a sequence is connected to every other by an `edge` row; contracting each state cluster to one node gives the quotient graph over states, whose edges carry `state_similarity`.
|
| 176 |
+
|
| 177 |
+
### Indexes
|
| 178 |
+
|
| 179 |
+
Both tables are indexed for per-chain lookup: `node(pdb_id COLLATE NOCASE, auth_asym_id)` and `edge(pdb_id_A COLLATE NOCASE, auth_asym_id_A)`. Use the same collation in queries to hit the index:
|
| 180 |
+
|
| 181 |
+
```sql
|
| 182 |
+
SELECT * FROM edge WHERE pdb_id_A = '3vgk' COLLATE NOCASE AND auth_asym_id_A = 'A';
|
| 183 |
+
```
|
| 184 |
|
| 185 |
---
|
| 186 |
|
| 187 |
## Usage Examples
|
| 188 |
|
| 189 |
+
### Python (sqlite3 + pandas)
|
| 190 |
|
| 191 |
```python
|
| 192 |
import sqlite3
|
|
|
|
| 194 |
|
| 195 |
conn = sqlite3.connect("MuSProt.db")
|
| 196 |
|
| 197 |
+
# One observation
|
| 198 |
+
node = pd.read_sql(
|
| 199 |
+
"SELECT * FROM node WHERE pdb_id = ? COLLATE NOCASE AND auth_asym_id = ?",
|
| 200 |
+
conn, params=("3vgk", "A"),
|
| 201 |
)
|
| 202 |
|
| 203 |
+
# Every transition from that observation
|
| 204 |
+
edges = pd.read_sql(
|
| 205 |
+
"SELECT * FROM edge WHERE pdb_id_A = ? COLLATE NOCASE AND auth_asym_id_A = ?",
|
| 206 |
+
conn, params=("3vgk", "A"),
|
| 207 |
)
|
|
|
|
|
|
|
| 208 |
```
|
| 209 |
|
| 210 |
+
### Transitions between distinct states with a large change
|
| 211 |
|
| 212 |
```python
|
|
|
|
| 213 |
df = pd.read_sql("""
|
| 214 |
+
SELECT pdb_id_A, auth_asym_id_A, pdb_id_B, auth_asym_id_B,
|
| 215 |
+
CAST(pair_similarity AS REAL) AS similarity,
|
| 216 |
+
CAST(RMSD AS REAL) AS rmsd,
|
| 217 |
+
CAST(delta_Rosetta AS REAL) AS d_rosetta
|
| 218 |
FROM edge
|
| 219 |
+
WHERE pdb_id_A = ? COLLATE NOCASE AND auth_asym_id_A = ?
|
| 220 |
+
AND state_id_A <> state_id_B
|
| 221 |
+
AND pair_fidelity IN ('medium', 'high')
|
| 222 |
+
ORDER BY similarity
|
| 223 |
+
""", conn, params=("1anf", "A"))
|
| 224 |
```
|
| 225 |
|
| 226 |
+
### Apo / holo pairs for one UniProt accession
|
| 227 |
|
| 228 |
```python
|
|
|
|
| 229 |
df = pd.read_sql("""
|
| 230 |
+
SELECT e.pdb_id_A, e.auth_asym_id_A, e.pdb_id_B, e.auth_asym_id_B,
|
| 231 |
+
e.pair_similarity, e.pair_fidelity, nB.binders
|
| 232 |
+
FROM node nA
|
| 233 |
+
JOIN edge e ON e.pdb_id_A = nA.pdb_id COLLATE NOCASE AND e.auth_asym_id_A = nA.auth_asym_id
|
| 234 |
+
JOIN node nB ON nB.pdb_id = e.pdb_id_B COLLATE NOCASE AND nB.auth_asym_id = e.auth_asym_id_B
|
| 235 |
+
WHERE nA.uniprot_id = 'P0AEX9'
|
| 236 |
+
AND nA.binding_status = 'apo' AND nB.binding_status = 'holo'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
""", conn)
|
| 238 |
```
|
| 239 |
|
| 240 |
+
Scanning `node` by `uniprot_id` or `sequence_id` reads the whole table; for repeated queries create an index first (`CREATE INDEX idx_node_seq ON node(sequence_id)`).
|
| 241 |
+
|
| 242 |
---
|
| 243 |
|
| 244 |
## Notes
|
| 245 |
|
| 246 |
+
- All columns are stored as `TEXT`; cast numeric fields with `CAST(col AS REAL)` / `CAST(col AS INTEGER)`.
|
| 247 |
+
- `ranked_functions` and `matching_scores` are JSON strings; parse with `json.loads()`.
|
| 248 |
+
- `edge` is directional: A → B and B → A are separate rows with identical structural fields and opposite energy deltas.
|
|
|
backend/assets/musprot_summary.json
CHANGED
|
@@ -1,31 +1,31 @@
|
|
| 1 |
{
|
| 2 |
-
"total_records":
|
| 3 |
-
"unique_chains":
|
| 4 |
-
"avg_rmsd":
|
| 5 |
-
"avg_tm_score": 0.
|
| 6 |
-
"avg_sequence_length":
|
| 7 |
"rmsd_distribution": {
|
| 8 |
-
"0-0.5":
|
| 9 |
-
"0.5-1.0":
|
| 10 |
-
"1.0-1.5":
|
| 11 |
-
"1.5-2.0":
|
| 12 |
-
"2.0-5.0":
|
| 13 |
-
">5.0":
|
| 14 |
},
|
| 15 |
"tm_score_distribution": {
|
| 16 |
-
"0-0.5":
|
| 17 |
-
"0.5-0.7":
|
| 18 |
-
"0.7-0.85":
|
| 19 |
-
"0.85-0.95":
|
| 20 |
-
"0.95-1.0":
|
| 21 |
},
|
| 22 |
"length_distribution": {
|
| 23 |
-
"300-500":
|
| 24 |
-
"500-1000":
|
| 25 |
-
"
|
| 26 |
-
"
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
},
|
| 30 |
"tm_score_range": {
|
| 31 |
"min": 0.0,
|
|
@@ -33,10 +33,10 @@
|
|
| 33 |
},
|
| 34 |
"rmsd_range": {
|
| 35 |
"min": 0.0,
|
| 36 |
-
"max":
|
| 37 |
},
|
| 38 |
"length_range": {
|
| 39 |
-
"min":
|
| 40 |
-
"max":
|
| 41 |
}
|
| 42 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"total_records": 113582934,
|
| 3 |
+
"unique_chains": 793278,
|
| 4 |
+
"avg_rmsd": 1.021488560772695,
|
| 5 |
+
"avg_tm_score": 0.9077313277727972,
|
| 6 |
+
"avg_sequence_length": 304.6724704328117,
|
| 7 |
"rmsd_distribution": {
|
| 8 |
+
"0-0.5": 34431100,
|
| 9 |
+
"0.5-1.0": 39665526,
|
| 10 |
+
"1.0-1.5": 17562924,
|
| 11 |
+
"1.5-2.0": 6594622,
|
| 12 |
+
"2.0-5.0": 15208444,
|
| 13 |
+
">5.0": 120318
|
| 14 |
},
|
| 15 |
"tm_score_distribution": {
|
| 16 |
+
"0-0.5": 4580963,
|
| 17 |
+
"0.5-0.7": 4431774,
|
| 18 |
+
"0.7-0.85": 12281664,
|
| 19 |
+
"0.85-0.95": 20890577,
|
| 20 |
+
"0.95-1.0": 71397956
|
| 21 |
},
|
| 22 |
"length_distribution": {
|
| 23 |
+
"300-500": 193657,
|
| 24 |
+
"500-1000": 86502,
|
| 25 |
+
"200-300": 162341,
|
| 26 |
+
">1000": 20178,
|
| 27 |
+
"0-100": 110685,
|
| 28 |
+
"100-200": 219915
|
| 29 |
},
|
| 30 |
"tm_score_range": {
|
| 31 |
"min": 0.0,
|
|
|
|
| 33 |
},
|
| 34 |
"rmsd_range": {
|
| 35 |
"min": 0.0,
|
| 36 |
+
"max": 12.44
|
| 37 |
},
|
| 38 |
"length_range": {
|
| 39 |
+
"min": 3,
|
| 40 |
+
"max": 5247
|
| 41 |
}
|
| 42 |
}
|
backend/scripts/build_index.py
ADDED
|
@@ -0,0 +1,656 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Build the website sidecars from MuSProt.db.
|
| 4 |
+
|
| 5 |
+
Outputs (into --out-dir):
|
| 6 |
+
MuSProt-index.db compact, indexed lookup tables the website queries directly
|
| 7 |
+
sequence one row per sequence_id (catalog: counts, representative, function)
|
| 8 |
+
member one row per chain observation (light columns only)
|
| 9 |
+
state one row per (sequence_id, state_id)
|
| 10 |
+
state_pair mean similarity / fidelity between two states of a sequence
|
| 11 |
+
function_fts FTS5 over UniProt / CATH / ECOD / Pfam / top ranked functions per sequence
|
| 12 |
+
ecod_h, ecod_f ECOD homology / family names (from ecod.latest.domains.txt)
|
| 13 |
+
ecod_group outer-graph groups: sequences sharing the same set of ECOD H-groups
|
| 14 |
+
overview.json dataset-level counts and histograms for the home page
|
| 15 |
+
|
| 16 |
+
ECOD names (homology / family) come from ECOD's own domain list via --ecod.
|
| 17 |
+
|
| 18 |
+
The main DB is only read. Everything is derived in a few SQL scans, so the
|
| 19 |
+
edge table (100M+ rows) is aggregated inside SQLite rather than in Python.
|
| 20 |
+
|
| 21 |
+
Usage:
|
| 22 |
+
python backend/scripts/build_index.py /path/MuSProt.db --out-dir /tmp/musprot-bucket \
|
| 23 |
+
--ecod /path/ecod.latest.domains.txt
|
| 24 |
+
"""
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import argparse
|
| 28 |
+
import json
|
| 29 |
+
import math
|
| 30 |
+
import sqlite3
|
| 31 |
+
import time
|
| 32 |
+
from collections import Counter, defaultdict
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
FUNCTION_TEXT_TOP_N = 3
|
| 36 |
+
FUNCTION_TEXT_MAX_CHARS = 600
|
| 37 |
+
MAX_PAIR_STATES = 40 # state_pair rows are kept only among a sequence's largest states
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def log(msg: str) -> None:
|
| 41 |
+
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def to_float(v):
|
| 45 |
+
try:
|
| 46 |
+
f = float(v)
|
| 47 |
+
return None if math.isnan(f) else f
|
| 48 |
+
except (TypeError, ValueError):
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def to_int(v):
|
| 53 |
+
f = to_float(v)
|
| 54 |
+
return None if f is None else int(f)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def parse_list(raw):
|
| 58 |
+
raw = (raw or "").strip()
|
| 59 |
+
if not raw:
|
| 60 |
+
return []
|
| 61 |
+
try:
|
| 62 |
+
val = json.loads(raw)
|
| 63 |
+
return val if isinstance(val, list) else []
|
| 64 |
+
except ValueError:
|
| 65 |
+
import ast
|
| 66 |
+
try:
|
| 67 |
+
val = ast.literal_eval(raw)
|
| 68 |
+
return val if isinstance(val, list) else []
|
| 69 |
+
except (ValueError, SyntaxError):
|
| 70 |
+
return []
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def hist(values, edges):
|
| 74 |
+
"""Counts of values in [edges[i], edges[i+1]); last bin is closed."""
|
| 75 |
+
counts = [0] * (len(edges) - 1)
|
| 76 |
+
for v in values:
|
| 77 |
+
if v is None:
|
| 78 |
+
continue
|
| 79 |
+
for i in range(len(edges) - 1):
|
| 80 |
+
if v < edges[i + 1] or i == len(edges) - 2:
|
| 81 |
+
if v >= edges[i]:
|
| 82 |
+
counts[i] += 1
|
| 83 |
+
break
|
| 84 |
+
return counts
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ─────────────────────────────────────────────────────────────── domain inputs
|
| 88 |
+
def split_ids(raw) -> list[str]:
|
| 89 |
+
return [x for x in (raw or "").split(";") if x]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def ecod_hset(fids: str) -> tuple:
|
| 93 |
+
"""ECOD F-ids ('292.2.1.1;11.1.1.3') -> sorted homology-level set ('11.1', '292.2')."""
|
| 94 |
+
return tuple(sorted({".".join(f.split(".")[:2]) for f in split_ids(fids)}))
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def load_ecod_names(path: Path | None) -> tuple[dict, dict]:
|
| 98 |
+
"""f_id -> (t_name, f_name) and h_id -> (architecture, x_name, h_name)."""
|
| 99 |
+
fam, hom = {}, {}
|
| 100 |
+
if path is None:
|
| 101 |
+
return fam, hom
|
| 102 |
+
header = None
|
| 103 |
+
with open(path, encoding="utf-8") as fh:
|
| 104 |
+
for line in fh:
|
| 105 |
+
if line.startswith("#"):
|
| 106 |
+
cols = line.lstrip("#").rstrip("\n").split("\t")
|
| 107 |
+
if "f_id" in cols:
|
| 108 |
+
header = cols
|
| 109 |
+
continue
|
| 110 |
+
parts = line.rstrip("\n").split("\t")
|
| 111 |
+
if header is None:
|
| 112 |
+
if "f_id" in parts:
|
| 113 |
+
header = parts
|
| 114 |
+
continue
|
| 115 |
+
r = dict(zip(header, parts))
|
| 116 |
+
f_id = r.get("f_id", "")
|
| 117 |
+
if not f_id or f_id in fam:
|
| 118 |
+
continue
|
| 119 |
+
clean = lambda v: (v or "").strip('"') if (v or "").strip('"') not in ("NO_X_NAME", "NO_H_NAME", "NO_T_NAME", "F_UNCLASSIFIED") else ""
|
| 120 |
+
fam[f_id] = (clean(r.get("t_name")), clean(r.get("f_name")))
|
| 121 |
+
h_id = ".".join(f_id.split(".")[:2])
|
| 122 |
+
if h_id not in hom:
|
| 123 |
+
hom[h_id] = (clean(r.get("architecture_name")), clean(r.get("x_name")), clean(r.get("h_name")))
|
| 124 |
+
return fam, hom
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
# ─────────────────────────────────────────────────────────────── node pass
|
| 128 |
+
NODE_SQL = """
|
| 129 |
+
SELECT sequence_id, uniprot_id, pdb_id, auth_asym_id, sequence_length,
|
| 130 |
+
n_resolved_aa, resolved_coverage, binders, binding_status,
|
| 131 |
+
experimental_method, resolution, pH, temp_K, chain_composition,
|
| 132 |
+
non_protein_polymer_binding, initial_release_date, cath_superfamily,
|
| 133 |
+
state_id, ranked_functions, cath_id, pfam_id, ecod_id, ecod_fid
|
| 134 |
+
FROM node
|
| 135 |
+
"""
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def build_members(src: sqlite3.Connection, dst: sqlite3.Connection, limit: int | None):
|
| 139 |
+
dst.executescript(
|
| 140 |
+
"""
|
| 141 |
+
DROP TABLE IF EXISTS member;
|
| 142 |
+
CREATE TABLE member (
|
| 143 |
+
sequence_id TEXT NOT NULL,
|
| 144 |
+
state_id INTEGER,
|
| 145 |
+
pdb_id TEXT NOT NULL,
|
| 146 |
+
auth_asym_id TEXT NOT NULL,
|
| 147 |
+
uniprot_id TEXT,
|
| 148 |
+
experimental_method TEXT,
|
| 149 |
+
resolution REAL,
|
| 150 |
+
binding_status TEXT,
|
| 151 |
+
chain_composition TEXT,
|
| 152 |
+
binders TEXT,
|
| 153 |
+
release_date TEXT,
|
| 154 |
+
resolved_coverage REAL,
|
| 155 |
+
n_resolved_aa INTEGER,
|
| 156 |
+
pH REAL,
|
| 157 |
+
temp_K REAL,
|
| 158 |
+
cath_id TEXT,
|
| 159 |
+
pfam_id TEXT,
|
| 160 |
+
ecod_id TEXT,
|
| 161 |
+
ecod_fid TEXT
|
| 162 |
+
);
|
| 163 |
+
"""
|
| 164 |
+
)
|
| 165 |
+
seqs: dict[str, dict] = {}
|
| 166 |
+
functions: dict[str, list[str]] = {}
|
| 167 |
+
ov = {
|
| 168 |
+
"method": Counter(), "binding": Counter(), "composition": Counter(),
|
| 169 |
+
"npp": Counter(), "year": Counter(), "cath": Counter(),
|
| 170 |
+
"resolution": [], "length": {}, "uniprot": set(), "entries": set(),
|
| 171 |
+
}
|
| 172 |
+
sql = NODE_SQL + (f" LIMIT {int(limit)}" if limit else "")
|
| 173 |
+
batch = []
|
| 174 |
+
n = 0
|
| 175 |
+
for row in src.execute(sql):
|
| 176 |
+
(seq_id, uniprot, pdb, chain, seq_len, n_res, res_cov, binders, binding,
|
| 177 |
+
method, resolution, ph, temp, comp, npp, date, cath_sf, state, funcs, cath_id,
|
| 178 |
+
pfam, ecod_ids, ecod_fid) = row
|
| 179 |
+
state_i = to_int(state)
|
| 180 |
+
res_f = to_float(resolution)
|
| 181 |
+
batch.append((
|
| 182 |
+
seq_id, state_i, pdb, chain, uniprot or None, method or None, res_f,
|
| 183 |
+
binding or None, comp or None, binders or None, date or None,
|
| 184 |
+
to_float(res_cov), to_int(n_res), to_float(ph), to_float(temp),
|
| 185 |
+
cath_id or None, pfam or None, ecod_ids or None, ecod_fid or None,
|
| 186 |
+
))
|
| 187 |
+
s = seqs.get(seq_id)
|
| 188 |
+
if s is None:
|
| 189 |
+
s = seqs[seq_id] = {
|
| 190 |
+
"uniprot": Counter(), "length": to_int(seq_len), "n_obs": 0,
|
| 191 |
+
"states": Counter(), "n_holo": 0, "entries": set(), "methods": Counter(),
|
| 192 |
+
"cath": Counter(), "dates": [], "rep": None,
|
| 193 |
+
"ecod": Counter(), "pfam": Counter(),
|
| 194 |
+
}
|
| 195 |
+
s["n_obs"] += 1
|
| 196 |
+
if uniprot:
|
| 197 |
+
s["uniprot"][uniprot] += 1
|
| 198 |
+
s["states"][state_i] += 1
|
| 199 |
+
s["n_holo"] += binding == "holo"
|
| 200 |
+
s["entries"].add(pdb)
|
| 201 |
+
if method:
|
| 202 |
+
s["methods"][method] += 1
|
| 203 |
+
if cath_sf:
|
| 204 |
+
s["cath"][cath_sf] += 1
|
| 205 |
+
hs = ecod_hset(ecod_fid)
|
| 206 |
+
if hs:
|
| 207 |
+
s["ecod"][hs] += 1
|
| 208 |
+
pf = tuple(sorted(set(split_ids(pfam))))
|
| 209 |
+
if pf:
|
| 210 |
+
s["pfam"][pf] += 1
|
| 211 |
+
if date:
|
| 212 |
+
s["dates"].append(date)
|
| 213 |
+
# representative: best (lowest) resolution among well-resolved chains
|
| 214 |
+
key = ((to_float(res_cov) or 0) < 0.9, res_f if res_f is not None else 99.0)
|
| 215 |
+
if s["rep"] is None or key < s["rep"][0]:
|
| 216 |
+
s["rep"] = (key, pdb, chain)
|
| 217 |
+
if seq_id not in functions and funcs:
|
| 218 |
+
fl = [str(f).strip() for f in parse_list(funcs) if len(str(f).strip()) > 10]
|
| 219 |
+
if fl:
|
| 220 |
+
functions[seq_id] = fl[:FUNCTION_TEXT_TOP_N]
|
| 221 |
+
|
| 222 |
+
ov["method"][method or "Unknown"] += 1
|
| 223 |
+
ov["binding"][binding or "Unknown"] += 1
|
| 224 |
+
ov["composition"][comp or "Unknown"] += 1
|
| 225 |
+
ov["npp"][npp or "None"] += 1
|
| 226 |
+
if date:
|
| 227 |
+
ov["year"][date[:4]] += 1
|
| 228 |
+
if res_f is not None:
|
| 229 |
+
ov["resolution"].append(res_f)
|
| 230 |
+
if uniprot:
|
| 231 |
+
ov["uniprot"].add(uniprot)
|
| 232 |
+
ov["entries"].add(pdb.lower())
|
| 233 |
+
ov["length"][seq_id] = to_int(seq_len)
|
| 234 |
+
|
| 235 |
+
n += 1
|
| 236 |
+
if len(batch) >= 50000:
|
| 237 |
+
dst.executemany(f"INSERT INTO member VALUES ({','.join('?' * 19)})", batch)
|
| 238 |
+
batch.clear()
|
| 239 |
+
log(f" node rows: {n:,}")
|
| 240 |
+
if batch:
|
| 241 |
+
dst.executemany(f"INSERT INTO member VALUES ({','.join('?' * 19)})", batch)
|
| 242 |
+
log(f" node rows total: {n:,}; sequences: {len(seqs):,}")
|
| 243 |
+
dst.executescript(
|
| 244 |
+
"""
|
| 245 |
+
CREATE INDEX idx_member_seq ON member(sequence_id, state_id);
|
| 246 |
+
CREATE INDEX idx_member_pdb ON member(pdb_id COLLATE NOCASE, auth_asym_id);
|
| 247 |
+
CREATE INDEX idx_member_uniprot ON member(uniprot_id COLLATE NOCASE);
|
| 248 |
+
"""
|
| 249 |
+
)
|
| 250 |
+
return seqs, functions, ov, n
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def consensus(counter: Counter) -> tuple:
|
| 254 |
+
"""Most common non-empty assignment set across a sequence's chains (ties: larger set)."""
|
| 255 |
+
if not counter:
|
| 256 |
+
return ()
|
| 257 |
+
return max(counter.items(), key=lambda kv: (kv[1], len(kv[0]), kv[0]))[0]
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def write_sequences(dst: sqlite3.Connection, seqs: dict, functions: dict, ecod_names: tuple):
|
| 261 |
+
dst.executescript(
|
| 262 |
+
"""
|
| 263 |
+
DROP TABLE IF EXISTS sequence;
|
| 264 |
+
CREATE TABLE sequence (
|
| 265 |
+
sequence_id TEXT PRIMARY KEY,
|
| 266 |
+
uniprot_id TEXT,
|
| 267 |
+
length INTEGER,
|
| 268 |
+
n_obs INTEGER,
|
| 269 |
+
n_states INTEGER,
|
| 270 |
+
n_entries INTEGER,
|
| 271 |
+
n_holo INTEGER,
|
| 272 |
+
n_apo INTEGER,
|
| 273 |
+
n_cross_pairs INTEGER,
|
| 274 |
+
largest_state INTEGER,
|
| 275 |
+
rep_pdb TEXT,
|
| 276 |
+
rep_chain TEXT,
|
| 277 |
+
cath_superfamily TEXT,
|
| 278 |
+
methods TEXT,
|
| 279 |
+
first_release TEXT,
|
| 280 |
+
last_release TEXT,
|
| 281 |
+
top_function TEXT,
|
| 282 |
+
ecod_hset TEXT,
|
| 283 |
+
pfam_ids TEXT
|
| 284 |
+
);
|
| 285 |
+
DROP TABLE IF EXISTS state;
|
| 286 |
+
CREATE TABLE state (
|
| 287 |
+
sequence_id TEXT NOT NULL,
|
| 288 |
+
state_id INTEGER NOT NULL,
|
| 289 |
+
n_members INTEGER,
|
| 290 |
+
PRIMARY KEY (sequence_id, state_id)
|
| 291 |
+
);
|
| 292 |
+
"""
|
| 293 |
+
)
|
| 294 |
+
rows, state_rows = [], []
|
| 295 |
+
for seq_id, s in seqs.items():
|
| 296 |
+
sizes = list(s["states"].values())
|
| 297 |
+
n_obs = s["n_obs"]
|
| 298 |
+
cross = n_obs * n_obs - sum(x * x for x in sizes) # directed cross-state pairs
|
| 299 |
+
dates = sorted(s["dates"])
|
| 300 |
+
rows.append((
|
| 301 |
+
seq_id,
|
| 302 |
+
s["uniprot"].most_common(1)[0][0] if s["uniprot"] else None,
|
| 303 |
+
s["length"], n_obs, len(sizes), len(s["entries"]), s["n_holo"], n_obs - s["n_holo"],
|
| 304 |
+
cross, max(sizes),
|
| 305 |
+
s["rep"][1], s["rep"][2],
|
| 306 |
+
s["cath"].most_common(1)[0][0] if s["cath"] else None,
|
| 307 |
+
";".join(m for m, _ in s["methods"].most_common()),
|
| 308 |
+
dates[0] if dates else None, dates[-1] if dates else None,
|
| 309 |
+
(functions.get(seq_id) or [None])[0],
|
| 310 |
+
";".join(consensus(s["ecod"])) or None,
|
| 311 |
+
";".join(consensus(s["pfam"])) or None,
|
| 312 |
+
))
|
| 313 |
+
for st, cnt in s["states"].items():
|
| 314 |
+
state_rows.append((seq_id, st, cnt))
|
| 315 |
+
dst.executemany(f"INSERT INTO sequence VALUES ({','.join('?' * 19)})", rows)
|
| 316 |
+
dst.executemany("INSERT INTO state VALUES (?,?,?)", state_rows)
|
| 317 |
+
dst.executescript(
|
| 318 |
+
"""
|
| 319 |
+
CREATE INDEX idx_seq_states ON sequence(n_states DESC, n_obs DESC);
|
| 320 |
+
CREATE INDEX idx_seq_obs ON sequence(n_obs DESC);
|
| 321 |
+
CREATE INDEX idx_seq_uniprot ON sequence(uniprot_id COLLATE NOCASE);
|
| 322 |
+
CREATE INDEX idx_seq_cath ON sequence(cath_superfamily);
|
| 323 |
+
CREATE INDEX idx_seq_ecod ON sequence(ecod_hset, n_obs DESC);
|
| 324 |
+
"""
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
fam, hom = ecod_names
|
| 328 |
+
dst.executescript(
|
| 329 |
+
"""
|
| 330 |
+
DROP TABLE IF EXISTS ecod_h;
|
| 331 |
+
CREATE TABLE ecod_h (h_id TEXT PRIMARY KEY, architecture TEXT, x_name TEXT, h_name TEXT);
|
| 332 |
+
DROP TABLE IF EXISTS ecod_f;
|
| 333 |
+
CREATE TABLE ecod_f (f_id TEXT PRIMARY KEY, t_name TEXT, f_name TEXT);
|
| 334 |
+
DROP TABLE IF EXISTS ecod_group;
|
| 335 |
+
CREATE TABLE ecod_group (
|
| 336 |
+
hset TEXT PRIMARY KEY,
|
| 337 |
+
label TEXT,
|
| 338 |
+
n_sequences INTEGER,
|
| 339 |
+
n_obs INTEGER,
|
| 340 |
+
n_multistate INTEGER,
|
| 341 |
+
n_states INTEGER
|
| 342 |
+
);
|
| 343 |
+
"""
|
| 344 |
+
)
|
| 345 |
+
dst.executemany("INSERT INTO ecod_h VALUES (?,?,?,?)", [(k, *v) for k, v in hom.items()])
|
| 346 |
+
dst.executemany("INSERT INTO ecod_f VALUES (?,?,?)", [(k, *v) for k, v in fam.items()])
|
| 347 |
+
groups: dict[tuple, list] = {}
|
| 348 |
+
for s in seqs.values():
|
| 349 |
+
hs = consensus(s["ecod"])
|
| 350 |
+
if hs:
|
| 351 |
+
g = groups.setdefault(hs, [0, 0, 0, 0])
|
| 352 |
+
g[0] += 1
|
| 353 |
+
g[1] += s["n_obs"]
|
| 354 |
+
g[2] += len(s["states"]) > 1
|
| 355 |
+
g[3] += len(s["states"])
|
| 356 |
+
dst.executemany(
|
| 357 |
+
"INSERT INTO ecod_group VALUES (?,?,?,?,?,?)",
|
| 358 |
+
[
|
| 359 |
+
(";".join(hs), " + ".join(hom.get(h, ("", "", ""))[2] or h for h in hs), *v)
|
| 360 |
+
for hs, v in groups.items()
|
| 361 |
+
],
|
| 362 |
+
)
|
| 363 |
+
dst.execute("CREATE INDEX idx_group_size ON ecod_group(n_sequences DESC)")
|
| 364 |
+
|
| 365 |
+
dst.executescript(
|
| 366 |
+
"""
|
| 367 |
+
DROP TABLE IF EXISTS function_fts;
|
| 368 |
+
CREATE VIRTUAL TABLE function_fts USING fts5(
|
| 369 |
+
sequence_id UNINDEXED, uniprot_id, cath_superfamily, domains, text,
|
| 370 |
+
tokenize = 'porter unicode61'
|
| 371 |
+
);
|
| 372 |
+
"""
|
| 373 |
+
)
|
| 374 |
+
fts_rows = []
|
| 375 |
+
for seq_id, s in seqs.items():
|
| 376 |
+
text = " ".join(functions.get(seq_id, []))[:FUNCTION_TEXT_MAX_CHARS * FUNCTION_TEXT_TOP_N]
|
| 377 |
+
uni = " ".join(s["uniprot"].keys())
|
| 378 |
+
cath = " ".join(s["cath"].keys()).replace(";", " ")
|
| 379 |
+
hs = consensus(s["ecod"])
|
| 380 |
+
dom = " ".join(
|
| 381 |
+
[*(h.replace(".", "_") for h in hs), *(hom.get(h, ("", "", ""))[2] for h in hs),
|
| 382 |
+
*consensus(s["pfam"])]
|
| 383 |
+
)
|
| 384 |
+
fts_rows.append((seq_id, uni, cath, dom, text))
|
| 385 |
+
dst.executemany("INSERT INTO function_fts VALUES (?,?,?,?,?)", fts_rows)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
# ─────────────────────────────────────────────────────────────── edge passes
|
| 389 |
+
def build_state_pairs(src: sqlite3.Connection, dst: sqlite3.Connection, seqs: dict,
|
| 390 |
+
limit: int | None) -> dict:
|
| 391 |
+
"""Store state-pair similarities (top states only); return fidelity counts over all pairs."""
|
| 392 |
+
keep = {
|
| 393 |
+
seq_id: {st for st, _ in s["states"].most_common(MAX_PAIR_STATES)}
|
| 394 |
+
for seq_id, s in seqs.items() if len(s["states"]) > MAX_PAIR_STATES
|
| 395 |
+
}
|
| 396 |
+
fid_counts: dict = defaultdict(lambda: {"state_pairs": 0, "observation_pairs": 0})
|
| 397 |
+
dst.executescript(
|
| 398 |
+
"""
|
| 399 |
+
DROP TABLE IF EXISTS state_pair;
|
| 400 |
+
CREATE TABLE state_pair (
|
| 401 |
+
sequence_id TEXT NOT NULL,
|
| 402 |
+
state_a INTEGER NOT NULL,
|
| 403 |
+
state_b INTEGER NOT NULL,
|
| 404 |
+
similarity REAL,
|
| 405 |
+
fidelity TEXT,
|
| 406 |
+
n_pairs INTEGER,
|
| 407 |
+
PRIMARY KEY (sequence_id, state_a, state_b)
|
| 408 |
+
);
|
| 409 |
+
"""
|
| 410 |
+
)
|
| 411 |
+
src_tbl = f"(SELECT * FROM edge LIMIT {int(limit)})" if limit else "edge"
|
| 412 |
+
# directed edges -> keep one direction (A <= B) and count undirected pairs
|
| 413 |
+
sql = f"""
|
| 414 |
+
SELECT sequence_id, CAST(state_id_A AS INTEGER) a, CAST(state_id_B AS INTEGER) b,
|
| 415 |
+
MIN(CAST(state_similarity AS REAL)), MIN(state_fidelity), COUNT(*)
|
| 416 |
+
FROM {src_tbl}
|
| 417 |
+
WHERE CAST(state_id_A AS INTEGER) <= CAST(state_id_B AS INTEGER)
|
| 418 |
+
GROUP BY sequence_id, a, b
|
| 419 |
+
"""
|
| 420 |
+
cur = src.execute(sql)
|
| 421 |
+
n = 0
|
| 422 |
+
while True:
|
| 423 |
+
rows = cur.fetchmany(100000)
|
| 424 |
+
if not rows:
|
| 425 |
+
break
|
| 426 |
+
out = []
|
| 427 |
+
for s, a, b, sim, fid, c in rows:
|
| 428 |
+
# within-state rows appear in both directions with A==B -> halve the count
|
| 429 |
+
c = c // 2 if a == b else c
|
| 430 |
+
if a != b:
|
| 431 |
+
fc = fid_counts[fid or "NA"]
|
| 432 |
+
fc["state_pairs"] += 1
|
| 433 |
+
fc["observation_pairs"] += c
|
| 434 |
+
allowed = keep.get(s)
|
| 435 |
+
if allowed is None or (a in allowed and b in allowed):
|
| 436 |
+
out.append((s, a, b, sim, fid, c))
|
| 437 |
+
dst.executemany("INSERT INTO state_pair VALUES (?,?,?,?,?,?)", out)
|
| 438 |
+
n += len(rows)
|
| 439 |
+
log(f" state pairs: {n:,}")
|
| 440 |
+
return dict(fid_counts)
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def edge_histograms(src: sqlite3.Connection, limit: int | None) -> dict:
|
| 444 |
+
src_tbl = f"(SELECT * FROM edge LIMIT {int(limit)})" if limit else "edge"
|
| 445 |
+
sql = f"""
|
| 446 |
+
SELECT state_id_A = state_id_B AS same_state,
|
| 447 |
+
COALESCE(NULLIF(pair_fidelity, ''), 'NA') AS fid,
|
| 448 |
+
-- pairs without shared residues store '' similarity and 0.0 RMSD/coverage: keep them
|
| 449 |
+
-- out of every histogram (they are counted under fidelity 'NA')
|
| 450 |
+
CASE WHEN pair_similarity = '' THEN NULL
|
| 451 |
+
ELSE MIN(CAST(CAST(pair_similarity AS REAL) * 50 AS INTEGER), 49) END AS sim_bin,
|
| 452 |
+
CASE WHEN pair_similarity = '' THEN NULL
|
| 453 |
+
ELSE MIN(CAST(CAST(RMSD AS REAL) * 2 AS INTEGER), 40) END AS rmsd_bin,
|
| 454 |
+
CASE WHEN pair_similarity = '' THEN NULL
|
| 455 |
+
ELSE MIN(CAST(CAST(coverage AS REAL) * 20 AS INTEGER), 19) END AS cov_bin,
|
| 456 |
+
CASE WHEN tm_aln = '' THEN NULL
|
| 457 |
+
ELSE MIN(CAST(CAST(tm_aln AS REAL) * 50 AS INTEGER), 49) END AS tm_bin,
|
| 458 |
+
COUNT(*)
|
| 459 |
+
FROM {src_tbl}
|
| 460 |
+
GROUP BY 1, 2, 3, 4, 5, 6
|
| 461 |
+
"""
|
| 462 |
+
out = {
|
| 463 |
+
"total": 0, "cross_state": 0,
|
| 464 |
+
"pair_fidelity": Counter(), "pair_fidelity_cross": Counter(),
|
| 465 |
+
"similarity": [0] * 50, "similarity_cross": [0] * 50,
|
| 466 |
+
"tm_aln": [0] * 50, "rmsd": [0] * 41, "coverage": [0] * 20,
|
| 467 |
+
}
|
| 468 |
+
for same, fid, sim_b, rmsd_b, cov_b, tm_b, c in src.execute(sql):
|
| 469 |
+
out["total"] += c
|
| 470 |
+
out["pair_fidelity"][fid] += c
|
| 471 |
+
if not same:
|
| 472 |
+
out["cross_state"] += c
|
| 473 |
+
out["pair_fidelity_cross"][fid] += c
|
| 474 |
+
if sim_b is not None and sim_b >= 0:
|
| 475 |
+
out["similarity"][sim_b] += c
|
| 476 |
+
if not same:
|
| 477 |
+
out["similarity_cross"][sim_b] += c
|
| 478 |
+
if tm_b is not None and tm_b >= 0:
|
| 479 |
+
out["tm_aln"][tm_b] += c
|
| 480 |
+
if rmsd_b is not None and rmsd_b >= 0:
|
| 481 |
+
out["rmsd"][rmsd_b] += c
|
| 482 |
+
if cov_b is not None and cov_b >= 0:
|
| 483 |
+
out["coverage"][cov_b] += c
|
| 484 |
+
return out
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
def order_stats(src: sqlite3.Connection, limit: int | None) -> dict:
|
| 488 |
+
"""Counts of the observed order/disorder labels over all directed transitions."""
|
| 489 |
+
src_tbl = f"(SELECT * FROM edge LIMIT {int(limit)})" if limit else "edge"
|
| 490 |
+
rows = src.execute(f"""
|
| 491 |
+
SELECT order_invalid_reason, order_evidence, ordering_with_ligand_change,
|
| 492 |
+
state_id_A = state_id_B, COUNT(*)
|
| 493 |
+
FROM {src_tbl} GROUP BY 1, 2, 3, 4
|
| 494 |
+
""").fetchall()
|
| 495 |
+
reason, evidence, evidence_cross, ligand = Counter(), Counter(), Counter(), Counter()
|
| 496 |
+
for r, ev, lig, same, c in rows:
|
| 497 |
+
reason[r or "NA"] += c
|
| 498 |
+
if r == "valid":
|
| 499 |
+
evidence[ev or "NA"] += c
|
| 500 |
+
if not same:
|
| 501 |
+
evidence_cross[ev or "NA"] += c
|
| 502 |
+
if ev in ("low", "medium", "high"):
|
| 503 |
+
ligand[(ev, lig == "True")] += c
|
| 504 |
+
order = ["high", "medium", "low", "not_applicable"]
|
| 505 |
+
return {
|
| 506 |
+
"validity": [{"label": k, "value": v} for k, v in reason.most_common()],
|
| 507 |
+
"evidence": [{"label": k, "value": evidence.get(k, 0), "cross_state": evidence_cross.get(k, 0)} for k in order],
|
| 508 |
+
"with_ligand_change": [
|
| 509 |
+
{"label": k, "with": ligand.get((k, True), 0), "without": ligand.get((k, False), 0)}
|
| 510 |
+
for k in order[:3]
|
| 511 |
+
],
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
# ─────────────────────────────────────────────────────────────── overview
|
| 516 |
+
def outer_graph_stats(dst: sqlite3.Connection, n_sequences: int) -> dict:
|
| 517 |
+
rows = dst.execute(
|
| 518 |
+
"SELECT hset, label, n_sequences, n_obs, n_multistate FROM ecod_group ORDER BY n_sequences DESC"
|
| 519 |
+
).fetchall()
|
| 520 |
+
covered = sum(r[2] for r in rows)
|
| 521 |
+
size_bins = Counter()
|
| 522 |
+
for r in rows:
|
| 523 |
+
n = r[2]
|
| 524 |
+
size_bins["1" if n == 1 else "2–5" if n <= 5 else "6–20" if n <= 20 else "21–100" if n <= 100 else ">100"] += 1
|
| 525 |
+
return {
|
| 526 |
+
"level": "ECOD homology (H)",
|
| 527 |
+
"groups": len(rows),
|
| 528 |
+
"covered_sequences": covered,
|
| 529 |
+
"coverage": covered / n_sequences if n_sequences else 0,
|
| 530 |
+
"edges": sum(r[2] * (r[2] - 1) // 2 for r in rows),
|
| 531 |
+
"group_sizes": [{"label": k, "value": size_bins[k]} for k in ["1", "2–5", "6–20", "21–100", ">100"]],
|
| 532 |
+
"largest": [
|
| 533 |
+
{"hset": r[0], "label": r[1], "n_sequences": r[2], "n_obs": r[3], "n_multistate": r[4]}
|
| 534 |
+
for r in rows[:12]
|
| 535 |
+
],
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
def build_overview(seqs, ov, n_nodes, edges, state_fid) -> dict:
|
| 540 |
+
states_per_seq = Counter(len(s["states"]) for s in seqs.values())
|
| 541 |
+
obs_per_seq = [s["n_obs"] for s in seqs.values()]
|
| 542 |
+
n_states_total = sum(len(s["states"]) for s in seqs.values())
|
| 543 |
+
multi_state = sum(1 for s in seqs.values() if len(s["states"]) > 1)
|
| 544 |
+
lengths = [v for v in ov["length"].values() if v]
|
| 545 |
+
|
| 546 |
+
def capped(counter, cap):
|
| 547 |
+
out = Counter()
|
| 548 |
+
for k, v in counter.items():
|
| 549 |
+
out[k if k < cap else cap] += v
|
| 550 |
+
return [{"label": (f"{k}+" if k == cap else str(k)), "value": out[k]} for k in sorted(out)]
|
| 551 |
+
|
| 552 |
+
obs_edges = [2, 3, 4, 5, 6, 11, 21, 51, 101, 1_000_000]
|
| 553 |
+
obs_labels = ["2", "3", "4", "5", "6–10", "11–20", "21–50", "51–100", ">100"]
|
| 554 |
+
len_edges = [0, 100, 200, 300, 400, 500, 750, 1000, 1_000_000]
|
| 555 |
+
len_labels = ["<100", "100–199", "200–299", "300–399", "400–499", "500–749", "750–999", "≥1000"]
|
| 556 |
+
res_edges = [0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0, 1000]
|
| 557 |
+
res_labels = ["<1.5", "1.5–2.0", "2.0–2.5", "2.5–3.0", "3.0–3.5", "3.5–4.0", "4.0–5.0", "≥5.0"]
|
| 558 |
+
fid_order = ["identical", "low", "medium", "high", "no_shared_residues", "NA"]
|
| 559 |
+
|
| 560 |
+
return {
|
| 561 |
+
"generated": time.strftime("%Y-%m-%d"),
|
| 562 |
+
"counts": {
|
| 563 |
+
"observations": n_nodes,
|
| 564 |
+
"sequences": len(seqs),
|
| 565 |
+
"multi_state_sequences": multi_state,
|
| 566 |
+
"state_clusters": n_states_total,
|
| 567 |
+
"pdb_entries": len(ov["entries"]),
|
| 568 |
+
"uniprot_accessions": len(ov["uniprot"]),
|
| 569 |
+
"transitions": edges["total"],
|
| 570 |
+
"cross_state_transitions": edges["cross_state"],
|
| 571 |
+
},
|
| 572 |
+
"states_per_sequence": capped(states_per_seq, 10),
|
| 573 |
+
"observations_per_sequence": [
|
| 574 |
+
{"label": l, "value": v} for l, v in zip(obs_labels, hist(obs_per_seq, obs_edges))
|
| 575 |
+
],
|
| 576 |
+
"sequence_length": [
|
| 577 |
+
{"label": l, "value": v} for l, v in zip(len_labels, hist(lengths, len_edges))
|
| 578 |
+
],
|
| 579 |
+
"resolution": [
|
| 580 |
+
{"label": l, "value": v} for l, v in zip(res_labels, hist(ov["resolution"], res_edges))
|
| 581 |
+
],
|
| 582 |
+
"experimental_method": [{"label": k, "value": v} for k, v in ov["method"].most_common()],
|
| 583 |
+
"binding_status": [{"label": k, "value": v} for k, v in ov["binding"].most_common()],
|
| 584 |
+
"chain_composition": [{"label": k, "value": v} for k, v in ov["composition"].most_common()],
|
| 585 |
+
"nucleic_acid_binding": [{"label": k, "value": v} for k, v in ov["npp"].most_common()],
|
| 586 |
+
"release_year": [{"label": k, "value": ov["year"][k]} for k in sorted(ov["year"])],
|
| 587 |
+
"pair_fidelity": [
|
| 588 |
+
{"label": k, "value": edges["pair_fidelity"].get(k, 0),
|
| 589 |
+
"cross_state": edges["pair_fidelity_cross"].get(k, 0)}
|
| 590 |
+
for k in fid_order if edges["pair_fidelity"].get(k)
|
| 591 |
+
],
|
| 592 |
+
"state_fidelity": [
|
| 593 |
+
{"label": k, **state_fid[k]} for k in fid_order if k in state_fid
|
| 594 |
+
],
|
| 595 |
+
"pair_similarity": {
|
| 596 |
+
"bin_width": 0.02, "all": edges["similarity"], "cross_state": edges["similarity_cross"],
|
| 597 |
+
},
|
| 598 |
+
"tm_aln": {"bin_width": 0.02, "all": edges["tm_aln"]},
|
| 599 |
+
"rmsd": {"bin_width": 0.5, "all": edges["rmsd"], "last_bin_open": True},
|
| 600 |
+
"coverage": {"bin_width": 0.05, "all": edges["coverage"]},
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
|
| 604 |
+
def main():
|
| 605 |
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 606 |
+
ap.add_argument("db", type=Path)
|
| 607 |
+
ap.add_argument("--out-dir", type=Path, required=True)
|
| 608 |
+
ap.add_argument("--limit", type=int, default=None, help="debug: only read the first N rows")
|
| 609 |
+
ap.add_argument("--ecod", type=Path, default=None, help="ecod.latest.domains.txt (for ECOD names)")
|
| 610 |
+
args = ap.parse_args()
|
| 611 |
+
|
| 612 |
+
args.out_dir.mkdir(parents=True, exist_ok=True)
|
| 613 |
+
index_path = args.out_dir / "MuSProt-index.db"
|
| 614 |
+
tmp_path = index_path.with_suffix(".db.tmp")
|
| 615 |
+
tmp_path.unlink(missing_ok=True)
|
| 616 |
+
|
| 617 |
+
src = sqlite3.connect(f"file:{args.db}?mode=ro", uri=True)
|
| 618 |
+
src.execute("PRAGMA temp_store = FILE")
|
| 619 |
+
src.execute("PRAGMA cache_size = -2000000")
|
| 620 |
+
dst = sqlite3.connect(tmp_path)
|
| 621 |
+
dst.execute("PRAGMA journal_mode = OFF")
|
| 622 |
+
dst.execute("PRAGMA synchronous = OFF")
|
| 623 |
+
|
| 624 |
+
ecod_names = load_ecod_names(args.ecod)
|
| 625 |
+
log(f"ECOD names: {len(ecod_names[0]):,} families, {len(ecod_names[1]):,} homology groups")
|
| 626 |
+
|
| 627 |
+
log("pass 1/4: node table")
|
| 628 |
+
seqs, functions, ov, n_nodes = build_members(src, dst, args.limit)
|
| 629 |
+
write_sequences(dst, seqs, functions, ecod_names)
|
| 630 |
+
dst.commit()
|
| 631 |
+
|
| 632 |
+
log("pass 2/4: state pairs (edge GROUP BY)")
|
| 633 |
+
state_fid = build_state_pairs(src, dst, seqs, args.limit)
|
| 634 |
+
dst.commit()
|
| 635 |
+
|
| 636 |
+
log("pass 3/4: edge histograms")
|
| 637 |
+
edges = edge_histograms(src, args.limit)
|
| 638 |
+
log("pass 4/4: order / disorder labels")
|
| 639 |
+
order = order_stats(src, args.limit)
|
| 640 |
+
|
| 641 |
+
overview = build_overview(seqs, ov, n_nodes, edges, state_fid)
|
| 642 |
+
overview["outer_graph"] = outer_graph_stats(dst, len(seqs))
|
| 643 |
+
overview["order"] = order
|
| 644 |
+
dst.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)")
|
| 645 |
+
dst.execute("INSERT INTO meta VALUES ('overview', ?)", (json.dumps(overview),))
|
| 646 |
+
dst.commit()
|
| 647 |
+
dst.execute("VACUUM")
|
| 648 |
+
dst.close()
|
| 649 |
+
tmp_path.replace(index_path)
|
| 650 |
+
(args.out_dir / "overview.json").write_text(json.dumps(overview, indent=1))
|
| 651 |
+
log(f"done → {index_path} ({index_path.stat().st_size / 1e6:.1f} MB)")
|
| 652 |
+
log(json.dumps(overview["counts"], indent=1))
|
| 653 |
+
|
| 654 |
+
|
| 655 |
+
if __name__ == "__main__":
|
| 656 |
+
main()
|
backend/scripts/generate_node_lookup.py
DELETED
|
@@ -1,68 +0,0 @@
|
|
| 1 |
-
"""Generate an indexed read-only node lookup sidecar from MuSProt.db."""
|
| 2 |
-
from __future__ import annotations
|
| 3 |
-
|
| 4 |
-
import argparse
|
| 5 |
-
import sqlite3
|
| 6 |
-
import zlib
|
| 7 |
-
from pathlib import Path
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
NODE_COLUMNS = (
|
| 11 |
-
"pdb_id", "auth_asym_id", "binding_status", "sequence", "sequence_length",
|
| 12 |
-
"cath_id", "cath_superfamily", "Rosetta", "FoldX", "EvoEF2", "RW", "RW+",
|
| 13 |
-
"ranked_functions", "state_id", "experimental_method", "pH", "temp_K",
|
| 14 |
-
"chain_composition", "matching_scores",
|
| 15 |
-
)
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def main() -> None:
|
| 19 |
-
parser = argparse.ArgumentParser()
|
| 20 |
-
parser.add_argument("source", type=Path)
|
| 21 |
-
parser.add_argument("-o", "--output", type=Path, default=Path("MuSProt-node-lookup.db"))
|
| 22 |
-
args = parser.parse_args()
|
| 23 |
-
|
| 24 |
-
output = args.output.resolve()
|
| 25 |
-
output.unlink(missing_ok=True)
|
| 26 |
-
|
| 27 |
-
conn = sqlite3.connect(output)
|
| 28 |
-
try:
|
| 29 |
-
conn.execute("PRAGMA journal_mode = OFF")
|
| 30 |
-
conn.execute("PRAGMA synchronous = OFF")
|
| 31 |
-
conn.execute(
|
| 32 |
-
'CREATE TABLE node (pdb_id, auth_asym_id, binding_status, sequence BLOB,'
|
| 33 |
-
' sequence_length, cath_id, cath_superfamily, Rosetta, FoldX, EvoEF2,'
|
| 34 |
-
' RW, "RW+", ranked_functions BLOB, state_id, experimental_method, pH, temp_K,'
|
| 35 |
-
' chain_composition, matching_scores BLOB)'
|
| 36 |
-
)
|
| 37 |
-
source = sqlite3.connect(f"file:{args.source.resolve()}?mode=ro", uri=True)
|
| 38 |
-
try:
|
| 39 |
-
select_columns = ", ".join(f'"{column}"' for column in NODE_COLUMNS)
|
| 40 |
-
rows = source.execute(f"SELECT {select_columns} FROM node")
|
| 41 |
-
placeholders = ", ".join("?" for _ in NODE_COLUMNS)
|
| 42 |
-
batch = []
|
| 43 |
-
for row in rows:
|
| 44 |
-
row = list(row)
|
| 45 |
-
for index in (3, 12, 18): # sequence, ranked_functions, matching_scores
|
| 46 |
-
row[index] = zlib.compress((row[index] or "").encode("utf-8"), 1)
|
| 47 |
-
batch.append(row)
|
| 48 |
-
if len(batch) == 1000:
|
| 49 |
-
conn.executemany(f"INSERT INTO node VALUES ({placeholders})", batch)
|
| 50 |
-
batch.clear()
|
| 51 |
-
if batch:
|
| 52 |
-
conn.executemany(f"INSERT INTO node VALUES ({placeholders})", batch)
|
| 53 |
-
finally:
|
| 54 |
-
source.close()
|
| 55 |
-
conn.execute(
|
| 56 |
-
"CREATE INDEX idx_node_chain "
|
| 57 |
-
"ON node(LOWER(pdb_id), UPPER(auth_asym_id))"
|
| 58 |
-
)
|
| 59 |
-
conn.execute("ANALYZE")
|
| 60 |
-
conn.commit()
|
| 61 |
-
finally:
|
| 62 |
-
conn.close()
|
| 63 |
-
|
| 64 |
-
print(f"Created {output} ({output.stat().st_size:,} bytes)")
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
if __name__ == "__main__":
|
| 68 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/space_app.py
CHANGED
|
@@ -7,11 +7,13 @@ from contextlib import asynccontextmanager
|
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
from fastapi import FastAPI, HTTPException
|
|
|
|
| 10 |
from fastapi.responses import FileResponse, PlainTextResponse, RedirectResponse
|
| 11 |
from fastapi.staticfiles import StaticFiles
|
| 12 |
|
| 13 |
from app.protein.api.routes.protein import router as protein_router
|
| 14 |
from app.protein.api.routes.protein import set_data_manager
|
|
|
|
| 15 |
from app.protein.config import get_database_path, get_docs_path, get_plots_dir
|
| 16 |
from app.protein.data_loader import DataManager
|
| 17 |
from app.protein import tsv_loader
|
|
@@ -52,7 +54,9 @@ app = FastAPI(
|
|
| 52 |
redoc_url="/api/redoc",
|
| 53 |
openapi_url="/api/openapi.json",
|
| 54 |
)
|
|
|
|
| 55 |
app.include_router(protein_router, prefix="/api/protein", tags=["MuSProt"])
|
|
|
|
| 56 |
|
| 57 |
|
| 58 |
@app.get("/health")
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
from fastapi import FastAPI, HTTPException
|
| 10 |
+
from fastapi.middleware.gzip import GZipMiddleware
|
| 11 |
from fastapi.responses import FileResponse, PlainTextResponse, RedirectResponse
|
| 12 |
from fastapi.staticfiles import StaticFiles
|
| 13 |
|
| 14 |
from app.protein.api.routes.protein import router as protein_router
|
| 15 |
from app.protein.api.routes.protein import set_data_manager
|
| 16 |
+
from app.protein.api.routes.explore import router as explore_router
|
| 17 |
from app.protein.config import get_database_path, get_docs_path, get_plots_dir
|
| 18 |
from app.protein.data_loader import DataManager
|
| 19 |
from app.protein import tsv_loader
|
|
|
|
| 54 |
redoc_url="/api/redoc",
|
| 55 |
openapi_url="/api/openapi.json",
|
| 56 |
)
|
| 57 |
+
app.add_middleware(GZipMiddleware, minimum_size=2048)
|
| 58 |
app.include_router(protein_router, prefix="/api/protein", tags=["MuSProt"])
|
| 59 |
+
app.include_router(explore_router, prefix="/api/protein", tags=["MuSProt explorer"])
|
| 60 |
|
| 61 |
|
| 62 |
@app.get("/health")
|
frontend/index.html
CHANGED
|
@@ -1,16 +1,21 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
<
|
| 4 |
-
<
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
</
|
| 10 |
-
|
| 11 |
-
<
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, viewport-fit=cover" />
|
| 6 |
+
<title>MuSProt — Multistate Protein Database</title>
|
| 7 |
+
<meta name="description" content="Search and download experimentally observed conformational states of proteins, clustered into states with annotated state transitions." />
|
| 8 |
+
<meta name="theme-color" content="#0b1524" />
|
| 9 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 10 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 11 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 12 |
+
<link
|
| 13 |
+
rel="stylesheet"
|
| 14 |
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap"
|
| 15 |
+
/>
|
| 16 |
+
</head>
|
| 17 |
+
<body>
|
| 18 |
+
<div id="root"></div>
|
| 19 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 20 |
+
</body>
|
| 21 |
+
</html>
|
frontend/package-lock.json
CHANGED
|
@@ -10,6 +10,7 @@
|
|
| 10 |
"license": "MIT",
|
| 11 |
"dependencies": {
|
| 12 |
"axios": "^1.13.4",
|
|
|
|
| 13 |
"lucide-react": "^0.487.0",
|
| 14 |
"react": "^18.3.1",
|
| 15 |
"react-dom": "^18.3.1",
|
|
@@ -19,6 +20,7 @@
|
|
| 19 |
"remark-gfm": "^4.0.1"
|
| 20 |
},
|
| 21 |
"devDependencies": {
|
|
|
|
| 22 |
"@types/node": "^20.10.0",
|
| 23 |
"@types/react": "^19.2.9",
|
| 24 |
"@types/react-dom": "^19.2.3",
|
|
@@ -1636,6 +1638,13 @@
|
|
| 1636 |
"@swc/counter": "^0.1.3"
|
| 1637 |
}
|
| 1638 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1639 |
"node_modules/@types/debug": {
|
| 1640 |
"version": "4.1.13",
|
| 1641 |
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
|
@@ -2385,6 +2394,47 @@
|
|
| 2385 |
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
| 2386 |
"license": "MIT"
|
| 2387 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2388 |
"node_modules/debug": {
|
| 2389 |
"version": "4.4.3",
|
| 2390 |
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
|
|
|
| 10 |
"license": "MIT",
|
| 11 |
"dependencies": {
|
| 12 |
"axios": "^1.13.4",
|
| 13 |
+
"d3-force": "^3.0.0",
|
| 14 |
"lucide-react": "^0.487.0",
|
| 15 |
"react": "^18.3.1",
|
| 16 |
"react-dom": "^18.3.1",
|
|
|
|
| 20 |
"remark-gfm": "^4.0.1"
|
| 21 |
},
|
| 22 |
"devDependencies": {
|
| 23 |
+
"@types/d3-force": "^3.0.10",
|
| 24 |
"@types/node": "^20.10.0",
|
| 25 |
"@types/react": "^19.2.9",
|
| 26 |
"@types/react-dom": "^19.2.3",
|
|
|
|
| 1638 |
"@swc/counter": "^0.1.3"
|
| 1639 |
}
|
| 1640 |
},
|
| 1641 |
+
"node_modules/@types/d3-force": {
|
| 1642 |
+
"version": "3.0.10",
|
| 1643 |
+
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
| 1644 |
+
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
|
| 1645 |
+
"dev": true,
|
| 1646 |
+
"license": "MIT"
|
| 1647 |
+
},
|
| 1648 |
"node_modules/@types/debug": {
|
| 1649 |
"version": "4.1.13",
|
| 1650 |
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
|
|
|
| 2394 |
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
| 2395 |
"license": "MIT"
|
| 2396 |
},
|
| 2397 |
+
"node_modules/d3-dispatch": {
|
| 2398 |
+
"version": "3.0.1",
|
| 2399 |
+
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
| 2400 |
+
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
| 2401 |
+
"license": "ISC",
|
| 2402 |
+
"engines": {
|
| 2403 |
+
"node": ">=12"
|
| 2404 |
+
}
|
| 2405 |
+
},
|
| 2406 |
+
"node_modules/d3-force": {
|
| 2407 |
+
"version": "3.0.0",
|
| 2408 |
+
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
| 2409 |
+
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
| 2410 |
+
"license": "ISC",
|
| 2411 |
+
"dependencies": {
|
| 2412 |
+
"d3-dispatch": "1 - 3",
|
| 2413 |
+
"d3-quadtree": "1 - 3",
|
| 2414 |
+
"d3-timer": "1 - 3"
|
| 2415 |
+
},
|
| 2416 |
+
"engines": {
|
| 2417 |
+
"node": ">=12"
|
| 2418 |
+
}
|
| 2419 |
+
},
|
| 2420 |
+
"node_modules/d3-quadtree": {
|
| 2421 |
+
"version": "3.0.1",
|
| 2422 |
+
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
| 2423 |
+
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
| 2424 |
+
"license": "ISC",
|
| 2425 |
+
"engines": {
|
| 2426 |
+
"node": ">=12"
|
| 2427 |
+
}
|
| 2428 |
+
},
|
| 2429 |
+
"node_modules/d3-timer": {
|
| 2430 |
+
"version": "3.0.1",
|
| 2431 |
+
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
| 2432 |
+
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
| 2433 |
+
"license": "ISC",
|
| 2434 |
+
"engines": {
|
| 2435 |
+
"node": ">=12"
|
| 2436 |
+
}
|
| 2437 |
+
},
|
| 2438 |
"node_modules/debug": {
|
| 2439 |
"version": "4.4.3",
|
| 2440 |
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
frontend/package.json
CHANGED
|
@@ -7,6 +7,7 @@
|
|
| 7 |
"homepage": "https://huggingface.co/spaces/omaib/MuSProt",
|
| 8 |
"dependencies": {
|
| 9 |
"axios": "^1.13.4",
|
|
|
|
| 10 |
"lucide-react": "^0.487.0",
|
| 11 |
"react": "^18.3.1",
|
| 12 |
"react-dom": "^18.3.1",
|
|
@@ -16,6 +17,7 @@
|
|
| 16 |
"remark-gfm": "^4.0.1"
|
| 17 |
},
|
| 18 |
"devDependencies": {
|
|
|
|
| 19 |
"@types/node": "^20.10.0",
|
| 20 |
"@types/react": "^19.2.9",
|
| 21 |
"@types/react-dom": "^19.2.3",
|
|
|
|
| 7 |
"homepage": "https://huggingface.co/spaces/omaib/MuSProt",
|
| 8 |
"dependencies": {
|
| 9 |
"axios": "^1.13.4",
|
| 10 |
+
"d3-force": "^3.0.0",
|
| 11 |
"lucide-react": "^0.487.0",
|
| 12 |
"react": "^18.3.1",
|
| 13 |
"react-dom": "^18.3.1",
|
|
|
|
| 17 |
"remark-gfm": "^4.0.1"
|
| 18 |
},
|
| 19 |
"devDependencies": {
|
| 20 |
+
"@types/d3-force": "^3.0.10",
|
| 21 |
"@types/node": "^20.10.0",
|
| 22 |
"@types/react": "^19.2.9",
|
| 23 |
"@types/react-dom": "^19.2.3",
|
frontend/public/favicon.svg
ADDED
|
|
frontend/public/sitemap.xml
CHANGED
|
@@ -5,6 +5,16 @@
|
|
| 5 |
<changefreq>monthly</changefreq>
|
| 6 |
<priority>1.0</priority>
|
| 7 |
</url>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
<url>
|
| 9 |
<loc>https://omaib-musprot.hf.space/docs</loc>
|
| 10 |
<changefreq>monthly</changefreq>
|
|
|
|
| 5 |
<changefreq>monthly</changefreq>
|
| 6 |
<priority>1.0</priority>
|
| 7 |
</url>
|
| 8 |
+
<url>
|
| 9 |
+
<loc>https://omaib-musprot.hf.space/browse</loc>
|
| 10 |
+
<changefreq>monthly</changefreq>
|
| 11 |
+
<priority>0.9</priority>
|
| 12 |
+
</url>
|
| 13 |
+
<url>
|
| 14 |
+
<loc>https://omaib-musprot.hf.space/statistics</loc>
|
| 15 |
+
<changefreq>monthly</changefreq>
|
| 16 |
+
<priority>0.7</priority>
|
| 17 |
+
</url>
|
| 18 |
<url>
|
| 19 |
<loc>https://omaib-musprot.hf.space/docs</loc>
|
| 20 |
<changefreq>monthly</changefreq>
|
frontend/scripts/prerender.mjs
CHANGED
|
@@ -8,14 +8,24 @@ const template = readFileSync(join(BUILD_DIR, 'index.html'), 'utf-8');
|
|
| 8 |
const routes = [
|
| 9 |
{
|
| 10 |
path: '',
|
| 11 |
-
title: 'MuSProt
|
| 12 |
description:
|
| 13 |
-
'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
},
|
| 15 |
{
|
| 16 |
path: 'docs',
|
| 17 |
-
title: '
|
| 18 |
-
description: 'MuSProt
|
| 19 |
},
|
| 20 |
];
|
| 21 |
|
|
|
|
| 8 |
const routes = [
|
| 9 |
{
|
| 10 |
path: '',
|
| 11 |
+
title: 'MuSProt — Multistate Protein Database',
|
| 12 |
description:
|
| 13 |
+
'Search and download 863,643 experimentally observed conformations of 102,790 protein sequences, clustered into 162,601 states with 113.7 million annotated state transitions.',
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
path: 'browse',
|
| 17 |
+
title: 'Browse sequences — MuSProt',
|
| 18 |
+
description: 'Browse protein sequences with multiple experimentally observed conformations, sorted by number of conformational states.',
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
path: 'statistics',
|
| 22 |
+
title: 'Statistics — MuSProt',
|
| 23 |
+
description: 'Distributions of conformational states, transitions, structural similarity, fidelity and experimental metadata across MuSProt.',
|
| 24 |
},
|
| 25 |
{
|
| 26 |
path: 'docs',
|
| 27 |
+
title: 'Documentation — MuSProt',
|
| 28 |
+
description: 'MuSProt SQLite schema, field definitions and usage examples.',
|
| 29 |
},
|
| 30 |
];
|
| 31 |
|
frontend/src/App.tsx
CHANGED
|
@@ -1,20 +1,62 @@
|
|
| 1 |
-
import {
|
| 2 |
-
import {
|
| 3 |
-
import { Footer } from './components/
|
| 4 |
-
import {
|
| 5 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
export default function App() {
|
| 8 |
return (
|
| 9 |
-
<div className="
|
|
|
|
|
|
|
| 10 |
<Header />
|
| 11 |
-
<main>
|
| 12 |
<Routes>
|
| 13 |
-
<Route path="/" element={<
|
| 14 |
-
<Route path="/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
<Route path="/datasets/musprot" element={<Navigate to="/" replace />} />
|
| 16 |
<Route path="/datasets/musprot/docs" element={<Navigate to="/docs" replace />} />
|
| 17 |
-
<Route path="*" element={<
|
| 18 |
</Routes>
|
| 19 |
</main>
|
| 20 |
<Footer />
|
|
|
|
| 1 |
+
import { useEffect } from 'react';
|
| 2 |
+
import { Link, Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
|
| 3 |
+
import { Footer, Header } from './components/Layout';
|
| 4 |
+
import { DatasetJsonLd, PageMeta } from './components/PageMeta';
|
| 5 |
+
import { HomePage } from './pages/HomePage';
|
| 6 |
+
import { BrowsePage } from './pages/BrowsePage';
|
| 7 |
+
import { SearchPage } from './pages/SearchPage';
|
| 8 |
+
import { SequencePage } from './pages/SequencePage';
|
| 9 |
+
import { ChainPage } from './pages/ChainPage';
|
| 10 |
+
import { StatisticsPage } from './pages/StatisticsPage';
|
| 11 |
+
import { DocsPage } from './pages/DocsPage';
|
| 12 |
+
|
| 13 |
+
function ScrollToTop() {
|
| 14 |
+
const { pathname } = useLocation();
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
window.scrollTo(0, 0);
|
| 17 |
+
}, [pathname]);
|
| 18 |
+
return null;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/** /chain/3vgk.A → /chain/3vgk/A */
|
| 22 |
+
function ChainShorthand() {
|
| 23 |
+
const { id = '' } = useParams();
|
| 24 |
+
const m = /^([0-9][A-Za-z0-9]{3})[.:_-](.+)$/.exec(id);
|
| 25 |
+
return m ? <Navigate to={`/chain/${m[1].toLowerCase()}/${m[2]}`} replace /> : <Navigate to={`/search?q=${id}`} replace />;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function NotFound() {
|
| 29 |
+
return (
|
| 30 |
+
<div className="page page--narrow not-found">
|
| 31 |
+
<PageMeta title="Not found — MuSProt" path="/404" />
|
| 32 |
+
<p className="eyebrow">404</p>
|
| 33 |
+
<h1>This page is in an unobserved state.</h1>
|
| 34 |
+
<p>
|
| 35 |
+
Try the <Link to="/browse">sequence browser</Link> or search from the header.
|
| 36 |
+
</p>
|
| 37 |
+
</div>
|
| 38 |
+
);
|
| 39 |
+
}
|
| 40 |
|
| 41 |
export default function App() {
|
| 42 |
return (
|
| 43 |
+
<div className="app">
|
| 44 |
+
<DatasetJsonLd />
|
| 45 |
+
<ScrollToTop />
|
| 46 |
<Header />
|
| 47 |
+
<main id="main">
|
| 48 |
<Routes>
|
| 49 |
+
<Route path="/" element={<HomePage />} />
|
| 50 |
+
<Route path="/browse" element={<BrowsePage />} />
|
| 51 |
+
<Route path="/search" element={<SearchPage />} />
|
| 52 |
+
<Route path="/statistics" element={<StatisticsPage />} />
|
| 53 |
+
<Route path="/docs" element={<DocsPage />} />
|
| 54 |
+
<Route path="/sequence/:id" element={<SequencePage />} />
|
| 55 |
+
<Route path="/chain/:pdb/:chain" element={<ChainPage />} />
|
| 56 |
+
<Route path="/chain/:id" element={<ChainShorthand />} />
|
| 57 |
<Route path="/datasets/musprot" element={<Navigate to="/" replace />} />
|
| 58 |
<Route path="/datasets/musprot/docs" element={<Navigate to="/docs" replace />} />
|
| 59 |
+
<Route path="*" element={<NotFound />} />
|
| 60 |
</Routes>
|
| 61 |
</main>
|
| 62 |
<Footer />
|
frontend/src/api/protein.ts
DELETED
|
@@ -1,122 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* API client for protein visualization backend
|
| 3 |
-
* Connects to FastAPI backend (default: http://localhost:8000)
|
| 4 |
-
*/
|
| 5 |
-
import axios from 'axios';
|
| 6 |
-
import type { FilterParams, FilterOptions, DataResponse, SummaryStats, ChainMetadata, RankedFunction } from '../types/protein';
|
| 7 |
-
|
| 8 |
-
// API base URL - uses Vite proxy in development
|
| 9 |
-
const API_BASE_URL = import.meta.env.VITE_PROTEIN_API_BASE || '/api';
|
| 10 |
-
|
| 11 |
-
const api = axios.create({
|
| 12 |
-
baseURL: API_BASE_URL,
|
| 13 |
-
headers: {
|
| 14 |
-
'Content-Type': 'application/json',
|
| 15 |
-
},
|
| 16 |
-
});
|
| 17 |
-
|
| 18 |
-
// Enhanced error handling with diagnostics
|
| 19 |
-
api.interceptors.response.use(
|
| 20 |
-
(response) => response,
|
| 21 |
-
async (error) => {
|
| 22 |
-
const url = error.config?.url || 'unknown';
|
| 23 |
-
const method = error.config?.method?.toUpperCase() || 'REQUEST';
|
| 24 |
-
const status = error.response?.status || 'NO_RESPONSE';
|
| 25 |
-
const statusText = error.response?.statusText || 'Connection failed';
|
| 26 |
-
|
| 27 |
-
// Try to get response body
|
| 28 |
-
let responseBody = '';
|
| 29 |
-
if (error.response?.data) {
|
| 30 |
-
if (typeof error.response.data === 'string') {
|
| 31 |
-
responseBody = error.response.data;
|
| 32 |
-
} else {
|
| 33 |
-
responseBody = JSON.stringify(error.response.data);
|
| 34 |
-
}
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
const diagnosticMessage = `API Error: ${method} ${url} → ${status} ${statusText}${responseBody ? '\nResponse: ' + responseBody : ''}`;
|
| 38 |
-
console.error(diagnosticMessage);
|
| 39 |
-
|
| 40 |
-
// Re-throw with enhanced message
|
| 41 |
-
error.message = diagnosticMessage;
|
| 42 |
-
return Promise.reject(error);
|
| 43 |
-
}
|
| 44 |
-
);
|
| 45 |
-
|
| 46 |
-
export const proteinApi = {
|
| 47 |
-
/**
|
| 48 |
-
* Get available filter options
|
| 49 |
-
*/
|
| 50 |
-
async getFilters(): Promise<FilterOptions> {
|
| 51 |
-
const response = await api.get<FilterOptions>('/protein/filters');
|
| 52 |
-
return response.data;
|
| 53 |
-
},
|
| 54 |
-
|
| 55 |
-
/**
|
| 56 |
-
* Get filtered protein data
|
| 57 |
-
*/
|
| 58 |
-
async getData(filters: FilterParams): Promise<DataResponse> {
|
| 59 |
-
const response = await api.post<DataResponse>('/protein/data', filters);
|
| 60 |
-
return response.data;
|
| 61 |
-
},
|
| 62 |
-
|
| 63 |
-
/**
|
| 64 |
-
* Get summary statistics
|
| 65 |
-
*/
|
| 66 |
-
async getSummary(params?: Partial<FilterParams>): Promise<SummaryStats> {
|
| 67 |
-
const response = await api.get<SummaryStats>('/protein/summary', { params });
|
| 68 |
-
return response.data;
|
| 69 |
-
},
|
| 70 |
-
|
| 71 |
-
/**
|
| 72 |
-
* Resolve chain metadata from RCSB web API
|
| 73 |
-
* @param signal - AbortSignal for request cancellation
|
| 74 |
-
*/
|
| 75 |
-
async resolveChain(
|
| 76 |
-
pdb_id: string,
|
| 77 |
-
auth_asym_id: string,
|
| 78 |
-
options?: { use_cache?: boolean; signal?: AbortSignal }
|
| 79 |
-
): Promise<ChainMetadata> {
|
| 80 |
-
const { use_cache = true, signal } = options || {};
|
| 81 |
-
try {
|
| 82 |
-
const response = await api.get<ChainMetadata>('/protein/chain/resolve', {
|
| 83 |
-
params: { pdb_id, auth_asym_id, use_cache },
|
| 84 |
-
signal
|
| 85 |
-
});
|
| 86 |
-
return response.data;
|
| 87 |
-
} catch (error: any) {
|
| 88 |
-
// Normalize 404 errors
|
| 89 |
-
if (error.response?.status === 404) {
|
| 90 |
-
const notFoundError: any = new Error('Chain not found');
|
| 91 |
-
notFoundError.code = 'NOT_FOUND';
|
| 92 |
-
notFoundError.status = 404;
|
| 93 |
-
notFoundError.detail = error.response?.data?.detail || 'Chain not found in PDB';
|
| 94 |
-
throw notFoundError;
|
| 95 |
-
}
|
| 96 |
-
throw error;
|
| 97 |
-
}
|
| 98 |
-
},
|
| 99 |
-
|
| 100 |
-
/**
|
| 101 |
-
* Batch resolve multiple chains
|
| 102 |
-
*/
|
| 103 |
-
async batchResolveChains(chains: Array<{ pdb_id: string; auth_asym_id: string }>, use_cache: boolean = true): Promise<Record<string, ChainMetadata>> {
|
| 104 |
-
const response = await api.post<Record<string, ChainMetadata>>('/protein/chain/batch-resolve', chains, {
|
| 105 |
-
params: { use_cache }
|
| 106 |
-
});
|
| 107 |
-
return response.data;
|
| 108 |
-
},
|
| 109 |
-
|
| 110 |
-
/**
|
| 111 |
-
* Get ranked functional annotations for a chain from local TSV
|
| 112 |
-
*/
|
| 113 |
-
async getChainFunctions(
|
| 114 |
-
pdb_id: string,
|
| 115 |
-
auth_asym_id: string,
|
| 116 |
-
): Promise<RankedFunction[]> {
|
| 117 |
-
const response = await api.get<{ functions: RankedFunction[] }>('/protein/chain/functions', {
|
| 118 |
-
params: { pdb_id, auth_asym_id },
|
| 119 |
-
});
|
| 120 |
-
return response.data.functions;
|
| 121 |
-
},
|
| 122 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/Domains.tsx
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ReactNode } from 'react';
|
| 2 |
+
import { Link } from 'react-router-dom';
|
| 3 |
+
import { pfamName, type EcodNames } from '../lib/api';
|
| 4 |
+
import { usePromise } from '../lib/hooks';
|
| 5 |
+
import { ExternalLink } from './ui';
|
| 6 |
+
|
| 7 |
+
/* ECOD's site is served over plain http; https does not resolve. */
|
| 8 |
+
const ECOD = 'http://prodata.swmed.edu/ecod/complete';
|
| 9 |
+
|
| 10 |
+
export const links = {
|
| 11 |
+
uniprot: (id: string) => `https://www.uniprot.org/uniprotkb/${id}`,
|
| 12 |
+
rcsb: (pdb: string) => `https://www.rcsb.org/structure/${pdb.toUpperCase()}`,
|
| 13 |
+
pdbe: (pdb: string) => `https://www.ebi.ac.uk/pdbe/entry/pdb/${pdb.toLowerCase()}`,
|
| 14 |
+
cathDomain: (id: string) => `https://www.cathdb.info/version/latest/domain/${id}`,
|
| 15 |
+
cathSuperfamily: (id: string) => `https://www.cathdb.info/version/latest/superfamily/${id}`,
|
| 16 |
+
ecodDomain: (id: string) => `${ECOD}/domain/${id}`,
|
| 17 |
+
ecodTree: (id: string) => `${ECOD}/tree?id=${id}`,
|
| 18 |
+
pfam: (id: string) => `https://www.ebi.ac.uk/interpro/entry/pfam/${id}/`,
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
function PfamChip({ id }: { id: string }) {
|
| 22 |
+
const name = usePromise(() => pfamName(id), [id]);
|
| 23 |
+
return (
|
| 24 |
+
<span className="idchip">
|
| 25 |
+
<ExternalLink href={links.pfam(id)}>
|
| 26 |
+
<span className="mono">{id}</span>
|
| 27 |
+
</ExternalLink>
|
| 28 |
+
{name && <span className="idchip__name">{name}</span>}
|
| 29 |
+
</span>
|
| 30 |
+
);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function Row({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
| 34 |
+
return (
|
| 35 |
+
<div className="idlist__row">
|
| 36 |
+
<dt title={hint}>{label}</dt>
|
| 37 |
+
<dd>{children}</dd>
|
| 38 |
+
</div>
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
const None = () => <span className="muted">Not assigned</span>;
|
| 43 |
+
|
| 44 |
+
export function DomainList({
|
| 45 |
+
uniprot,
|
| 46 |
+
pdb,
|
| 47 |
+
cathDomains = [],
|
| 48 |
+
cathSuperfamilies = [],
|
| 49 |
+
ecodDomains = [],
|
| 50 |
+
ecodFids = [],
|
| 51 |
+
ecodHset = [],
|
| 52 |
+
ecodNames,
|
| 53 |
+
pfamIds = [],
|
| 54 |
+
sequenceId,
|
| 55 |
+
}: {
|
| 56 |
+
uniprot?: string | null;
|
| 57 |
+
pdb?: string;
|
| 58 |
+
cathDomains?: string[];
|
| 59 |
+
cathSuperfamilies?: string[];
|
| 60 |
+
ecodDomains?: string[];
|
| 61 |
+
ecodFids?: string[];
|
| 62 |
+
ecodHset?: string[];
|
| 63 |
+
ecodNames?: EcodNames;
|
| 64 |
+
pfamIds?: string[];
|
| 65 |
+
sequenceId?: string;
|
| 66 |
+
}) {
|
| 67 |
+
const homs = ecodNames?.homologies ?? {};
|
| 68 |
+
const fams = ecodNames?.families ?? {};
|
| 69 |
+
const hIds = ecodHset.length ? ecodHset : [...new Set(ecodFids.map((f) => f.split('.').slice(0, 2).join('.')))];
|
| 70 |
+
const uniprots = (uniprot ?? '').split(';').filter(Boolean);
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<dl className="idlist">
|
| 74 |
+
<Row label="UniProt">
|
| 75 |
+
{uniprots.length ? uniprots.map((u) => <ExternalLink key={u} href={links.uniprot(u)}><span className="mono">{u}</span></ExternalLink>) : <None />}
|
| 76 |
+
</Row>
|
| 77 |
+
{pdb && (
|
| 78 |
+
<Row label="PDB entry">
|
| 79 |
+
<ExternalLink href={links.rcsb(pdb)}>
|
| 80 |
+
<span className="mono">{pdb.toUpperCase()}</span> · RCSB
|
| 81 |
+
</ExternalLink>
|
| 82 |
+
<ExternalLink href={links.pdbe(pdb)}>PDBe</ExternalLink>
|
| 83 |
+
</Row>
|
| 84 |
+
)}
|
| 85 |
+
{sequenceId && (
|
| 86 |
+
<Row label="Sequence">
|
| 87 |
+
<Link to={`/sequence/${sequenceId}`} className="mono">
|
| 88 |
+
{sequenceId}
|
| 89 |
+
</Link>
|
| 90 |
+
</Row>
|
| 91 |
+
)}
|
| 92 |
+
{(cathDomains.length > 0 || pdb) && (
|
| 93 |
+
<Row label="CATH domains">
|
| 94 |
+
{cathDomains.length ? cathDomains.map((d) => <ExternalLink key={d} href={links.cathDomain(d)}><span className="mono">{d}</span></ExternalLink>) : <None />}
|
| 95 |
+
</Row>
|
| 96 |
+
)}
|
| 97 |
+
<Row label="CATH superfamily">
|
| 98 |
+
{cathSuperfamilies.length ? cathSuperfamilies.map((d) => <ExternalLink key={d} href={links.cathSuperfamily(d)}><span className="mono">{d}</span></ExternalLink>) : <None />}
|
| 99 |
+
</Row>
|
| 100 |
+
{(ecodDomains.length > 0 || pdb) && (
|
| 101 |
+
<Row label="ECOD domains">
|
| 102 |
+
{ecodDomains.length ? ecodDomains.map((d) => <ExternalLink key={d} href={links.ecodDomain(d)}><span className="mono">{d}</span></ExternalLink>) : <None />}
|
| 103 |
+
</Row>
|
| 104 |
+
)}
|
| 105 |
+
{ecodFids.length > 0 && (
|
| 106 |
+
<Row label="ECOD family" hint="Family id x.h.t.f; homology (x.h) and family names from ECOD">
|
| 107 |
+
<span className="idstack">
|
| 108 |
+
{ecodFids.map((f) => (
|
| 109 |
+
<span key={f} className="idchip">
|
| 110 |
+
<ExternalLink href={links.ecodTree(f)}>
|
| 111 |
+
<span className="mono">{f}</span>
|
| 112 |
+
</ExternalLink>
|
| 113 |
+
{fams[f]?.f_name && <span className="idchip__name">{fams[f].f_name}</span>}
|
| 114 |
+
</span>
|
| 115 |
+
))}
|
| 116 |
+
</span>
|
| 117 |
+
</Row>
|
| 118 |
+
)}
|
| 119 |
+
<Row label="ECOD homology" hint="H-level groups; sequences with the same set are linked in the outer graph">
|
| 120 |
+
{hIds.length ? (
|
| 121 |
+
<span className="idstack">
|
| 122 |
+
{hIds.map((h) => (
|
| 123 |
+
<span key={h} className="idchip">
|
| 124 |
+
<ExternalLink href={links.ecodTree(h)}>
|
| 125 |
+
<span className="mono">{h}</span>
|
| 126 |
+
</ExternalLink>
|
| 127 |
+
{homs[h]?.h_name && <span className="idchip__name">{homs[h].h_name}</span>}
|
| 128 |
+
<Link to={`/browse?ecod=${h}`} className="idchip__more">
|
| 129 |
+
sequences →
|
| 130 |
+
</Link>
|
| 131 |
+
</span>
|
| 132 |
+
))}
|
| 133 |
+
</span>
|
| 134 |
+
) : (
|
| 135 |
+
<None />
|
| 136 |
+
)}
|
| 137 |
+
</Row>
|
| 138 |
+
<Row label="Pfam">
|
| 139 |
+
{pfamIds.length ? (
|
| 140 |
+
<span className="idstack">
|
| 141 |
+
{pfamIds.map((p) => (
|
| 142 |
+
<PfamChip key={p} id={p} />
|
| 143 |
+
))}
|
| 144 |
+
</span>
|
| 145 |
+
) : (
|
| 146 |
+
<None />
|
| 147 |
+
)}
|
| 148 |
+
</Row>
|
| 149 |
+
</dl>
|
| 150 |
+
);
|
| 151 |
+
}
|
frontend/src/components/ForceGraph.tsx
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
forceCollide,
|
| 4 |
+
forceLink,
|
| 5 |
+
forceManyBody,
|
| 6 |
+
forceSimulation,
|
| 7 |
+
forceX,
|
| 8 |
+
forceY,
|
| 9 |
+
type Simulation,
|
| 10 |
+
type SimulationLinkDatum,
|
| 11 |
+
type SimulationNodeDatum,
|
| 12 |
+
} from 'd3-force';
|
| 13 |
+
import { Tooltip, useWidth, type TipState } from './charts';
|
| 14 |
+
|
| 15 |
+
export interface FGNode {
|
| 16 |
+
id: string;
|
| 17 |
+
r: number;
|
| 18 |
+
fill: string;
|
| 19 |
+
/** nodes sharing a group are enclosed by a shaded circle */
|
| 20 |
+
group?: string | number;
|
| 21 |
+
ring?: boolean;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export interface FGLink {
|
| 25 |
+
source: string;
|
| 26 |
+
target: string;
|
| 27 |
+
distance: number;
|
| 28 |
+
color: string;
|
| 29 |
+
width?: number;
|
| 30 |
+
opacity?: number;
|
| 31 |
+
/** drawn only while one of its endpoints is hovered */
|
| 32 |
+
hidden?: boolean;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export interface FGGroup {
|
| 36 |
+
key: string | number;
|
| 37 |
+
color: string;
|
| 38 |
+
label?: string;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
type SimNode = FGNode & SimulationNodeDatum & { phase: number };
|
| 42 |
+
type SimLink = SimulationLinkDatum<SimNode> & FGLink;
|
| 43 |
+
|
| 44 |
+
const reducedMotion = () =>
|
| 45 |
+
typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* Live force-directed graph: nodes keep drifting gently and can be dragged.
|
| 49 |
+
* Positions are written to the DOM on every tick (no React re-render per frame).
|
| 50 |
+
*/
|
| 51 |
+
export function ForceGraph({
|
| 52 |
+
nodes,
|
| 53 |
+
links,
|
| 54 |
+
groups = [],
|
| 55 |
+
height = 460,
|
| 56 |
+
charge = -40,
|
| 57 |
+
linkStrength,
|
| 58 |
+
onNodeClick,
|
| 59 |
+
nodeTip,
|
| 60 |
+
ariaLabel,
|
| 61 |
+
}: {
|
| 62 |
+
nodes: FGNode[];
|
| 63 |
+
links: FGLink[];
|
| 64 |
+
groups?: FGGroup[];
|
| 65 |
+
height?: number;
|
| 66 |
+
charge?: number;
|
| 67 |
+
linkStrength?: number;
|
| 68 |
+
onNodeClick?: (id: string) => void;
|
| 69 |
+
nodeTip?: (id: string) => ReactNode;
|
| 70 |
+
ariaLabel: string;
|
| 71 |
+
}) {
|
| 72 |
+
const [wrapRef, width] = useWidth<HTMLDivElement>();
|
| 73 |
+
const svgRef = useRef<SVGSVGElement>(null);
|
| 74 |
+
const nodeEls = useRef(new Map<string, SVGCircleElement>());
|
| 75 |
+
const linkEls = useRef<Array<SVGLineElement | null>>([]);
|
| 76 |
+
const hullEls = useRef(new Map<string | number, SVGGElement>());
|
| 77 |
+
const simRef = useRef<Simulation<SimNode, SimLink> | null>(null);
|
| 78 |
+
const simNodes = useRef<SimNode[]>([]);
|
| 79 |
+
const positions = useRef(new Map<string, { x: number; y: number }>());
|
| 80 |
+
const [hover, setHover] = useState<string | null>(null);
|
| 81 |
+
const [tip, setTip] = useState<TipState | null>(null);
|
| 82 |
+
const drag = useRef<{ id: string; moved: boolean; x0: number; y0: number } | null>(null);
|
| 83 |
+
const W = Math.max(280, width);
|
| 84 |
+
const H = height;
|
| 85 |
+
|
| 86 |
+
useEffect(() => {
|
| 87 |
+
if (!width) return;
|
| 88 |
+
const keep = positions.current;
|
| 89 |
+
const sn: SimNode[] = nodes.map((n, i) => {
|
| 90 |
+
const p = keep.get(n.id);
|
| 91 |
+
const a = (i / Math.max(1, nodes.length)) * Math.PI * 2;
|
| 92 |
+
return { ...n, phase: Math.random() * Math.PI * 2, x: p?.x ?? W / 2 + Math.cos(a) * 80, y: p?.y ?? H / 2 + Math.sin(a) * 80 };
|
| 93 |
+
});
|
| 94 |
+
simNodes.current = sn;
|
| 95 |
+
const sl: SimLink[] = links.map((l) => ({ ...l }));
|
| 96 |
+
const still = reducedMotion();
|
| 97 |
+
let t = 0;
|
| 98 |
+
// slow, per-node sinusoidal nudge keeps the layout alive
|
| 99 |
+
const wander = () => {
|
| 100 |
+
if (still) return;
|
| 101 |
+
t += 1;
|
| 102 |
+
for (const n of sn) {
|
| 103 |
+
if (n.fx != null) continue;
|
| 104 |
+
n.vx = (n.vx ?? 0) + Math.cos(n.phase + t * 0.013) * 0.035;
|
| 105 |
+
n.vy = (n.vy ?? 0) + Math.sin(n.phase * 1.3 + t * 0.011) * 0.035;
|
| 106 |
+
}
|
| 107 |
+
};
|
| 108 |
+
// keep each group compact and push overlapping group circles apart
|
| 109 |
+
const hasGroups = groups.length > 0;
|
| 110 |
+
const hullR = new Map<string | number, number>();
|
| 111 |
+
const cohesion = () => {
|
| 112 |
+
if (!hasGroups) return;
|
| 113 |
+
const acc = new Map<string | number, { x: number; y: number; n: number; members: SimNode[] }>();
|
| 114 |
+
for (const n of sn) {
|
| 115 |
+
if (n.group == null) continue;
|
| 116 |
+
const a = acc.get(n.group) ?? { x: 0, y: 0, n: 0, members: [] };
|
| 117 |
+
a.x += n.x!;
|
| 118 |
+
a.y += n.y!;
|
| 119 |
+
a.n += 1;
|
| 120 |
+
a.members.push(n);
|
| 121 |
+
acc.set(n.group, a);
|
| 122 |
+
}
|
| 123 |
+
const cs = [...acc.entries()].map(([k, a]) => ({ k, x: a.x / a.n, y: a.y / a.n, members: a.members }));
|
| 124 |
+
for (const c of cs) {
|
| 125 |
+
for (const n of c.members) {
|
| 126 |
+
if (n.fx != null) continue;
|
| 127 |
+
n.vx = (n.vx ?? 0) + (c.x - n.x!) * 0.035;
|
| 128 |
+
n.vy = (n.vy ?? 0) + (c.y - n.y!) * 0.035;
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
for (let i = 0; i < cs.length; i++) {
|
| 132 |
+
for (let j = i + 1; j < cs.length; j++) {
|
| 133 |
+
const a = cs[i];
|
| 134 |
+
const b = cs[j];
|
| 135 |
+
const dx = b.x - a.x;
|
| 136 |
+
const dy = b.y - a.y;
|
| 137 |
+
const d = Math.hypot(dx, dy) || 1;
|
| 138 |
+
const overlap = (hullR.get(a.k) ?? 10) + (hullR.get(b.k) ?? 10) + 10 - d;
|
| 139 |
+
if (overlap <= 0) continue;
|
| 140 |
+
const push = Math.min(overlap, 30) * 0.02;
|
| 141 |
+
const ux = (dx / d) * push;
|
| 142 |
+
const uy = (dy / d) * push;
|
| 143 |
+
for (const n of a.members) {
|
| 144 |
+
if (n.fx != null) continue;
|
| 145 |
+
n.vx = (n.vx ?? 0) - ux;
|
| 146 |
+
n.vy = (n.vy ?? 0) - uy;
|
| 147 |
+
}
|
| 148 |
+
for (const n of b.members) {
|
| 149 |
+
if (n.fx != null) continue;
|
| 150 |
+
n.vx = (n.vx ?? 0) + ux;
|
| 151 |
+
n.vy = (n.vy ?? 0) + uy;
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
};
|
| 156 |
+
const strength = linkStrength ?? 0.7 / Math.max(1, Math.sqrt(sn.length));
|
| 157 |
+
const sim = forceSimulation<SimNode, SimLink>(sn)
|
| 158 |
+
.force('link', forceLink<SimNode, SimLink>(sl).id((d) => d.id).distance((l) => l.distance).strength(strength))
|
| 159 |
+
.force('charge', forceManyBody<SimNode>().strength(charge))
|
| 160 |
+
.force('x', forceX<SimNode>(W / 2).strength(0.035))
|
| 161 |
+
.force('y', forceY<SimNode>(H / 2).strength(0.055))
|
| 162 |
+
.force('collide', forceCollide<SimNode>((d) => d.r + 2.5))
|
| 163 |
+
.force('wander', wander)
|
| 164 |
+
.force('cohesion', cohesion)
|
| 165 |
+
.velocityDecay(0.32)
|
| 166 |
+
.alphaTarget(still ? 0 : 0.02);
|
| 167 |
+
|
| 168 |
+
const draw = () => {
|
| 169 |
+
for (const n of sn) {
|
| 170 |
+
n.x = Math.max(n.r + 4, Math.min(W - n.r - 4, n.x!));
|
| 171 |
+
n.y = Math.max(n.r + 4, Math.min(H - n.r - 4, n.y!));
|
| 172 |
+
keep.set(n.id, { x: n.x, y: n.y });
|
| 173 |
+
const el = nodeEls.current.get(n.id);
|
| 174 |
+
if (el) {
|
| 175 |
+
el.setAttribute('cx', n.x.toFixed(1));
|
| 176 |
+
el.setAttribute('cy', n.y.toFixed(1));
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
sl.forEach((l, i) => {
|
| 180 |
+
const el = linkEls.current[i];
|
| 181 |
+
if (!el) return;
|
| 182 |
+
const s = l.source as SimNode;
|
| 183 |
+
const d = l.target as SimNode;
|
| 184 |
+
el.setAttribute('x1', s.x!.toFixed(1));
|
| 185 |
+
el.setAttribute('y1', s.y!.toFixed(1));
|
| 186 |
+
el.setAttribute('x2', d.x!.toFixed(1));
|
| 187 |
+
el.setAttribute('y2', d.y!.toFixed(1));
|
| 188 |
+
});
|
| 189 |
+
if (hullEls.current.size) {
|
| 190 |
+
const byGroup = new Map<string | number, SimNode[]>();
|
| 191 |
+
for (const n of sn) {
|
| 192 |
+
if (n.group == null) continue;
|
| 193 |
+
const arr = byGroup.get(n.group) ?? [];
|
| 194 |
+
arr.push(n);
|
| 195 |
+
byGroup.set(n.group, arr);
|
| 196 |
+
}
|
| 197 |
+
for (const [key, members] of byGroup) {
|
| 198 |
+
const g = hullEls.current.get(key);
|
| 199 |
+
if (!g) continue;
|
| 200 |
+
const cx = members.reduce((s, n) => s + n.x!, 0) / members.length;
|
| 201 |
+
const cy = members.reduce((s, n) => s + n.y!, 0) / members.length;
|
| 202 |
+
const r = Math.max(...members.map((n) => Math.hypot(n.x! - cx, n.y! - cy) + n.r)) + 9;
|
| 203 |
+
hullR.set(key, r);
|
| 204 |
+
const c = g.firstElementChild as SVGCircleElement;
|
| 205 |
+
c.setAttribute('cx', cx.toFixed(1));
|
| 206 |
+
c.setAttribute('cy', cy.toFixed(1));
|
| 207 |
+
c.setAttribute('r', r.toFixed(1));
|
| 208 |
+
const label = g.lastElementChild as SVGTextElement | null;
|
| 209 |
+
if (label && label.tagName === 'text') {
|
| 210 |
+
label.setAttribute('x', cx.toFixed(1));
|
| 211 |
+
label.setAttribute('y', (cy - r - 4).toFixed(1));
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
};
|
| 216 |
+
sim.on('tick', draw);
|
| 217 |
+
if (still) sim.tick(200);
|
| 218 |
+
draw();
|
| 219 |
+
simRef.current = sim;
|
| 220 |
+
return () => {
|
| 221 |
+
sim.stop();
|
| 222 |
+
simRef.current = null;
|
| 223 |
+
};
|
| 224 |
+
}, [nodes, links, groups, W, H, width, charge, linkStrength]);
|
| 225 |
+
|
| 226 |
+
const toLocal = (e: React.PointerEvent) => {
|
| 227 |
+
const r = svgRef.current!.getBoundingClientRect();
|
| 228 |
+
return { x: e.clientX - r.left, y: e.clientY - r.top };
|
| 229 |
+
};
|
| 230 |
+
|
| 231 |
+
const onPointerDown = (id: string) => (e: React.PointerEvent<SVGCircleElement>) => {
|
| 232 |
+
const n = simNodes.current.find((m) => m.id === id);
|
| 233 |
+
if (!n) return;
|
| 234 |
+
(e.target as Element).setPointerCapture(e.pointerId);
|
| 235 |
+
const p = toLocal(e);
|
| 236 |
+
drag.current = { id, moved: false, x0: p.x, y0: p.y };
|
| 237 |
+
n.fx = n.x;
|
| 238 |
+
n.fy = n.y;
|
| 239 |
+
simRef.current?.alphaTarget(0.25).restart();
|
| 240 |
+
setTip(null);
|
| 241 |
+
};
|
| 242 |
+
|
| 243 |
+
const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
|
| 244 |
+
const d = drag.current;
|
| 245 |
+
if (!d) return;
|
| 246 |
+
const p = toLocal(e);
|
| 247 |
+
if (Math.hypot(p.x - d.x0, p.y - d.y0) > 3) d.moved = true;
|
| 248 |
+
const n = simNodes.current.find((m) => m.id === d.id);
|
| 249 |
+
if (n) {
|
| 250 |
+
n.fx = Math.max(n.r, Math.min(W - n.r, p.x));
|
| 251 |
+
n.fy = Math.max(n.r, Math.min(H - n.r, p.y));
|
| 252 |
+
}
|
| 253 |
+
};
|
| 254 |
+
|
| 255 |
+
const onPointerUp = () => {
|
| 256 |
+
const d = drag.current;
|
| 257 |
+
if (!d) return;
|
| 258 |
+
const n = simNodes.current.find((m) => m.id === d.id);
|
| 259 |
+
if (n) {
|
| 260 |
+
n.fx = null;
|
| 261 |
+
n.fy = null;
|
| 262 |
+
}
|
| 263 |
+
simRef.current?.alphaTarget(reducedMotion() ? 0 : 0.02);
|
| 264 |
+
drag.current = null;
|
| 265 |
+
if (!d.moved) onNodeClick?.(d.id);
|
| 266 |
+
};
|
| 267 |
+
|
| 268 |
+
const showTip = (id: string, e: React.PointerEvent | React.MouseEvent) => {
|
| 269 |
+
if (drag.current || !nodeTip) return;
|
| 270 |
+
const r = svgRef.current!.getBoundingClientRect();
|
| 271 |
+
setTip({ x: e.clientX - r.left, y: e.clientY - r.top, content: nodeTip(id) });
|
| 272 |
+
};
|
| 273 |
+
|
| 274 |
+
return (
|
| 275 |
+
<div className="fg" ref={wrapRef}>
|
| 276 |
+
{width > 0 && (
|
| 277 |
+
<svg
|
| 278 |
+
ref={svgRef}
|
| 279 |
+
width={W}
|
| 280 |
+
height={H}
|
| 281 |
+
role="img"
|
| 282 |
+
aria-label={ariaLabel}
|
| 283 |
+
onPointerMove={onPointerMove}
|
| 284 |
+
onPointerUp={onPointerUp}
|
| 285 |
+
onPointerLeave={onPointerUp}
|
| 286 |
+
>
|
| 287 |
+
{groups.length > 0 && (
|
| 288 |
+
<g className="fg__hulls">
|
| 289 |
+
{groups.map((g) => (
|
| 290 |
+
<g
|
| 291 |
+
key={g.key}
|
| 292 |
+
ref={(el) => {
|
| 293 |
+
if (el) hullEls.current.set(g.key, el);
|
| 294 |
+
else hullEls.current.delete(g.key);
|
| 295 |
+
}}
|
| 296 |
+
>
|
| 297 |
+
<circle
|
| 298 |
+
className="fg__hull"
|
| 299 |
+
style={{ fill: g.color, stroke: g.color }}
|
| 300 |
+
r={0}
|
| 301 |
+
/>
|
| 302 |
+
{g.label && (
|
| 303 |
+
<text className="fg__hull-label" textAnchor="middle">
|
| 304 |
+
{g.label}
|
| 305 |
+
</text>
|
| 306 |
+
)}
|
| 307 |
+
</g>
|
| 308 |
+
))}
|
| 309 |
+
</g>
|
| 310 |
+
)}
|
| 311 |
+
<g className="fg__links">
|
| 312 |
+
{links.map((l, i) => {
|
| 313 |
+
const hot = hover != null && (l.source === hover || l.target === hover);
|
| 314 |
+
const visible = !l.hidden || hot;
|
| 315 |
+
return (
|
| 316 |
+
<line
|
| 317 |
+
key={`${l.source}-${l.target}`}
|
| 318 |
+
ref={(el) => {
|
| 319 |
+
linkEls.current[i] = el;
|
| 320 |
+
}}
|
| 321 |
+
stroke={l.color}
|
| 322 |
+
strokeWidth={hot ? (l.width ?? 1) + 0.8 : l.width ?? 1}
|
| 323 |
+
strokeOpacity={!visible ? 0 : hover == null ? l.opacity ?? 0.4 : hot ? 0.95 : 0.05}
|
| 324 |
+
/>
|
| 325 |
+
);
|
| 326 |
+
})}
|
| 327 |
+
</g>
|
| 328 |
+
<g className="fg__nodes">
|
| 329 |
+
{nodes.map((n) => (
|
| 330 |
+
<circle
|
| 331 |
+
key={n.id}
|
| 332 |
+
ref={(el) => {
|
| 333 |
+
if (el) nodeEls.current.set(n.id, el);
|
| 334 |
+
else nodeEls.current.delete(n.id);
|
| 335 |
+
}}
|
| 336 |
+
r={hover === n.id ? n.r + 2 : n.r}
|
| 337 |
+
fill={n.fill}
|
| 338 |
+
className={`fg__node ${n.ring ? 'is-ring' : ''}`}
|
| 339 |
+
onPointerDown={onPointerDown(n.id)}
|
| 340 |
+
onPointerEnter={(e) => {
|
| 341 |
+
setHover(n.id);
|
| 342 |
+
showTip(n.id, e);
|
| 343 |
+
}}
|
| 344 |
+
onPointerMove={(e) => showTip(n.id, e)}
|
| 345 |
+
onPointerLeave={() => {
|
| 346 |
+
setHover(null);
|
| 347 |
+
setTip(null);
|
| 348 |
+
}}
|
| 349 |
+
/>
|
| 350 |
+
))}
|
| 351 |
+
</g>
|
| 352 |
+
</svg>
|
| 353 |
+
)}
|
| 354 |
+
<Tooltip tip={tip} />
|
| 355 |
+
</div>
|
| 356 |
+
);
|
| 357 |
+
}
|
frontend/src/components/HeroArt.tsx
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo } from 'react';
|
| 2 |
+
import { rng } from './StateGlyph';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* Background illustration for the hero: a field of observation clusters
|
| 6 |
+
* (states) connected by faint transition lines. Decorative only.
|
| 7 |
+
*/
|
| 8 |
+
export function HeroArt() {
|
| 9 |
+
const art = useMemo(() => {
|
| 10 |
+
const r = rng('musprot-hero');
|
| 11 |
+
const clusters = Array.from({ length: 14 }, () => ({
|
| 12 |
+
x: 560 + r() * 640,
|
| 13 |
+
y: 40 + r() * 440,
|
| 14 |
+
n: 4 + Math.floor(r() * 14),
|
| 15 |
+
s: 10 + r() * 22,
|
| 16 |
+
}));
|
| 17 |
+
const dots = clusters.flatMap((c, ci) =>
|
| 18 |
+
Array.from({ length: c.n }, () => {
|
| 19 |
+
const a = r() * Math.PI * 2;
|
| 20 |
+
const d = Math.sqrt(r()) * c.s;
|
| 21 |
+
return { x: c.x + Math.cos(a) * d, y: c.y + Math.sin(a) * d, ci, big: r() > 0.85 };
|
| 22 |
+
}),
|
| 23 |
+
);
|
| 24 |
+
const edges: Array<[number, number]> = [];
|
| 25 |
+
clusters.forEach((a, i) => {
|
| 26 |
+
const near = clusters
|
| 27 |
+
.map((b, j) => ({ j, d: Math.hypot(a.x - b.x, a.y - b.y) }))
|
| 28 |
+
.filter((o) => o.j !== i)
|
| 29 |
+
.sort((p, q) => p.d - q.d)
|
| 30 |
+
.slice(0, 2);
|
| 31 |
+
near.forEach((o) => {
|
| 32 |
+
if (i < o.j) edges.push([i, o.j]);
|
| 33 |
+
});
|
| 34 |
+
});
|
| 35 |
+
return { clusters, dots, edges };
|
| 36 |
+
}, []);
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<svg className="hero__art" viewBox="0 0 1200 520" preserveAspectRatio="xMaxYMid slice" aria-hidden>
|
| 40 |
+
<defs>
|
| 41 |
+
<radialGradient id="hero-glow" cx="75%" cy="45%" r="55%">
|
| 42 |
+
<stop offset="0%" stopColor="#2a78d6" stopOpacity="0.28" />
|
| 43 |
+
<stop offset="100%" stopColor="#2a78d6" stopOpacity="0" />
|
| 44 |
+
</radialGradient>
|
| 45 |
+
</defs>
|
| 46 |
+
<rect width="1200" height="520" fill="url(#hero-glow)" />
|
| 47 |
+
{art.edges.map(([i, j]) => {
|
| 48 |
+
const a = art.clusters[i];
|
| 49 |
+
const b = art.clusters[j];
|
| 50 |
+
const mx = (a.x + b.x) / 2;
|
| 51 |
+
const my = (a.y + b.y) / 2 - 30;
|
| 52 |
+
return <path key={`${i}-${j}`} d={`M${a.x},${a.y} Q${mx},${my} ${b.x},${b.y}`} className="hero__edge" />;
|
| 53 |
+
})}
|
| 54 |
+
{art.clusters.map((c, i) => (
|
| 55 |
+
<circle key={i} cx={c.x} cy={c.y} r={c.s + 8} className="hero__halo" />
|
| 56 |
+
))}
|
| 57 |
+
{art.dots.map((d, i) => (
|
| 58 |
+
<circle key={i} cx={d.x} cy={d.y} r={d.big ? 3.2 : 2} className={`hero__dot hero__dot--${d.ci % 4}`} />
|
| 59 |
+
))}
|
| 60 |
+
</svg>
|
| 61 |
+
);
|
| 62 |
+
}
|
frontend/src/components/KnowledgeGraph.tsx
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo, useState } from 'react';
|
| 2 |
+
import { Link, useNavigate } from 'react-router-dom';
|
| 3 |
+
import { ArrowRight } from 'lucide-react';
|
| 4 |
+
import { api, type InnerGraph, type OuterGraph } from '../lib/api';
|
| 5 |
+
import { useApi } from '../lib/hooks';
|
| 6 |
+
import { FIDELITY_INFO, fidelityColor } from '../lib/fidelity';
|
| 7 |
+
import { chainLabel, chainPath, cleanFunction, fmtInt, fmtNum, methodShort, sequencePath } from '../lib/format';
|
| 8 |
+
import { ForceGraph, type FGGroup, type FGLink, type FGNode } from './ForceGraph';
|
| 9 |
+
import { Spinner, Swatch } from './ui';
|
| 10 |
+
|
| 11 |
+
/* Categorical slots (validated order) for the largest states; the rest fold into "other". */
|
| 12 |
+
const STATE_COLORS = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7'];
|
| 13 |
+
const OTHER = '#98a2b3';
|
| 14 |
+
|
| 15 |
+
/* Ordinal ramp for "number of states" on outer-graph nodes. */
|
| 16 |
+
const STATE_BINS: Array<{ max: number; label: string; color: string }> = [
|
| 17 |
+
{ max: 1, label: '1 state', color: 'var(--nst-1)' },
|
| 18 |
+
{ max: 2, label: '2', color: 'var(--nst-2)' },
|
| 19 |
+
{ max: 5, label: '3–5', color: 'var(--nst-3)' },
|
| 20 |
+
{ max: 20, label: '6–20', color: 'var(--nst-4)' },
|
| 21 |
+
{ max: Infinity, label: '> 20', color: 'var(--nst-5)' },
|
| 22 |
+
];
|
| 23 |
+
const statesColor = (n: number) => STATE_BINS.find((b) => n <= b.max)!.color;
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* Thin a dense complete graph without hiding transitions: keep each node's k most
|
| 27 |
+
* similar same-state partners, and the `perPair` most similar edges between every
|
| 28 |
+
* pair of states (so every state pair stays visibly connected).
|
| 29 |
+
*/
|
| 30 |
+
function backbone(edges: InnerGraph['edges'], state: number[], k = 2, perPair = 3): Set<number> {
|
| 31 |
+
const within = new Map<number, number[]>();
|
| 32 |
+
const across = new Map<string, number[]>();
|
| 33 |
+
edges.forEach((e, i) => {
|
| 34 |
+
const a = state[e.s];
|
| 35 |
+
const b = state[e.t];
|
| 36 |
+
if (a === b) {
|
| 37 |
+
for (const n of [e.s, e.t]) {
|
| 38 |
+
const arr = within.get(n) ?? [];
|
| 39 |
+
arr.push(i);
|
| 40 |
+
within.set(n, arr);
|
| 41 |
+
}
|
| 42 |
+
} else {
|
| 43 |
+
const key = a < b ? `${a}|${b}` : `${b}|${a}`;
|
| 44 |
+
const arr = across.get(key) ?? [];
|
| 45 |
+
arr.push(i);
|
| 46 |
+
across.set(key, arr);
|
| 47 |
+
}
|
| 48 |
+
});
|
| 49 |
+
const bySim = (x: number, y: number) => (edges[y].sim ?? -1) - (edges[x].sim ?? -1);
|
| 50 |
+
const keep = new Set<number>();
|
| 51 |
+
for (const arr of within.values()) arr.sort(bySim).slice(0, k).forEach((i) => keep.add(i));
|
| 52 |
+
for (const arr of across.values()) arr.sort(bySim).slice(0, perPair).forEach((i) => keep.add(i));
|
| 53 |
+
return keep;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/* ───────────────────────────── outer layer: one ECOD homology group */
|
| 57 |
+
|
| 58 |
+
function OuterGraphView({
|
| 59 |
+
data,
|
| 60 |
+
selected,
|
| 61 |
+
onSelect,
|
| 62 |
+
names,
|
| 63 |
+
}: {
|
| 64 |
+
data: OuterGraph;
|
| 65 |
+
selected: string;
|
| 66 |
+
onSelect: (id: string) => void;
|
| 67 |
+
names: Record<string, string>;
|
| 68 |
+
}) {
|
| 69 |
+
const group = data.groups[0];
|
| 70 |
+
const { nodes, links } = useMemo(() => {
|
| 71 |
+
const seqs = group?.sequences ?? [];
|
| 72 |
+
const nodes: FGNode[] = seqs.map((s) => ({
|
| 73 |
+
id: s.sequence_id,
|
| 74 |
+
r: 5 + 2.1 * Math.log2(1 + s.n_obs),
|
| 75 |
+
fill: statesColor(s.n_states),
|
| 76 |
+
}));
|
| 77 |
+
const links: FGLink[] = [];
|
| 78 |
+
for (let i = 0; i < seqs.length; i++)
|
| 79 |
+
for (let j = i + 1; j < seqs.length; j++)
|
| 80 |
+
links.push({ source: seqs[i].sequence_id, target: seqs[j].sequence_id, distance: 140, color: 'var(--faint)', opacity: 0.3 });
|
| 81 |
+
return { nodes, links };
|
| 82 |
+
}, [group]);
|
| 83 |
+
const withRing = useMemo(() => nodes.map((n) => ({ ...n, ring: n.id === selected })), [nodes, selected]);
|
| 84 |
+
const bySeq = useMemo(() => new Map((group?.sequences ?? []).map((s) => [s.sequence_id, s])), [group]);
|
| 85 |
+
if (!group) return <p className="muted">This sequence has no ECOD homology assignment.</p>;
|
| 86 |
+
|
| 87 |
+
return (
|
| 88 |
+
<ForceGraph
|
| 89 |
+
ariaLabel={`Outer graph: ${group.label}`}
|
| 90 |
+
nodes={withRing}
|
| 91 |
+
links={links}
|
| 92 |
+
charge={-220}
|
| 93 |
+
linkStrength={0.035}
|
| 94 |
+
onNodeClick={onSelect}
|
| 95 |
+
nodeTip={(id) => {
|
| 96 |
+
const s = bySeq.get(id)!;
|
| 97 |
+
return (
|
| 98 |
+
<>
|
| 99 |
+
<div className="chart-tip__title">
|
| 100 |
+
{names[id] ?? <span className="mono">{id}</span>}
|
| 101 |
+
{names[id] && <span className="muted mono"> · {id}</span>}
|
| 102 |
+
</div>
|
| 103 |
+
{s.top_function && <div className="chart-tip__wrap">{cleanFunction(s.top_function).slice(0, 90)}</div>}
|
| 104 |
+
<div className="chart-tip__row">
|
| 105 |
+
<span>Observations</span>
|
| 106 |
+
<b className="tabular">{fmtInt(s.n_obs)}</b>
|
| 107 |
+
</div>
|
| 108 |
+
<div className="chart-tip__row">
|
| 109 |
+
<span>States</span>
|
| 110 |
+
<b className="tabular">{fmtInt(s.n_states)}</b>
|
| 111 |
+
</div>
|
| 112 |
+
{s.uniprot_id && (
|
| 113 |
+
<div className="chart-tip__row">
|
| 114 |
+
<span>UniProt</span>
|
| 115 |
+
<b className="mono">{s.uniprot_id}</b>
|
| 116 |
+
</div>
|
| 117 |
+
)}
|
| 118 |
+
<div className="chart-tip__foot">Click to open its inner graph · drag to move</div>
|
| 119 |
+
</>
|
| 120 |
+
);
|
| 121 |
+
}}
|
| 122 |
+
/>
|
| 123 |
+
);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/* ───────────────────────────── inner layer: observations, states as shaded circles */
|
| 127 |
+
|
| 128 |
+
function InnerGraphView({ g }: { g: InnerGraph }) {
|
| 129 |
+
const navigate = useNavigate();
|
| 130 |
+
const { nodes, links, groups, colorOf } = useMemo(() => {
|
| 131 |
+
const top = g.states.slice(0, STATE_COLORS.length);
|
| 132 |
+
const colorMap = new Map(top.map((s, i) => [s.state_id, STATE_COLORS[i]]));
|
| 133 |
+
const colorOf = (st: number) => colorMap.get(st) ?? OTHER;
|
| 134 |
+
const nodes: FGNode[] = g.nodes.map((n, i) => ({
|
| 135 |
+
id: String(i),
|
| 136 |
+
r: 6,
|
| 137 |
+
fill: colorOf(n.state_id),
|
| 138 |
+
group: n.state_id,
|
| 139 |
+
}));
|
| 140 |
+
const keep = g.nodes.length > 40 ? backbone(g.edges, g.nodes.map((n) => n.state_id)) : null;
|
| 141 |
+
const links: FGLink[] = g.edges.map((e, i) => {
|
| 142 |
+
const same = g.nodes[e.s].state_id === g.nodes[e.t].state_id;
|
| 143 |
+
return {
|
| 144 |
+
source: String(e.s),
|
| 145 |
+
target: String(e.t),
|
| 146 |
+
// spring length grows with structural dissimilarity
|
| 147 |
+
distance: Math.min(230, 22 + 560 * (1 - (e.sim ?? 0.3))),
|
| 148 |
+
color: same ? 'var(--line-strong)' : fidelityColor(e.fid),
|
| 149 |
+
opacity: same ? 0.45 : keep ? 0.75 : 0.3,
|
| 150 |
+
width: same ? 0.9 : keep ? 1.4 : 0.9,
|
| 151 |
+
hidden: keep ? !keep.has(i) : false,
|
| 152 |
+
};
|
| 153 |
+
});
|
| 154 |
+
const present = new Set(g.nodes.map((n) => n.state_id));
|
| 155 |
+
const groups: FGGroup[] = g.states
|
| 156 |
+
.filter((s) => present.has(s.state_id))
|
| 157 |
+
.map((s) => ({
|
| 158 |
+
key: s.state_id,
|
| 159 |
+
color: colorOf(s.state_id),
|
| 160 |
+
label: g.nodes.filter((n) => n.state_id === s.state_id).length > 1 ? `State ${s.state_id}` : undefined,
|
| 161 |
+
}));
|
| 162 |
+
return { nodes, links, groups, colorOf };
|
| 163 |
+
}, [g]);
|
| 164 |
+
|
| 165 |
+
return (
|
| 166 |
+
<>
|
| 167 |
+
<ForceGraph
|
| 168 |
+
ariaLabel="Inner graph: observations of one sequence grouped by conformational state"
|
| 169 |
+
nodes={nodes}
|
| 170 |
+
links={links}
|
| 171 |
+
groups={groups}
|
| 172 |
+
charge={-55}
|
| 173 |
+
linkStrength={0.03}
|
| 174 |
+
onNodeClick={(id) => {
|
| 175 |
+
const n = g.nodes[Number(id)];
|
| 176 |
+
navigate(chainPath(n.pdb_id, n.auth_asym_id));
|
| 177 |
+
}}
|
| 178 |
+
nodeTip={(id) => {
|
| 179 |
+
const n = g.nodes[Number(id)];
|
| 180 |
+
return (
|
| 181 |
+
<>
|
| 182 |
+
<div className="chart-tip__title mono">{chainLabel(n.pdb_id, n.auth_asym_id)}</div>
|
| 183 |
+
<div className="chart-tip__row">
|
| 184 |
+
<Swatch color={colorOf(n.state_id)} shape="dot" />
|
| 185 |
+
<span>State</span>
|
| 186 |
+
<b>{n.state_id}</b>
|
| 187 |
+
</div>
|
| 188 |
+
<div className="chart-tip__row">
|
| 189 |
+
<span>Method</span>
|
| 190 |
+
<b>
|
| 191 |
+
{methodShort(n.experimental_method)}
|
| 192 |
+
{n.resolution != null ? ` · ${fmtNum(n.resolution, 2)} Å` : ''}
|
| 193 |
+
</b>
|
| 194 |
+
</div>
|
| 195 |
+
<div className="chart-tip__row">
|
| 196 |
+
<span>Ligand</span>
|
| 197 |
+
<b>{n.binding_status ?? '—'}</b>
|
| 198 |
+
</div>
|
| 199 |
+
<div className="chart-tip__foot">Click to open this chain · drag to move</div>
|
| 200 |
+
</>
|
| 201 |
+
);
|
| 202 |
+
}}
|
| 203 |
+
/>
|
| 204 |
+
<ul className="legend">
|
| 205 |
+
{g.states.slice(0, STATE_COLORS.length).map((s) => (
|
| 206 |
+
<li key={s.state_id}>
|
| 207 |
+
<Swatch color={colorOf(s.state_id)} shape="dot" /> State {s.state_id}
|
| 208 |
+
<span className="muted">({fmtInt(s.n_members)})</span>
|
| 209 |
+
</li>
|
| 210 |
+
))}
|
| 211 |
+
|
| 212 |
+
</ul>
|
| 213 |
+
<ul className="legend">
|
| 214 |
+
<li>
|
| 215 |
+
<span className="legend-line" style={{ background: 'var(--line-strong)' }} /> Same state
|
| 216 |
+
</li>
|
| 217 |
+
{['low', 'medium', 'high'].map((f) => (
|
| 218 |
+
<li key={f}>
|
| 219 |
+
<span className="legend-line" style={{ background: fidelityColor(f) }} /> {FIDELITY_INFO[f].label} fidelity
|
| 220 |
+
</li>
|
| 221 |
+
))}
|
| 222 |
+
</ul>
|
| 223 |
+
</>
|
| 224 |
+
);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
/* ───────────────────────────── container */
|
| 228 |
+
|
| 229 |
+
export function KnowledgeGraph({ featured }: { featured: Array<{ id: string; name: string }> }) {
|
| 230 |
+
const names = Object.fromEntries(featured.map((f) => [f.id, f.name]));
|
| 231 |
+
const [example, setExample] = useState(featured[0].id);
|
| 232 |
+
const [selected, setSelected] = useState(featured[0].id);
|
| 233 |
+
const outer = useApi(`outer1:${example}`, (sig) => api.outerGraph([example], sig));
|
| 234 |
+
const inner = useApi(`inner:${selected}`, (sig) => api.innerGraph(selected, 60, sig));
|
| 235 |
+
const group = outer.data?.groups[0];
|
| 236 |
+
const g = inner.data;
|
| 237 |
+
const selectedSeq = group?.sequences.find((s) => s.sequence_id === selected) ?? g?.sequence;
|
| 238 |
+
|
| 239 |
+
return (
|
| 240 |
+
<div className="kg">
|
| 241 |
+
<div className="kg__picks" role="group" aria-label="Examples">
|
| 242 |
+
<span className="muted small">Example</span>
|
| 243 |
+
{featured.map((f) => (
|
| 244 |
+
<button
|
| 245 |
+
key={f.id}
|
| 246 |
+
type="button"
|
| 247 |
+
className={`chip ${f.id === example ? 'is-on' : ''}`}
|
| 248 |
+
onClick={() => {
|
| 249 |
+
setExample(f.id);
|
| 250 |
+
setSelected(f.id);
|
| 251 |
+
}}
|
| 252 |
+
>
|
| 253 |
+
{f.name}
|
| 254 |
+
</button>
|
| 255 |
+
))}
|
| 256 |
+
</div>
|
| 257 |
+
<div className="kg__panels">
|
| 258 |
+
<div className="card kg__panel">
|
| 259 |
+
<header className="kg__head">
|
| 260 |
+
<p className="eyebrow">Outer layer · sequences</p>
|
| 261 |
+
<h3 className="card__title">{group ? group.label || group.hset.join(' + ') : 'ECOD homology group'}</h3>
|
| 262 |
+
<p className="card__kicker">
|
| 263 |
+
{group
|
| 264 |
+
? `${fmtInt(group.n_sequences)} sequences share this set of ECOD homology groups and form one clique; the ${group.sequences.length} most multistate are shown.`
|
| 265 |
+
: 'Sequences sharing the same ECOD homology groups are linked.'}{' '}
|
| 266 |
+
Node size: observations · colour: states.
|
| 267 |
+
</p>
|
| 268 |
+
</header>
|
| 269 |
+
{outer.loading && <Spinner />}
|
| 270 |
+
{outer.data && <OuterGraphView data={outer.data} selected={selected} onSelect={setSelected} names={names} />}
|
| 271 |
+
<ul className="legend">
|
| 272 |
+
{STATE_BINS.map((b) => (
|
| 273 |
+
<li key={b.label}>
|
| 274 |
+
<Swatch color={b.color} shape="dot" /> {b.label}
|
| 275 |
+
</li>
|
| 276 |
+
))}
|
| 277 |
+
<li>
|
| 278 |
+
<span className="swatch swatch--dot swatch--sel" /> Selected
|
| 279 |
+
</li>
|
| 280 |
+
</ul>
|
| 281 |
+
{group && (
|
| 282 |
+
<div className="kg__foot">
|
| 283 |
+
<Link to={`/browse?ecod=${encodeURIComponent(group.hset.join(';'))}`} className="btn btn--ghost btn--sm">
|
| 284 |
+
All {fmtInt(group.n_sequences)} sequences <ArrowRight aria-hidden />
|
| 285 |
+
</Link>
|
| 286 |
+
</div>
|
| 287 |
+
)}
|
| 288 |
+
</div>
|
| 289 |
+
|
| 290 |
+
<div className="card kg__panel">
|
| 291 |
+
<header className="kg__head">
|
| 292 |
+
<p className="eyebrow">Inner layer · observations</p>
|
| 293 |
+
<h3 className="card__title">
|
| 294 |
+
{names[selected] ?? (g?.sequence.uniprot_id ? `UniProt ${g.sequence.uniprot_id}` : 'Sequence')}{' '}
|
| 295 |
+
<span className="muted mono">{selected}</span>
|
| 296 |
+
</h3>
|
| 297 |
+
<p className="card__kicker">
|
| 298 |
+
Every observation is linked to every other; spring length follows structural dissimilarity. Shaded circles
|
| 299 |
+
are conformational states; cross-state edges are coloured by fidelity.
|
| 300 |
+
</p>
|
| 301 |
+
</header>
|
| 302 |
+
{inner.loading && <Spinner label="Loading inner graph…" />}
|
| 303 |
+
{g && <InnerGraphView g={g} />}
|
| 304 |
+
{g && (
|
| 305 |
+
<div className="kg__foot">
|
| 306 |
+
<span className="muted small">
|
| 307 |
+
{g.nodes.length} of {fmtInt(g.n_total)} observations
|
| 308 |
+
{g.states.length > STATE_COLORS.length
|
| 309 |
+
? ` · ${STATE_COLORS.length} largest of ${fmtInt(g.states.length)} states`
|
| 310 |
+
: ` · ${fmtInt(g.states.length)} states`}
|
| 311 |
+
{g.nodes.length > 40 && ' · showing the 3 closest links between each pair of states; hover a node for all its triplets'}
|
| 312 |
+
</span>
|
| 313 |
+
<Link to={sequencePath(selected)} className="btn btn--ghost btn--sm">
|
| 314 |
+
Open {selectedSeq ? `${fmtInt(selectedSeq.n_obs)} observations` : 'sequence'} <ArrowRight aria-hidden />
|
| 315 |
+
</Link>
|
| 316 |
+
</div>
|
| 317 |
+
)}
|
| 318 |
+
</div>
|
| 319 |
+
</div>
|
| 320 |
+
</div>
|
| 321 |
+
);
|
| 322 |
+
}
|
frontend/src/components/Layout.tsx
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react';
|
| 2 |
+
import { Link, NavLink, useLocation } from 'react-router-dom';
|
| 3 |
+
import { Download, Menu, X } from 'lucide-react';
|
| 4 |
+
import { api } from '../lib/api';
|
| 5 |
+
import { SearchBox } from './SearchBox';
|
| 6 |
+
|
| 7 |
+
export function Logo({ size = 28 }: { size?: number }) {
|
| 8 |
+
// Three conformational "states" joined by transitions.
|
| 9 |
+
return (
|
| 10 |
+
<svg width={size} height={size} viewBox="0 0 32 32" aria-hidden className="logo-mark">
|
| 11 |
+
<rect width="32" height="32" rx="8" className="logo-mark__bg" />
|
| 12 |
+
<path d="M9 21.5 L16 10 L23 21.5" className="logo-mark__edge" />
|
| 13 |
+
<path d="M9 21.5 H23" className="logo-mark__edge logo-mark__edge--soft" />
|
| 14 |
+
<circle cx="16" cy="10" r="3.4" className="logo-mark__n1" />
|
| 15 |
+
<circle cx="9" cy="21.5" r="3.4" className="logo-mark__n2" />
|
| 16 |
+
<circle cx="23" cy="21.5" r="3.4" className="logo-mark__n3" />
|
| 17 |
+
</svg>
|
| 18 |
+
);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const NAV = [
|
| 22 |
+
{ to: '/browse', label: 'Browse' },
|
| 23 |
+
{ to: '/statistics', label: 'Statistics' },
|
| 24 |
+
{ to: '/docs', label: 'Docs' },
|
| 25 |
+
];
|
| 26 |
+
|
| 27 |
+
export function Header() {
|
| 28 |
+
const location = useLocation();
|
| 29 |
+
const [open, setOpen] = useState(false);
|
| 30 |
+
const isHome = location.pathname === '/';
|
| 31 |
+
|
| 32 |
+
useEffect(() => setOpen(false), [location.pathname, location.search]);
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<header className={`site-header ${isHome ? 'site-header--home' : ''}`}>
|
| 36 |
+
<div className="site-header__inner">
|
| 37 |
+
<Link to="/" className="brand" aria-label="MuSProt home">
|
| 38 |
+
<Logo />
|
| 39 |
+
<span className="brand__name">MuSProt</span>
|
| 40 |
+
</Link>
|
| 41 |
+
{!isHome && (
|
| 42 |
+
<div className="site-header__search">
|
| 43 |
+
<SearchBox placeholder="Search chain, entry, UniProt, function…" />
|
| 44 |
+
</div>
|
| 45 |
+
)}
|
| 46 |
+
<nav className="site-nav" aria-label="Main">
|
| 47 |
+
{NAV.map((n) => (
|
| 48 |
+
<NavLink key={n.to} to={n.to} className={({ isActive }) => `site-nav__link ${isActive ? 'is-active' : ''}`}>
|
| 49 |
+
{n.label}
|
| 50 |
+
</NavLink>
|
| 51 |
+
))}
|
| 52 |
+
<a href={api.downloadUrl} className="btn btn--primary btn--sm">
|
| 53 |
+
<Download aria-hidden /> Download
|
| 54 |
+
</a>
|
| 55 |
+
</nav>
|
| 56 |
+
<button
|
| 57 |
+
type="button"
|
| 58 |
+
className="menu-toggle"
|
| 59 |
+
aria-label="Menu"
|
| 60 |
+
aria-expanded={open}
|
| 61 |
+
onClick={() => setOpen((o) => !o)}
|
| 62 |
+
>
|
| 63 |
+
{open ? <X aria-hidden /> : <Menu aria-hidden />}
|
| 64 |
+
</button>
|
| 65 |
+
</div>
|
| 66 |
+
{open && (
|
| 67 |
+
<div className="mobile-nav">
|
| 68 |
+
{!isHome && <SearchBox placeholder="Search MuSProt…" />}
|
| 69 |
+
{NAV.map((n) => (
|
| 70 |
+
<NavLink key={n.to} to={n.to} className="mobile-nav__link">
|
| 71 |
+
{n.label}
|
| 72 |
+
</NavLink>
|
| 73 |
+
))}
|
| 74 |
+
<a href={api.downloadUrl} className="btn btn--primary">
|
| 75 |
+
<Download aria-hidden /> Download MuSProt.db
|
| 76 |
+
</a>
|
| 77 |
+
</div>
|
| 78 |
+
)}
|
| 79 |
+
</header>
|
| 80 |
+
);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
export function Footer() {
|
| 84 |
+
return (
|
| 85 |
+
<footer className="site-footer">
|
| 86 |
+
<div className="site-footer__inner">
|
| 87 |
+
<div className="site-footer__brand">
|
| 88 |
+
<Link to="/" className="brand">
|
| 89 |
+
<Logo size={24} />
|
| 90 |
+
<span className="brand__name">MuSProt</span>
|
| 91 |
+
</Link>
|
| 92 |
+
<p>
|
| 93 |
+
A million-scale database of experimentally observed conformational states and state transitions of proteins,
|
| 94 |
+
built from the Protein Data Bank.
|
| 95 |
+
</p>
|
| 96 |
+
</div>
|
| 97 |
+
<div className="site-footer__cols">
|
| 98 |
+
<div>
|
| 99 |
+
<h3>Explore</h3>
|
| 100 |
+
<Link to="/browse">Browse sequences</Link>
|
| 101 |
+
<Link to="/browse?min_states=5">Highly multistate</Link>
|
| 102 |
+
<Link to="/chain/3vgk/A">Example chain</Link>
|
| 103 |
+
</div>
|
| 104 |
+
<div>
|
| 105 |
+
<h3>Data</h3>
|
| 106 |
+
<Link to="/docs">Documentation</Link>
|
| 107 |
+
<a href={api.downloadUrl}>Download SQLite</a>
|
| 108 |
+
<a href="/api/docs" target="_blank" rel="noopener noreferrer">
|
| 109 |
+
REST API
|
| 110 |
+
</a>
|
| 111 |
+
</div>
|
| 112 |
+
<div>
|
| 113 |
+
<h3>Sources</h3>
|
| 114 |
+
<a href="https://www.rcsb.org" target="_blank" rel="noopener noreferrer">
|
| 115 |
+
RCSB PDB
|
| 116 |
+
</a>
|
| 117 |
+
<a href="https://www.uniprot.org" target="_blank" rel="noopener noreferrer">
|
| 118 |
+
UniProt
|
| 119 |
+
</a>
|
| 120 |
+
<a href="https://www.cathdb.info" target="_blank" rel="noopener noreferrer">
|
| 121 |
+
CATH
|
| 122 |
+
</a>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
<div className="site-footer__base">
|
| 127 |
+
<span>MuSProt · Multistate Protein Database</span>
|
| 128 |
+
<a href="https://huggingface.co/spaces/omaib/MuSProt" target="_blank" rel="noopener noreferrer">
|
| 129 |
+
Hosted on Hugging Face
|
| 130 |
+
</a>
|
| 131 |
+
</div>
|
| 132 |
+
</footer>
|
| 133 |
+
);
|
| 134 |
+
}
|
frontend/src/components/PageMeta.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Helmet } from 'react-helmet-async';
|
| 2 |
+
|
| 3 |
+
const BASE = 'https://omaib-musprot.hf.space';
|
| 4 |
+
|
| 5 |
+
export function PageMeta({ title, description, path }: { title: string; description?: string; path: string }) {
|
| 6 |
+
const url = `${BASE}${path}`;
|
| 7 |
+
return (
|
| 8 |
+
<Helmet>
|
| 9 |
+
<title>{title}</title>
|
| 10 |
+
{description && <meta name="description" content={description} />}
|
| 11 |
+
<link rel="canonical" href={url} />
|
| 12 |
+
<meta property="og:type" content="website" />
|
| 13 |
+
<meta property="og:title" content={title} />
|
| 14 |
+
{description && <meta property="og:description" content={description} />}
|
| 15 |
+
<meta property="og:url" content={url} />
|
| 16 |
+
<meta property="og:site_name" content="MuSProt" />
|
| 17 |
+
<meta name="twitter:card" content="summary" />
|
| 18 |
+
</Helmet>
|
| 19 |
+
);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const DATASET_LD = {
|
| 23 |
+
'@context': 'https://schema.org',
|
| 24 |
+
'@type': 'Dataset',
|
| 25 |
+
name: 'MuSProt: Multistate Protein Database',
|
| 26 |
+
description:
|
| 27 |
+
'A million-scale database of experimentally observed protein conformational states from the Protein Data Bank: 863,643 chain observations of 102,790 sequences, clustered into 162,601 conformational states, with 113.7 million pairwise transitions annotated with structural similarity (TM-score, RMSD, coverage), energy differences (Rosetta, FoldX, EvoEF2, RW, RW+), fidelity labels and ranked functional annotations.',
|
| 28 |
+
url: `${BASE}/`,
|
| 29 |
+
creator: { '@type': 'Organization', name: 'MuSProt', url: `${BASE}/` },
|
| 30 |
+
license: 'https://creativecommons.org/licenses/by/4.0/',
|
| 31 |
+
keywords: [
|
| 32 |
+
'protein structure',
|
| 33 |
+
'conformational states',
|
| 34 |
+
'multistate proteins',
|
| 35 |
+
'Protein Data Bank',
|
| 36 |
+
'structural biology',
|
| 37 |
+
'protein design',
|
| 38 |
+
'TM-score',
|
| 39 |
+
'energy scoring',
|
| 40 |
+
],
|
| 41 |
+
distribution: [
|
| 42 |
+
{
|
| 43 |
+
'@type': 'DataDownload',
|
| 44 |
+
encodingFormat: 'application/x-sqlite3',
|
| 45 |
+
contentUrl: `${BASE}/api/protein/download/db`,
|
| 46 |
+
name: 'MuSProt.db (SQLite, ~37 GB)',
|
| 47 |
+
},
|
| 48 |
+
],
|
| 49 |
+
measurementTechnique: 'X-ray crystallography, cryo-EM, NMR',
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
export function DatasetJsonLd() {
|
| 53 |
+
return (
|
| 54 |
+
<Helmet>
|
| 55 |
+
<script type="application/ld+json">{JSON.stringify(DATASET_LD)}</script>
|
| 56 |
+
</Helmet>
|
| 57 |
+
);
|
| 58 |
+
}
|
frontend/src/components/Panels.tsx
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import { Download } from 'lucide-react';
|
| 3 |
+
import type { RankedFunction } from '../lib/api';
|
| 4 |
+
import { cleanFunction, fmtNum } from '../lib/format';
|
| 5 |
+
import { CopyButton, EmptyState } from './ui';
|
| 6 |
+
|
| 7 |
+
/** Ranked functional annotations with their matching scores as bars. */
|
| 8 |
+
export function FunctionList({ functions, initial = 6 }: { functions: RankedFunction[]; initial?: number }) {
|
| 9 |
+
const [all, setAll] = useState(false);
|
| 10 |
+
if (!functions.length) return <EmptyState title="No functional annotation" />;
|
| 11 |
+
const max = Math.max(...functions.map((f) => f.score ?? 0), 1e-9);
|
| 12 |
+
const shown = all ? functions : functions.slice(0, initial);
|
| 13 |
+
return (
|
| 14 |
+
<>
|
| 15 |
+
<ol className="fnlist">
|
| 16 |
+
{shown.map((f, i) => (
|
| 17 |
+
<li key={i} className="fnlist__item">
|
| 18 |
+
<span className="fnlist__rank tabular">{i + 1}</span>
|
| 19 |
+
<span className="fnlist__text">{cleanFunction(f.function)}</span>
|
| 20 |
+
<span className="fnlist__score" title="Matching score">
|
| 21 |
+
<span className="fnlist__bar">
|
| 22 |
+
<span style={{ width: `${((f.score ?? 0) / max) * 100}%` }} />
|
| 23 |
+
</span>
|
| 24 |
+
<span className="tabular">{fmtNum(f.score, 2)}</span>
|
| 25 |
+
</span>
|
| 26 |
+
</li>
|
| 27 |
+
))}
|
| 28 |
+
</ol>
|
| 29 |
+
{functions.length > initial && (
|
| 30 |
+
<button type="button" className="btn btn--ghost btn--sm btn--block" onClick={() => setAll((a) => !a)}>
|
| 31 |
+
{all ? 'Show fewer' : `Show all ${functions.length} annotations`}
|
| 32 |
+
</button>
|
| 33 |
+
)}
|
| 34 |
+
</>
|
| 35 |
+
);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Sequence in blocks of 10; residues outside the resolved ranges are dimmed.
|
| 40 |
+
* A coverage track above shows modelled segments at a glance.
|
| 41 |
+
*/
|
| 42 |
+
export function SequenceTrack({
|
| 43 |
+
sequence,
|
| 44 |
+
ranges,
|
| 45 |
+
fastaHeader,
|
| 46 |
+
fileName,
|
| 47 |
+
}: {
|
| 48 |
+
sequence: string;
|
| 49 |
+
ranges?: Array<[number, number]>;
|
| 50 |
+
fastaHeader: string;
|
| 51 |
+
fileName: string;
|
| 52 |
+
}) {
|
| 53 |
+
const [expanded, setExpanded] = useState(false);
|
| 54 |
+
if (!sequence) return <EmptyState title="No sequence available" />;
|
| 55 |
+
const L = sequence.length;
|
| 56 |
+
const resolved = new Uint8Array(L);
|
| 57 |
+
for (const [a, b] of ranges ?? []) for (let i = Math.max(1, a); i <= Math.min(L, b); i++) resolved[i - 1] = 1;
|
| 58 |
+
const hasRanges = !!ranges?.length;
|
| 59 |
+
const perLine = 50;
|
| 60 |
+
const lines = Math.ceil(L / perLine);
|
| 61 |
+
const shownLines = expanded ? lines : Math.min(lines, 8);
|
| 62 |
+
|
| 63 |
+
const download = () => {
|
| 64 |
+
const body = sequence.match(/.{1,60}/g)?.join('\n') ?? sequence;
|
| 65 |
+
const blob = new Blob([`>${fastaHeader}\n${body}\n`], { type: 'text/plain' });
|
| 66 |
+
const url = URL.createObjectURL(blob);
|
| 67 |
+
const a = document.createElement('a');
|
| 68 |
+
a.href = url;
|
| 69 |
+
a.download = fileName;
|
| 70 |
+
a.click();
|
| 71 |
+
URL.revokeObjectURL(url);
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
return (
|
| 75 |
+
<div className="seqtrack">
|
| 76 |
+
<div className="seqtrack__bar">
|
| 77 |
+
<div className="seqtrack__actions">
|
| 78 |
+
<CopyButton text={sequence} label="Copy" />
|
| 79 |
+
<button type="button" className="btn btn--ghost btn--sm" onClick={download}>
|
| 80 |
+
<Download aria-hidden /> FASTA
|
| 81 |
+
</button>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
{hasRanges && (
|
| 85 |
+
<div className="coverage-track" aria-label="Resolved residues">
|
| 86 |
+
{(ranges ?? []).map(([a, b]) => (
|
| 87 |
+
<span
|
| 88 |
+
key={`${a}-${b}`}
|
| 89 |
+
className="coverage-track__seg"
|
| 90 |
+
style={{ left: `${((a - 1) / L) * 100}%`, width: `${((b - a + 1) / L) * 100}%` }}
|
| 91 |
+
title={`Resolved ${a}–${b}`}
|
| 92 |
+
/>
|
| 93 |
+
))}
|
| 94 |
+
<span className="coverage-track__labels">
|
| 95 |
+
<span>1</span>
|
| 96 |
+
<span>{L}</span>
|
| 97 |
+
</span>
|
| 98 |
+
</div>
|
| 99 |
+
)}
|
| 100 |
+
<div className="seqtrack__body mono">
|
| 101 |
+
{Array.from({ length: shownLines }, (_, li) => {
|
| 102 |
+
const start = li * perLine;
|
| 103 |
+
const blocks = [];
|
| 104 |
+
for (let b = 0; b < perLine && start + b < L; b += 10) {
|
| 105 |
+
const chars = [];
|
| 106 |
+
for (let k = 0; k < 10 && start + b + k < L; k++) {
|
| 107 |
+
const i = start + b + k;
|
| 108 |
+
chars.push(
|
| 109 |
+
<span key={i} className={hasRanges && !resolved[i] ? 'res-off' : undefined}>
|
| 110 |
+
{sequence[i]}
|
| 111 |
+
</span>,
|
| 112 |
+
);
|
| 113 |
+
}
|
| 114 |
+
blocks.push(
|
| 115 |
+
<span key={b} className="seqtrack__block">
|
| 116 |
+
{chars}
|
| 117 |
+
</span>,
|
| 118 |
+
);
|
| 119 |
+
}
|
| 120 |
+
return (
|
| 121 |
+
<div key={li} className="seqtrack__line">
|
| 122 |
+
<span className="seqtrack__num tabular">{start + 1}</span>
|
| 123 |
+
<span className="seqtrack__res">{blocks}</span>
|
| 124 |
+
</div>
|
| 125 |
+
);
|
| 126 |
+
})}
|
| 127 |
+
</div>
|
| 128 |
+
<div className="seqtrack__foot">
|
| 129 |
+
{hasRanges && (
|
| 130 |
+
<span className="muted">
|
| 131 |
+
<span className="res-key" /> dimmed residues are not modelled in this structure
|
| 132 |
+
</span>
|
| 133 |
+
)}
|
| 134 |
+
{lines > 8 && (
|
| 135 |
+
<button type="button" className="btn btn--ghost btn--sm" onClick={() => setExpanded((e) => !e)}>
|
| 136 |
+
{expanded ? 'Collapse' : `Show all ${L} residues`}
|
| 137 |
+
</button>
|
| 138 |
+
)}
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
);
|
| 142 |
+
}
|
frontend/src/components/SearchBox.tsx
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useId, useRef, useState } from 'react';
|
| 2 |
+
import { useNavigate } from 'react-router-dom';
|
| 3 |
+
import { Search, X, CornerDownLeft, Layers, Box, FileText } from 'lucide-react';
|
| 4 |
+
import { api, type SearchResponse } from '../lib/api';
|
| 5 |
+
import { useDebounced } from '../lib/hooks';
|
| 6 |
+
import { chainLabel, chainPath, cleanFunction, sequencePath } from '../lib/format';
|
| 7 |
+
|
| 8 |
+
const CHAIN_RE = /^([0-9][A-Za-z0-9]{3})\s*[.:_\s/-]\s*([A-Za-z0-9]{1,4})$/;
|
| 9 |
+
|
| 10 |
+
interface Suggestion {
|
| 11 |
+
key: string;
|
| 12 |
+
to: string;
|
| 13 |
+
icon: 'chain' | 'sequence' | 'text';
|
| 14 |
+
primary: string;
|
| 15 |
+
secondary?: string;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function toSuggestions(r: SearchResponse): Suggestion[] {
|
| 19 |
+
const out: Suggestion[] = [];
|
| 20 |
+
for (const c of r.chains.slice(0, 8)) {
|
| 21 |
+
out.push({
|
| 22 |
+
key: `c-${c.pdb_id}-${c.auth_asym_id}`,
|
| 23 |
+
to: chainPath(c.pdb_id, c.auth_asym_id),
|
| 24 |
+
icon: 'chain',
|
| 25 |
+
primary: chainLabel(c.pdb_id, c.auth_asym_id),
|
| 26 |
+
secondary: [c.uniprot_id, c.sequence_id, c.state_id != null ? `state ${c.state_id}` : null].filter(Boolean).join(' · '),
|
| 27 |
+
});
|
| 28 |
+
}
|
| 29 |
+
for (const s of r.sequences.slice(0, 8)) {
|
| 30 |
+
out.push({
|
| 31 |
+
key: `s-${s.sequence_id}`,
|
| 32 |
+
to: sequencePath(s.sequence_id),
|
| 33 |
+
icon: 'sequence',
|
| 34 |
+
primary: `${s.sequence_id}${s.uniprot_id ? ` · ${s.uniprot_id}` : ''}`,
|
| 35 |
+
secondary: `${s.n_obs.toLocaleString()} observations · ${s.n_states.toLocaleString()} states${
|
| 36 |
+
s.top_function ? ` — ${cleanFunction(s.top_function)}` : ''
|
| 37 |
+
}`,
|
| 38 |
+
});
|
| 39 |
+
}
|
| 40 |
+
return out;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export function SearchBox({
|
| 44 |
+
size = 'md',
|
| 45 |
+
autoFocus = false,
|
| 46 |
+
placeholder = 'Search a chain (3vgk.A), PDB entry, UniProt, SEQ ID or function…',
|
| 47 |
+
}: {
|
| 48 |
+
size?: 'md' | 'lg';
|
| 49 |
+
autoFocus?: boolean;
|
| 50 |
+
placeholder?: string;
|
| 51 |
+
}) {
|
| 52 |
+
const navigate = useNavigate();
|
| 53 |
+
const [value, setValue] = useState('');
|
| 54 |
+
const [open, setOpen] = useState(false);
|
| 55 |
+
const [active, setActive] = useState(-1);
|
| 56 |
+
const [items, setItems] = useState<Suggestion[]>([]);
|
| 57 |
+
const [loading, setLoading] = useState(false);
|
| 58 |
+
const debounced = useDebounced(value.trim(), 220);
|
| 59 |
+
const listId = useId();
|
| 60 |
+
const wrapRef = useRef<HTMLDivElement>(null);
|
| 61 |
+
|
| 62 |
+
useEffect(() => {
|
| 63 |
+
if (debounced.length < 2) {
|
| 64 |
+
setItems([]);
|
| 65 |
+
return;
|
| 66 |
+
}
|
| 67 |
+
const controller = new AbortController();
|
| 68 |
+
setLoading(true);
|
| 69 |
+
api
|
| 70 |
+
.search(debounced, controller.signal)
|
| 71 |
+
.then((r) => {
|
| 72 |
+
setItems(toSuggestions(r));
|
| 73 |
+
setActive(-1);
|
| 74 |
+
})
|
| 75 |
+
.catch(() => {
|
| 76 |
+
if (!controller.signal.aborted) setItems([]);
|
| 77 |
+
})
|
| 78 |
+
.finally(() => {
|
| 79 |
+
if (!controller.signal.aborted) setLoading(false);
|
| 80 |
+
});
|
| 81 |
+
return () => controller.abort();
|
| 82 |
+
}, [debounced]);
|
| 83 |
+
|
| 84 |
+
useEffect(() => {
|
| 85 |
+
const onDoc = (e: MouseEvent) => {
|
| 86 |
+
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
|
| 87 |
+
};
|
| 88 |
+
document.addEventListener('mousedown', onDoc);
|
| 89 |
+
return () => document.removeEventListener('mousedown', onDoc);
|
| 90 |
+
}, []);
|
| 91 |
+
|
| 92 |
+
const go = (to: string) => {
|
| 93 |
+
setOpen(false);
|
| 94 |
+
setValue('');
|
| 95 |
+
navigate(to);
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
const submit = () => {
|
| 99 |
+
if (active >= 0 && items[active]) return go(items[active].to);
|
| 100 |
+
const q = value.trim();
|
| 101 |
+
if (!q) return;
|
| 102 |
+
const m = CHAIN_RE.exec(q);
|
| 103 |
+
if (m) return go(chainPath(m[1], m[2]));
|
| 104 |
+
go(`/search?q=${encodeURIComponent(q)}`);
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
const showList = open && value.trim().length >= 2;
|
| 108 |
+
|
| 109 |
+
return (
|
| 110 |
+
<div className={`search search--${size}`} ref={wrapRef}>
|
| 111 |
+
<form
|
| 112 |
+
role="search"
|
| 113 |
+
onSubmit={(e) => {
|
| 114 |
+
e.preventDefault();
|
| 115 |
+
submit();
|
| 116 |
+
}}
|
| 117 |
+
>
|
| 118 |
+
<Search className="search__icon" aria-hidden />
|
| 119 |
+
<input
|
| 120 |
+
type="search"
|
| 121 |
+
value={value}
|
| 122 |
+
autoFocus={autoFocus}
|
| 123 |
+
placeholder={placeholder}
|
| 124 |
+
aria-label="Search MuSProt"
|
| 125 |
+
aria-expanded={showList}
|
| 126 |
+
aria-controls={listId}
|
| 127 |
+
aria-autocomplete="list"
|
| 128 |
+
role="combobox"
|
| 129 |
+
autoComplete="off"
|
| 130 |
+
spellCheck={false}
|
| 131 |
+
onFocus={() => setOpen(true)}
|
| 132 |
+
onChange={(e) => {
|
| 133 |
+
setValue(e.target.value);
|
| 134 |
+
setOpen(true);
|
| 135 |
+
}}
|
| 136 |
+
onKeyDown={(e) => {
|
| 137 |
+
if (e.key === 'ArrowDown') {
|
| 138 |
+
e.preventDefault();
|
| 139 |
+
setActive((a) => Math.min(items.length - 1, a + 1));
|
| 140 |
+
} else if (e.key === 'ArrowUp') {
|
| 141 |
+
e.preventDefault();
|
| 142 |
+
setActive((a) => Math.max(-1, a - 1));
|
| 143 |
+
} else if (e.key === 'Escape') {
|
| 144 |
+
setOpen(false);
|
| 145 |
+
}
|
| 146 |
+
}}
|
| 147 |
+
/>
|
| 148 |
+
{value && (
|
| 149 |
+
<button type="button" className="search__clear" aria-label="Clear" onClick={() => setValue('')}>
|
| 150 |
+
<X aria-hidden />
|
| 151 |
+
</button>
|
| 152 |
+
)}
|
| 153 |
+
<button type="submit" className="search__submit">
|
| 154 |
+
Search
|
| 155 |
+
</button>
|
| 156 |
+
</form>
|
| 157 |
+
{showList && (
|
| 158 |
+
<div className="search__pop" id={listId} role="listbox">
|
| 159 |
+
{loading && items.length === 0 && <div className="search__hint">Searching…</div>}
|
| 160 |
+
{!loading && items.length === 0 && (
|
| 161 |
+
<div className="search__hint">
|
| 162 |
+
Press <kbd>Enter</kbd> to search all fields for “{value.trim()}”
|
| 163 |
+
</div>
|
| 164 |
+
)}
|
| 165 |
+
{items.map((it, i) => (
|
| 166 |
+
<button
|
| 167 |
+
key={it.key}
|
| 168 |
+
type="button"
|
| 169 |
+
role="option"
|
| 170 |
+
aria-selected={i === active}
|
| 171 |
+
className={`search__item ${i === active ? 'is-active' : ''}`}
|
| 172 |
+
onMouseEnter={() => setActive(i)}
|
| 173 |
+
onClick={() => go(it.to)}
|
| 174 |
+
>
|
| 175 |
+
<span className={`search__kind search__kind--${it.icon}`}>
|
| 176 |
+
{it.icon === 'chain' ? <Box aria-hidden /> : it.icon === 'sequence' ? <Layers aria-hidden /> : <FileText aria-hidden />}
|
| 177 |
+
</span>
|
| 178 |
+
<span className="search__text">
|
| 179 |
+
<span className="search__primary mono">{it.primary}</span>
|
| 180 |
+
{it.secondary && <span className="search__secondary">{it.secondary}</span>}
|
| 181 |
+
</span>
|
| 182 |
+
{i === active && <CornerDownLeft className="search__enter" aria-hidden />}
|
| 183 |
+
</button>
|
| 184 |
+
))}
|
| 185 |
+
</div>
|
| 186 |
+
)}
|
| 187 |
+
</div>
|
| 188 |
+
);
|
| 189 |
+
}
|
frontend/src/components/SequenceTable.tsx
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useNavigate } from 'react-router-dom';
|
| 2 |
+
import type { SequenceSummary } from '../lib/api';
|
| 3 |
+
import { cleanFunction, fmtInt, methodShort, sequencePath, year } from '../lib/format';
|
| 4 |
+
import { MiniSplit } from './charts';
|
| 5 |
+
|
| 6 |
+
export type SortKey = 'sequence' | 'length' | 'observations' | 'states' | 'entries' | 'released';
|
| 7 |
+
|
| 8 |
+
function Th({
|
| 9 |
+
k,
|
| 10 |
+
label,
|
| 11 |
+
sort,
|
| 12 |
+
order,
|
| 13 |
+
onSort,
|
| 14 |
+
num,
|
| 15 |
+
}: {
|
| 16 |
+
k: SortKey;
|
| 17 |
+
label: string;
|
| 18 |
+
sort?: SortKey;
|
| 19 |
+
order?: 'asc' | 'desc';
|
| 20 |
+
onSort?: (k: SortKey) => void;
|
| 21 |
+
num?: boolean;
|
| 22 |
+
}) {
|
| 23 |
+
const active = sort === k;
|
| 24 |
+
if (!onSort) return <th className={num ? 'num' : ''}>{label}</th>;
|
| 25 |
+
return (
|
| 26 |
+
<th className={num ? 'num' : ''} aria-sort={active ? (order === 'asc' ? 'ascending' : 'descending') : 'none'}>
|
| 27 |
+
<button type="button" className={`th-sort ${active ? 'is-active' : ''}`} onClick={() => onSort(k)}>
|
| 28 |
+
{label}
|
| 29 |
+
<span className="th-sort__arrow" aria-hidden>
|
| 30 |
+
{active ? (order === 'asc' ? '↑' : '↓') : '↕'}
|
| 31 |
+
</span>
|
| 32 |
+
</button>
|
| 33 |
+
</th>
|
| 34 |
+
);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export function SequenceTable({
|
| 38 |
+
items,
|
| 39 |
+
sort,
|
| 40 |
+
order,
|
| 41 |
+
onSort,
|
| 42 |
+
}: {
|
| 43 |
+
items: SequenceSummary[];
|
| 44 |
+
sort?: SortKey;
|
| 45 |
+
order?: 'asc' | 'desc';
|
| 46 |
+
onSort?: (k: SortKey) => void;
|
| 47 |
+
}) {
|
| 48 |
+
const navigate = useNavigate();
|
| 49 |
+
const p = { sort, order, onSort };
|
| 50 |
+
return (
|
| 51 |
+
<div className="table-wrap">
|
| 52 |
+
<table className="table table--rows">
|
| 53 |
+
<thead>
|
| 54 |
+
<tr>
|
| 55 |
+
<Th k="sequence" label="Sequence" {...p} />
|
| 56 |
+
<th>Function</th>
|
| 57 |
+
<Th k="length" label="Length" num {...p} />
|
| 58 |
+
<Th k="observations" label="Obs." num {...p} />
|
| 59 |
+
<Th k="states" label="States" num {...p} />
|
| 60 |
+
<Th k="entries" label="Entries" num {...p} />
|
| 61 |
+
<th>Apo / holo</th>
|
| 62 |
+
<th>Methods</th>
|
| 63 |
+
<Th k="released" label="Latest" num {...p} />
|
| 64 |
+
</tr>
|
| 65 |
+
</thead>
|
| 66 |
+
<tbody>
|
| 67 |
+
{items.map((s) => (
|
| 68 |
+
<tr
|
| 69 |
+
key={s.sequence_id}
|
| 70 |
+
className="is-clickable"
|
| 71 |
+
onClick={(e) => {
|
| 72 |
+
if ((e.target as HTMLElement).closest('a')) return;
|
| 73 |
+
navigate(sequencePath(s.sequence_id));
|
| 74 |
+
}}
|
| 75 |
+
>
|
| 76 |
+
<td>
|
| 77 |
+
<a
|
| 78 |
+
href={sequencePath(s.sequence_id)}
|
| 79 |
+
className="mono strong"
|
| 80 |
+
onClick={(e) => {
|
| 81 |
+
e.preventDefault();
|
| 82 |
+
navigate(sequencePath(s.sequence_id));
|
| 83 |
+
}}
|
| 84 |
+
>
|
| 85 |
+
{s.sequence_id}
|
| 86 |
+
</a>
|
| 87 |
+
<div className="sub mono">{s.uniprot_id ?? 'no UniProt'}</div>
|
| 88 |
+
</td>
|
| 89 |
+
<td className="fn-cell">
|
| 90 |
+
<span title={s.top_function ?? undefined}>{s.top_function ? cleanFunction(s.top_function) : '—'}</span>
|
| 91 |
+
</td>
|
| 92 |
+
<td className="num tabular">{fmtInt(s.length)}</td>
|
| 93 |
+
<td className="num tabular">{fmtInt(s.n_obs)}</td>
|
| 94 |
+
<td className="num tabular strong">{fmtInt(s.n_states)}</td>
|
| 95 |
+
<td className="num tabular">{fmtInt(s.n_entries)}</td>
|
| 96 |
+
<td>
|
| 97 |
+
<MiniSplit
|
| 98 |
+
a={s.n_apo}
|
| 99 |
+
b={s.n_holo}
|
| 100 |
+
colorA="var(--apo)"
|
| 101 |
+
colorB="var(--holo)"
|
| 102 |
+
title={`${s.n_apo} apo · ${s.n_holo} holo`}
|
| 103 |
+
/>
|
| 104 |
+
</td>
|
| 105 |
+
<td className="methods-cell">{s.methods.slice(0, 2).map(methodShort).join(', ')}</td>
|
| 106 |
+
<td className="num tabular">{year(s.last_release)}</td>
|
| 107 |
+
</tr>
|
| 108 |
+
))}
|
| 109 |
+
</tbody>
|
| 110 |
+
</table>
|
| 111 |
+
</div>
|
| 112 |
+
);
|
| 113 |
+
}
|
frontend/src/components/StateGlyph.tsx
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo } from 'react';
|
| 2 |
+
|
| 3 |
+
/** Deterministic PRNG so glyphs are stable across renders. */
|
| 4 |
+
export function rng(seed: string) {
|
| 5 |
+
let h = 2166136261;
|
| 6 |
+
for (let i = 0; i < seed.length; i++) h = Math.imul(h ^ seed.charCodeAt(i), 16777619);
|
| 7 |
+
return () => {
|
| 8 |
+
h += 0x6d2b79f5;
|
| 9 |
+
let t = h;
|
| 10 |
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
| 11 |
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
| 12 |
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
| 13 |
+
};
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* A small abstract "state map": a few clusters of observations joined by
|
| 18 |
+
* transition arcs. Purely illustrative — used on cards.
|
| 19 |
+
*/
|
| 20 |
+
export function StateGlyph({ seed, states, size = 88 }: { seed: string; states: number; size?: number }) {
|
| 21 |
+
const g = useMemo(() => {
|
| 22 |
+
const r = rng(seed);
|
| 23 |
+
const k = Math.max(2, Math.min(5, states));
|
| 24 |
+
const cx = 50;
|
| 25 |
+
const cy = 50;
|
| 26 |
+
const centers = Array.from({ length: k }, (_, i) => {
|
| 27 |
+
const a = (i / k) * Math.PI * 2 + r() * 0.8;
|
| 28 |
+
const rad = 22 + r() * 8;
|
| 29 |
+
return { x: cx + Math.cos(a) * rad, y: cy + Math.sin(a) * rad, n: 3 + Math.floor(r() * 5) };
|
| 30 |
+
});
|
| 31 |
+
const dots = centers.flatMap((c, ci) =>
|
| 32 |
+
Array.from({ length: c.n }, () => {
|
| 33 |
+
const a = r() * Math.PI * 2;
|
| 34 |
+
const d = r() * 7;
|
| 35 |
+
return { x: c.x + Math.cos(a) * d, y: c.y + Math.sin(a) * d, ci };
|
| 36 |
+
}),
|
| 37 |
+
);
|
| 38 |
+
return { centers, dots };
|
| 39 |
+
}, [seed, states]);
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<svg width={size} height={size} viewBox="0 0 100 100" aria-hidden className="glyph">
|
| 43 |
+
{g.centers.map((a, i) =>
|
| 44 |
+
g.centers.slice(i + 1).map((b, j) => (
|
| 45 |
+
<line key={`${i}-${j}`} x1={a.x} y1={a.y} x2={b.x} y2={b.y} className="glyph__edge" />
|
| 46 |
+
)),
|
| 47 |
+
)}
|
| 48 |
+
{g.centers.map((c, i) => (
|
| 49 |
+
<circle key={i} cx={c.x} cy={c.y} r={12} className="glyph__halo" />
|
| 50 |
+
))}
|
| 51 |
+
{g.dots.map((d, i) => (
|
| 52 |
+
<circle key={i} cx={d.x} cy={d.y} r={2.4} className={`glyph__dot glyph__dot--${d.ci % 3}`} />
|
| 53 |
+
))}
|
| 54 |
+
</svg>
|
| 55 |
+
);
|
| 56 |
+
}
|
frontend/src/components/StateViews.tsx
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo, useState } from 'react';
|
| 2 |
+
import type { Member, StateInfo, StatePair } from '../lib/api';
|
| 3 |
+
import { FIDELITY_INFO, fidelityKey, similarityColor } from '../lib/fidelity';
|
| 4 |
+
import { chainLabel, fmtNum } from '../lib/format';
|
| 5 |
+
import { Tooltip, useWidth, type TipState } from './charts';
|
| 6 |
+
import { Swatch } from './ui';
|
| 7 |
+
|
| 8 |
+
const MAX_TILES = 40;
|
| 9 |
+
const MAX_DOTS = 120;
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* One tile per conformational state; each dot is one observation, coloured
|
| 13 |
+
* by ligand state. Clicking a tile selects the state.
|
| 14 |
+
*/
|
| 15 |
+
export function StateLandscape({
|
| 16 |
+
states,
|
| 17 |
+
members,
|
| 18 |
+
selected,
|
| 19 |
+
onSelect,
|
| 20 |
+
highlight,
|
| 21 |
+
}: {
|
| 22 |
+
states: StateInfo[];
|
| 23 |
+
members: Member[];
|
| 24 |
+
selected: number | null;
|
| 25 |
+
onSelect: (s: number | null) => void;
|
| 26 |
+
highlight?: { pdb_id: string; auth_asym_id: string } | null;
|
| 27 |
+
}) {
|
| 28 |
+
const byState = useMemo(() => {
|
| 29 |
+
const m = new Map<number, Member[]>();
|
| 30 |
+
for (const mem of members) {
|
| 31 |
+
const arr = m.get(mem.state_id) ?? [];
|
| 32 |
+
arr.push(mem);
|
| 33 |
+
m.set(mem.state_id, arr);
|
| 34 |
+
}
|
| 35 |
+
return m;
|
| 36 |
+
}, [members]);
|
| 37 |
+
const shown = states.slice(0, MAX_TILES);
|
| 38 |
+
const rest = states.slice(MAX_TILES);
|
| 39 |
+
const restObs = rest.reduce((s, x) => s + x.n_members, 0);
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<div className="landscape">
|
| 43 |
+
<div className="landscape__grid">
|
| 44 |
+
{shown.map((st) => {
|
| 45 |
+
const mem = byState.get(st.state_id) ?? [];
|
| 46 |
+
const holo = mem.filter((m) => m.binding_status === 'holo').length;
|
| 47 |
+
const isSel = selected === st.state_id;
|
| 48 |
+
const hasHi = highlight && mem.some((m) => m.pdb_id === highlight.pdb_id && m.auth_asym_id === highlight.auth_asym_id);
|
| 49 |
+
return (
|
| 50 |
+
<button
|
| 51 |
+
key={st.state_id}
|
| 52 |
+
type="button"
|
| 53 |
+
className={`state-tile ${isSel ? 'is-selected' : ''} ${hasHi ? 'is-home' : ''}`}
|
| 54 |
+
onClick={() => onSelect(isSel ? null : st.state_id)}
|
| 55 |
+
aria-pressed={isSel}
|
| 56 |
+
title={`State ${st.state_id}: ${st.n_members} observations (${holo} holo)`}
|
| 57 |
+
>
|
| 58 |
+
<span className="state-tile__head">
|
| 59 |
+
<span className="state-tile__name">State {st.state_id}</span>
|
| 60 |
+
<span className="state-tile__n tabular">{st.n_members.toLocaleString()}</span>
|
| 61 |
+
</span>
|
| 62 |
+
<span className="state-tile__dots">
|
| 63 |
+
{mem.slice(0, MAX_DOTS).map((m) => {
|
| 64 |
+
const isHi = highlight && m.pdb_id === highlight.pdb_id && m.auth_asym_id === highlight.auth_asym_id;
|
| 65 |
+
return (
|
| 66 |
+
<span
|
| 67 |
+
key={`${m.pdb_id}.${m.auth_asym_id}`}
|
| 68 |
+
className={`state-dot ${m.binding_status === 'holo' ? 'is-holo' : ''} ${isHi ? 'is-hi' : ''}`}
|
| 69 |
+
/>
|
| 70 |
+
);
|
| 71 |
+
})}
|
| 72 |
+
{mem.length > MAX_DOTS && <span className="state-tile__more">+{mem.length - MAX_DOTS}</span>}
|
| 73 |
+
</span>
|
| 74 |
+
</button>
|
| 75 |
+
);
|
| 76 |
+
})}
|
| 77 |
+
</div>
|
| 78 |
+
<div className="landscape__foot">
|
| 79 |
+
<ul className="legend">
|
| 80 |
+
<li>
|
| 81 |
+
<Swatch color="var(--apo)" shape="dot" /> Apo observation
|
| 82 |
+
</li>
|
| 83 |
+
<li>
|
| 84 |
+
<Swatch color="var(--holo)" shape="dot" /> Holo observation
|
| 85 |
+
</li>
|
| 86 |
+
{highlight && (
|
| 87 |
+
<li>
|
| 88 |
+
<span className="state-dot is-hi legend-dot" /> This chain
|
| 89 |
+
</li>
|
| 90 |
+
)}
|
| 91 |
+
</ul>
|
| 92 |
+
{rest.length > 0 && (
|
| 93 |
+
<span className="muted">
|
| 94 |
+
+ {rest.length.toLocaleString()} smaller states ({restObs.toLocaleString()} observations) not shown
|
| 95 |
+
</span>
|
| 96 |
+
)}
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/** Mean pair similarity between states (diagonal = within-state). */
|
| 103 |
+
export function StateHeatmap({
|
| 104 |
+
states,
|
| 105 |
+
pairs,
|
| 106 |
+
selected,
|
| 107 |
+
onSelect,
|
| 108 |
+
}: {
|
| 109 |
+
states: StateInfo[];
|
| 110 |
+
pairs: StatePair[];
|
| 111 |
+
selected: number | null;
|
| 112 |
+
onSelect: (s: number | null) => void;
|
| 113 |
+
}) {
|
| 114 |
+
const [ref, width] = useWidth<HTMLDivElement>();
|
| 115 |
+
const [tip, setTip] = useState<TipState | null>(null);
|
| 116 |
+
const [hover, setHover] = useState<[number, number] | null>(null);
|
| 117 |
+
const order = states.slice(0, MAX_TILES).map((s) => s.state_id);
|
| 118 |
+
const lookup = useMemo(() => {
|
| 119 |
+
const m = new Map<string, StatePair>();
|
| 120 |
+
for (const p of pairs) {
|
| 121 |
+
m.set(`${p.a}|${p.b}`, p);
|
| 122 |
+
m.set(`${p.b}|${p.a}`, p);
|
| 123 |
+
}
|
| 124 |
+
return m;
|
| 125 |
+
}, [pairs]);
|
| 126 |
+
|
| 127 |
+
const n = order.length;
|
| 128 |
+
const label = 34;
|
| 129 |
+
const cell = n ? Math.max(8, Math.min(34, Math.floor((Math.min(width, 560) - label - 4) / n))) : 0;
|
| 130 |
+
const size = label + n * cell;
|
| 131 |
+
const showLabels = cell >= 14;
|
| 132 |
+
|
| 133 |
+
if (n < 2) return null;
|
| 134 |
+
return (
|
| 135 |
+
<div className="heatmap" ref={ref}>
|
| 136 |
+
{width > 0 && (
|
| 137 |
+
<svg width={size} height={size} role="img" aria-label="State similarity matrix">
|
| 138 |
+
{order.map((s, i) => (
|
| 139 |
+
<g key={s}>
|
| 140 |
+
{(showLabels || i % 5 === 0) && (
|
| 141 |
+
<>
|
| 142 |
+
<text x={label - 6} y={label + i * cell + cell / 2} dy="0.32em" textAnchor="end" className="tick">
|
| 143 |
+
{s}
|
| 144 |
+
</text>
|
| 145 |
+
<text x={label + i * cell + cell / 2} y={label - 8} textAnchor="middle" className="tick">
|
| 146 |
+
{s}
|
| 147 |
+
</text>
|
| 148 |
+
</>
|
| 149 |
+
)}
|
| 150 |
+
</g>
|
| 151 |
+
))}
|
| 152 |
+
{order.map((a, i) =>
|
| 153 |
+
order.map((b, j) => {
|
| 154 |
+
const p = lookup.get(`${a}|${b}`);
|
| 155 |
+
const dim =
|
| 156 |
+
(selected != null && a !== selected && b !== selected) ||
|
| 157 |
+
(hover && hover[0] !== i && hover[1] !== j);
|
| 158 |
+
return (
|
| 159 |
+
<rect
|
| 160 |
+
key={`${a}-${b}`}
|
| 161 |
+
x={label + j * cell + 1}
|
| 162 |
+
y={label + i * cell + 1}
|
| 163 |
+
width={cell - 2}
|
| 164 |
+
height={cell - 2}
|
| 165 |
+
rx={Math.min(3, cell / 6)}
|
| 166 |
+
fill={similarityColor(p?.similarity)}
|
| 167 |
+
opacity={dim ? 0.35 : 1}
|
| 168 |
+
className="heatmap__cell"
|
| 169 |
+
onClick={() => onSelect(selected === b ? null : b)}
|
| 170 |
+
onMouseMove={(e) => {
|
| 171 |
+
const r = (e.currentTarget.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
|
| 172 |
+
setHover([i, j]);
|
| 173 |
+
setTip({
|
| 174 |
+
x: e.clientX - r.left,
|
| 175 |
+
y: e.clientY - r.top,
|
| 176 |
+
content: (
|
| 177 |
+
<>
|
| 178 |
+
<div className="chart-tip__title">
|
| 179 |
+
{a === b ? `Within state ${a}` : `State ${a} ↔ state ${b}`}
|
| 180 |
+
</div>
|
| 181 |
+
<div className="chart-tip__row">
|
| 182 |
+
<span>Mean similarity</span>
|
| 183 |
+
<b className="tabular">{fmtNum(p?.similarity, 3)}</b>
|
| 184 |
+
</div>
|
| 185 |
+
{a !== b && p && (
|
| 186 |
+
<div className="chart-tip__row">
|
| 187 |
+
<span>Fidelity</span>
|
| 188 |
+
<b>{FIDELITY_INFO[fidelityKey(p.fidelity)].label}</b>
|
| 189 |
+
</div>
|
| 190 |
+
)}
|
| 191 |
+
<div className="chart-tip__foot">{p ? `${p.n_pairs.toLocaleString()} observation pairs` : 'No pairs'}</div>
|
| 192 |
+
</>
|
| 193 |
+
),
|
| 194 |
+
});
|
| 195 |
+
}}
|
| 196 |
+
onMouseLeave={() => {
|
| 197 |
+
setTip(null);
|
| 198 |
+
setHover(null);
|
| 199 |
+
}}
|
| 200 |
+
/>
|
| 201 |
+
);
|
| 202 |
+
}),
|
| 203 |
+
)}
|
| 204 |
+
</svg>
|
| 205 |
+
)}
|
| 206 |
+
<Tooltip tip={tip} />
|
| 207 |
+
<div className="heatmap__scale">
|
| 208 |
+
<span>Mean similarity</span>
|
| 209 |
+
{[
|
| 210 |
+
['< 0.50', 0.4],
|
| 211 |
+
['0.50', 0.6],
|
| 212 |
+
['0.70', 0.8],
|
| 213 |
+
['0.85', 0.9],
|
| 214 |
+
['0.95', 0.96],
|
| 215 |
+
['0.98+', 0.99],
|
| 216 |
+
].map(([l, v]) => (
|
| 217 |
+
<span key={l as string} className="heatmap__step">
|
| 218 |
+
<Swatch color={similarityColor(v as number)} />
|
| 219 |
+
{l}
|
| 220 |
+
</span>
|
| 221 |
+
))}
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
export function memberKey(m: { pdb_id: string; auth_asym_id: string }) {
|
| 228 |
+
return chainLabel(m.pdb_id, m.auth_asym_id);
|
| 229 |
+
}
|
frontend/src/components/StructureViewer.tsx
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef, useState } from 'react';
|
| 2 |
+
import { Box } from 'lucide-react';
|
| 3 |
+
|
| 4 |
+
/* PDBe Mol* is ~6 MB, so it is loaded from the CDN only when a viewer is opened. */
|
| 5 |
+
const MOLSTAR_VERSION = '3.12.0';
|
| 6 |
+
const BASE = `https://cdn.jsdelivr.net/npm/pdbe-molstar@${MOLSTAR_VERSION}/build`;
|
| 7 |
+
|
| 8 |
+
interface MolstarInstance {
|
| 9 |
+
render: (el: HTMLElement, options: Record<string, unknown>) => Promise<void> | void;
|
| 10 |
+
visual: { select: (params: Record<string, unknown>) => Promise<void> | void };
|
| 11 |
+
events: { loadComplete: { subscribe: (cb: (ok: boolean) => void) => { unsubscribe?: () => void } } };
|
| 12 |
+
plugin?: { dispose?: () => void };
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
declare global {
|
| 16 |
+
interface Window {
|
| 17 |
+
PDBeMolstarPlugin?: new () => MolstarInstance;
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
let loader: Promise<void> | null = null;
|
| 22 |
+
function loadMolstar(): Promise<void> {
|
| 23 |
+
if (window.PDBeMolstarPlugin) return Promise.resolve();
|
| 24 |
+
if (!loader) {
|
| 25 |
+
loader = new Promise<void>((resolve, reject) => {
|
| 26 |
+
const css = document.createElement('link');
|
| 27 |
+
css.rel = 'stylesheet';
|
| 28 |
+
css.href = `${BASE}/pdbe-molstar-light.css`;
|
| 29 |
+
document.head.appendChild(css);
|
| 30 |
+
const s = document.createElement('script');
|
| 31 |
+
s.src = `${BASE}/pdbe-molstar-plugin.js`;
|
| 32 |
+
s.async = true;
|
| 33 |
+
s.onload = () => resolve();
|
| 34 |
+
s.onerror = () => {
|
| 35 |
+
loader = null;
|
| 36 |
+
reject(new Error('Could not load the Mol* viewer'));
|
| 37 |
+
};
|
| 38 |
+
document.head.appendChild(s);
|
| 39 |
+
});
|
| 40 |
+
}
|
| 41 |
+
return loader;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function hexToRgb(hex: string) {
|
| 45 |
+
const n = parseInt(hex.replace('#', ''), 16);
|
| 46 |
+
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function isDark() {
|
| 50 |
+
const t = document.documentElement.dataset.theme;
|
| 51 |
+
if (t) return t === 'dark';
|
| 52 |
+
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export function StructureViewer({
|
| 56 |
+
pdbId,
|
| 57 |
+
chain,
|
| 58 |
+
color = '#2a78d6',
|
| 59 |
+
height = 380,
|
| 60 |
+
label,
|
| 61 |
+
}: {
|
| 62 |
+
pdbId: string;
|
| 63 |
+
chain: string;
|
| 64 |
+
color?: string;
|
| 65 |
+
height?: number;
|
| 66 |
+
label?: string;
|
| 67 |
+
}) {
|
| 68 |
+
const ref = useRef<HTMLDivElement>(null);
|
| 69 |
+
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
| 70 |
+
|
| 71 |
+
useEffect(() => {
|
| 72 |
+
let alive = true;
|
| 73 |
+
let sub: { unsubscribe?: () => void } | undefined;
|
| 74 |
+
let instance: MolstarInstance | undefined;
|
| 75 |
+
const el = ref.current;
|
| 76 |
+
setStatus('loading');
|
| 77 |
+
// Mol* reports nothing when WebGL is unavailable; don't spin forever
|
| 78 |
+
const timeout = setTimeout(() => alive && setStatus((st) => (st === 'loading' ? 'error' : st)), 90000);
|
| 79 |
+
loadMolstar()
|
| 80 |
+
.then(() => {
|
| 81 |
+
if (!alive || !el || !window.PDBeMolstarPlugin) return;
|
| 82 |
+
instance = new window.PDBeMolstarPlugin();
|
| 83 |
+
sub = instance.events.loadComplete.subscribe((ok) => {
|
| 84 |
+
if (!alive) return;
|
| 85 |
+
if (ok === false) {
|
| 86 |
+
setStatus('error');
|
| 87 |
+
return;
|
| 88 |
+
}
|
| 89 |
+
setStatus('ready');
|
| 90 |
+
instance?.visual.select({
|
| 91 |
+
data: [{ auth_asym_id: chain, color: hexToRgb(color), focus: true }],
|
| 92 |
+
nonSelectedColor: isDark() ? { r: 70, g: 78, b: 92 } : { r: 214, g: 219, b: 226 },
|
| 93 |
+
});
|
| 94 |
+
});
|
| 95 |
+
const bg = isDark() ? { r: 18, g: 24, b: 33 } : { r: 255, g: 255, b: 255 };
|
| 96 |
+
instance.render(el, {
|
| 97 |
+
moleculeId: pdbId.toLowerCase(),
|
| 98 |
+
bgColor: bg,
|
| 99 |
+
hideControls: true,
|
| 100 |
+
sequencePanel: false,
|
| 101 |
+
landscape: true,
|
| 102 |
+
visualStyle: 'cartoon',
|
| 103 |
+
hideCanvasControls: ['expand', 'selection', 'animation', 'controlToggle', 'controlInfo'],
|
| 104 |
+
loadingOverlay: true,
|
| 105 |
+
});
|
| 106 |
+
})
|
| 107 |
+
.catch(() => alive && setStatus('error'));
|
| 108 |
+
return () => {
|
| 109 |
+
alive = false;
|
| 110 |
+
clearTimeout(timeout);
|
| 111 |
+
sub?.unsubscribe?.();
|
| 112 |
+
instance?.plugin?.dispose?.();
|
| 113 |
+
if (el) el.innerHTML = '';
|
| 114 |
+
};
|
| 115 |
+
}, [pdbId, chain, color]);
|
| 116 |
+
|
| 117 |
+
return (
|
| 118 |
+
<figure className="viewer" style={{ height }}>
|
| 119 |
+
<div ref={ref} className="viewer__canvas" />
|
| 120 |
+
{status !== 'ready' && (
|
| 121 |
+
<div className="viewer__overlay">
|
| 122 |
+
{status === 'loading' ? (
|
| 123 |
+
<>
|
| 124 |
+
<span className="spinner" aria-hidden /> Loading {pdbId.toUpperCase()}…
|
| 125 |
+
</>
|
| 126 |
+
) : (
|
| 127 |
+
<>
|
| 128 |
+
<Box aria-hidden /> 3D view unavailable for {pdbId.toUpperCase()}
|
| 129 |
+
</>
|
| 130 |
+
)}
|
| 131 |
+
</div>
|
| 132 |
+
)}
|
| 133 |
+
{label && <figcaption className="viewer__label">{label}</figcaption>}
|
| 134 |
+
</figure>
|
| 135 |
+
);
|
| 136 |
+
}
|
frontend/src/components/Transitions.tsx
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo, useState } from 'react';
|
| 2 |
+
import { ENERGY_TERMS, type EnergyTerm, type OrderChange, type Transition } from '../lib/api';
|
| 3 |
+
import { FIDELITY_INFO, FIDELITY_ORDER, fidelityColor, fidelityKey } from '../lib/fidelity';
|
| 4 |
+
import { chainLabel, fmtNum, fmtSigned, methodShort } from '../lib/format';
|
| 5 |
+
import { niceTicks, Tooltip, useWidth, type TipState } from './charts';
|
| 6 |
+
import { ChainLink, EmptyState, FidelityBadge, LigandBadge, Pagination, Segmented, Swatch } from './ui';
|
| 7 |
+
|
| 8 |
+
export type Scope = 'cross' | 'same' | 'all';
|
| 9 |
+
|
| 10 |
+
/* ───────────────────────────── neighbourhood scatter */
|
| 11 |
+
|
| 12 |
+
export function TransitionScatter({
|
| 13 |
+
items,
|
| 14 |
+
homeState,
|
| 15 |
+
selected,
|
| 16 |
+
onSelect,
|
| 17 |
+
}: {
|
| 18 |
+
items: Transition[];
|
| 19 |
+
homeState: number | null;
|
| 20 |
+
selected: string | null;
|
| 21 |
+
onSelect: (t: Transition) => void;
|
| 22 |
+
}) {
|
| 23 |
+
const [ref, width] = useWidth<HTMLDivElement>();
|
| 24 |
+
const [tip, setTip] = useState<TipState | null>(null);
|
| 25 |
+
const pts = items.filter((t) => t.similarity != null && t.rmsd != null);
|
| 26 |
+
const height = 300;
|
| 27 |
+
const m = { top: 16, right: 16, bottom: 44, left: 52 };
|
| 28 |
+
const iw = Math.max(0, width - m.left - m.right);
|
| 29 |
+
const ih = height - m.top - m.bottom;
|
| 30 |
+
const xMin = Math.min(0.3, ...pts.map((p) => p.similarity!)) - 0.02;
|
| 31 |
+
const xMax = 1;
|
| 32 |
+
const yTicks = niceTicks(Math.max(2, ...pts.map((p) => p.rmsd!)));
|
| 33 |
+
const yMax = yTicks[yTicks.length - 1];
|
| 34 |
+
const x = (v: number) => ((v - xMin) / (xMax - xMin)) * iw;
|
| 35 |
+
const y = (v: number) => ih - (v / yMax) * ih;
|
| 36 |
+
const xTicks = [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1].filter((t) => t >= xMin);
|
| 37 |
+
// draw larger changes last so they sit on top
|
| 38 |
+
const ordered = [...pts].sort((a, b) => (b.similarity ?? 0) - (a.similarity ?? 0));
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<div className="chart scatter" ref={ref}>
|
| 42 |
+
{width > 0 && (
|
| 43 |
+
<svg width={width} height={height} role="img" aria-label="Similarity versus RMSD for every partner observation">
|
| 44 |
+
<g transform={`translate(${m.left},${m.top})`}>
|
| 45 |
+
{[0.5, 0.7, 0.95].filter((t) => t > xMin).map((t) => (
|
| 46 |
+
<g key={t}>
|
| 47 |
+
<line x1={x(t)} x2={x(t)} y1={0} y2={ih} className="threshold" />
|
| 48 |
+
</g>
|
| 49 |
+
))}
|
| 50 |
+
{yTicks.map((t) => (
|
| 51 |
+
<g key={t}>
|
| 52 |
+
<line x1={0} x2={iw} y1={y(t)} y2={y(t)} className={t === 0 ? 'axis-line' : 'grid-line'} />
|
| 53 |
+
<text x={-8} y={y(t)} dy="0.32em" textAnchor="end" className="tick">
|
| 54 |
+
{Number.isInteger(t) ? t : t.toFixed(1)}
|
| 55 |
+
</text>
|
| 56 |
+
</g>
|
| 57 |
+
))}
|
| 58 |
+
{xTicks.map((t) => (
|
| 59 |
+
<text key={t} x={x(t)} y={ih + 16} textAnchor="middle" className="tick">
|
| 60 |
+
{t.toFixed(1)}
|
| 61 |
+
</text>
|
| 62 |
+
))}
|
| 63 |
+
<text x={iw / 2} y={ih + 36} textAnchor="middle" className="axis-title">
|
| 64 |
+
Pair similarity (aligned core) → more similar
|
| 65 |
+
</text>
|
| 66 |
+
<text transform={`translate(${-38},${ih / 2}) rotate(-90)`} textAnchor="middle" className="axis-title">
|
| 67 |
+
RMSD (Å)
|
| 68 |
+
</text>
|
| 69 |
+
{ordered.map((p) => {
|
| 70 |
+
const key = chainLabel(p.pdb_id, p.auth_asym_id);
|
| 71 |
+
const same = p.state_id === homeState;
|
| 72 |
+
const isSel = key === selected;
|
| 73 |
+
return (
|
| 74 |
+
<circle
|
| 75 |
+
key={key}
|
| 76 |
+
cx={x(p.similarity!)}
|
| 77 |
+
cy={y(p.rmsd!)}
|
| 78 |
+
r={isSel ? 7 : 4.5}
|
| 79 |
+
fill={same ? 'var(--surface)' : fidelityColor(p.fidelity)}
|
| 80 |
+
stroke={same ? 'var(--faint)' : 'var(--surface)'}
|
| 81 |
+
strokeWidth={same ? 1.5 : 1.5}
|
| 82 |
+
className={`scatter__pt ${isSel ? 'is-sel' : ''}`}
|
| 83 |
+
onClick={() => onSelect(p)}
|
| 84 |
+
onMouseMove={(e) => {
|
| 85 |
+
const r = (e.currentTarget.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
|
| 86 |
+
setTip({
|
| 87 |
+
x: e.clientX - r.left,
|
| 88 |
+
y: e.clientY - r.top,
|
| 89 |
+
content: (
|
| 90 |
+
<>
|
| 91 |
+
<div className="chart-tip__title mono">{key}</div>
|
| 92 |
+
<div className="chart-tip__row">
|
| 93 |
+
<span>State</span>
|
| 94 |
+
<b>{p.state_id ?? '—'}{same ? ' (same)' : ''}</b>
|
| 95 |
+
</div>
|
| 96 |
+
<div className="chart-tip__row">
|
| 97 |
+
<span>Similarity</span>
|
| 98 |
+
<b className="tabular">{fmtNum(p.similarity, 3)}</b>
|
| 99 |
+
</div>
|
| 100 |
+
<div className="chart-tip__row">
|
| 101 |
+
<span>RMSD</span>
|
| 102 |
+
<b className="tabular">{fmtNum(p.rmsd, 2)} Å</b>
|
| 103 |
+
</div>
|
| 104 |
+
<div className="chart-tip__row">
|
| 105 |
+
<span>Fidelity</span>
|
| 106 |
+
<b>{FIDELITY_INFO[fidelityKey(p.fidelity)].label}</b>
|
| 107 |
+
</div>
|
| 108 |
+
<div className="chart-tip__foot">Click to compare</div>
|
| 109 |
+
</>
|
| 110 |
+
),
|
| 111 |
+
});
|
| 112 |
+
}}
|
| 113 |
+
onMouseLeave={() => setTip(null)}
|
| 114 |
+
/>
|
| 115 |
+
);
|
| 116 |
+
})}
|
| 117 |
+
</g>
|
| 118 |
+
</svg>
|
| 119 |
+
)}
|
| 120 |
+
<Tooltip tip={tip} />
|
| 121 |
+
<ul className="legend">
|
| 122 |
+
<li>
|
| 123 |
+
<span className="swatch swatch--dot swatch--ring" /> Same state
|
| 124 |
+
</li>
|
| 125 |
+
{FIDELITY_ORDER.filter((f) => f !== 'identical').map((f) => (
|
| 126 |
+
<li key={f}>
|
| 127 |
+
<Swatch color={fidelityColor(f)} shape="dot" /> {FIDELITY_INFO[f].label} fidelity
|
| 128 |
+
</li>
|
| 129 |
+
))}
|
| 130 |
+
<li className="muted">Lines at 0.50 / 0.70 / 0.95 mark the fidelity bins</li>
|
| 131 |
+
</ul>
|
| 132 |
+
</div>
|
| 133 |
+
);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/* ───────────────────────────── table */
|
| 137 |
+
|
| 138 |
+
type SortKey = 'similarity' | 'tm_aln' | 'rmsd' | 'coverage' | 'state' | 'resolution' | 'date' | 'order' | EnergyTerm;
|
| 139 |
+
const PAGE = 25;
|
| 140 |
+
|
| 141 |
+
function sortValue(t: Transition, k: SortKey): number | string | null {
|
| 142 |
+
switch (k) {
|
| 143 |
+
case 'similarity':
|
| 144 |
+
return t.similarity;
|
| 145 |
+
case 'tm_aln':
|
| 146 |
+
return t.tm_aln;
|
| 147 |
+
case 'rmsd':
|
| 148 |
+
return t.rmsd;
|
| 149 |
+
case 'coverage':
|
| 150 |
+
return t.coverage;
|
| 151 |
+
case 'state':
|
| 152 |
+
return t.state_id;
|
| 153 |
+
case 'resolution':
|
| 154 |
+
return t.resolution ?? null;
|
| 155 |
+
case 'date':
|
| 156 |
+
return t.release_date ?? null;
|
| 157 |
+
case 'order':
|
| 158 |
+
return t.order.valid ? (t.order.n_ordered ?? 0) + (t.order.n_disordered ?? 0) : null;
|
| 159 |
+
default:
|
| 160 |
+
return t.delta[k];
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
export function TransitionsTable({
|
| 165 |
+
items,
|
| 166 |
+
homeState,
|
| 167 |
+
selected,
|
| 168 |
+
onSelect,
|
| 169 |
+
scope,
|
| 170 |
+
onScope,
|
| 171 |
+
}: {
|
| 172 |
+
items: Transition[];
|
| 173 |
+
homeState: number | null;
|
| 174 |
+
selected: string | null;
|
| 175 |
+
onSelect: (t: Transition) => void;
|
| 176 |
+
scope: Scope;
|
| 177 |
+
onScope: (s: Scope) => void;
|
| 178 |
+
}) {
|
| 179 |
+
const [fid, setFid] = useState<string>('all');
|
| 180 |
+
const [sort, setSort] = useState<{ k: SortKey; dir: 1 | -1 }>({ k: 'similarity', dir: 1 });
|
| 181 |
+
const [page, setPage] = useState(0);
|
| 182 |
+
|
| 183 |
+
const nSame = items.filter((t) => t.state_id === homeState).length;
|
| 184 |
+
const scoped = useMemo(
|
| 185 |
+
() =>
|
| 186 |
+
items.filter((t) =>
|
| 187 |
+
scope === 'all' ? true : scope === 'same' ? t.state_id === homeState : t.state_id !== homeState,
|
| 188 |
+
),
|
| 189 |
+
[items, scope, homeState],
|
| 190 |
+
);
|
| 191 |
+
const rows = useMemo(() => {
|
| 192 |
+
const r = fid === 'all' ? scoped : scoped.filter((t) => fidelityKey(t.fidelity) === fid);
|
| 193 |
+
return [...r].sort((a, b) => {
|
| 194 |
+
const va = sortValue(a, sort.k);
|
| 195 |
+
const vb = sortValue(b, sort.k);
|
| 196 |
+
if (va == null) return 1;
|
| 197 |
+
if (vb == null) return -1;
|
| 198 |
+
return (va < vb ? -1 : va > vb ? 1 : 0) * sort.dir;
|
| 199 |
+
});
|
| 200 |
+
}, [scoped, fid, sort]);
|
| 201 |
+
const fidCounts = useMemo(() => {
|
| 202 |
+
const c: Record<string, number> = {};
|
| 203 |
+
for (const t of scoped) c[fidelityKey(t.fidelity)] = (c[fidelityKey(t.fidelity)] ?? 0) + 1;
|
| 204 |
+
return c;
|
| 205 |
+
}, [scoped]);
|
| 206 |
+
|
| 207 |
+
const th = (k: SortKey, label: string, num = true) => (
|
| 208 |
+
<th className={num ? 'num' : ''} aria-sort={sort.k === k ? (sort.dir === 1 ? 'ascending' : 'descending') : 'none'}>
|
| 209 |
+
<button
|
| 210 |
+
type="button"
|
| 211 |
+
className={`th-sort ${sort.k === k ? 'is-active' : ''}`}
|
| 212 |
+
onClick={() => {
|
| 213 |
+
setSort((s) => ({ k, dir: s.k === k ? ((-s.dir) as 1 | -1) : 1 }));
|
| 214 |
+
setPage(0);
|
| 215 |
+
}}
|
| 216 |
+
>
|
| 217 |
+
{label}
|
| 218 |
+
<span className="th-sort__arrow" aria-hidden>
|
| 219 |
+
{sort.k === k ? (sort.dir === 1 ? '↑' : '↓') : '↕'}
|
| 220 |
+
</span>
|
| 221 |
+
</button>
|
| 222 |
+
</th>
|
| 223 |
+
);
|
| 224 |
+
|
| 225 |
+
const fidOptions = [
|
| 226 |
+
{ value: 'all', label: 'Any fidelity', count: scoped.length },
|
| 227 |
+
...[...FIDELITY_ORDER, 'NA']
|
| 228 |
+
.filter((f) => fidCounts[f])
|
| 229 |
+
.map((f) => ({ value: f, label: FIDELITY_INFO[f].label, count: fidCounts[f] })),
|
| 230 |
+
];
|
| 231 |
+
|
| 232 |
+
return (
|
| 233 |
+
<>
|
| 234 |
+
<div className="table-toolbar table-toolbar--wrap">
|
| 235 |
+
<Segmented
|
| 236 |
+
label="Partner scope"
|
| 237 |
+
value={scope}
|
| 238 |
+
onChange={(v) => {
|
| 239 |
+
onScope(v);
|
| 240 |
+
setFid('all');
|
| 241 |
+
setPage(0);
|
| 242 |
+
}}
|
| 243 |
+
options={[
|
| 244 |
+
{ value: 'cross', label: 'Other states', count: items.length - nSame },
|
| 245 |
+
{ value: 'same', label: 'Same state', count: nSame },
|
| 246 |
+
{ value: 'all', label: 'All', count: items.length },
|
| 247 |
+
]}
|
| 248 |
+
/>
|
| 249 |
+
<Segmented
|
| 250 |
+
label="Fidelity"
|
| 251 |
+
value={fid}
|
| 252 |
+
onChange={(v) => {
|
| 253 |
+
setFid(v);
|
| 254 |
+
setPage(0);
|
| 255 |
+
}}
|
| 256 |
+
options={fidOptions}
|
| 257 |
+
/>
|
| 258 |
+
</div>
|
| 259 |
+
{rows.length === 0 ? (
|
| 260 |
+
<EmptyState title="No partner observations in this view" />
|
| 261 |
+
) : (
|
| 262 |
+
<div className="table-wrap">
|
| 263 |
+
<table className="table table--dense table--rows">
|
| 264 |
+
<thead>
|
| 265 |
+
<tr>
|
| 266 |
+
<th>Partner</th>
|
| 267 |
+
{th('state', 'State')}
|
| 268 |
+
<th>Fidelity</th>
|
| 269 |
+
{th('similarity', 'Similarity')}
|
| 270 |
+
{th('tm_aln', 'TM (aln)')}
|
| 271 |
+
{th('rmsd', 'RMSD Å')}
|
| 272 |
+
{th('coverage', 'Coverage')}
|
| 273 |
+
{th('order', 'Order Δ')}
|
| 274 |
+
{ENERGY_TERMS.map((e) => th(e, `Δ${e}`))}
|
| 275 |
+
<th>Method</th>
|
| 276 |
+
{th('resolution', 'Res. Å')}
|
| 277 |
+
<th>Ligand</th>
|
| 278 |
+
{th('date', 'Released')}
|
| 279 |
+
</tr>
|
| 280 |
+
</thead>
|
| 281 |
+
<tbody>
|
| 282 |
+
{rows.slice(page * PAGE, (page + 1) * PAGE).map((t) => {
|
| 283 |
+
const key = chainLabel(t.pdb_id, t.auth_asym_id);
|
| 284 |
+
return (
|
| 285 |
+
<tr
|
| 286 |
+
key={key}
|
| 287 |
+
className={`is-clickable ${key === selected ? 'is-selected' : ''}`}
|
| 288 |
+
onClick={(e) => {
|
| 289 |
+
if ((e.target as HTMLElement).closest('a')) return;
|
| 290 |
+
onSelect(t);
|
| 291 |
+
}}
|
| 292 |
+
>
|
| 293 |
+
<td>
|
| 294 |
+
<ChainLink pdb={t.pdb_id} chain={t.auth_asym_id} strong />
|
| 295 |
+
</td>
|
| 296 |
+
<td className="num tabular">{t.state_id ?? '—'}</td>
|
| 297 |
+
<td>
|
| 298 |
+
<FidelityBadge value={t.fidelity} />
|
| 299 |
+
</td>
|
| 300 |
+
<td className="num tabular strong">{fmtNum(t.similarity, 3)}</td>
|
| 301 |
+
<td className="num tabular">{fmtNum(t.tm_aln, 3)}</td>
|
| 302 |
+
<td className="num tabular">{fmtNum(t.rmsd, 2)}</td>
|
| 303 |
+
<td className="num tabular">{fmtNum(t.coverage, 2)}</td>
|
| 304 |
+
<td className="num">
|
| 305 |
+
<OrderCell order={t.order} />
|
| 306 |
+
</td>
|
| 307 |
+
{ENERGY_TERMS.map((e) => {
|
| 308 |
+
const v = t.delta[e];
|
| 309 |
+
return (
|
| 310 |
+
<td key={e} className={`num tabular delta ${v == null ? '' : v > 0 ? 'delta--up' : v < 0 ? 'delta--down' : ''}`}>
|
| 311 |
+
{fmtSigned(v, 1)}
|
| 312 |
+
</td>
|
| 313 |
+
);
|
| 314 |
+
})}
|
| 315 |
+
<td>{methodShort(t.experimental_method)}</td>
|
| 316 |
+
<td className="num tabular">{fmtNum(t.resolution, 2)}</td>
|
| 317 |
+
<td>
|
| 318 |
+
<LigandBadge status={t.binding_status} />
|
| 319 |
+
</td>
|
| 320 |
+
<td className="num tabular">{t.release_date ?? '—'}</td>
|
| 321 |
+
</tr>
|
| 322 |
+
);
|
| 323 |
+
})}
|
| 324 |
+
</tbody>
|
| 325 |
+
</table>
|
| 326 |
+
</div>
|
| 327 |
+
)}
|
| 328 |
+
<Pagination page={page} pageSize={PAGE} total={rows.length} pageCount={Math.ceil(rows.length / PAGE)} onPage={setPage} />
|
| 329 |
+
</>
|
| 330 |
+
);
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
/* ───────────────────────────── energy deltas (diverging bars) */
|
| 334 |
+
|
| 335 |
+
export function DeltaBars({ delta }: { delta: Record<EnergyTerm, number | null> }) {
|
| 336 |
+
const vals = ENERGY_TERMS.map((e) => delta[e]).filter((v): v is number => v != null);
|
| 337 |
+
const max = Math.max(1, ...vals.map(Math.abs));
|
| 338 |
+
return (
|
| 339 |
+
<ul className="deltas">
|
| 340 |
+
{ENERGY_TERMS.map((e) => {
|
| 341 |
+
const v = delta[e];
|
| 342 |
+
const w = v == null ? 0 : (Math.abs(v) / max) * 50;
|
| 343 |
+
return (
|
| 344 |
+
<li key={e}>
|
| 345 |
+
<span className="deltas__label">Δ{e}</span>
|
| 346 |
+
<span className="deltas__track">
|
| 347 |
+
<span className="deltas__mid" />
|
| 348 |
+
{v != null && (
|
| 349 |
+
<span
|
| 350 |
+
className={`deltas__bar ${v > 0 ? 'is-up' : 'is-down'}`}
|
| 351 |
+
style={v > 0 ? { left: '50%', width: `${w}%` } : { right: '50%', width: `${w}%` }}
|
| 352 |
+
/>
|
| 353 |
+
)}
|
| 354 |
+
</span>
|
| 355 |
+
<span className={`deltas__val tabular ${v == null ? '' : v > 0 ? 'delta--up' : 'delta--down'}`}>
|
| 356 |
+
{fmtSigned(v, 1)}
|
| 357 |
+
</span>
|
| 358 |
+
</li>
|
| 359 |
+
);
|
| 360 |
+
})}
|
| 361 |
+
</ul>
|
| 362 |
+
);
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
/* ───────────────────────────── observed order / disorder */
|
| 366 |
+
|
| 367 |
+
export const ORDER_REASON: Record<string, string> = {
|
| 368 |
+
cross_method: 'different experimental methods',
|
| 369 |
+
nmr: 'both are NMR ensembles',
|
| 370 |
+
no_shared_residues: 'no shared resolved residues',
|
| 371 |
+
missing_data: 'missing data',
|
| 372 |
+
};
|
| 373 |
+
|
| 374 |
+
export const EVIDENCE_LABEL: Record<string, string> = {
|
| 375 |
+
high: 'High evidence',
|
| 376 |
+
medium: 'Medium evidence',
|
| 377 |
+
low: 'Low evidence',
|
| 378 |
+
not_applicable: 'No ordering',
|
| 379 |
+
};
|
| 380 |
+
|
| 381 |
+
function OrderCell({ order }: { order: OrderChange }) {
|
| 382 |
+
if (!order.valid) {
|
| 383 |
+
return (
|
| 384 |
+
<span className="muted small" title={`Not comparable: ${ORDER_REASON[order.reason ?? ''] ?? order.reason ?? 'n/a'}`}>
|
| 385 |
+
n/a
|
| 386 |
+
</span>
|
| 387 |
+
);
|
| 388 |
+
}
|
| 389 |
+
const plus = order.n_ordered ?? 0;
|
| 390 |
+
const minus = order.n_disordered ?? 0;
|
| 391 |
+
if (!plus && !minus) return <span className="muted">0</span>;
|
| 392 |
+
return (
|
| 393 |
+
<span className="order-cell" title={EVIDENCE_LABEL[order.evidence ?? ''] ?? ''}>
|
| 394 |
+
{plus > 0 && <span className="order-cell__plus tabular">+{plus}</span>}
|
| 395 |
+
{minus > 0 && <span className="order-cell__minus tabular">−{minus}</span>}
|
| 396 |
+
{order.evidence && order.evidence !== 'not_applicable' && (
|
| 397 |
+
<span className={`evid evid--${order.evidence}`}>{order.evidence}</span>
|
| 398 |
+
)}
|
| 399 |
+
</span>
|
| 400 |
+
);
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
function fmtRanges(r: Array<[number, number]>, max = 8): string {
|
| 404 |
+
const parts = r.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`));
|
| 405 |
+
return parts.length > max ? `${parts.slice(0, max).join(', ')} … (+${parts.length - max})` : parts.join(', ');
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
/**
|
| 409 |
+
* Residues resolved in the partner but not in this chain ("ordered") and the reverse
|
| 410 |
+
* ("disordered"), on the SEQRES axis. Resolved-state sense, not intrinsic disorder.
|
| 411 |
+
*/
|
| 412 |
+
export function OrderChangeView({ order, length, from, to }: { order: OrderChange; length: number; from: string; to: string }) {
|
| 413 |
+
const L = Math.max(1, length);
|
| 414 |
+
return (
|
| 415 |
+
<div className="orderview">
|
| 416 |
+
<h3 className="compare__sub">Order ↔ disorder (resolved residues)</h3>
|
| 417 |
+
{!order.valid ? (
|
| 418 |
+
<p className="muted small">
|
| 419 |
+
Not comparable for this pair: {ORDER_REASON[order.reason ?? ''] ?? order.reason ?? 'unknown'}. Resolved-residue
|
| 420 |
+
changes are only reported between structures solved with the same method.
|
| 421 |
+
</p>
|
| 422 |
+
) : !order.n_ordered && !order.n_disordered ? (
|
| 423 |
+
<p className="muted small">
|
| 424 |
+
Both structures resolve the same residues ({order.n_shared_resolved?.toLocaleString() ?? '—'} shared).
|
| 425 |
+
</p>
|
| 426 |
+
) : (
|
| 427 |
+
<>
|
| 428 |
+
<div className="orderview__stats">
|
| 429 |
+
<span>
|
| 430 |
+
<span className="swatch" style={{ background: 'var(--ord)' }} /> <b className="tabular">{order.n_ordered ?? 0}</b> residues
|
| 431 |
+
ordered in {to}
|
| 432 |
+
{order.longest_ordered ? <span className="muted"> (longest {order.longest_ordered})</span> : null}
|
| 433 |
+
</span>
|
| 434 |
+
<span>
|
| 435 |
+
<span className="swatch" style={{ background: 'var(--disord)' }} /> <b className="tabular">{order.n_disordered ?? 0}</b>{' '}
|
| 436 |
+
residues resolved only in {from}
|
| 437 |
+
{order.longest_disordered ? <span className="muted"> (longest {order.longest_disordered})</span> : null}
|
| 438 |
+
</span>
|
| 439 |
+
{order.evidence && order.evidence !== 'not_applicable' && (
|
| 440 |
+
<span>
|
| 441 |
+
<span className={`evid evid--${order.evidence}`}>{EVIDENCE_LABEL[order.evidence]}</span>
|
| 442 |
+
<span className="muted">
|
| 443 |
+
{' '}
|
| 444 |
+
{order.n_internal_ordered ?? 0} internal · {order.n_terminal_ordered ?? 0} terminal
|
| 445 |
+
</span>
|
| 446 |
+
</span>
|
| 447 |
+
)}
|
| 448 |
+
{order.with_ligand_change && <span className="badge badge--outline">Co-occurs with a ligand change</span>}
|
| 449 |
+
</div>
|
| 450 |
+
<div className="order-track" aria-label="Order changes along the sequence">
|
| 451 |
+
{order.ordered_ranges.map(([a, b]) => (
|
| 452 |
+
<span
|
| 453 |
+
key={`o${a}`}
|
| 454 |
+
className="order-track__seg order-track__seg--ord"
|
| 455 |
+
style={{ left: `${((a - 1) / L) * 100}%`, width: `${Math.max(0.4, ((b - a + 1) / L) * 100)}%` }}
|
| 456 |
+
title={`Ordered in ${to}: ${a}–${b}`}
|
| 457 |
+
/>
|
| 458 |
+
))}
|
| 459 |
+
{order.disordered_ranges.map(([a, b]) => (
|
| 460 |
+
<span
|
| 461 |
+
key={`d${a}`}
|
| 462 |
+
className="order-track__seg order-track__seg--dis"
|
| 463 |
+
style={{ left: `${((a - 1) / L) * 100}%`, width: `${Math.max(0.4, ((b - a + 1) / L) * 100)}%` }}
|
| 464 |
+
title={`Resolved only in ${from}: ${a}–${b}`}
|
| 465 |
+
/>
|
| 466 |
+
))}
|
| 467 |
+
<span className="order-track__labels">
|
| 468 |
+
<span>1</span>
|
| 469 |
+
<span>{L}</span>
|
| 470 |
+
</span>
|
| 471 |
+
</div>
|
| 472 |
+
<p className="muted small orderview__ranges">
|
| 473 |
+
{order.ordered_ranges.length > 0 && (
|
| 474 |
+
<>
|
| 475 |
+
Ordered: <span className="mono">{fmtRanges(order.ordered_ranges)}</span>
|
| 476 |
+
</>
|
| 477 |
+
)}
|
| 478 |
+
{order.ordered_ranges.length > 0 && order.disordered_ranges.length > 0 && ' · '}
|
| 479 |
+
{order.disordered_ranges.length > 0 && (
|
| 480 |
+
<>
|
| 481 |
+
Only in {from}: <span className="mono">{fmtRanges(order.disordered_ranges)}</span>
|
| 482 |
+
</>
|
| 483 |
+
)}
|
| 484 |
+
</p>
|
| 485 |
+
</>
|
| 486 |
+
)}
|
| 487 |
+
</div>
|
| 488 |
+
);
|
| 489 |
+
}
|
frontend/src/components/charts.tsx
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
| 2 |
+
import { fmtCompact, fmtShare } from '../lib/format';
|
| 3 |
+
import { Swatch } from './ui';
|
| 4 |
+
|
| 5 |
+
/* ───────────────────────────── helpers */
|
| 6 |
+
|
| 7 |
+
function useWidth<T extends HTMLElement>(): [React.RefObject<T>, number] {
|
| 8 |
+
const ref = useRef<T>(null);
|
| 9 |
+
const [w, setW] = useState(0);
|
| 10 |
+
useLayoutEffect(() => {
|
| 11 |
+
const el = ref.current;
|
| 12 |
+
if (!el) return;
|
| 13 |
+
setW(el.clientWidth);
|
| 14 |
+
const ro = new ResizeObserver(([entry]) => setW(Math.floor(entry.contentRect.width)));
|
| 15 |
+
ro.observe(el);
|
| 16 |
+
return () => ro.disconnect();
|
| 17 |
+
}, []);
|
| 18 |
+
return [ref, w];
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/** Round tick step ≈ v/4 (1, 2, 2.5, 5 × 10ⁿ) and the axis max it implies. */
|
| 22 |
+
function niceTicks(v: number, target = 4): number[] {
|
| 23 |
+
if (v <= 0) return [0, 1];
|
| 24 |
+
const raw = v / target;
|
| 25 |
+
const exp = Math.pow(10, Math.floor(Math.log10(raw)));
|
| 26 |
+
const f = raw / exp;
|
| 27 |
+
const step = (f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10) * exp;
|
| 28 |
+
const n = Math.ceil(v / step - 1e-9);
|
| 29 |
+
return Array.from({ length: n + 1 }, (_, i) => i * step);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function niceMax(v: number): number {
|
| 33 |
+
if (v <= 0) return 1;
|
| 34 |
+
const exp = Math.pow(10, Math.floor(Math.log10(v)));
|
| 35 |
+
const f = v / exp;
|
| 36 |
+
const nf = f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10;
|
| 37 |
+
return nf * exp;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/** Bar path: square at the baseline, 4px rounded data-end. */
|
| 41 |
+
function colPath(x: number, y: number, w: number, h: number, r = 4): string {
|
| 42 |
+
if (h <= 0) return '';
|
| 43 |
+
const rr = Math.min(r, w / 2, h);
|
| 44 |
+
return `M${x},${y + h}V${y + rr}Q${x},${y} ${x + rr},${y}H${x + w - rr}Q${x + w},${y} ${x + w},${y + rr}V${y + h}Z`;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function barPath(x: number, y: number, w: number, h: number, r = 4): string {
|
| 48 |
+
if (w <= 0) return '';
|
| 49 |
+
const rr = Math.min(r, h / 2, w);
|
| 50 |
+
return `M${x},${y}H${x + w - rr}Q${x + w},${y} ${x + w},${y + rr}V${y + h - rr}Q${x + w},${y + h} ${x + w - rr},${y + h}H${x}Z`;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
interface TipState {
|
| 54 |
+
x: number;
|
| 55 |
+
y: number;
|
| 56 |
+
content: ReactNode;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function Tooltip({ tip }: { tip: TipState | null }) {
|
| 60 |
+
if (!tip) return null;
|
| 61 |
+
return (
|
| 62 |
+
<div className="chart-tip" style={{ left: tip.x, top: tip.y }} role="tooltip">
|
| 63 |
+
{tip.content}
|
| 64 |
+
</div>
|
| 65 |
+
);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export interface Series {
|
| 69 |
+
key: string;
|
| 70 |
+
label: string;
|
| 71 |
+
color: string;
|
| 72 |
+
values: number[];
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/* ───────────────────────────── column chart (optionally stacked) */
|
| 76 |
+
|
| 77 |
+
export function ColumnChart({
|
| 78 |
+
labels,
|
| 79 |
+
series,
|
| 80 |
+
height = 220,
|
| 81 |
+
xTitle,
|
| 82 |
+
yTitle,
|
| 83 |
+
tickEvery,
|
| 84 |
+
format = fmtCompact,
|
| 85 |
+
tipLabel,
|
| 86 |
+
}: {
|
| 87 |
+
labels: string[];
|
| 88 |
+
series: Series[];
|
| 89 |
+
height?: number;
|
| 90 |
+
xTitle?: string;
|
| 91 |
+
yTitle?: string;
|
| 92 |
+
tickEvery?: number;
|
| 93 |
+
format?: (n: number) => string;
|
| 94 |
+
tipLabel?: (i: number) => string;
|
| 95 |
+
}) {
|
| 96 |
+
const [ref, width] = useWidth<HTMLDivElement>();
|
| 97 |
+
const [tip, setTip] = useState<TipState | null>(null);
|
| 98 |
+
const [hover, setHover] = useState<number | null>(null);
|
| 99 |
+
|
| 100 |
+
const totals = useMemo(
|
| 101 |
+
() => labels.map((_, i) => series.reduce((s, se) => s + (se.values[i] || 0), 0)),
|
| 102 |
+
[labels, series],
|
| 103 |
+
);
|
| 104 |
+
const grand = totals.reduce((a, b) => a + b, 0);
|
| 105 |
+
const yTicks = niceTicks(Math.max(...totals, 0));
|
| 106 |
+
const max = yTicks[yTicks.length - 1];
|
| 107 |
+
const m = { top: 12, right: 8, bottom: xTitle ? 44 : 28, left: 48 };
|
| 108 |
+
const iw = Math.max(0, width - m.left - m.right);
|
| 109 |
+
const ih = height - m.top - m.bottom;
|
| 110 |
+
const band = labels.length ? iw / labels.length : 0;
|
| 111 |
+
const bw = Math.max(2, Math.min(24, band - 2));
|
| 112 |
+
const every = tickEvery ?? Math.max(1, Math.ceil(labels.length / Math.max(1, Math.floor(iw / 44))));
|
| 113 |
+
|
| 114 |
+
return (
|
| 115 |
+
<div className="chart" ref={ref}>
|
| 116 |
+
{width > 0 && (
|
| 117 |
+
<svg width={width} height={height} role="img" aria-label={yTitle ?? 'Column chart'}>
|
| 118 |
+
<g transform={`translate(${m.left},${m.top})`}>
|
| 119 |
+
{yTicks.map((t) => {
|
| 120 |
+
const y = ih - (t / max) * ih;
|
| 121 |
+
return (
|
| 122 |
+
<g key={t}>
|
| 123 |
+
<line x1={0} x2={iw} y1={y} y2={y} className={t === 0 ? 'axis-line' : 'grid-line'} />
|
| 124 |
+
<text x={-8} y={y} dy="0.32em" textAnchor="end" className="tick">
|
| 125 |
+
{format(t)}
|
| 126 |
+
</text>
|
| 127 |
+
</g>
|
| 128 |
+
);
|
| 129 |
+
})}
|
| 130 |
+
{labels.map((lab, i) => {
|
| 131 |
+
let acc = 0;
|
| 132 |
+
const x = i * band + (band - bw) / 2;
|
| 133 |
+
const segs = series.map((se, si) => {
|
| 134 |
+
const v = se.values[i] || 0;
|
| 135 |
+
const h = (v / max) * ih;
|
| 136 |
+
const y = ih - ((acc + v) / max) * ih;
|
| 137 |
+
acc += v;
|
| 138 |
+
const isTop = series.slice(si + 1).every((s2) => !(s2.values[i] > 0));
|
| 139 |
+
const gap = si > 0 && h > 2 ? 2 : 0;
|
| 140 |
+
return (
|
| 141 |
+
<path
|
| 142 |
+
key={se.key}
|
| 143 |
+
d={isTop ? colPath(x, y, bw, Math.max(0, h - gap)) : `M${x},${y}h${bw}v${Math.max(0, h - gap)}h${-bw}Z`}
|
| 144 |
+
fill={se.color}
|
| 145 |
+
opacity={hover === null || hover === i ? 1 : 0.55}
|
| 146 |
+
/>
|
| 147 |
+
);
|
| 148 |
+
});
|
| 149 |
+
return (
|
| 150 |
+
<g key={lab + i}>
|
| 151 |
+
{segs}
|
| 152 |
+
{i % every === 0 && (
|
| 153 |
+
<text x={i * band + band / 2} y={ih + 16} textAnchor="middle" className="tick">
|
| 154 |
+
{lab}
|
| 155 |
+
</text>
|
| 156 |
+
)}
|
| 157 |
+
<rect
|
| 158 |
+
x={i * band}
|
| 159 |
+
y={0}
|
| 160 |
+
width={band}
|
| 161 |
+
height={ih}
|
| 162 |
+
fill="transparent"
|
| 163 |
+
onMouseMove={(e) => {
|
| 164 |
+
const r = (e.currentTarget.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
|
| 165 |
+
setHover(i);
|
| 166 |
+
setTip({
|
| 167 |
+
x: e.clientX - r.left,
|
| 168 |
+
y: e.clientY - r.top,
|
| 169 |
+
content: (
|
| 170 |
+
<>
|
| 171 |
+
<div className="chart-tip__title">{tipLabel ? tipLabel(i) : lab}</div>
|
| 172 |
+
{series.map((se) => (
|
| 173 |
+
<div key={se.key} className="chart-tip__row">
|
| 174 |
+
{series.length > 1 && <Swatch color={se.color} />}
|
| 175 |
+
<span>{series.length > 1 ? se.label : 'Count'}</span>
|
| 176 |
+
<b className="tabular">{(se.values[i] || 0).toLocaleString()}</b>
|
| 177 |
+
</div>
|
| 178 |
+
))}
|
| 179 |
+
<div className="chart-tip__foot">{fmtShare(totals[i], grand)} of total</div>
|
| 180 |
+
</>
|
| 181 |
+
),
|
| 182 |
+
});
|
| 183 |
+
}}
|
| 184 |
+
onMouseLeave={() => {
|
| 185 |
+
setHover(null);
|
| 186 |
+
setTip(null);
|
| 187 |
+
}}
|
| 188 |
+
/>
|
| 189 |
+
</g>
|
| 190 |
+
);
|
| 191 |
+
})}
|
| 192 |
+
{xTitle && (
|
| 193 |
+
<text x={iw / 2} y={ih + 38} textAnchor="middle" className="axis-title">
|
| 194 |
+
{xTitle}
|
| 195 |
+
</text>
|
| 196 |
+
)}
|
| 197 |
+
</g>
|
| 198 |
+
</svg>
|
| 199 |
+
)}
|
| 200 |
+
<Tooltip tip={tip} />
|
| 201 |
+
</div>
|
| 202 |
+
);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
/* ───────────────────────────── histogram over numeric bins */
|
| 206 |
+
|
| 207 |
+
export function Histogram({
|
| 208 |
+
counts,
|
| 209 |
+
binWidth,
|
| 210 |
+
start = 0,
|
| 211 |
+
series,
|
| 212 |
+
xTitle,
|
| 213 |
+
height,
|
| 214 |
+
digits = 2,
|
| 215 |
+
openLast = false,
|
| 216 |
+
}: {
|
| 217 |
+
counts?: number[];
|
| 218 |
+
series?: Series[];
|
| 219 |
+
binWidth: number;
|
| 220 |
+
start?: number;
|
| 221 |
+
xTitle?: string;
|
| 222 |
+
height?: number;
|
| 223 |
+
digits?: number;
|
| 224 |
+
openLast?: boolean;
|
| 225 |
+
}) {
|
| 226 |
+
const n = series ? series[0].values.length : counts!.length;
|
| 227 |
+
const labels = Array.from({ length: n }, (_, i) => (start + i * binWidth).toFixed(digits).replace(/\.?0+$/, '') || '0');
|
| 228 |
+
const s = series ?? [{ key: 'count', label: 'Count', color: 'var(--accent)', values: counts! }];
|
| 229 |
+
const tickEvery = Math.max(1, Math.round(n / 5));
|
| 230 |
+
return (
|
| 231 |
+
<ColumnChart
|
| 232 |
+
labels={labels}
|
| 233 |
+
series={s}
|
| 234 |
+
xTitle={xTitle}
|
| 235 |
+
height={height}
|
| 236 |
+
tickEvery={tickEvery}
|
| 237 |
+
tipLabel={(i) => {
|
| 238 |
+
const lo = start + i * binWidth;
|
| 239 |
+
return openLast && i === n - 1 ? `≥ ${lo.toFixed(digits)}` : `${lo.toFixed(digits)} – ${(lo + binWidth).toFixed(digits)}`;
|
| 240 |
+
}}
|
| 241 |
+
/>
|
| 242 |
+
);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
/* ───────────────────────────── horizontal bar list */
|
| 246 |
+
|
| 247 |
+
export function BarList({
|
| 248 |
+
items,
|
| 249 |
+
color = 'var(--accent)',
|
| 250 |
+
format = (n: number) => n.toLocaleString(),
|
| 251 |
+
max,
|
| 252 |
+
}: {
|
| 253 |
+
items: Array<{ label: ReactNode; value: number; key?: string }>;
|
| 254 |
+
color?: string;
|
| 255 |
+
format?: (n: number) => string;
|
| 256 |
+
max?: number;
|
| 257 |
+
}) {
|
| 258 |
+
const top = max ?? Math.max(...items.map((i) => i.value), 1);
|
| 259 |
+
const total = items.reduce((s, i) => s + i.value, 0);
|
| 260 |
+
return (
|
| 261 |
+
<ul className="barlist">
|
| 262 |
+
{items.map((it, idx) => (
|
| 263 |
+
<li key={it.key ?? idx} className="barlist__row" title={`${fmtShare(it.value, total)} of total`}>
|
| 264 |
+
<span className="barlist__label">{it.label}</span>
|
| 265 |
+
<span className="barlist__track">
|
| 266 |
+
<svg width="100%" height="10" preserveAspectRatio="none" viewBox="0 0 100 10" aria-hidden>
|
| 267 |
+
<path d={barPath(0, 0, Math.max(0.6, (it.value / top) * 100), 10, 2)} fill={color} />
|
| 268 |
+
</svg>
|
| 269 |
+
</span>
|
| 270 |
+
<span className="barlist__value tabular">{format(it.value)}</span>
|
| 271 |
+
</li>
|
| 272 |
+
))}
|
| 273 |
+
</ul>
|
| 274 |
+
);
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
/* ───────────────────────────── 100% stacked bar with legend */
|
| 278 |
+
|
| 279 |
+
export function StackedBar({
|
| 280 |
+
parts,
|
| 281 |
+
height = 14,
|
| 282 |
+
showLegend = true,
|
| 283 |
+
}: {
|
| 284 |
+
parts: Array<{ key: string; label: ReactNode; value: number; color: string; note?: string }>;
|
| 285 |
+
height?: number;
|
| 286 |
+
showLegend?: boolean;
|
| 287 |
+
}) {
|
| 288 |
+
const total = parts.reduce((s, p) => s + p.value, 0) || 1;
|
| 289 |
+
const [tip, setTip] = useState<TipState | null>(null);
|
| 290 |
+
return (
|
| 291 |
+
<div className="stacked">
|
| 292 |
+
<div className="stacked__bar" style={{ height }} onMouseLeave={() => setTip(null)}>
|
| 293 |
+
{parts
|
| 294 |
+
.filter((p) => p.value > 0)
|
| 295 |
+
.map((p) => (
|
| 296 |
+
<span
|
| 297 |
+
key={p.key}
|
| 298 |
+
className="stacked__seg"
|
| 299 |
+
style={{ flexGrow: p.value, background: p.color }}
|
| 300 |
+
onMouseMove={(e) => {
|
| 301 |
+
const r = (e.currentTarget.parentElement as HTMLElement).getBoundingClientRect();
|
| 302 |
+
setTip({
|
| 303 |
+
x: e.clientX - r.left,
|
| 304 |
+
y: e.clientY - r.top,
|
| 305 |
+
content: (
|
| 306 |
+
<>
|
| 307 |
+
<div className="chart-tip__title">{p.label}</div>
|
| 308 |
+
<div className="chart-tip__row">
|
| 309 |
+
<b className="tabular">{p.value.toLocaleString()}</b>
|
| 310 |
+
<span>{fmtShare(p.value, total)}</span>
|
| 311 |
+
</div>
|
| 312 |
+
{p.note && <div className="chart-tip__foot">{p.note}</div>}
|
| 313 |
+
</>
|
| 314 |
+
),
|
| 315 |
+
});
|
| 316 |
+
}}
|
| 317 |
+
/>
|
| 318 |
+
))}
|
| 319 |
+
<Tooltip tip={tip} />
|
| 320 |
+
</div>
|
| 321 |
+
{showLegend && (
|
| 322 |
+
<ul className="legend">
|
| 323 |
+
{parts.map((p) => (
|
| 324 |
+
<li key={p.key}>
|
| 325 |
+
<Swatch color={p.color} />
|
| 326 |
+
<span>{p.label}</span>
|
| 327 |
+
<b className="tabular">{fmtShare(p.value, total)}</b>
|
| 328 |
+
</li>
|
| 329 |
+
))}
|
| 330 |
+
</ul>
|
| 331 |
+
)}
|
| 332 |
+
</div>
|
| 333 |
+
);
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
/* ───────────────────────────── sparkline-sized bar strip (tables) */
|
| 337 |
+
|
| 338 |
+
export function MiniSplit({ a, b, colorA, colorB, title }: { a: number; b: number; colorA: string; colorB: string; title?: string }) {
|
| 339 |
+
const t = a + b || 1;
|
| 340 |
+
return (
|
| 341 |
+
<span className="minisplit" title={title}>
|
| 342 |
+
<span style={{ width: `${(a / t) * 100}%`, background: colorA }} />
|
| 343 |
+
<span style={{ width: `${(b / t) * 100}%`, background: colorB }} />
|
| 344 |
+
</span>
|
| 345 |
+
);
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
export { Tooltip, useWidth, niceMax, niceTicks };
|
| 349 |
+
export type { TipState };
|
frontend/src/components/datasets/protein/BindersPanel.tsx
DELETED
|
@@ -1,108 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Binders panel for a selected chain
|
| 3 |
-
* Displays nonpolymer entities (ligands/binders) with name and SMILES
|
| 4 |
-
*/
|
| 5 |
-
import React, { useState } from 'react';
|
| 6 |
-
import { ChainMetadata } from '../../../types/protein';
|
| 7 |
-
import { formatText, truncateText } from '../../../utils/format';
|
| 8 |
-
|
| 9 |
-
interface BindersPanelProps {
|
| 10 |
-
metadata: ChainMetadata | null;
|
| 11 |
-
isLoading?: boolean;
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
const BindersPanel: React.FC<BindersPanelProps> = ({ metadata, isLoading }) => {
|
| 15 |
-
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
| 16 |
-
|
| 17 |
-
const binders = metadata?.nonpolymer_entities || [];
|
| 18 |
-
|
| 19 |
-
const handleCopySmiles = async (smiles: string | null | undefined, index: number) => {
|
| 20 |
-
if (!smiles) return;
|
| 21 |
-
|
| 22 |
-
try {
|
| 23 |
-
await navigator.clipboard.writeText(smiles);
|
| 24 |
-
setCopiedIndex(index);
|
| 25 |
-
setTimeout(() => setCopiedIndex(null), 2000);
|
| 26 |
-
} catch (err) {
|
| 27 |
-
console.error('Failed to copy SMILES:', err);
|
| 28 |
-
}
|
| 29 |
-
};
|
| 30 |
-
|
| 31 |
-
if (isLoading) {
|
| 32 |
-
return (
|
| 33 |
-
<section className="space-y-4">
|
| 34 |
-
<h3 className="text-xl font-semibold text-slate-900">Binders</h3>
|
| 35 |
-
<div className="text-center py-10 text-slate-600">Loading binders...</div>
|
| 36 |
-
</section>
|
| 37 |
-
);
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
if (binders.length === 0) {
|
| 41 |
-
return (
|
| 42 |
-
<section className="space-y-4">
|
| 43 |
-
<h3 className="text-xl font-semibold text-slate-900">Binders</h3>
|
| 44 |
-
<div className="text-center py-10 text-slate-600">No binder annotations available</div>
|
| 45 |
-
</section>
|
| 46 |
-
);
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
return (
|
| 50 |
-
<section className="space-y-4">
|
| 51 |
-
{/* Title + Count Chip */}
|
| 52 |
-
<div className="flex items-start justify-between">
|
| 53 |
-
<h3 className="text-xl font-semibold text-slate-900">Binders</h3>
|
| 54 |
-
<span className="px-3 py-1.5 bg-amber-50 text-amber-700 text-xs font-medium rounded-full border border-amber-200">
|
| 55 |
-
{binders.length} {binders.length === 1 ? 'binder' : 'binders'}
|
| 56 |
-
</span>
|
| 57 |
-
</div>
|
| 58 |
-
|
| 59 |
-
{/* Table */}
|
| 60 |
-
<div className="overflow-x-auto rounded-lg border border-slate-200">
|
| 61 |
-
<table className="w-full text-sm">
|
| 62 |
-
<thead className="bg-slate-50 border-b border-slate-200">
|
| 63 |
-
<tr>
|
| 64 |
-
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">Name</th>
|
| 65 |
-
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">SMILES</th>
|
| 66 |
-
<th className="px-4 py-3 text-center text-xs font-semibold text-slate-700 uppercase tracking-wider w-20">Copy</th>
|
| 67 |
-
</tr>
|
| 68 |
-
</thead>
|
| 69 |
-
<tbody>
|
| 70 |
-
{binders.map((binder, idx) => {
|
| 71 |
-
const name = formatText(binder.name);
|
| 72 |
-
const smiles = formatText(binder.smiles, '');
|
| 73 |
-
const truncatedSmiles = smiles ? truncateText(smiles, 50) : '—';
|
| 74 |
-
|
| 75 |
-
return (
|
| 76 |
-
<tr key={idx} className="border-b border-slate-100 hover:bg-slate-50 transition-colors last:border-b-0">
|
| 77 |
-
<td className="px-4 py-3 text-slate-900 font-medium">{name}</td>
|
| 78 |
-
<td className="px-4 py-3 text-slate-900">
|
| 79 |
-
{smiles ? (
|
| 80 |
-
<code className="bg-slate-100 px-2 py-1 rounded font-mono text-xs inline-block max-w-md overflow-hidden text-ellipsis whitespace-nowrap border border-slate-200" title={smiles}>
|
| 81 |
-
{truncatedSmiles}
|
| 82 |
-
</code>
|
| 83 |
-
) : (
|
| 84 |
-
<span className="text-slate-400">—</span>
|
| 85 |
-
)}
|
| 86 |
-
</td>
|
| 87 |
-
<td className="px-4 py-3 text-center">
|
| 88 |
-
{smiles && (
|
| 89 |
-
<button
|
| 90 |
-
className="inline-flex items-center justify-center w-8 h-8 border border-gray-300 text-slate-700 rounded-lg hover:bg-slate-50 hover:border-slate-400 transition-colors text-sm"
|
| 91 |
-
onClick={() => handleCopySmiles(smiles, idx)}
|
| 92 |
-
title="Copy SMILES"
|
| 93 |
-
>
|
| 94 |
-
{copiedIndex === idx ? '✓' : '📋'}
|
| 95 |
-
</button>
|
| 96 |
-
)}
|
| 97 |
-
</td>
|
| 98 |
-
</tr>
|
| 99 |
-
);
|
| 100 |
-
})}
|
| 101 |
-
</tbody>
|
| 102 |
-
</table>
|
| 103 |
-
</div>
|
| 104 |
-
</section>
|
| 105 |
-
);
|
| 106 |
-
};
|
| 107 |
-
|
| 108 |
-
export default BindersPanel;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/datasets/protein/ChainBasicInfoCards.tsx
DELETED
|
@@ -1,146 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Basic information card for a selected chain
|
| 3 |
-
* Displays: PDB ID, Protein name, Chain ID, UniProt ID in a single well-designed card
|
| 4 |
-
*/
|
| 5 |
-
import { ChainMetadata } from '../../../types/protein';
|
| 6 |
-
|
| 7 |
-
interface ChainBasicInfoCardsProps {
|
| 8 |
-
metadata: ChainMetadata | null;
|
| 9 |
-
isLoading?: boolean;
|
| 10 |
-
}
|
| 11 |
-
|
| 12 |
-
const ChainBasicInfoCards: React.FC<ChainBasicInfoCardsProps> = ({ metadata, isLoading }) => {
|
| 13 |
-
const getUniProtId = () => {
|
| 14 |
-
if (!metadata?.polymer_entity?.reference_sequences) return '—';
|
| 15 |
-
|
| 16 |
-
const refSeqs = metadata.polymer_entity.reference_sequences;
|
| 17 |
-
const uniprotRefs = refSeqs.filter((ref: any) => ref.database_name === 'UniProt');
|
| 18 |
-
|
| 19 |
-
if (uniprotRefs.length > 0) {
|
| 20 |
-
const first = uniprotRefs[0].database_accession;
|
| 21 |
-
if (uniprotRefs.length > 1) {
|
| 22 |
-
return (
|
| 23 |
-
<span title={uniprotRefs.map((r: any) => r.database_accession).join(', ')}>
|
| 24 |
-
{first} <span className="text-gray-500 text-xs">+{uniprotRefs.length - 1} more</span>
|
| 25 |
-
</span>
|
| 26 |
-
);
|
| 27 |
-
}
|
| 28 |
-
return first;
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
// Fallback to first reference sequence
|
| 32 |
-
if (refSeqs.length > 0) {
|
| 33 |
-
return refSeqs[0].database_accession || '—';
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
return '—';
|
| 37 |
-
};
|
| 38 |
-
|
| 39 |
-
if (isLoading) {
|
| 40 |
-
return (
|
| 41 |
-
<div className="border border-gray-200 rounded-2xl p-6 bg-white shadow-sm">
|
| 42 |
-
<div className="flex items-center gap-3">
|
| 43 |
-
<div className="w-8 h-8 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
|
| 44 |
-
<span className="text-slate-600 text-sm">Loading chain metadata...</span>
|
| 45 |
-
</div>
|
| 46 |
-
</div>
|
| 47 |
-
);
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
const pdbId = metadata?.entry?.rcsb_id || metadata?.pdb_id;
|
| 51 |
-
const chainId = metadata?.chain_id || metadata?.polymer_entity_instance?.auth_asym_id;
|
| 52 |
-
|
| 53 |
-
return (
|
| 54 |
-
<div className="h-full w-full border border-gray-200 rounded-2xl p-6 bg-white shadow-sm hover:border-slate-900 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300">
|
| 55 |
-
<div className="flex items-start justify-between mb-4">
|
| 56 |
-
<h3 className="text-xl font-semibold text-slate-900">Chain Metadata</h3>
|
| 57 |
-
<div className="flex flex-wrap gap-2">
|
| 58 |
-
<span className="px-3 py-1.5 bg-blue-50 text-blue-700 text-xs font-medium rounded-full border border-blue-200">
|
| 59 |
-
{pdbId || 'N/A'}
|
| 60 |
-
</span>
|
| 61 |
-
{chainId && (
|
| 62 |
-
<span className="px-3 py-1.5 bg-purple-50 text-purple-700 text-xs font-medium rounded-full border border-purple-200">
|
| 63 |
-
Chain {chainId}
|
| 64 |
-
</span>
|
| 65 |
-
)}
|
| 66 |
-
</div>
|
| 67 |
-
</div>
|
| 68 |
-
|
| 69 |
-
{/* Chips Row */}
|
| 70 |
-
|
| 71 |
-
{/* Compact three-column metadata flow */}
|
| 72 |
-
<div className="gap-4" style={{ columnCount: 3, columnGap: '1rem' }}>
|
| 73 |
-
<div className="break-inside-avoid mb-3" style={{ breakInside: 'avoid' }}>
|
| 74 |
-
<div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">Protein Name</div>
|
| 75 |
-
<div className="text-sm font-semibold text-slate-900 font-mono" title={metadata?.entry?.title || ''}>
|
| 76 |
-
{metadata?.entry?.title || '—'}
|
| 77 |
-
</div>
|
| 78 |
-
</div>
|
| 79 |
-
|
| 80 |
-
<div className="break-inside-avoid mb-3" style={{ breakInside: 'avoid' }}>
|
| 81 |
-
<div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">PDB ID</div>
|
| 82 |
-
<div className="text-sm font-semibold text-slate-900">
|
| 83 |
-
<a
|
| 84 |
-
href={`https://www.rcsb.org/structure/${pdbId}`}
|
| 85 |
-
target="_blank"
|
| 86 |
-
rel="noopener noreferrer"
|
| 87 |
-
className="text-blue-600 hover:text-blue-800 font-mono hover:underline inline-flex items-center gap-1 transition-colors"
|
| 88 |
-
>
|
| 89 |
-
{pdbId || '—'}
|
| 90 |
-
<span className="text-xs">↗</span>
|
| 91 |
-
</a>
|
| 92 |
-
</div>
|
| 93 |
-
</div>
|
| 94 |
-
|
| 95 |
-
<div className="break-inside-avoid mb-3" style={{ breakInside: 'avoid' }}>
|
| 96 |
-
<div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">Chain ID</div>
|
| 97 |
-
<div className="text-sm font-semibold text-slate-900 font-mono">{chainId || '—'}</div>
|
| 98 |
-
</div>
|
| 99 |
-
|
| 100 |
-
<div className="break-inside-avoid mb-3" style={{ breakInside: 'avoid' }}>
|
| 101 |
-
<div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">UniProt ID</div>
|
| 102 |
-
<div className="text-sm font-semibold text-slate-900 font-mono">{getUniProtId()}</div>
|
| 103 |
-
</div>
|
| 104 |
-
|
| 105 |
-
<div className="break-inside-avoid mb-3" style={{ breakInside: 'avoid' }}>
|
| 106 |
-
<div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">Binding Status</div>
|
| 107 |
-
<div className="text-sm font-semibold text-slate-900 capitalize">{metadata?.binding_status || '—'}</div>
|
| 108 |
-
</div>
|
| 109 |
-
|
| 110 |
-
<div className="break-inside-avoid mb-3" style={{ breakInside: 'avoid' }}>
|
| 111 |
-
<div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">Chain Composition</div>
|
| 112 |
-
<div className="text-sm font-semibold text-slate-900 capitalize">{metadata?.chain_composition || '—'}</div>
|
| 113 |
-
</div>
|
| 114 |
-
|
| 115 |
-
<div className="break-inside-avoid mb-3" style={{ breakInside: 'avoid' }}>
|
| 116 |
-
<div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">CATH ID</div>
|
| 117 |
-
<div className="text-sm font-semibold text-slate-900 font-mono">{metadata?.cath_id || 'uncategorized'}</div>
|
| 118 |
-
</div>
|
| 119 |
-
|
| 120 |
-
<div className="break-inside-avoid mb-3" style={{ breakInside: 'avoid' }}>
|
| 121 |
-
<div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">CATH Domain</div>
|
| 122 |
-
<div className="text-sm font-semibold text-slate-900 font-mono">
|
| 123 |
-
{metadata?.cath_superfamily ? (
|
| 124 |
-
<a
|
| 125 |
-
href={`https://www.cathdb.info/version/latest/superfamily/${metadata.cath_superfamily}`}
|
| 126 |
-
target="_blank"
|
| 127 |
-
rel="noopener noreferrer"
|
| 128 |
-
className="text-blue-600 hover:text-blue-800 hover:underline inline-flex items-center gap-1 transition-colors"
|
| 129 |
-
>
|
| 130 |
-
{metadata.cath_superfamily}
|
| 131 |
-
<span className="text-xs">↗</span>
|
| 132 |
-
</a>
|
| 133 |
-
) : '—'}
|
| 134 |
-
</div>
|
| 135 |
-
</div>
|
| 136 |
-
|
| 137 |
-
<div className="break-inside-avoid mb-3" style={{ breakInside: 'avoid' }}>
|
| 138 |
-
<div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">State ID</div>
|
| 139 |
-
<div className="text-sm font-semibold text-slate-900 font-mono">{metadata?.state_id || '—'}</div>
|
| 140 |
-
</div>
|
| 141 |
-
</div>
|
| 142 |
-
</div>
|
| 143 |
-
);
|
| 144 |
-
};
|
| 145 |
-
|
| 146 |
-
export default ChainBasicInfoCards;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/datasets/protein/ChainSearchInput.tsx
DELETED
|
@@ -1,204 +0,0 @@
|
|
| 1 |
-
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
-
import type { ChainMetadata } from '../../../types/protein';
|
| 3 |
-
|
| 4 |
-
type SearchStatus = 'idle' | 'typing' | 'loading' | 'success' | 'not_found' | 'error';
|
| 5 |
-
|
| 6 |
-
interface ChainSearchInputProps {
|
| 7 |
-
value: string;
|
| 8 |
-
onValueChange: (value: string) => void;
|
| 9 |
-
onChainSelect: (pdb_id: string, auth_asym_id: string) => void;
|
| 10 |
-
onMetadataLoaded: (metadata: ChainMetadata) => void;
|
| 11 |
-
onClear: () => void;
|
| 12 |
-
resolveChain: (pdb_id: string, auth_asym_id: string) => Promise<ChainMetadata>;
|
| 13 |
-
searchStatus: SearchStatus;
|
| 14 |
-
resolvedLabel?: string;
|
| 15 |
-
errorMessage?: string;
|
| 16 |
-
disabled?: boolean;
|
| 17 |
-
}
|
| 18 |
-
|
| 19 |
-
interface ParsedChain {
|
| 20 |
-
pdb_id: string;
|
| 21 |
-
auth_asym_id: string;
|
| 22 |
-
valid: boolean;
|
| 23 |
-
error?: string;
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
export const ChainSearchInput: React.FC<ChainSearchInputProps> = ({
|
| 27 |
-
value,
|
| 28 |
-
onValueChange,
|
| 29 |
-
onChainSelect,
|
| 30 |
-
onMetadataLoaded,
|
| 31 |
-
onClear,
|
| 32 |
-
resolveChain,
|
| 33 |
-
searchStatus,
|
| 34 |
-
resolvedLabel,
|
| 35 |
-
errorMessage,
|
| 36 |
-
disabled
|
| 37 |
-
}) => {
|
| 38 |
-
const [parsed, setParsed] = useState<ParsedChain | null>(null);
|
| 39 |
-
const [debouncedValue, setDebouncedValue] = useState('');
|
| 40 |
-
const lastResolvedKey = useRef<string | null>(null);
|
| 41 |
-
|
| 42 |
-
// Debounce input (500ms)
|
| 43 |
-
useEffect(() => {
|
| 44 |
-
const timer = setTimeout(() => {
|
| 45 |
-
setDebouncedValue(value);
|
| 46 |
-
}, 500);
|
| 47 |
-
|
| 48 |
-
return () => clearTimeout(timer);
|
| 49 |
-
}, [value]);
|
| 50 |
-
|
| 51 |
-
// Parse chain identifier
|
| 52 |
-
const parseChainId = (value: string): ParsedChain => {
|
| 53 |
-
const trimmed = value.trim();
|
| 54 |
-
if (!trimmed) {
|
| 55 |
-
return { pdb_id: '', auth_asym_id: '', valid: false };
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
// Accept formats: "3vgk.A", "3VGK:A", "3VGK_A", "3vgk A"
|
| 59 |
-
const separatorRegex = /[.\s:_]+/;
|
| 60 |
-
const parts = trimmed.split(separatorRegex);
|
| 61 |
-
|
| 62 |
-
if (parts.length < 2) {
|
| 63 |
-
return {
|
| 64 |
-
pdb_id: '',
|
| 65 |
-
auth_asym_id: '',
|
| 66 |
-
valid: false,
|
| 67 |
-
error: 'Format: PDB_ID + Chain (e.g., "3vgk.A", "3VGK:A", "3VGK_A")'
|
| 68 |
-
};
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
const pdb_id = parts[0].trim();
|
| 72 |
-
const auth_asym_id = parts.slice(1).join('').trim(); // Handle multi-char chain IDs
|
| 73 |
-
|
| 74 |
-
// Validate PDB ID (4 characters)
|
| 75 |
-
if (pdb_id.length !== 4) {
|
| 76 |
-
return {
|
| 77 |
-
pdb_id,
|
| 78 |
-
auth_asym_id,
|
| 79 |
-
valid: false,
|
| 80 |
-
error: 'PDB ID must be exactly 4 characters'
|
| 81 |
-
};
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
// Validate chain ID (at least 1 character)
|
| 85 |
-
if (auth_asym_id.length === 0) {
|
| 86 |
-
return {
|
| 87 |
-
pdb_id,
|
| 88 |
-
auth_asym_id,
|
| 89 |
-
valid: false,
|
| 90 |
-
error: 'Chain ID is required (e.g., "A", "B", "AA")'
|
| 91 |
-
};
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
return {
|
| 95 |
-
pdb_id: pdb_id.toLowerCase(),
|
| 96 |
-
auth_asym_id: auth_asym_id.toUpperCase(),
|
| 97 |
-
valid: true
|
| 98 |
-
};
|
| 99 |
-
};
|
| 100 |
-
|
| 101 |
-
// Parse and validate when debounced value changes
|
| 102 |
-
useEffect(() => {
|
| 103 |
-
if (!debouncedValue) {
|
| 104 |
-
setParsed(null);
|
| 105 |
-
lastResolvedKey.current = null;
|
| 106 |
-
return;
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
const result = parseChainId(debouncedValue);
|
| 110 |
-
setParsed(result);
|
| 111 |
-
|
| 112 |
-
if (!result.valid) {
|
| 113 |
-
return;
|
| 114 |
-
}
|
| 115 |
-
|
| 116 |
-
// Create key for this chain
|
| 117 |
-
const currentKey = `${result.pdb_id}|${result.auth_asym_id}`;
|
| 118 |
-
|
| 119 |
-
// Only resolve if key changed (prevent duplicate requests)
|
| 120 |
-
if (currentKey === lastResolvedKey.current) {
|
| 121 |
-
return;
|
| 122 |
-
}
|
| 123 |
-
|
| 124 |
-
lastResolvedKey.current = currentKey;
|
| 125 |
-
|
| 126 |
-
// Valid - trigger resolution (parent manages loading/error state)
|
| 127 |
-
resolveChain(result.pdb_id, result.auth_asym_id)
|
| 128 |
-
.then(metadata => {
|
| 129 |
-
onChainSelect(result.pdb_id, result.auth_asym_id);
|
| 130 |
-
onMetadataLoaded(metadata);
|
| 131 |
-
})
|
| 132 |
-
.catch(() => {
|
| 133 |
-
// Parent handles error state
|
| 134 |
-
});
|
| 135 |
-
}, [debouncedValue, resolveChain, onChainSelect, onMetadataLoaded]);
|
| 136 |
-
|
| 137 |
-
const handleClear = () => {
|
| 138 |
-
setParsed(null);
|
| 139 |
-
setDebouncedValue('');
|
| 140 |
-
lastResolvedKey.current = null;
|
| 141 |
-
onClear();
|
| 142 |
-
};
|
| 143 |
-
|
| 144 |
-
const getBorderColor = () => {
|
| 145 |
-
if (searchStatus === 'loading') return 'border-blue-400';
|
| 146 |
-
if (searchStatus === 'error' || searchStatus === 'not_found' || (parsed && !parsed.valid)) return 'border-red-400';
|
| 147 |
-
if (searchStatus === 'success') return 'border-green-400';
|
| 148 |
-
return 'border-gray-300';
|
| 149 |
-
};
|
| 150 |
-
|
| 151 |
-
const showError = searchStatus === 'error' || searchStatus === 'not_found' || (parsed && !parsed.valid && value);
|
| 152 |
-
const displayError = errorMessage || parsed?.error || 'Invalid chain identifier';
|
| 153 |
-
|
| 154 |
-
return (
|
| 155 |
-
<div className="space-y-3">
|
| 156 |
-
<label className="block">
|
| 157 |
-
<span className="text-sm font-medium text-slate-900">Chain Identifier</span>
|
| 158 |
-
<span className="block text-xs text-slate-500 mt-1">
|
| 159 |
-
Format: PDB ID + Chain (e.g., 3vgk.A, 3VGK:A, 3VGK_A, 3vgk A)
|
| 160 |
-
</span>
|
| 161 |
-
</label>
|
| 162 |
-
|
| 163 |
-
<div className={`relative flex items-center border-2 ${getBorderColor()} rounded-lg transition-all duration-200 focus-within:ring-2 focus-within:ring-slate-900 focus-within:ring-offset-2 bg-white`}>
|
| 164 |
-
<input
|
| 165 |
-
type="text"
|
| 166 |
-
className="flex-1 px-4 py-3 text-sm text-slate-900 bg-transparent rounded-lg outline-none placeholder:text-slate-400 disabled:bg-slate-50 disabled:cursor-not-allowed"
|
| 167 |
-
value={value}
|
| 168 |
-
onChange={(e) => onValueChange(e.target.value)}
|
| 169 |
-
placeholder="e.g., 3vgk.A"
|
| 170 |
-
disabled={disabled}
|
| 171 |
-
/>
|
| 172 |
-
|
| 173 |
-
{value && searchStatus !== 'loading' && (
|
| 174 |
-
<button
|
| 175 |
-
className="absolute right-3 w-7 h-7 flex items-center justify-center text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-md transition-colors disabled:cursor-not-allowed"
|
| 176 |
-
onClick={handleClear}
|
| 177 |
-
disabled={disabled}
|
| 178 |
-
title="Clear"
|
| 179 |
-
>
|
| 180 |
-
✕
|
| 181 |
-
</button>
|
| 182 |
-
)}
|
| 183 |
-
|
| 184 |
-
{searchStatus === 'loading' && (
|
| 185 |
-
<div className="absolute right-3 w-5 h-5 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin" title="Resolving chain..."></div>
|
| 186 |
-
)}
|
| 187 |
-
</div>
|
| 188 |
-
|
| 189 |
-
{searchStatus === 'success' && resolvedLabel && (
|
| 190 |
-
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 px-3 py-2 rounded-lg border border-green-200">
|
| 191 |
-
<span className="text-base">✓</span>
|
| 192 |
-
<span className="font-medium">{resolvedLabel}</span>
|
| 193 |
-
</div>
|
| 194 |
-
)}
|
| 195 |
-
|
| 196 |
-
{showError && (
|
| 197 |
-
<div className="flex items-center gap-2 text-sm text-red-700 bg-red-50 px-3 py-2 rounded-lg border border-red-200">
|
| 198 |
-
<span className="text-base">✗</span>
|
| 199 |
-
<span>{displayError}</span>
|
| 200 |
-
</div>
|
| 201 |
-
)}
|
| 202 |
-
</div>
|
| 203 |
-
);
|
| 204 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/datasets/protein/DataTable.css
DELETED
|
@@ -1,177 +0,0 @@
|
|
| 1 |
-
.data-table-container {
|
| 2 |
-
background: white;
|
| 3 |
-
border-radius: 8px;
|
| 4 |
-
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
| 5 |
-
overflow: hidden;
|
| 6 |
-
display: flex;
|
| 7 |
-
flex-direction: column;
|
| 8 |
-
}
|
| 9 |
-
|
| 10 |
-
.table-header {
|
| 11 |
-
padding: 15px 20px;
|
| 12 |
-
border-bottom: 2px solid #e0e0e0;
|
| 13 |
-
display: flex;
|
| 14 |
-
justify-content: space-between;
|
| 15 |
-
align-items: center;
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
.table-header h3 {
|
| 19 |
-
margin: 0;
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
.table-stats {
|
| 23 |
-
display: flex;
|
| 24 |
-
gap: 10px;
|
| 25 |
-
align-items: center;
|
| 26 |
-
color: #666;
|
| 27 |
-
font-size: 0.9rem;
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
.separator {
|
| 31 |
-
color: #ccc;
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
.table-wrapper {
|
| 35 |
-
flex: 1;
|
| 36 |
-
overflow: auto;
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
.data-table {
|
| 40 |
-
width: 100%;
|
| 41 |
-
border-collapse: collapse;
|
| 42 |
-
font-size: 0.9rem;
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
.data-table thead {
|
| 46 |
-
position: sticky;
|
| 47 |
-
top: 0;
|
| 48 |
-
background: #f5f5f5;
|
| 49 |
-
z-index: 10;
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
.data-table th {
|
| 53 |
-
padding: 12px 15px;
|
| 54 |
-
text-align: left;
|
| 55 |
-
font-weight: 600;
|
| 56 |
-
color: #555;
|
| 57 |
-
border-bottom: 2px solid #e0e0e0;
|
| 58 |
-
cursor: pointer;
|
| 59 |
-
user-select: none;
|
| 60 |
-
white-space: nowrap;
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
.data-table th:hover {
|
| 64 |
-
background: #e8e8e8;
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
.data-table td {
|
| 68 |
-
padding: 10px 15px;
|
| 69 |
-
border-bottom: 1px solid #f0f0f0;
|
| 70 |
-
}
|
| 71 |
-
|
| 72 |
-
.data-table tbody tr:hover {
|
| 73 |
-
background: #fafafa;
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
.chain-id {
|
| 77 |
-
font-weight: 600;
|
| 78 |
-
color: #2196f3;
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
-
.asym-id {
|
| 82 |
-
color: #666;
|
| 83 |
-
font-size: 0.85rem;
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
.numeric {
|
| 87 |
-
text-align: right;
|
| 88 |
-
font-family: 'Courier New', monospace;
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
.cluster {
|
| 92 |
-
font-size: 0.85rem;
|
| 93 |
-
color: #666;
|
| 94 |
-
text-transform: capitalize;
|
| 95 |
-
}
|
| 96 |
-
|
| 97 |
-
.pagination {
|
| 98 |
-
padding: 15px 20px;
|
| 99 |
-
border-top: 1px solid #e0e0e0;
|
| 100 |
-
display: flex;
|
| 101 |
-
justify-content: center;
|
| 102 |
-
align-items: center;
|
| 103 |
-
gap: 15px;
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
.pagination button {
|
| 107 |
-
padding: 8px 16px;
|
| 108 |
-
background: #2196f3;
|
| 109 |
-
color: white;
|
| 110 |
-
border: none;
|
| 111 |
-
border-radius: 4px;
|
| 112 |
-
cursor: pointer;
|
| 113 |
-
font-size: 0.9rem;
|
| 114 |
-
transition: background 0.3s;
|
| 115 |
-
}
|
| 116 |
-
|
| 117 |
-
.pagination button:hover:not(:disabled) {
|
| 118 |
-
background: #1976d2;
|
| 119 |
-
}
|
| 120 |
-
|
| 121 |
-
.pagination button:disabled {
|
| 122 |
-
background: #ccc;
|
| 123 |
-
cursor: not-allowed;
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
.page-info {
|
| 127 |
-
color: #666;
|
| 128 |
-
font-size: 0.9rem;
|
| 129 |
-
}
|
| 130 |
-
|
| 131 |
-
.table-loading,
|
| 132 |
-
.table-empty {
|
| 133 |
-
display: flex;
|
| 134 |
-
align-items: center;
|
| 135 |
-
justify-content: center;
|
| 136 |
-
height: 100%;
|
| 137 |
-
color: #888;
|
| 138 |
-
font-size: 1.1rem;
|
| 139 |
-
background: white;
|
| 140 |
-
border-radius: 8px;
|
| 141 |
-
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
| 142 |
-
}
|
| 143 |
-
|
| 144 |
-
.table-empty {
|
| 145 |
-
color: #999;
|
| 146 |
-
}
|
| 147 |
-
|
| 148 |
-
.table-hint {
|
| 149 |
-
font-size: 0.8em;
|
| 150 |
-
font-weight: normal;
|
| 151 |
-
color: #888;
|
| 152 |
-
}
|
| 153 |
-
|
| 154 |
-
.placeholder {
|
| 155 |
-
color: #999;
|
| 156 |
-
font-style: italic;
|
| 157 |
-
}
|
| 158 |
-
|
| 159 |
-
.experimental-method {
|
| 160 |
-
max-width: 200px;
|
| 161 |
-
overflow: hidden;
|
| 162 |
-
text-overflow: ellipsis;
|
| 163 |
-
white-space: nowrap;
|
| 164 |
-
}
|
| 165 |
-
|
| 166 |
-
.delta-cell {
|
| 167 |
-
font-family: 'Courier New', monospace;
|
| 168 |
-
text-align: right;
|
| 169 |
-
}
|
| 170 |
-
|
| 171 |
-
.delta-pos {
|
| 172 |
-
color: #d32f2f;
|
| 173 |
-
}
|
| 174 |
-
|
| 175 |
-
.delta-neg {
|
| 176 |
-
color: #2e7d32;
|
| 177 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/datasets/protein/DataTable.tsx
DELETED
|
@@ -1,214 +0,0 @@
|
|
| 1 |
-
import React, { useState } from 'react';
|
| 2 |
-
import type { DataRecord } from '../../../types/protein';
|
| 3 |
-
import { formatTemperature, formatPH } from '../../../utils/format';
|
| 4 |
-
import './DataTable.css';
|
| 5 |
-
|
| 6 |
-
interface DataTableProps {
|
| 7 |
-
data: DataRecord[];
|
| 8 |
-
loading: boolean;
|
| 9 |
-
total: number;
|
| 10 |
-
filtered: number;
|
| 11 |
-
}
|
| 12 |
-
|
| 13 |
-
type SortField = keyof DataRecord;
|
| 14 |
-
type SortDirection = 'asc' | 'desc';
|
| 15 |
-
|
| 16 |
-
export const DataTable: React.FC<DataTableProps> = ({ data, loading, total, filtered }) => {
|
| 17 |
-
const [sortField, setSortField] = useState<SortField>('rmsd');
|
| 18 |
-
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
| 19 |
-
const [currentPage, setCurrentPage] = useState(1);
|
| 20 |
-
const itemsPerPage = 50;
|
| 21 |
-
|
| 22 |
-
if (loading) {
|
| 23 |
-
return <div className="table-loading">Loading data...</div>;
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
if (data.length === 0) {
|
| 27 |
-
return <div className="table-empty">No data to display. Adjust filters to see results.</div>;
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
// Sorting
|
| 31 |
-
const sortedData = [...data].sort((a, b) => {
|
| 32 |
-
const aValue = a[sortField];
|
| 33 |
-
const bValue = b[sortField];
|
| 34 |
-
|
| 35 |
-
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
| 36 |
-
return sortDirection === 'asc' ? aValue - bValue : bValue - aValue;
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
| 40 |
-
return sortDirection === 'asc'
|
| 41 |
-
? aValue.localeCompare(bValue)
|
| 42 |
-
: bValue.localeCompare(aValue);
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
return 0;
|
| 46 |
-
});
|
| 47 |
-
|
| 48 |
-
// Pagination
|
| 49 |
-
const totalPages = Math.ceil(sortedData.length / itemsPerPage);
|
| 50 |
-
const startIndex = (currentPage - 1) * itemsPerPage;
|
| 51 |
-
const endIndex = startIndex + itemsPerPage;
|
| 52 |
-
const paginatedData = sortedData.slice(startIndex, endIndex);
|
| 53 |
-
|
| 54 |
-
const handleSort = (field: SortField) => {
|
| 55 |
-
if (sortField === field) {
|
| 56 |
-
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
| 57 |
-
} else {
|
| 58 |
-
setSortField(field);
|
| 59 |
-
setSortDirection('asc');
|
| 60 |
-
}
|
| 61 |
-
setCurrentPage(1);
|
| 62 |
-
};
|
| 63 |
-
|
| 64 |
-
const getSortIcon = (field: SortField) => {
|
| 65 |
-
if (sortField !== field) return '↕';
|
| 66 |
-
return sortDirection === 'asc' ? '↑' : '↓';
|
| 67 |
-
};
|
| 68 |
-
|
| 69 |
-
return (
|
| 70 |
-
<div className="data-table-container">
|
| 71 |
-
<div className="table-header">
|
| 72 |
-
<h3 className="text-xl font-semibold text-slate-900">Alternative observations</h3>
|
| 73 |
-
<div className="table-stats">
|
| 74 |
-
<span>Showing {paginatedData.length} of {filtered.toLocaleString()} filtered</span>
|
| 75 |
-
<span className="separator">|</span>
|
| 76 |
-
<span>Total: {total.toLocaleString()}</span>
|
| 77 |
-
</div>
|
| 78 |
-
</div>
|
| 79 |
-
|
| 80 |
-
<div className="table-wrapper">
|
| 81 |
-
<table className="data-table">
|
| 82 |
-
<thead>
|
| 83 |
-
<tr>
|
| 84 |
-
<th onClick={() => handleSort('pdb_id_b')}>
|
| 85 |
-
PDB ID {getSortIcon('pdb_id_b')}
|
| 86 |
-
</th>
|
| 87 |
-
<th onClick={() => handleSort('auth_asym_id_b')}>
|
| 88 |
-
Chain ID {getSortIcon('auth_asym_id_b')}
|
| 89 |
-
</th>
|
| 90 |
-
<th onClick={() => handleSort('tm_score')}>
|
| 91 |
-
TM-score {getSortIcon('tm_score')}
|
| 92 |
-
</th>
|
| 93 |
-
<th onClick={() => handleSort('rmsd')}>
|
| 94 |
-
RMSD (Å) {getSortIcon('rmsd')}
|
| 95 |
-
</th>
|
| 96 |
-
<th>
|
| 97 |
-
Experimental Method
|
| 98 |
-
</th>
|
| 99 |
-
<th>
|
| 100 |
-
Temp (K)
|
| 101 |
-
</th>
|
| 102 |
-
<th>
|
| 103 |
-
pH
|
| 104 |
-
</th>
|
| 105 |
-
<th onClick={() => handleSort('binding_status')}>
|
| 106 |
-
Binding status {getSortIcon('binding_status')}
|
| 107 |
-
</th>
|
| 108 |
-
<th onClick={() => handleSort('chain_composition')}>
|
| 109 |
-
Chain composition {getSortIcon('chain_composition')}
|
| 110 |
-
</th>
|
| 111 |
-
<th onClick={() => handleSort('delta_rosetta')}>
|
| 112 |
-
ΔRosetta {getSortIcon('delta_rosetta')}
|
| 113 |
-
</th>
|
| 114 |
-
<th onClick={() => handleSort('delta_foldx')}>
|
| 115 |
-
ΔFoldX {getSortIcon('delta_foldx')}
|
| 116 |
-
</th>
|
| 117 |
-
<th onClick={() => handleSort('delta_evoef2')}>
|
| 118 |
-
ΔEvoEF2 {getSortIcon('delta_evoef2')}
|
| 119 |
-
</th>
|
| 120 |
-
<th onClick={() => handleSort('delta_rw')}>
|
| 121 |
-
ΔRW {getSortIcon('delta_rw')}
|
| 122 |
-
</th>
|
| 123 |
-
<th onClick={() => handleSort('delta_rw_plus')}>
|
| 124 |
-
ΔRW+ {getSortIcon('delta_rw_plus')}
|
| 125 |
-
</th>
|
| 126 |
-
<th onClick={() => handleSort('state_id_b')}>
|
| 127 |
-
State ID {getSortIcon('state_id_b')}
|
| 128 |
-
</th>
|
| 129 |
-
<th onClick={() => handleSort('state_fidelity')}>
|
| 130 |
-
State fidelity {getSortIcon('state_fidelity')}
|
| 131 |
-
</th>
|
| 132 |
-
<th onClick={() => handleSort('avg_sim')}>
|
| 133 |
-
State avg. similarity {getSortIcon('avg_sim')}
|
| 134 |
-
</th>
|
| 135 |
-
<th onClick={() => handleSort('observation_fidelity')}>
|
| 136 |
-
Observation fidelity {getSortIcon('observation_fidelity')}
|
| 137 |
-
</th>
|
| 138 |
-
<th onClick={() => handleSort('structure_sim')}>
|
| 139 |
-
Observation similarity {getSortIcon('structure_sim')}
|
| 140 |
-
</th>
|
| 141 |
-
</tr>
|
| 142 |
-
</thead>
|
| 143 |
-
<tbody>
|
| 144 |
-
{paginatedData.map((record, index) => (
|
| 145 |
-
<tr key={`${record.pdb_id_a}-${record.auth_asym_id_a}-${record.pdb_id_b}-${record.auth_asym_id_b}-${index}`}>
|
| 146 |
-
<td>
|
| 147 |
-
<span className="chain-id">{record.pdb_id_b.toUpperCase()}</span>
|
| 148 |
-
</td>
|
| 149 |
-
<td>
|
| 150 |
-
<span className="asym-id">{record.auth_asym_id_b}</span>
|
| 151 |
-
</td>
|
| 152 |
-
<td className="numeric">{record.tm_score.toFixed(3)}</td>
|
| 153 |
-
<td className="numeric">{record.rmsd.toFixed(2)}</td>
|
| 154 |
-
<td className="experimental-method">
|
| 155 |
-
{record.exptl_method || <span className="placeholder">—</span>}
|
| 156 |
-
</td>
|
| 157 |
-
<td className="numeric">
|
| 158 |
-
{formatTemperature(record.temp)}
|
| 159 |
-
</td>
|
| 160 |
-
<td className="numeric">
|
| 161 |
-
{formatPH(record.pH)}
|
| 162 |
-
</td>
|
| 163 |
-
<td className="capitalize">
|
| 164 |
-
{record.binding_status || <span className="placeholder">—</span>}
|
| 165 |
-
</td>
|
| 166 |
-
<td className="capitalize">
|
| 167 |
-
{record.chain_composition || <span className="placeholder">—</span>}
|
| 168 |
-
</td>
|
| 169 |
-
{(['delta_rosetta', 'delta_foldx', 'delta_evoef2', 'delta_rw', 'delta_rw_plus'] as const).map(field => {
|
| 170 |
-
const val = record[field];
|
| 171 |
-
return (
|
| 172 |
-
<td key={field} className={`numeric delta-cell${val == null ? '' : val > 0 ? ' delta-pos' : val < 0 ? ' delta-neg' : ''}`}>
|
| 173 |
-
{val == null ? <span className="placeholder">—</span> : `${val > 0 ? '+' : ''}${val.toFixed(2)}`}
|
| 174 |
-
</td>
|
| 175 |
-
);
|
| 176 |
-
})}
|
| 177 |
-
<td className="font-mono">{record.state_id_b ?? <span className="placeholder">—</span>}</td>
|
| 178 |
-
<td>{record.state_fidelity ?? <span className="placeholder">—</span>}</td>
|
| 179 |
-
<td className="numeric">
|
| 180 |
-
{(() => {
|
| 181 |
-
const n = record.avg_sim != null && record.avg_sim !== '' ? Number(record.avg_sim) : NaN;
|
| 182 |
-
return Number.isFinite(n) ? n.toFixed(3) : (record.avg_sim || <span className="placeholder">—</span>);
|
| 183 |
-
})()}
|
| 184 |
-
</td>
|
| 185 |
-
<td>{record.observation_fidelity ?? <span className="placeholder">—</span>}</td>
|
| 186 |
-
<td className="numeric">{record.structure_sim != null ? record.structure_sim.toFixed(3) : <span className="placeholder">—</span>}</td>
|
| 187 |
-
</tr>
|
| 188 |
-
))}
|
| 189 |
-
</tbody>
|
| 190 |
-
</table>
|
| 191 |
-
</div>
|
| 192 |
-
|
| 193 |
-
{totalPages > 1 && (
|
| 194 |
-
<div className="pagination">
|
| 195 |
-
<button
|
| 196 |
-
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
| 197 |
-
disabled={currentPage === 1}
|
| 198 |
-
>
|
| 199 |
-
Previous
|
| 200 |
-
</button>
|
| 201 |
-
<span className="page-info">
|
| 202 |
-
Page {currentPage} of {totalPages}
|
| 203 |
-
</span>
|
| 204 |
-
<button
|
| 205 |
-
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
| 206 |
-
disabled={currentPage === totalPages}
|
| 207 |
-
>
|
| 208 |
-
Next
|
| 209 |
-
</button>
|
| 210 |
-
</div>
|
| 211 |
-
)}
|
| 212 |
-
</div>
|
| 213 |
-
);
|
| 214 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/datasets/protein/ErrorBoundary.css
DELETED
|
@@ -1,93 +0,0 @@
|
|
| 1 |
-
/* Error Boundary Fallback Styling */
|
| 2 |
-
|
| 3 |
-
.error-boundary-fallback {
|
| 4 |
-
background: #fff;
|
| 5 |
-
border: 2px solid #fecaca;
|
| 6 |
-
border-radius: 12px;
|
| 7 |
-
padding: 32px;
|
| 8 |
-
text-align: center;
|
| 9 |
-
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
| 10 |
-
}
|
| 11 |
-
|
| 12 |
-
.error-boundary-icon {
|
| 13 |
-
font-size: 48px;
|
| 14 |
-
margin-bottom: 16px;
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
.error-boundary-fallback h3 {
|
| 18 |
-
margin: 0 0 8px 0;
|
| 19 |
-
font-size: 18px;
|
| 20 |
-
font-weight: 600;
|
| 21 |
-
color: #991b1b;
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
.error-boundary-fallback p {
|
| 25 |
-
margin: 0 0 24px 0;
|
| 26 |
-
font-size: 14px;
|
| 27 |
-
color: #6b7280;
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
.error-boundary-actions {
|
| 31 |
-
display: flex;
|
| 32 |
-
gap: 12px;
|
| 33 |
-
justify-content: center;
|
| 34 |
-
margin-bottom: 16px;
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
.error-boundary-btn {
|
| 38 |
-
padding: 10px 20px;
|
| 39 |
-
border-radius: 8px;
|
| 40 |
-
font-size: 14px;
|
| 41 |
-
font-weight: 500;
|
| 42 |
-
cursor: pointer;
|
| 43 |
-
transition: all 0.2s ease;
|
| 44 |
-
border: none;
|
| 45 |
-
}
|
| 46 |
-
|
| 47 |
-
.error-boundary-btn.primary {
|
| 48 |
-
background: #667eea;
|
| 49 |
-
color: white;
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
.error-boundary-btn.primary:hover {
|
| 53 |
-
background: #5568d3;
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
.error-boundary-btn.secondary {
|
| 57 |
-
background: #e5e7eb;
|
| 58 |
-
color: #374151;
|
| 59 |
-
}
|
| 60 |
-
|
| 61 |
-
.error-boundary-btn.secondary:hover {
|
| 62 |
-
background: #d1d5db;
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
-
.error-boundary-details {
|
| 66 |
-
margin-top: 16px;
|
| 67 |
-
text-align: left;
|
| 68 |
-
background: #f9fafb;
|
| 69 |
-
border: 1px solid #e5e7eb;
|
| 70 |
-
border-radius: 8px;
|
| 71 |
-
padding: 12px;
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
.error-boundary-details summary {
|
| 75 |
-
cursor: pointer;
|
| 76 |
-
font-weight: 600;
|
| 77 |
-
font-size: 13px;
|
| 78 |
-
color: #6b7280;
|
| 79 |
-
margin-bottom: 8px;
|
| 80 |
-
}
|
| 81 |
-
|
| 82 |
-
.error-boundary-stack {
|
| 83 |
-
margin: 8px 0 0 0;
|
| 84 |
-
padding: 12px;
|
| 85 |
-
background: #1f2937;
|
| 86 |
-
color: #f9fafb;
|
| 87 |
-
border-radius: 6px;
|
| 88 |
-
font-size: 12px;
|
| 89 |
-
font-family: 'Courier New', monospace;
|
| 90 |
-
overflow-x: auto;
|
| 91 |
-
white-space: pre-wrap;
|
| 92 |
-
word-break: break-word;
|
| 93 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/datasets/protein/ErrorBoundary.tsx
DELETED
|
@@ -1,105 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Error Boundary component to catch React render errors
|
| 3 |
-
* Prevents a single component crash from taking down the entire app
|
| 4 |
-
*/
|
| 5 |
-
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
| 6 |
-
import './ErrorBoundary.css';
|
| 7 |
-
|
| 8 |
-
interface Props {
|
| 9 |
-
children: ReactNode;
|
| 10 |
-
fallback?: ReactNode;
|
| 11 |
-
componentName?: string;
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
interface State {
|
| 15 |
-
hasError: boolean;
|
| 16 |
-
error: Error | null;
|
| 17 |
-
errorInfo: ErrorInfo | null;
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
class ErrorBoundary extends Component<Props, State> {
|
| 21 |
-
constructor(props: Props) {
|
| 22 |
-
super(props);
|
| 23 |
-
this.state = {
|
| 24 |
-
hasError: false,
|
| 25 |
-
error: null,
|
| 26 |
-
errorInfo: null,
|
| 27 |
-
};
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
static getDerivedStateFromError(error: Error): State {
|
| 31 |
-
return {
|
| 32 |
-
hasError: true,
|
| 33 |
-
error,
|
| 34 |
-
errorInfo: null,
|
| 35 |
-
};
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
| 39 |
-
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
| 40 |
-
this.setState({
|
| 41 |
-
error,
|
| 42 |
-
errorInfo,
|
| 43 |
-
});
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
handleReset = () => {
|
| 47 |
-
this.setState({
|
| 48 |
-
hasError: false,
|
| 49 |
-
error: null,
|
| 50 |
-
errorInfo: null,
|
| 51 |
-
});
|
| 52 |
-
};
|
| 53 |
-
|
| 54 |
-
handleCopyError = () => {
|
| 55 |
-
const { error, errorInfo } = this.state;
|
| 56 |
-
const errorText = `Error: ${error?.toString()}\n\nStack: ${errorInfo?.componentStack}`;
|
| 57 |
-
navigator.clipboard.writeText(errorText).then(() => {
|
| 58 |
-
alert('Error details copied to clipboard');
|
| 59 |
-
});
|
| 60 |
-
};
|
| 61 |
-
|
| 62 |
-
render() {
|
| 63 |
-
if (this.state.hasError) {
|
| 64 |
-
// Custom fallback if provided
|
| 65 |
-
if (this.props.fallback) {
|
| 66 |
-
return this.props.fallback;
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
// Default fallback UI
|
| 70 |
-
const componentName = this.props.componentName || 'this component';
|
| 71 |
-
const isDev = import.meta.env?.DEV || process.env.NODE_ENV === 'development';
|
| 72 |
-
|
| 73 |
-
return (
|
| 74 |
-
<div className="error-boundary-fallback">
|
| 75 |
-
<div className="error-boundary-icon">⚠️</div>
|
| 76 |
-
<h3>Unable to render {componentName}</h3>
|
| 77 |
-
<p>Something went wrong while displaying this section.</p>
|
| 78 |
-
<div className="error-boundary-actions">
|
| 79 |
-
<button onClick={this.handleReset} className="error-boundary-btn primary">
|
| 80 |
-
Try Again
|
| 81 |
-
</button>
|
| 82 |
-
{isDev && this.state.error && (
|
| 83 |
-
<button onClick={this.handleCopyError} className="error-boundary-btn secondary">
|
| 84 |
-
Copy Error Details
|
| 85 |
-
</button>
|
| 86 |
-
)}
|
| 87 |
-
</div>
|
| 88 |
-
{isDev && this.state.error && (
|
| 89 |
-
<details className="error-boundary-details">
|
| 90 |
-
<summary>Error Details (dev only)</summary>
|
| 91 |
-
<pre className="error-boundary-stack">
|
| 92 |
-
{this.state.error.toString()}
|
| 93 |
-
{this.state.errorInfo?.componentStack}
|
| 94 |
-
</pre>
|
| 95 |
-
</details>
|
| 96 |
-
)}
|
| 97 |
-
</div>
|
| 98 |
-
);
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
return this.props.children;
|
| 102 |
-
}
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
export default ErrorBoundary;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/datasets/protein/ExperimentalConditionsPanel.tsx
DELETED
|
@@ -1,117 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Experimental conditions panel for a selected chain
|
| 3 |
-
* Displays method, resolution, pH, temperature, details, and RCSB link
|
| 4 |
-
*/
|
| 5 |
-
import React, { useState } from 'react';
|
| 6 |
-
import { ChainMetadata } from '../../../types/protein';
|
| 7 |
-
import { formatText, formatPH, formatTemperature, truncateText } from '../../../utils/format';
|
| 8 |
-
|
| 9 |
-
interface ExperimentalConditionsPanelProps {
|
| 10 |
-
metadata: ChainMetadata | null;
|
| 11 |
-
isLoading?: boolean;
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
const ExperimentalConditionsPanel: React.FC<ExperimentalConditionsPanelProps> = ({ metadata, isLoading }) => {
|
| 15 |
-
const [showFullDetails, setShowFullDetails] = useState(false);
|
| 16 |
-
|
| 17 |
-
if (isLoading) {
|
| 18 |
-
return (
|
| 19 |
-
<section className="space-y-4">
|
| 20 |
-
<h3 className="text-xl font-semibold text-slate-900">Experimental Conditions</h3>
|
| 21 |
-
<div className="text-center py-10 text-slate-600">Loading conditions...</div>
|
| 22 |
-
</section>
|
| 23 |
-
);
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
if (!metadata) {
|
| 27 |
-
return (
|
| 28 |
-
<section className="space-y-4">
|
| 29 |
-
<h3 className="text-xl font-semibold text-slate-900">Experimental Conditions</h3>
|
| 30 |
-
<div className="text-center py-10 text-slate-600">No experimental data available</div>
|
| 31 |
-
</section>
|
| 32 |
-
);
|
| 33 |
-
}
|
| 34 |
-
|
| 35 |
-
// Safe extraction with optional chaining and nullish coalescing
|
| 36 |
-
const pdbId = metadata.pdb_id || metadata.entry?.rcsb_id;
|
| 37 |
-
const website = pdbId ? `https://www.rcsb.org/structure/${String(pdbId).toUpperCase()}` : null;
|
| 38 |
-
const exptlMethod = metadata.entry?.exptl_method;
|
| 39 |
-
const pH = metadata.entry?.crystal_grow?.pH;
|
| 40 |
-
const temp = metadata.entry?.crystal_grow?.temp;
|
| 41 |
-
const details = metadata.entry?.crystal_grow?.pdbx_details;
|
| 42 |
-
|
| 43 |
-
// Safe formatting
|
| 44 |
-
const formattedMethod = formatText(exptlMethod);
|
| 45 |
-
const formattedPH = formatPH(pH);
|
| 46 |
-
const formattedTemp = formatTemperature(temp);
|
| 47 |
-
const formattedDetails = formatText(details, '');
|
| 48 |
-
|
| 49 |
-
const detailsPreview = formattedDetails ? truncateText(formattedDetails, 120) : '';
|
| 50 |
-
const hasLongDetails = formattedDetails && formattedDetails.length > 120;
|
| 51 |
-
|
| 52 |
-
return (
|
| 53 |
-
<section className="space-y-4">
|
| 54 |
-
{/* Title + Method Chip */}
|
| 55 |
-
<div className="flex items-start justify-between">
|
| 56 |
-
<h3 className="text-xl font-semibold text-slate-900">Experimental Conditions</h3>
|
| 57 |
-
{formattedMethod !== '—' && (
|
| 58 |
-
<span className="px-3 py-1.5 bg-green-50 text-green-700 text-xs font-medium rounded-full border border-green-200">
|
| 59 |
-
{formattedMethod}
|
| 60 |
-
</span>
|
| 61 |
-
)}
|
| 62 |
-
</div>
|
| 63 |
-
|
| 64 |
-
{/* Definition List */}
|
| 65 |
-
<dl className="space-y-3 pt-2">
|
| 66 |
-
{website && (
|
| 67 |
-
<div className="flex justify-between items-center py-2 border-b border-slate-100">
|
| 68 |
-
<dt className="text-xs font-medium text-slate-500 uppercase tracking-wider">Website</dt>
|
| 69 |
-
<dd>
|
| 70 |
-
<a href={website} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 font-medium hover:underline transition-colors">
|
| 71 |
-
RCSB PDB <span className="text-xs">↗</span>
|
| 72 |
-
</a>
|
| 73 |
-
</dd>
|
| 74 |
-
</div>
|
| 75 |
-
)}
|
| 76 |
-
|
| 77 |
-
<div className="flex justify-between items-center py-2 border-b border-slate-100">
|
| 78 |
-
<dt className="text-xs font-medium text-slate-500 uppercase tracking-wider">Method</dt>
|
| 79 |
-
<dd className="text-sm font-semibold text-slate-900">{formattedMethod}</dd>
|
| 80 |
-
</div>
|
| 81 |
-
|
| 82 |
-
<div className="flex justify-between items-center py-2 border-b border-slate-100">
|
| 83 |
-
<dt className="text-xs font-medium text-slate-500 uppercase tracking-wider">pH</dt>
|
| 84 |
-
<dd className="text-sm font-semibold text-slate-900">{formattedPH}</dd>
|
| 85 |
-
</div>
|
| 86 |
-
|
| 87 |
-
<div className="flex justify-between items-center py-2 border-b border-slate-100">
|
| 88 |
-
<dt className="text-xs font-medium text-slate-500 uppercase tracking-wider">Temperature</dt>
|
| 89 |
-
<dd className="text-sm font-semibold text-slate-900">{formattedTemp}</dd>
|
| 90 |
-
</div>
|
| 91 |
-
|
| 92 |
-
<div className="py-2 border-b border-slate-100">
|
| 93 |
-
{formattedDetails && (
|
| 94 |
-
<div className="experimental-item experimental-details min-w-0">
|
| 95 |
-
<dt className="text-xs font-medium text-slate-500 uppercase tracking-wider">Details</dt>
|
| 96 |
-
<dd className="text-sm font-semibold text-slate-900">
|
| 97 |
-
<div className="details-content wrap-anywhere mt-1">
|
| 98 |
-
{showFullDetails ? formattedDetails : detailsPreview}
|
| 99 |
-
</div>
|
| 100 |
-
{hasLongDetails && (
|
| 101 |
-
<button
|
| 102 |
-
className="details-toggle"
|
| 103 |
-
onClick={() => setShowFullDetails(!showFullDetails)}
|
| 104 |
-
>
|
| 105 |
-
{showFullDetails ? '▲ Show less' : '▼ Show more'}
|
| 106 |
-
</button>
|
| 107 |
-
)}
|
| 108 |
-
</dd>
|
| 109 |
-
</div>
|
| 110 |
-
)}
|
| 111 |
-
</div>
|
| 112 |
-
</dl>
|
| 113 |
-
</section>
|
| 114 |
-
);
|
| 115 |
-
};
|
| 116 |
-
|
| 117 |
-
export default ExperimentalConditionsPanel;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/datasets/protein/FilterPanel.tsx
DELETED
|
@@ -1,75 +0,0 @@
|
|
| 1 |
-
import React from 'react';
|
| 2 |
-
import type { FilterParams, FilterOptions, ChainMetadata } from '../../../types/protein';
|
| 3 |
-
import { ChainSearchInput } from './ChainSearchInput';
|
| 4 |
-
|
| 5 |
-
interface FilterPanelProps {
|
| 6 |
-
filterOptions: FilterOptions | null;
|
| 7 |
-
filters: FilterParams;
|
| 8 |
-
searchInputValue: string;
|
| 9 |
-
onSearchInputChange: (value: string) => void;
|
| 10 |
-
onSearchClear: () => void;
|
| 11 |
-
searchStatus: 'idle' | 'typing' | 'loading' | 'success' | 'not_found' | 'error';
|
| 12 |
-
searchError: string | null;
|
| 13 |
-
resolvedLabel?: string;
|
| 14 |
-
onFiltersChange: (filters: FilterParams) => void;
|
| 15 |
-
onChainMetadataLoaded: (metadata: ChainMetadata) => void;
|
| 16 |
-
resolveChain: (pdb_id: string, auth_asym_id: string) => Promise<ChainMetadata>;
|
| 17 |
-
loading: boolean;
|
| 18 |
-
showResetButton?: boolean;
|
| 19 |
-
}
|
| 20 |
-
|
| 21 |
-
export const FilterPanel: React.FC<FilterPanelProps> = ({
|
| 22 |
-
filterOptions,
|
| 23 |
-
searchInputValue,
|
| 24 |
-
onSearchInputChange,
|
| 25 |
-
onSearchClear,
|
| 26 |
-
searchStatus,
|
| 27 |
-
searchError,
|
| 28 |
-
resolvedLabel,
|
| 29 |
-
onFiltersChange,
|
| 30 |
-
onChainMetadataLoaded,
|
| 31 |
-
resolveChain,
|
| 32 |
-
loading,
|
| 33 |
-
showResetButton = false,
|
| 34 |
-
}) => {
|
| 35 |
-
const handleChainSelect = (pdb_id: string, auth_asym_id: string) => {
|
| 36 |
-
onFiltersChange({ pdb_id, auth_asym_id });
|
| 37 |
-
};
|
| 38 |
-
|
| 39 |
-
if (!filterOptions) {
|
| 40 |
-
return (
|
| 41 |
-
<div className="border border-gray-200 rounded-2xl p-6 bg-white shadow-sm">
|
| 42 |
-
<div className="flex items-center justify-center min-h-[100px] text-slate-500">
|
| 43 |
-
Loading...
|
| 44 |
-
</div>
|
| 45 |
-
</div>
|
| 46 |
-
);
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
return (
|
| 50 |
-
<div className="space-y-4">
|
| 51 |
-
<div className="flex flex-col gap-4">
|
| 52 |
-
<ChainSearchInput
|
| 53 |
-
value={searchInputValue}
|
| 54 |
-
onValueChange={onSearchInputChange}
|
| 55 |
-
onChainSelect={handleChainSelect}
|
| 56 |
-
onMetadataLoaded={onChainMetadataLoaded}
|
| 57 |
-
onClear={onSearchClear}
|
| 58 |
-
resolveChain={resolveChain}
|
| 59 |
-
searchStatus={searchStatus}
|
| 60 |
-
resolvedLabel={resolvedLabel}
|
| 61 |
-
errorMessage={searchError || undefined}
|
| 62 |
-
disabled={loading}
|
| 63 |
-
/>
|
| 64 |
-
{showResetButton && (
|
| 65 |
-
<button
|
| 66 |
-
onClick={onSearchClear}
|
| 67 |
-
className="inline-flex items-center justify-center px-5 py-2.5 border border-gray-300 text-slate-900 font-medium rounded-lg hover:bg-slate-50 transition-colors duration-200 text-sm"
|
| 68 |
-
>
|
| 69 |
-
Reset Search
|
| 70 |
-
</button>
|
| 71 |
-
)}
|
| 72 |
-
</div>
|
| 73 |
-
</div>
|
| 74 |
-
);
|
| 75 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/datasets/protein/FunctionPanel.tsx
DELETED
|
@@ -1,80 +0,0 @@
|
|
| 1 |
-
import React, { useEffect, useState } from 'react';
|
| 2 |
-
import { proteinApi } from '../../../api/protein';
|
| 3 |
-
import type { RankedFunction } from '../../../types/protein';
|
| 4 |
-
|
| 5 |
-
interface FunctionPanelProps {
|
| 6 |
-
pdbId: string;
|
| 7 |
-
chainId: string;
|
| 8 |
-
}
|
| 9 |
-
|
| 10 |
-
const FunctionPanel: React.FC<FunctionPanelProps> = ({ pdbId, chainId }) => {
|
| 11 |
-
const [functions, setFunctions] = useState<RankedFunction[] | null>(null);
|
| 12 |
-
const [loading, setLoading] = useState(true);
|
| 13 |
-
const [notFound, setNotFound] = useState(false);
|
| 14 |
-
|
| 15 |
-
useEffect(() => {
|
| 16 |
-
let cancelled = false;
|
| 17 |
-
setLoading(true);
|
| 18 |
-
setNotFound(false);
|
| 19 |
-
setFunctions(null);
|
| 20 |
-
|
| 21 |
-
proteinApi.getChainFunctions(pdbId, chainId)
|
| 22 |
-
.then(fns => { if (!cancelled) setFunctions(fns); })
|
| 23 |
-
.catch(() => { if (!cancelled) setNotFound(true); })
|
| 24 |
-
.finally(() => { if (!cancelled) setLoading(false); });
|
| 25 |
-
|
| 26 |
-
return () => { cancelled = true; };
|
| 27 |
-
}, [pdbId, chainId]);
|
| 28 |
-
|
| 29 |
-
return (
|
| 30 |
-
<div className="border border-gray-200 rounded-2xl bg-white shadow-sm hover:border-slate-900 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300">
|
| 31 |
-
{/* Header */}
|
| 32 |
-
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
| 33 |
-
<h3 className="text-xl font-semibold text-slate-900">Ranked Functions</h3>
|
| 34 |
-
{functions && (
|
| 35 |
-
<span className="text-sm text-slate-500">
|
| 36 |
-
{functions.length} annotation{functions.length !== 1 ? 's' : ''}
|
| 37 |
-
</span>
|
| 38 |
-
)}
|
| 39 |
-
</div>
|
| 40 |
-
|
| 41 |
-
{/* Column header */}
|
| 42 |
-
{!loading && functions && functions.length > 0 && (
|
| 43 |
-
<div className="flex items-center gap-3 px-6 pt-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
|
| 44 |
-
<span className="shrink-0 w-5" />
|
| 45 |
-
<span style={{ flex: 1, minWidth: 0 }}>Function</span>
|
| 46 |
-
<span className="shrink-0 w-20 text-right">Score</span>
|
| 47 |
-
</div>
|
| 48 |
-
)}
|
| 49 |
-
|
| 50 |
-
{/* Scrollable body */}
|
| 51 |
-
<div className="px-6 py-4" style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '14rem' }}>
|
| 52 |
-
{loading && (
|
| 53 |
-
<p className="text-sm text-slate-500 text-center py-4">Loading functions…</p>
|
| 54 |
-
)}
|
| 55 |
-
|
| 56 |
-
{!loading && (notFound || (functions && functions.length === 0)) && (
|
| 57 |
-
<p className="text-sm text-slate-500 text-center py-4">No functional annotation available</p>
|
| 58 |
-
)}
|
| 59 |
-
|
| 60 |
-
{!loading && functions && functions.length > 0 && (
|
| 61 |
-
<ol className="space-y-3">
|
| 62 |
-
{functions.map((fn, idx) => (
|
| 63 |
-
<li key={idx} className="flex items-start gap-3 text-sm text-slate-700" style={{ minWidth: 0 }}>
|
| 64 |
-
<span className="shrink-0 mt-0.5 w-5 h-5 rounded-full bg-slate-100 text-slate-500 text-[10px] font-semibold flex items-center justify-center">
|
| 65 |
-
{idx + 1}
|
| 66 |
-
</span>
|
| 67 |
-
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={fn.function}>{fn.function}</span>
|
| 68 |
-
<span className="shrink-0 w-20 text-right font-mono text-slate-500">
|
| 69 |
-
{fn.matching_score != null ? fn.matching_score.toFixed(3) : '—'}
|
| 70 |
-
</span>
|
| 71 |
-
</li>
|
| 72 |
-
))}
|
| 73 |
-
</ol>
|
| 74 |
-
)}
|
| 75 |
-
</div>
|
| 76 |
-
</div>
|
| 77 |
-
);
|
| 78 |
-
};
|
| 79 |
-
|
| 80 |
-
export default FunctionPanel;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/datasets/protein/ProteinDashboard.tsx
DELETED
|
@@ -1,391 +0,0 @@
|
|
| 1 |
-
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
| 2 |
-
import { FilterPanel } from './FilterPanel';
|
| 3 |
-
import { StaticDistributionPlots } from './StaticDistributionPlots';
|
| 4 |
-
import { DataTable } from './DataTable';
|
| 5 |
-
import { StatsPanel } from './StatsPanel';
|
| 6 |
-
import ChainBasicInfoCards from './ChainBasicInfoCards';
|
| 7 |
-
import SequencePanel from './SequencePanel';
|
| 8 |
-
import ExperimentalConditionsPanel from './ExperimentalConditionsPanel';
|
| 9 |
-
import BindersPanel from './BindersPanel';
|
| 10 |
-
import FunctionPanel from './FunctionPanel';
|
| 11 |
-
import ErrorBoundary from './ErrorBoundary';
|
| 12 |
-
import { proteinApi } from '../../../api/protein';
|
| 13 |
-
import type { FilterParams, FilterOptions, DataResponse, ChainMetadata } from '../../../types/protein';
|
| 14 |
-
import { PageSEO } from '../../seo/PageSEO';
|
| 15 |
-
import { MuSProtJsonLd } from '../../seo/MuSProtJsonLd';
|
| 16 |
-
|
| 17 |
-
interface CacheEntry {
|
| 18 |
-
status: 'success' | 'not_found' | 'error';
|
| 19 |
-
timestamp: number;
|
| 20 |
-
message?: string;
|
| 21 |
-
metadata?: ChainMetadata;
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
const NEGATIVE_CACHE_TTL = 10 * 60 * 1000; // 10 minutes
|
| 25 |
-
|
| 26 |
-
export function ProteinDashboard() {
|
| 27 |
-
// State management
|
| 28 |
-
const [filterOptions, setFilterOptions] = useState<FilterOptions | null>(null);
|
| 29 |
-
const [filters, setFilters] = useState<FilterParams>({});
|
| 30 |
-
const [dataResponse, setDataResponse] = useState<DataResponse | null>(null);
|
| 31 |
-
|
| 32 |
-
// Search input state (controlled)
|
| 33 |
-
const [searchInputValue, setSearchInputValue] = useState('');
|
| 34 |
-
const [searchStatus, setSearchStatus] = useState<'idle' | 'typing' | 'loading' | 'success' | 'not_found' | 'error'>('idle');
|
| 35 |
-
const [searchError, setSearchError] = useState<string | null>(null);
|
| 36 |
-
|
| 37 |
-
// Chain metadata cache (client-side) — used for the searched chain's detail panels
|
| 38 |
-
const [chainMetadataCache, setChainMetadataCache] = useState<Record<string, ChainMetadata>>({});
|
| 39 |
-
|
| 40 |
-
// Request control refs
|
| 41 |
-
const negativeCache = useRef<Map<string, CacheEntry>>(new Map());
|
| 42 |
-
const inFlightResolve = useRef<{ key: string; controller: AbortController } | null>(null);
|
| 43 |
-
const lastAttemptedKey = useRef<string | null>(null);
|
| 44 |
-
|
| 45 |
-
const [, setLoadingFilters] = useState(true);
|
| 46 |
-
const [loadingData, setLoadingData] = useState(false);
|
| 47 |
-
|
| 48 |
-
const [error, setError] = useState<string | null>(null);
|
| 49 |
-
|
| 50 |
-
// Load filter options on mount
|
| 51 |
-
useEffect(() => {
|
| 52 |
-
const loadFilters = async () => {
|
| 53 |
-
try {
|
| 54 |
-
setLoadingFilters(true);
|
| 55 |
-
const options = await proteinApi.getFilters();
|
| 56 |
-
setFilterOptions(options);
|
| 57 |
-
} catch (err) {
|
| 58 |
-
setError('Failed to load filter options. The MuSProt database may still be initializing.');
|
| 59 |
-
console.error('Error loading filters:', err);
|
| 60 |
-
} finally {
|
| 61 |
-
setLoadingFilters(false);
|
| 62 |
-
}
|
| 63 |
-
};
|
| 64 |
-
|
| 65 |
-
loadFilters();
|
| 66 |
-
}, []);
|
| 67 |
-
|
| 68 |
-
const fetchData = useCallback(async () => {
|
| 69 |
-
try {
|
| 70 |
-
setLoadingData(true);
|
| 71 |
-
setError(null);
|
| 72 |
-
const response = await proteinApi.getData(filters);
|
| 73 |
-
setDataResponse(response);
|
| 74 |
-
} catch (err) {
|
| 75 |
-
setError('Failed to fetch data. Please check your filters and try again.');
|
| 76 |
-
console.error('Error fetching data:', err);
|
| 77 |
-
} finally {
|
| 78 |
-
setLoadingData(false);
|
| 79 |
-
}
|
| 80 |
-
}, [filters]);
|
| 81 |
-
|
| 82 |
-
// Debounced data fetching — skip fetchData when there's no active chain search
|
| 83 |
-
useEffect(() => {
|
| 84 |
-
const timeoutId = setTimeout(() => {
|
| 85 |
-
if (filters.pdb_id && filters.auth_asym_id) {
|
| 86 |
-
fetchData();
|
| 87 |
-
}
|
| 88 |
-
}, 500);
|
| 89 |
-
|
| 90 |
-
return () => clearTimeout(timeoutId);
|
| 91 |
-
}, [fetchData, filters.pdb_id, filters.auth_asym_id]);
|
| 92 |
-
|
| 93 |
-
const handleFiltersChange = (newFilters: FilterParams) => {
|
| 94 |
-
setFilters(newFilters);
|
| 95 |
-
};
|
| 96 |
-
|
| 97 |
-
const handleSearchInputChange = useCallback((value: string) => {
|
| 98 |
-
setSearchInputValue(value);
|
| 99 |
-
if (value && searchStatus !== 'loading') {
|
| 100 |
-
setSearchStatus('typing');
|
| 101 |
-
// Clear last attempted key when user starts typing new value
|
| 102 |
-
lastAttemptedKey.current = null;
|
| 103 |
-
} else if (!value) {
|
| 104 |
-
setSearchStatus('idle');
|
| 105 |
-
setSearchError(null);
|
| 106 |
-
}
|
| 107 |
-
}, [searchStatus]);
|
| 108 |
-
|
| 109 |
-
const handleSearchClear = useCallback(() => {
|
| 110 |
-
setSearchInputValue('');
|
| 111 |
-
setSearchStatus('idle');
|
| 112 |
-
setSearchError(null);
|
| 113 |
-
setFilters({});
|
| 114 |
-
lastAttemptedKey.current = null;
|
| 115 |
-
// Abort any in-flight request
|
| 116 |
-
if (inFlightResolve.current) {
|
| 117 |
-
inFlightResolve.current.controller.abort();
|
| 118 |
-
inFlightResolve.current = null;
|
| 119 |
-
}
|
| 120 |
-
}, []);
|
| 121 |
-
|
| 122 |
-
// Cleanup on unmount
|
| 123 |
-
useEffect(() => {
|
| 124 |
-
return () => {
|
| 125 |
-
// Abort any in-flight requests
|
| 126 |
-
if (inFlightResolve.current) {
|
| 127 |
-
inFlightResolve.current.controller.abort();
|
| 128 |
-
}
|
| 129 |
-
};
|
| 130 |
-
}, []);
|
| 131 |
-
|
| 132 |
-
const handleChainMetadataLoaded = useCallback((metadata: ChainMetadata) => {
|
| 133 |
-
const key = `${metadata.pdb_id.toLowerCase()}|${metadata.chain_id.toUpperCase()}`;
|
| 134 |
-
setChainMetadataCache(prev => ({ ...prev, [key]: metadata }));
|
| 135 |
-
setSearchStatus('success');
|
| 136 |
-
setSearchError(null);
|
| 137 |
-
|
| 138 |
-
negativeCache.current.set(key, {
|
| 139 |
-
status: 'success',
|
| 140 |
-
timestamp: Date.now(),
|
| 141 |
-
metadata
|
| 142 |
-
});
|
| 143 |
-
}, []);
|
| 144 |
-
|
| 145 |
-
const resolveChainWrapper = useCallback(async (pdb_id: string, auth_asym_id: string): Promise<ChainMetadata> => {
|
| 146 |
-
const key = `${pdb_id.toLowerCase()}|${auth_asym_id.toUpperCase()}`;
|
| 147 |
-
|
| 148 |
-
// Check if same key as last attempt (prevent duplicate requests)
|
| 149 |
-
if (key === lastAttemptedKey.current && inFlightResolve.current?.key === key) {
|
| 150 |
-
throw new Error('Request already in flight');
|
| 151 |
-
}
|
| 152 |
-
|
| 153 |
-
// Check positive cache first
|
| 154 |
-
if (chainMetadataCache[key]) {
|
| 155 |
-
setSearchStatus('success');
|
| 156 |
-
return chainMetadataCache[key];
|
| 157 |
-
}
|
| 158 |
-
|
| 159 |
-
// Check negative cache
|
| 160 |
-
const cachedEntry = negativeCache.current.get(key);
|
| 161 |
-
if (cachedEntry) {
|
| 162 |
-
const age = Date.now() - cachedEntry.timestamp;
|
| 163 |
-
if (age < NEGATIVE_CACHE_TTL) {
|
| 164 |
-
if (cachedEntry.status === 'not_found') {
|
| 165 |
-
setSearchStatus('not_found');
|
| 166 |
-
setSearchError(cachedEntry.message || `No chain '${auth_asym_id}' found in PDB ${pdb_id.toUpperCase()}. Check the chain ID or entry.`);
|
| 167 |
-
const error: any = new Error('Chain not found');
|
| 168 |
-
error.code = 'NOT_FOUND';
|
| 169 |
-
throw error;
|
| 170 |
-
} else if (cachedEntry.status === 'error') {
|
| 171 |
-
setSearchStatus('error');
|
| 172 |
-
setSearchError(cachedEntry.message || 'Failed to resolve chain');
|
| 173 |
-
throw new Error(cachedEntry.message || 'Failed to resolve chain');
|
| 174 |
-
}
|
| 175 |
-
} else {
|
| 176 |
-
// TTL expired, remove from cache
|
| 177 |
-
negativeCache.current.delete(key);
|
| 178 |
-
}
|
| 179 |
-
}
|
| 180 |
-
|
| 181 |
-
// Abort previous request if different key
|
| 182 |
-
if (inFlightResolve.current && inFlightResolve.current.key !== key) {
|
| 183 |
-
inFlightResolve.current.controller.abort();
|
| 184 |
-
inFlightResolve.current = null;
|
| 185 |
-
}
|
| 186 |
-
|
| 187 |
-
// Start new request
|
| 188 |
-
lastAttemptedKey.current = key;
|
| 189 |
-
const controller = new AbortController();
|
| 190 |
-
inFlightResolve.current = { key, controller };
|
| 191 |
-
|
| 192 |
-
setSearchStatus('loading');
|
| 193 |
-
setSearchError(null);
|
| 194 |
-
|
| 195 |
-
try {
|
| 196 |
-
const metadata = await proteinApi.resolveChain(pdb_id, auth_asym_id, { signal: controller.signal });
|
| 197 |
-
|
| 198 |
-
// Only update state if not aborted
|
| 199 |
-
if (inFlightResolve.current?.key === key) {
|
| 200 |
-
inFlightResolve.current = null;
|
| 201 |
-
}
|
| 202 |
-
|
| 203 |
-
return metadata;
|
| 204 |
-
} catch (err: any) {
|
| 205 |
-
// Ignore aborted requests
|
| 206 |
-
if (err.name === 'AbortError' || err.name === 'CanceledError') {
|
| 207 |
-
return Promise.reject(err);
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
// Clear in-flight if this was our request
|
| 211 |
-
if (inFlightResolve.current?.key === key) {
|
| 212 |
-
inFlightResolve.current = null;
|
| 213 |
-
}
|
| 214 |
-
|
| 215 |
-
// Handle 404 not found
|
| 216 |
-
if (err.code === 'NOT_FOUND' || err.status === 404) {
|
| 217 |
-
const message = err.detail || `No chain '${auth_asym_id}' found in PDB ${pdb_id.toUpperCase()}. Check the chain ID or entry.`;
|
| 218 |
-
negativeCache.current.set(key, {
|
| 219 |
-
status: 'not_found',
|
| 220 |
-
timestamp: Date.now(),
|
| 221 |
-
message
|
| 222 |
-
});
|
| 223 |
-
setSearchStatus('not_found');
|
| 224 |
-
setSearchError(message);
|
| 225 |
-
} else {
|
| 226 |
-
// Other errors
|
| 227 |
-
const message = err.response?.data?.detail || err.message || 'Failed to resolve chain';
|
| 228 |
-
negativeCache.current.set(key, {
|
| 229 |
-
status: 'error',
|
| 230 |
-
timestamp: Date.now(),
|
| 231 |
-
message
|
| 232 |
-
});
|
| 233 |
-
setSearchStatus('error');
|
| 234 |
-
setSearchError(message);
|
| 235 |
-
}
|
| 236 |
-
|
| 237 |
-
throw err;
|
| 238 |
-
}
|
| 239 |
-
}, [chainMetadataCache]);
|
| 240 |
-
|
| 241 |
-
// exptl_method, temp, pH are now included directly in each record from the DB
|
| 242 |
-
const enrichedData = useMemo(() => dataResponse?.data ?? [], [dataResponse?.data]);
|
| 243 |
-
|
| 244 |
-
// Determine if we have an active chain search
|
| 245 |
-
const hasActiveChainSearch = filters.pdb_id && filters.auth_asym_id;
|
| 246 |
-
const activeChainKey = hasActiveChainSearch
|
| 247 |
-
? `${filters.pdb_id!.toLowerCase()}|${filters.auth_asym_id!.toUpperCase()}`
|
| 248 |
-
: null;
|
| 249 |
-
const activeChainMetadata = activeChainKey ? chainMetadataCache[activeChainKey] : null;
|
| 250 |
-
|
| 251 |
-
// Generate resolved label for success message
|
| 252 |
-
const resolvedLabel = hasActiveChainSearch
|
| 253 |
-
? `Found ${filters.pdb_id!.toUpperCase()} Chain ${filters.auth_asym_id!.toUpperCase()}`
|
| 254 |
-
: undefined;
|
| 255 |
-
|
| 256 |
-
return (
|
| 257 |
-
<div className="protein-dashboard min-h-screen bg-white">
|
| 258 |
-
<PageSEO
|
| 259 |
-
title="MuSProt: Multistate Protein Structure Database"
|
| 260 |
-
description="Explore 355,731 protein chains from 121,584 PDB entries with pairwise structural comparisons, energy scoring, and CATH functional annotations. Download the full SQLite database."
|
| 261 |
-
path="/"
|
| 262 |
-
/>
|
| 263 |
-
<MuSProtJsonLd />
|
| 264 |
-
|
| 265 |
-
{error && (
|
| 266 |
-
<div className="max-w-7xl mx-auto px-6 mt-4">
|
| 267 |
-
<div className="protein-error-banner">
|
| 268 |
-
<span>⚠️ {error}</span>
|
| 269 |
-
<button onClick={() => setError(null)}>×</button>
|
| 270 |
-
</div>
|
| 271 |
-
</div>
|
| 272 |
-
)}
|
| 273 |
-
|
| 274 |
-
<div className="max-w-7xl mx-auto px-6 py-6">
|
| 275 |
-
<main className="space-y-6">
|
| 276 |
-
{hasActiveChainSearch && activeChainMetadata ? (
|
| 277 |
-
<>
|
| 278 |
-
{/* First Row: Search + Chain Information (3:7 in landscape, stacked in portrait) */}
|
| 279 |
-
<div className="grid gap-6 items-stretch chain-detail-grid">
|
| 280 |
-
<section className="min-w-0 h-full border border-gray-200 rounded-2xl p-6 bg-white shadow-sm hover:border-slate-900 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300">
|
| 281 |
-
<FilterPanel
|
| 282 |
-
filterOptions={filterOptions}
|
| 283 |
-
filters={filters}
|
| 284 |
-
searchInputValue={searchInputValue}
|
| 285 |
-
onSearchInputChange={handleSearchInputChange}
|
| 286 |
-
onSearchClear={handleSearchClear}
|
| 287 |
-
searchStatus={searchStatus}
|
| 288 |
-
searchError={searchError}
|
| 289 |
-
resolvedLabel={resolvedLabel}
|
| 290 |
-
onFiltersChange={handleFiltersChange}
|
| 291 |
-
onChainMetadataLoaded={handleChainMetadataLoaded}
|
| 292 |
-
resolveChain={resolveChainWrapper}
|
| 293 |
-
loading={loadingData}
|
| 294 |
-
showResetButton={true}
|
| 295 |
-
/>
|
| 296 |
-
</section>
|
| 297 |
-
|
| 298 |
-
<section className="min-w-0 h-full flex">
|
| 299 |
-
<ErrorBoundary componentName="Chain Information">
|
| 300 |
-
<ChainBasicInfoCards metadata={activeChainMetadata} />
|
| 301 |
-
</ErrorBoundary>
|
| 302 |
-
</section>
|
| 303 |
-
</div>
|
| 304 |
-
|
| 305 |
-
{/* Second Row: Sequence, Experimental Conditions, Binders (3 columns, equal height) */}
|
| 306 |
-
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 307 |
-
<div className="min-w-0 border border-gray-200 rounded-2xl p-6 bg-white shadow-sm hover:border-slate-900 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300">
|
| 308 |
-
<ErrorBoundary componentName="Sequence Panel">
|
| 309 |
-
<SequencePanel metadata={activeChainMetadata} />
|
| 310 |
-
</ErrorBoundary>
|
| 311 |
-
</div>
|
| 312 |
-
<div className="min-w-0 border border-gray-200 rounded-2xl p-6 bg-white shadow-sm hover:border-slate-900 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300">
|
| 313 |
-
<ErrorBoundary componentName="Experimental Conditions">
|
| 314 |
-
<ExperimentalConditionsPanel metadata={activeChainMetadata} />
|
| 315 |
-
</ErrorBoundary>
|
| 316 |
-
</div>
|
| 317 |
-
{activeChainMetadata.nonpolymer_entities && activeChainMetadata.nonpolymer_entities.length > 0 && (
|
| 318 |
-
<div className="min-w-0 border border-gray-200 rounded-2xl p-6 bg-white shadow-sm hover:border-slate-900 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300">
|
| 319 |
-
<ErrorBoundary componentName="Binders Panel">
|
| 320 |
-
<BindersPanel metadata={activeChainMetadata} />
|
| 321 |
-
</ErrorBoundary>
|
| 322 |
-
</div>
|
| 323 |
-
)}
|
| 324 |
-
</div>
|
| 325 |
-
|
| 326 |
-
{/* Functions block — full width, between panels and Results */}
|
| 327 |
-
<ErrorBoundary componentName="Function Panel">
|
| 328 |
-
<FunctionPanel pdbId={filters.pdb_id!} chainId={filters.auth_asym_id!} />
|
| 329 |
-
</ErrorBoundary>
|
| 330 |
-
</>
|
| 331 |
-
) : (
|
| 332 |
-
<>
|
| 333 |
-
{/* Brief database introduction — borderless, no frame */}
|
| 334 |
-
<section className="px-1">
|
| 335 |
-
<h1 className="text-2xl font-semibold text-slate-900">MuSProt — Multistate Protein Database</h1>
|
| 336 |
-
<p className="mt-2 text-slate-600 leading-relaxed">
|
| 337 |
-
<span className="font-semibold text-slate-900">MuSProt</span> (Multistate Protein Database) is a
|
| 338 |
-
million-scale multimodal database for multistate proteins, designed to support programmable protein
|
| 339 |
-
design and AI model development. It links experimentally observed conformational states of identical
|
| 340 |
-
protein sequences from the PDB, organizes them into state clusters and transition relationships, and
|
| 341 |
-
enriches each record with structural similarity, experimental context, state-specific function
|
| 342 |
-
rankings and transition fidelity labels. Users can search, browse and download MuSProt records to
|
| 343 |
-
study conformational diversity, state-dependent functions and feasible protein state transitions.
|
| 344 |
-
</p>
|
| 345 |
-
</section>
|
| 346 |
-
|
| 347 |
-
{/* Search Panel Full Width */}
|
| 348 |
-
<section className="border border-gray-200 rounded-2xl p-6 bg-white shadow-sm hover:border-slate-900 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300">
|
| 349 |
-
<FilterPanel
|
| 350 |
-
filterOptions={filterOptions}
|
| 351 |
-
filters={filters}
|
| 352 |
-
searchInputValue={searchInputValue}
|
| 353 |
-
onSearchInputChange={handleSearchInputChange}
|
| 354 |
-
onSearchClear={handleSearchClear}
|
| 355 |
-
searchStatus={searchStatus}
|
| 356 |
-
searchError={searchError}
|
| 357 |
-
resolvedLabel={resolvedLabel}
|
| 358 |
-
onFiltersChange={handleFiltersChange}
|
| 359 |
-
onChainMetadataLoaded={handleChainMetadataLoaded}
|
| 360 |
-
resolveChain={resolveChainWrapper}
|
| 361 |
-
loading={loadingData}
|
| 362 |
-
showResetButton={false}
|
| 363 |
-
/>
|
| 364 |
-
</section>
|
| 365 |
-
|
| 366 |
-
{/* Default Dataset Summary View */}
|
| 367 |
-
<section className="border border-gray-200 rounded-2xl p-6 bg-white shadow-sm hover:border-slate-900 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300">
|
| 368 |
-
<StatsPanel />
|
| 369 |
-
</section>
|
| 370 |
-
|
| 371 |
-
<section>
|
| 372 |
-
<StaticDistributionPlots />
|
| 373 |
-
</section>
|
| 374 |
-
</>
|
| 375 |
-
)}
|
| 376 |
-
|
| 377 |
-
{hasActiveChainSearch && (
|
| 378 |
-
<section className="protein-table-section">
|
| 379 |
-
<DataTable
|
| 380 |
-
data={enrichedData}
|
| 381 |
-
loading={loadingData}
|
| 382 |
-
total={dataResponse?.total || 0}
|
| 383 |
-
filtered={dataResponse?.filtered || 0}
|
| 384 |
-
/>
|
| 385 |
-
</section>
|
| 386 |
-
)}
|
| 387 |
-
</main>
|
| 388 |
-
</div>
|
| 389 |
-
</div>
|
| 390 |
-
);
|
| 391 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/datasets/protein/SequencePanel.tsx
DELETED
|
@@ -1,137 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* Sequence display panel for a selected chain
|
| 3 |
-
* Shows sequence length and sequence with copy/download features
|
| 4 |
-
*/
|
| 5 |
-
import React, { useState } from 'react';
|
| 6 |
-
import { ChainMetadata } from '../../../types/protein';
|
| 7 |
-
import { formatText, formatNumber } from '../../../utils/format';
|
| 8 |
-
|
| 9 |
-
interface SequencePanelProps {
|
| 10 |
-
metadata: ChainMetadata | null;
|
| 11 |
-
isLoading?: boolean;
|
| 12 |
-
}
|
| 13 |
-
|
| 14 |
-
const SequencePanel: React.FC<SequencePanelProps> = ({ metadata, isLoading }) => {
|
| 15 |
-
const [copied, setCopied] = useState(false);
|
| 16 |
-
const [showAll, setShowAll] = useState(false);
|
| 17 |
-
|
| 18 |
-
const sequence = metadata?.polymer_entity?.seq_can;
|
| 19 |
-
const sequenceLength = metadata?.polymer_entity?.sequence_length;
|
| 20 |
-
|
| 21 |
-
// Format sequence with line breaks every 60 chars
|
| 22 |
-
const formatSequence = (seq: string): string[] => {
|
| 23 |
-
if (!seq) return [];
|
| 24 |
-
const lines = [];
|
| 25 |
-
for (let i = 0; i < seq.length; i += 60) {
|
| 26 |
-
lines.push(seq.substring(i, i + 60));
|
| 27 |
-
}
|
| 28 |
-
return lines;
|
| 29 |
-
};
|
| 30 |
-
|
| 31 |
-
const handleCopy = async () => {
|
| 32 |
-
if (sequence) {
|
| 33 |
-
try {
|
| 34 |
-
await navigator.clipboard.writeText(sequence);
|
| 35 |
-
setCopied(true);
|
| 36 |
-
setTimeout(() => setCopied(false), 2000);
|
| 37 |
-
} catch (err) {
|
| 38 |
-
console.error('Failed to copy:', err);
|
| 39 |
-
}
|
| 40 |
-
}
|
| 41 |
-
};
|
| 42 |
-
|
| 43 |
-
const handleDownload = () => {
|
| 44 |
-
if (!sequence || !metadata) return;
|
| 45 |
-
|
| 46 |
-
const pdbId = formatText(metadata.pdb_id || metadata.entry?.rcsb_id, 'unknown');
|
| 47 |
-
const chainId = formatText(metadata.chain_id || metadata.polymer_entity_instance?.auth_asym_id, 'X');
|
| 48 |
-
const title = formatText(metadata.entry?.title, 'Unknown protein');
|
| 49 |
-
|
| 50 |
-
const fasta = `>${pdbId}:${chainId} ${title}\n${sequence}`;
|
| 51 |
-
const blob = new Blob([fasta], { type: 'text/plain' });
|
| 52 |
-
const url = URL.createObjectURL(blob);
|
| 53 |
-
const a = document.createElement('a');
|
| 54 |
-
a.href = url;
|
| 55 |
-
a.download = `${pdbId}_${chainId}.fasta`;
|
| 56 |
-
a.click();
|
| 57 |
-
URL.revokeObjectURL(url);
|
| 58 |
-
};
|
| 59 |
-
|
| 60 |
-
if (isLoading) {
|
| 61 |
-
return (
|
| 62 |
-
<section className="space-y-4">
|
| 63 |
-
<h3 className="text-xl font-semibold text-slate-900">Amino Acid Sequence</h3>
|
| 64 |
-
<div className="text-center py-10 text-slate-600">Loading sequence...</div>
|
| 65 |
-
</section>
|
| 66 |
-
);
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
if (!sequence) {
|
| 70 |
-
return (
|
| 71 |
-
<section className="space-y-4">
|
| 72 |
-
<h3 className="text-xl font-semibold text-slate-900">Amino Acid Sequence</h3>
|
| 73 |
-
<div className="text-center py-10 text-slate-600">No sequence data available</div>
|
| 74 |
-
</section>
|
| 75 |
-
);
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
const lines = formatSequence(sequence);
|
| 79 |
-
const displayLines = showAll ? lines : lines.slice(0, 10);
|
| 80 |
-
const hasMore = lines.length > 10;
|
| 81 |
-
const formattedLength = formatNumber(sequenceLength, { decimals: 0 });
|
| 82 |
-
|
| 83 |
-
return (
|
| 84 |
-
<section className="space-y-4">
|
| 85 |
-
{/* Header + Actions */}
|
| 86 |
-
<div className="flex justify-between items-start">
|
| 87 |
-
<div>
|
| 88 |
-
<h3 className="text-xl font-semibold text-slate-900 mb-1">Amino Acid Sequence</h3>
|
| 89 |
-
{formattedLength !== '—' && (
|
| 90 |
-
<div className="text-sm text-slate-600">
|
| 91 |
-
<span className="font-medium">{formattedLength}</span> residues
|
| 92 |
-
</div>
|
| 93 |
-
)}
|
| 94 |
-
</div>
|
| 95 |
-
<div className="flex gap-2">
|
| 96 |
-
<button
|
| 97 |
-
className="inline-flex items-center gap-1.5 px-3 py-1.5 border border-gray-300 text-slate-900 font-medium rounded-lg hover:bg-slate-50 transition-colors text-xs"
|
| 98 |
-
onClick={handleCopy}
|
| 99 |
-
title="Copy to clipboard"
|
| 100 |
-
>
|
| 101 |
-
{copied ? '✓' : '📋'}
|
| 102 |
-
</button>
|
| 103 |
-
<button
|
| 104 |
-
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-900 text-white font-medium rounded-lg hover:bg-slate-800 transition-colors text-xs"
|
| 105 |
-
onClick={handleDownload}
|
| 106 |
-
title="Download FASTA"
|
| 107 |
-
>
|
| 108 |
-
⬇ FASTA
|
| 109 |
-
</button>
|
| 110 |
-
</div>
|
| 111 |
-
</div>
|
| 112 |
-
|
| 113 |
-
{/* Sequence Display */}
|
| 114 |
-
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 font-mono text-xs max-h-80 overflow-y-auto overflow-x-hidden">
|
| 115 |
-
{displayLines.map((line, idx) => (
|
| 116 |
-
<div key={idx} className="flex mb-1 leading-relaxed">
|
| 117 |
-
<span className="text-gray-400 mr-4 min-w-[50px] shrink-0 text-right select-none">{idx * 60 + 1}</span>
|
| 118 |
-
<span className="text-gray-800 tracking-wide wrap-anywhere">{line}</span>
|
| 119 |
-
</div>
|
| 120 |
-
))}
|
| 121 |
-
</div>
|
| 122 |
-
|
| 123 |
-
{hasMore && (
|
| 124 |
-
<div className="text-center">
|
| 125 |
-
<button
|
| 126 |
-
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 text-slate-900 font-medium rounded-lg hover:bg-slate-50 transition-colors text-sm"
|
| 127 |
-
onClick={() => setShowAll(!showAll)}
|
| 128 |
-
>
|
| 129 |
-
{showAll ? '▲ Show less' : `▼ Show all (${lines.length - 10} more lines)`}
|
| 130 |
-
</button>
|
| 131 |
-
</div>
|
| 132 |
-
)}
|
| 133 |
-
</section>
|
| 134 |
-
);
|
| 135 |
-
};
|
| 136 |
-
|
| 137 |
-
export default SequencePanel;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/datasets/protein/StaticDistributionPlots.tsx
DELETED
|
@@ -1,34 +0,0 @@
|
|
| 1 |
-
import React from 'react';
|
| 2 |
-
|
| 3 |
-
const IMG_BASE = '/api/protein/plots';
|
| 4 |
-
const CACHE_BUST = '?v=20260625';
|
| 5 |
-
|
| 6 |
-
const plots = [
|
| 7 |
-
{ name: 'Structure Similarity', file: 'Structure-similarity.png' },
|
| 8 |
-
{ name: 'Function Rank Correlation vs. Structure Similarity', file: 'Function-rank-correlation.png' },
|
| 9 |
-
{ name: 'Fidelity Distribution', file: 'Fidelity-distribution.png' },
|
| 10 |
-
{ name: 'Unique State Cluster Distribution', file: 'Unique-state-cluster-distribution.png' },
|
| 11 |
-
];
|
| 12 |
-
|
| 13 |
-
export const StaticDistributionPlots: React.FC = () => {
|
| 14 |
-
return (
|
| 15 |
-
<div style={{ columns: '400px', columnGap: '1.5rem' }}>
|
| 16 |
-
{plots.map(({ name, file }) => (
|
| 17 |
-
<div
|
| 18 |
-
key={file}
|
| 19 |
-
style={{ breakInside: 'avoid', marginBottom: '1.5rem' }}
|
| 20 |
-
className="border border-gray-200 rounded-2xl bg-white shadow-sm hover:border-slate-900 hover:shadow-lg hover:-translate-y-0.5 transition-all duration-300 overflow-hidden"
|
| 21 |
-
>
|
| 22 |
-
<div className="px-4 pt-5 pb-2 text-xl font-semibold text-slate-900">{name}</div>
|
| 23 |
-
<div className="px-4 pb-4">
|
| 24 |
-
<img
|
| 25 |
-
src={`${IMG_BASE}/${file}${CACHE_BUST}`}
|
| 26 |
-
alt={name}
|
| 27 |
-
style={{ width: '100%', height: 'auto', display: 'block' }}
|
| 28 |
-
/>
|
| 29 |
-
</div>
|
| 30 |
-
</div>
|
| 31 |
-
))}
|
| 32 |
-
</div>
|
| 33 |
-
);
|
| 34 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/datasets/protein/StatsPanel.css
DELETED
|
@@ -1,52 +0,0 @@
|
|
| 1 |
-
.stats-panel {
|
| 2 |
-
display: grid;
|
| 3 |
-
grid-template-columns: repeat(2, 1fr);
|
| 4 |
-
gap: 15px;
|
| 5 |
-
padding: 0;
|
| 6 |
-
}
|
| 7 |
-
|
| 8 |
-
.stat-card {
|
| 9 |
-
background: white;
|
| 10 |
-
padding: 10px 20px;
|
| 11 |
-
border-radius: 8px;
|
| 12 |
-
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
| 13 |
-
text-align: center;
|
| 14 |
-
transition: transform 0.2s, box-shadow 0.2s;
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
.stat-card:hover {
|
| 18 |
-
transform: translateY(-2px);
|
| 19 |
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
.stat-value {
|
| 23 |
-
font-size: 1.6rem;
|
| 24 |
-
font-weight: 700;
|
| 25 |
-
color: #2196f3;
|
| 26 |
-
margin-bottom: 2px;
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
.stat-label {
|
| 30 |
-
font-size: 0.9rem;
|
| 31 |
-
color: #666;
|
| 32 |
-
text-transform: uppercase;
|
| 33 |
-
letter-spacing: 0.5px;
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
.stats-loading {
|
| 37 |
-
display: flex;
|
| 38 |
-
align-items: center;
|
| 39 |
-
justify-content: center;
|
| 40 |
-
height: 100px;
|
| 41 |
-
color: #888;
|
| 42 |
-
font-size: 1.1rem;
|
| 43 |
-
background: white;
|
| 44 |
-
border-radius: 8px;
|
| 45 |
-
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
@media (max-width: 768px) {
|
| 49 |
-
.stats-panel {
|
| 50 |
-
grid-template-columns: repeat(2, 1fr);
|
| 51 |
-
}
|
| 52 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/datasets/protein/StatsPanel.tsx
DELETED
|
@@ -1,38 +0,0 @@
|
|
| 1 |
-
import React from 'react';
|
| 2 |
-
import './StatsPanel.css';
|
| 3 |
-
|
| 4 |
-
export const StatsPanel: React.FC = () => {
|
| 5 |
-
return (
|
| 6 |
-
<div className="stats-panel">
|
| 7 |
-
<div className="stat-card">
|
| 8 |
-
<div className="stat-value">102,790</div>
|
| 9 |
-
<div className="stat-label">Sequences with multiple conformational observations</div>
|
| 10 |
-
</div>
|
| 11 |
-
|
| 12 |
-
<div className="stat-card">
|
| 13 |
-
<div className="stat-value">24,796</div>
|
| 14 |
-
<div className="stat-label">Sequences with multiple state clusters</div>
|
| 15 |
-
</div>
|
| 16 |
-
|
| 17 |
-
<div className="stat-card">
|
| 18 |
-
<div className="stat-value">863,643</div>
|
| 19 |
-
<div className="stat-label">Conformational observations</div>
|
| 20 |
-
</div>
|
| 21 |
-
|
| 22 |
-
<div className="stat-card">
|
| 23 |
-
<div className="stat-value">163,869</div>
|
| 24 |
-
<div className="stat-label">Unique state clusters</div>
|
| 25 |
-
</div>
|
| 26 |
-
|
| 27 |
-
<div className="stat-card">
|
| 28 |
-
<div className="stat-value">113.6 million</div>
|
| 29 |
-
<div className="stat-label">Transition triplets between conformational observations</div>
|
| 30 |
-
</div>
|
| 31 |
-
|
| 32 |
-
<div className="stat-card">
|
| 33 |
-
<div className="stat-value">24.95 million</div>
|
| 34 |
-
<div className="stat-label">Transition triplets between distinct states</div>
|
| 35 |
-
</div>
|
| 36 |
-
</div>
|
| 37 |
-
);
|
| 38 |
-
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|