WinslowFan Claude Opus 5.5 commited on
Commit
dd079af
·
1 Parent(s): 8959aee

Redesign the MuSProt website for the new database release

Browse files

New 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
Files changed (50) hide show
  1. README.md +11 -5
  2. backend/app/protein/api/routes/explore.py +143 -0
  3. backend/app/protein/catalog.py +467 -0
  4. backend/app/protein/config.py +9 -9
  5. backend/app/protein/data_loader.py +15 -8
  6. backend/app/protein/models.py +144 -144
  7. backend/app/protein/records.py +270 -0
  8. backend/app/protein/tsv_loader.py +11 -10
  9. backend/assets/MuSProt_documentation.md +189 -92
  10. backend/assets/musprot_summary.json +25 -25
  11. backend/scripts/build_index.py +656 -0
  12. backend/scripts/generate_node_lookup.py +0 -68
  13. backend/space_app.py +4 -0
  14. frontend/index.html +21 -16
  15. frontend/package-lock.json +50 -0
  16. frontend/package.json +2 -0
  17. frontend/public/favicon.svg +1 -0
  18. frontend/public/sitemap.xml +10 -0
  19. frontend/scripts/prerender.mjs +14 -4
  20. frontend/src/App.tsx +52 -10
  21. frontend/src/api/protein.ts +0 -122
  22. frontend/src/components/Domains.tsx +151 -0
  23. frontend/src/components/ForceGraph.tsx +357 -0
  24. frontend/src/components/HeroArt.tsx +62 -0
  25. frontend/src/components/KnowledgeGraph.tsx +322 -0
  26. frontend/src/components/Layout.tsx +134 -0
  27. frontend/src/components/PageMeta.tsx +58 -0
  28. frontend/src/components/Panels.tsx +142 -0
  29. frontend/src/components/SearchBox.tsx +189 -0
  30. frontend/src/components/SequenceTable.tsx +113 -0
  31. frontend/src/components/StateGlyph.tsx +56 -0
  32. frontend/src/components/StateViews.tsx +229 -0
  33. frontend/src/components/StructureViewer.tsx +136 -0
  34. frontend/src/components/Transitions.tsx +489 -0
  35. frontend/src/components/charts.tsx +349 -0
  36. frontend/src/components/datasets/protein/BindersPanel.tsx +0 -108
  37. frontend/src/components/datasets/protein/ChainBasicInfoCards.tsx +0 -146
  38. frontend/src/components/datasets/protein/ChainSearchInput.tsx +0 -204
  39. frontend/src/components/datasets/protein/DataTable.css +0 -177
  40. frontend/src/components/datasets/protein/DataTable.tsx +0 -214
  41. frontend/src/components/datasets/protein/ErrorBoundary.css +0 -93
  42. frontend/src/components/datasets/protein/ErrorBoundary.tsx +0 -105
  43. frontend/src/components/datasets/protein/ExperimentalConditionsPanel.tsx +0 -117
  44. frontend/src/components/datasets/protein/FilterPanel.tsx +0 -75
  45. frontend/src/components/datasets/protein/FunctionPanel.tsx +0 -80
  46. frontend/src/components/datasets/protein/ProteinDashboard.tsx +0 -391
  47. frontend/src/components/datasets/protein/SequencePanel.tsx +0 -137
  48. frontend/src/components/datasets/protein/StaticDistributionPlots.tsx +0 -34
  49. frontend/src/components/datasets/protein/StatsPanel.css +0 -52
  50. 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-node-lookup.db
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
- MUSPROT_NODE_DB_PATH=/data/MuSProt-node-lookup.db
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`, regenerate the bundled summary sidecar:
 
 
65
 
66
  ```bash
67
- cd backend
68
- python scripts/generate_summary.py /path/to/MuSProt.db -o assets/musprot_summary.json
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 get_node_database_path() -> Path:
70
- """Return an optional indexed node lookup sidecar, falling back to the main DB."""
71
- explicit_path = os.getenv("MUSPROT_NODE_DB_PATH")
72
  if explicit_path:
73
  path = Path(explicit_path).expanduser().resolve()
74
- if path.exists():
75
- return path
76
 
77
- return (
78
- _existing_path(DEFAULT_VOLUME_DIR / "MuSProt-node-lookup.db")
79
- or get_database_path()
80
- ).resolve()
 
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(TM1 AS REAL)), MAX(CAST(TM1 AS REAL)),"
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(TM1 AS REAL) >= ?")
165
  params.append(tm_min)
166
  if tm_max is not None:
167
- where_clauses.append("CAST(TM1 AS REAL) <= ?")
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.TM1 AS REAL) AS TM1,
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(TM1 AS REAL) >= ?")
253
  params.append(tm_min)
254
  if tm_max is not None:
255
- where_clauses.append("CAST(TM1 AS REAL) <= ?")
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(TM1 AS REAL))"
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
- "TM1",
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, get_node_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 without blocking on a full-table in-memory index."""
87
- conn = connect_readonly(get_node_database_path())
 
 
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 LOWER(pdb_id) = ? AND UPPER(auth_asym_id) = ? LIMIT 1',
95
- (pdb_id.lower(), auth_asym_id.upper()),
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. Users can search, browse and download MuSProt records to study conformational diversity, state-dependent functions and feasible protein state transitions.
4
 
5
  ---
6
 
7
- ## Download
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
- The full dataset is distributed as a single SQLite database file (`MuSProt.db`, ~31 GB). Download it from the dataset page using the **Download DB** button.
10
 
11
- For detailed protein structures and atom coordinates, users are expected to download [Protein Data Bank (PDB)](https://rcsb.org) and fetch from `mmCIF` or `.pdb` files based on the PDB entry and chain ID.
 
 
 
 
 
 
 
 
 
 
12
 
13
  ---
14
 
15
- ## Database Schema
 
 
16
 
17
- The database contains two tables: **`node`** and **`edge`**.
18
 
19
  ---
20
 
 
 
21
  ### Table: `node`
22
 
23
- Each row represents a single protein chain instance (one PDB entry + chain).
24
 
25
  | Column | Type | Description |
26
  |---|---|---|
27
- | `sequence_id` | TEXT | Internal sequence cluster identifier (e.g. `SEQ087470`); shared by chains with the same sequence |
28
- | `uniprot_id` | TEXT | UniProt accession number |
29
  | `pdb_id` | TEXT | 4-character PDB entry ID (lowercase) |
30
- | `auth_asym_id` | TEXT | Author chain identifier (e.g. `A`) |
31
- | `sequence` | TEXT | SEQRES amino acid sequence |
32
- | `sequence_length` | INT | Number of residues in the SEQRES sequence |
33
- | `modeled_sequence` | TEXT | Amino acid sequence actually resolved (modeled) in the structure |
34
- | `modeled_sequence_length` | INT | Number of modeled residues |
35
- | `binders` | TEXT | Bound ligand / small-molecule component IDs present in the structure; empty when none |
36
- | `binding_status` | TEXT | Ligand-binding state of the chain: `apo` or `holo` |
37
- | `experimental_method` | TEXT | Structure determination method (e.g. `X-RAY DIFFRACTION`, `ELECTRON MICROSCOPY`, `SOLUTION NMR`) |
38
- | `resolution` | FLOAT | Experimental resolution in Å (lower is sharper; empty for methods without a resolution) |
39
- | `pH` | FLOAT | pH of the experimental / crystallization condition |
40
- | `temp_K` | FLOAT | Temperature of the experiment, in Kelvin |
41
- | `experimental_details` | TEXT | Free-text crystallization / sample-preparation details |
42
- | `chain_composition` | TEXT | Quaternary composition of the deposited assembly: `monomeric`, `homomeric`, or `heteromeric` |
43
- | `non_protein_polymer_binding` | TEXT | Non-protein polymer bound in the structure (`DNA`, `RNA`, or `DNA/RNA`); `None` when absent |
44
- | `initial_release_date` | TEXT | PDB entry initial release date (`YYYY-MM-DD`) |
45
- | `cath_id` | TEXT | CATH domain assignment; empty when unassigned |
46
- | `cath_superfamily` | TEXT | Full CATH superfamily code (e.g. `1.10.10.10`) |
47
- | `state_id` | TEXT | Conformational state cluster this chain is assigned to (`0`, `1`, `2`, …) |
48
- | `Rosetta` | FLOAT | Rosetta total energy score |
49
- | `FoldX` | FLOAT | FoldX total energy score |
50
- | `EvoEF2` | FLOAT | EvoEF2 total energy score |
51
- | `RW` | FLOAT | Random-Walk (RW) energy score |
52
- | `RW+` | FLOAT | Random-Walk+ (RWplus) energy score |
53
- | `ranked_functions` | TEXT | JSON-encoded list of ranked GO/functional annotations |
54
- | `matching_scores` | TEXT | JSON-encoded list of function-matching scores, aligned with `ranked_functions` |
55
-
56
-
57
- ---
 
 
 
 
 
 
 
 
 
 
 
58
 
59
  ### Table: `edge`
60
 
61
- Each row represents a pairwise structural comparison between two chain instances (A and B) that share the same UniProt identity.
62
 
63
  | Column | Type | Description |
64
  |---|---|---|
65
- | `pdb_id_A` | TEXT | PDB ID of chain A |
66
- | `auth_asym_id_A` | TEXT | Chain identifier of chain A |
67
- | `pdb_id_B` | TEXT | PDB ID of chain B |
68
- | `auth_asym_id_B` | TEXT | Chain identifier of chain B |
69
- | `TM1` | FLOAT | TM-score of the alignment (chain A as reference) |
70
- | `RMSD` | FLOAT | Root-mean-square deviation of Cα atoms (Å) |
71
- | `pair_similarity` | FLOAT | Composite structural similarity score for the A–B pair |
72
- | `delta_Rosetta` | FLOAT | Rosetta energy difference (B − A) |
73
- | `delta_FoldX` | FLOAT | FoldX energy difference (B − A) |
74
- | `delta_EvoEF2` | FLOAT | EvoEF2 energy difference (B − A) |
75
- | `delta_RW` | FLOAT | Random-Walk (RW) energy difference (B − A) |
76
- | `delta_RW+` | FLOAT | Random-Walk+ (RWplus) energy difference (B − A) |
77
- | `state_id_A` | TEXT | Conformational state cluster of chain A |
78
- | `state_id_B` | TEXT | Conformational state cluster of chain B |
79
- | `state_similarity` | TEXT | Average structural similarity within the state cluster |
80
- | `state_fidelity` | TEXT | State-level transition fidelity label: `identical`, `high`, `medium`, or `low` |
81
- | `pair_fidelity` | TEXT | Pairwise (observation-level) transition fidelity label: `identical`, `high`, `medium`, or `low` |
82
- | `sequence_id` | TEXT | Internal sequence cluster identifier shared by chains A and B |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Load all chains for a UniProt entry
97
- df_nodes = pd.read_sql(
98
- "SELECT * FROM node WHERE uniprot_id = 'P00533'",
99
- conn
100
  )
101
 
102
- # Find all structural neighbours of a given chain
103
- df_edges = pd.read_sql(
104
- "SELECT * FROM edge WHERE pdb_id_A = '1ivo' AND auth_asym_id_A = 'A'",
105
- conn
106
  )
107
-
108
- conn.close()
109
  ```
110
 
111
- ### Filter by structural similarity
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 CAST(TM1 AS REAL) > 0.8
119
- AND CAST(RMSD AS REAL) < 2.0
120
- LIMIT 1000
121
- """, conn)
 
122
  ```
123
 
124
- ### Join nodes and edges
125
 
126
  ```python
127
- # Get full info for both chains in each pair
128
  df = pd.read_sql("""
129
- SELECT
130
- e.pdb_id_A, e.auth_asym_id_A,
131
- e.pdb_id_B, e.auth_asym_id_B,
132
- e.TM1, e.RMSD,
133
- nA.sequence_length AS len_A,
134
- nB.sequence_length AS len_B,
135
- nA.cath_superfamily
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 numeric fields (energies, TM-score, RMSD, lengths) are stored as `TEXT`; cast them with `CAST(col AS REAL)` or `CAST(col AS INTEGER)` as needed.
149
- - `ranked_functions` in the `node` table is a JSON string. Parse it with `json.loads()`.
150
- - The `edge` table is directional: (A→B) and (B→A) are separate rows and may differ slightly in TM-score.
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": 27586348,
3
- "unique_chains": 325320,
4
- "avg_rmsd": 0.6675476253688963,
5
- "avg_tm_score": 0.9602746188440746,
6
- "avg_sequence_length": 235.7717984028132,
7
  "rmsd_distribution": {
8
- "0-0.5": 11970560,
9
- "0.5-1.0": 10422936,
10
- "1.0-1.5": 3551996,
11
- "1.5-2.0": 905068,
12
- "2.0-5.0": 730332,
13
- ">5.0": 5456
14
  },
15
  "tm_score_distribution": {
16
- "0-0.5": 144940,
17
- "0.5-0.7": 319272,
18
- "0.7-0.85": 917410,
19
- "0.85-0.95": 4632994,
20
- "0.95-1.0": 21571732
21
  },
22
  "length_distribution": {
23
- "300-500": 57390,
24
- "500-1000": 19801,
25
- "100-200": 101177,
26
- "0-100": 76718,
27
- "200-300": 65315,
28
- ">1000": 4921
29
  },
30
  "tm_score_range": {
31
  "min": 0.0,
@@ -33,10 +33,10 @@
33
  },
34
  "rmsd_range": {
35
  "min": 0.0,
36
- "max": 9.83
37
  },
38
  "length_range": {
39
- "min": 1,
40
- "max": 4542
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
- <!DOCTYPE html>
3
- <html lang="en">
4
- <head>
5
- <meta charset="UTF-8" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>MuSProt: Multistate Protein Structure Database</title>
8
- <meta name="description" content="Explore multistate protein structures, pairwise comparisons, energy scores, and functional annotations." />
9
- </head>
10
-
11
- <body>
12
- <div id="root"></div>
13
- <script type="module" src="/src/main.tsx"></script>
14
- </body>
15
- </html>
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: Multistate Protein Structure Database',
12
  description:
13
- 'Explore multistate protein structures, pairwise comparisons, energy scores, and functional annotations.',
 
 
 
 
 
 
 
 
 
 
14
  },
15
  {
16
  path: 'docs',
17
- title: 'MuSProt Documentation',
18
- description: 'MuSProt dataset format, SQLite schema, and usage examples.',
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 { Navigate, Route, Routes } from 'react-router-dom';
2
- import { Header } from './components/layout/Header';
3
- import { Footer } from './components/layout/Footer';
4
- import { ProteinDashboard } from './components/datasets/protein/ProteinDashboard';
5
- import { ProteinDocsPage } from './pages/datasets/ProteinDocsPage';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  export default function App() {
8
  return (
9
- <div className="min-h-screen bg-white">
 
 
10
  <Header />
11
- <main>
12
  <Routes>
13
- <Route path="/" element={<ProteinDashboard />} />
14
- <Route path="/docs" element={<ProteinDocsPage />} />
 
 
 
 
 
 
15
  <Route path="/datasets/musprot" element={<Navigate to="/" replace />} />
16
  <Route path="/datasets/musprot/docs" element={<Navigate to="/docs" replace />} />
17
- <Route path="*" element={<Navigate to="/" replace />} />
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
- };