wenruifan commited on
Commit
3993320
·
1 Parent(s): 6a5cae9

Deploy MuSProt React and FastAPI application

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +13 -0
  2. .env.example +11 -0
  3. .gitignore +18 -0
  4. Dockerfile +21 -13
  5. README.md +74 -10
  6. backend/app/__init__.py +0 -0
  7. backend/app/protein/__init__.py +1 -0
  8. backend/app/protein/api/__init__.py +1 -0
  9. backend/app/protein/api/routes/__init__.py +1 -0
  10. backend/app/protein/api/routes/protein.py +263 -0
  11. backend/app/protein/chain_resolver.py +519 -0
  12. backend/app/protein/config.py +94 -0
  13. backend/app/protein/data_loader.py +324 -0
  14. backend/app/protein/database.py +13 -0
  15. backend/app/protein/fetch_chain_info_from_pdb.py +303 -0
  16. backend/app/protein/models.py +140 -0
  17. backend/app/protein/tsv_loader.py +181 -0
  18. backend/assets/MuSProt_documentation.md +138 -0
  19. backend/assets/musprot_summary.json +42 -0
  20. backend/requirements.txt +6 -0
  21. backend/scripts/generate_summary.py +36 -0
  22. backend/space_app.py +111 -0
  23. frontend/.prettierignore +4 -0
  24. frontend/.prettierrc +6 -0
  25. frontend/eslint.config.js +61 -0
  26. frontend/index.html +16 -0
  27. frontend/package-lock.json +0 -0
  28. frontend/package.json +42 -0
  29. frontend/public/robots.txt +6 -0
  30. frontend/public/sitemap.xml +13 -0
  31. frontend/scripts/prerender.mjs +42 -0
  32. frontend/src/App.tsx +23 -0
  33. frontend/src/api/protein.ts +119 -0
  34. frontend/src/components/datasets/protein/BindersPanel.tsx +108 -0
  35. frontend/src/components/datasets/protein/ChainBasicInfoCards.tsx +141 -0
  36. frontend/src/components/datasets/protein/ChainSearchInput.tsx +204 -0
  37. frontend/src/components/datasets/protein/DataTable.css +177 -0
  38. frontend/src/components/datasets/protein/DataTable.tsx +197 -0
  39. frontend/src/components/datasets/protein/ErrorBoundary.css +93 -0
  40. frontend/src/components/datasets/protein/ErrorBoundary.tsx +105 -0
  41. frontend/src/components/datasets/protein/ExperimentalConditionsPanel.tsx +117 -0
  42. frontend/src/components/datasets/protein/FilterPanel.tsx +75 -0
  43. frontend/src/components/datasets/protein/FunctionPanel.tsx +67 -0
  44. frontend/src/components/datasets/protein/ProteinDashboard.tsx +426 -0
  45. frontend/src/components/datasets/protein/SequencePanel.tsx +137 -0
  46. frontend/src/components/datasets/protein/StaticDistributionPlots.tsx +36 -0
  47. frontend/src/components/datasets/protein/StatsPanel.css +52 -0
  48. frontend/src/components/datasets/protein/StatsPanel.tsx +43 -0
  49. frontend/src/components/layout/Footer.tsx +25 -0
  50. frontend/src/components/layout/Header.tsx +50 -0
.dockerignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .claude
3
+ **/.git
4
+ **/__pycache__
5
+ **/*.pyc
6
+ **/node_modules
7
+ **/build
8
+ **/coverage
9
+ **/.env*
10
+ omaib-be-monorepo
11
+ omaib-fe
12
+ trial.ipynb
13
+ convert_csvtsv_to_db.py
.env.example ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MUSPROT_DATASET_REPO=wenruifan/<dataset-repository>
2
+ MUSPROT_DATASET_REVISION=main
3
+ MUSPROT_DB_FILENAME=MuSProt.db
4
+
5
+ # Optional mounted-volume overrides
6
+ # MUSPROT_DB_PATH=/data/MuSProt.db
7
+ # MUSPROT_SUMMARY_PATH=/data/musprot_summary.json
8
+ # MUSPROT_DOCS_PATH=/data/MuSProt_documentation.md
9
+ # MUSPROT_PLOTS_DIR=/data/plots
10
+
11
+ MUSPROT_PRELOAD_NODE_INDEX=1
.gitignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .DS_Store
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+
6
+ node_modules/
7
+ build/
8
+ dist/
9
+ coverage/
10
+
11
+ omaib-be-monorepo/
12
+ omaib-fe/node_modules/
13
+ omaib-fe/build/
14
+
15
+ __pycache__/
16
+ *.py[cod]
17
+ *.db
18
+ backend/data/
Dockerfile CHANGED
@@ -1,20 +1,28 @@
1
- FROM python:3.13.5-slim
2
 
3
- WORKDIR /app
 
 
 
 
 
4
 
5
- RUN apt-get update && apt-get install -y \
6
- build-essential \
7
- curl \
8
- git \
9
- && rm -rf /var/lib/apt/lists/*
10
 
11
- COPY requirements.txt ./
12
- COPY src/ ./src/
 
13
 
14
- RUN pip3 install -r requirements.txt
 
 
 
15
 
16
  EXPOSE 8501
 
 
17
 
18
- HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
19
-
20
- ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
 
1
+ # syntax=docker/dockerfile:1.7
2
 
3
+ FROM node:20-alpine AS frontend-builder
4
+ WORKDIR /frontend
5
+ COPY frontend/package.json frontend/package-lock.json ./
6
+ RUN npm ci
7
+ COPY frontend/ ./
8
+ RUN npm run build
9
 
10
+ FROM python:3.12-slim AS runtime
11
+ ENV PYTHONDONTWRITEBYTECODE=1 \
12
+ PYTHONUNBUFFERED=1 \
13
+ MUSPROT_FRONTEND_DIR=/app/frontend
 
14
 
15
+ WORKDIR /app
16
+ COPY backend/requirements.txt ./requirements.txt
17
+ RUN pip install --no-cache-dir -r requirements.txt
18
 
19
+ COPY backend/app ./app
20
+ COPY backend/assets ./assets
21
+ COPY backend/space_app.py ./space_app.py
22
+ COPY --from=frontend-builder /frontend/build ./frontend
23
 
24
  EXPOSE 8501
25
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=30m --retries=3 \
26
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8501/health')"
27
 
28
+ CMD ["uvicorn", "space_app:app", "--host", "0.0.0.0", "--port", "8501", "--workers", "1"]
 
 
README.md CHANGED
@@ -1,20 +1,84 @@
1
  ---
2
  title: MuSProt
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: red
6
  sdk: docker
7
  app_port: 8501
8
- tags:
9
- - streamlit
10
  pinned: false
11
- short_description: A Multistate Multimodal Protein Dataset
12
  license: mit
13
  ---
14
 
15
- # Welcome to Streamlit!
16
 
17
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
 
 
18
 
19
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
20
- forums](https://discuss.streamlit.io).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: MuSProt
3
+ emoji: 🧬
4
+ colorFrom: blue
5
+ colorTo: gray
6
  sdk: docker
7
  app_port: 8501
 
 
8
  pinned: false
9
+ short_description: A multistate multimodal protein structure dataset
10
  license: mit
11
  ---
12
 
13
+ # MuSProt
14
 
15
+ MuSProt is a multistate protein structure database with pairwise structural
16
+ comparisons, energy annotations, experimental metadata, and functional
17
+ annotations.
18
 
19
+ ## Space configuration
20
+
21
+ The Docker Space serves a React frontend and a MuSProt-only FastAPI backend.
22
+ The SQLite database is always opened in immutable read-only mode.
23
+
24
+ ```text
25
+ frontend/ React/Vite MuSProt explorer
26
+ backend/ MuSProt-only FastAPI API and bundled small assets
27
+ Dockerfile Hugging Face Docker Space build
28
+ ```
29
+
30
+ Provide the database using one of these approaches:
31
+
32
+ 1. Mount or upload it to `/data/MuSProt.db`.
33
+ 2. Set `MUSPROT_DATASET_REPO` to a Hugging Face Dataset repository containing
34
+ `MuSProt.db`.
35
+
36
+ Recommended Dataset repository files:
37
+
38
+ ```text
39
+ MuSProt.db
40
+ musprot_summary.json
41
+ MuSProt_documentation.md
42
+ plots/
43
+ ```
44
+
45
+ Optional Space variables:
46
+
47
+ ```text
48
+ MUSPROT_DB_PATH=/data/MuSProt.db
49
+ MUSPROT_SUMMARY_PATH=/data/musprot_summary.json
50
+ MUSPROT_DOCS_PATH=/data/MuSProt_documentation.md
51
+ MUSPROT_PLOTS_DIR=/data/plots
52
+ MUSPROT_DATASET_REPO=wenruifan/<dataset-repository>
53
+ MUSPROT_DATASET_REVISION=main
54
+ MUSPROT_DB_FILENAME=MuSProt.db
55
+ MUSPROT_PRELOAD_NODE_INDEX=1
56
+ ```
57
+
58
+ For a private Dataset repository, add `HF_TOKEN` as a Space secret.
59
+
60
+ ## Updating the database
61
+
62
+ When publishing a new `MuSProt.db`, regenerate the bundled summary sidecar:
63
+
64
+ ```bash
65
+ cd backend
66
+ python scripts/generate_summary.py /path/to/MuSProt.db -o assets/musprot_summary.json
67
+ ```
68
+
69
+ The deployed application does not create indexes or modify the SQLite file.
70
+ Create any required indexes before uploading the database to the Dataset
71
+ repository.
72
+
73
+ ## Local verification
74
+
75
+ ```bash
76
+ cd frontend
77
+ npm ci
78
+ npm run build
79
+
80
+ cd ../backend
81
+ MUSPROT_DB_PATH=/path/to/MuSProt.db \
82
+ MUSPROT_FRONTEND_DIR=../frontend/build \
83
+ uvicorn space_app:app --host 0.0.0.0 --port 8501
84
+ ```
backend/app/__init__.py ADDED
File without changes
backend/app/protein/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Empty init file
backend/app/protein/api/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Protein API package."""
backend/app/protein/api/routes/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Protein route modules."""
backend/app/protein/api/routes/protein.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Protein API routes."""
2
+ import logging
3
+ import math
4
+ from typing import Dict, List
5
+
6
+ from fastapi import APIRouter, HTTPException, Query
7
+
8
+ from app.protein.chain_resolver import (
9
+ batch_resolve_chains_async,
10
+ get_cache,
11
+ resolve_chain_async,
12
+ )
13
+ from app.protein.data_loader import DataManager
14
+ from app.protein import tsv_loader
15
+ from app.protein.models import (
16
+ ChainMetadata,
17
+ DataRecord,
18
+ DataResponse,
19
+ FilterOptions,
20
+ FilterParams,
21
+ SummaryStats,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+ router = APIRouter()
26
+
27
+ data_manager: DataManager | None = None
28
+
29
+
30
+ def set_data_manager(manager: DataManager | None) -> None:
31
+ """Set the active data manager for route handlers."""
32
+ global data_manager
33
+ data_manager = manager
34
+
35
+
36
+ def _require_data_manager() -> DataManager:
37
+ """Return data manager or raise if not initialized."""
38
+ if data_manager is None:
39
+ raise HTTPException(
40
+ status_code=503,
41
+ detail="Protein data is not initialized yet"
42
+ )
43
+ return data_manager
44
+
45
+
46
+ @router.get("/")
47
+ async def root():
48
+ """Root endpoint."""
49
+ return {
50
+ "message": "Protein Visualisation API",
51
+ "version": "1.0.0",
52
+ "endpoints": {
53
+ "/filters": "Get available filter options",
54
+ "/data": "Get filtered protein data",
55
+ "/summary": "Get aggregate statistics"
56
+ }
57
+ }
58
+
59
+
60
+ @router.get("/filters", response_model=FilterOptions)
61
+ async def get_filters():
62
+ """Get available filter options and ranges."""
63
+ try:
64
+ manager = _require_data_manager()
65
+ chains = sorted(list(manager.get_all_chains()))
66
+ fd = manager.get_filters_data()
67
+
68
+ return FilterOptions(
69
+ chain_ids=chains[:100],
70
+ rmsd_range={"min": float(fd["rmsd_min"]), "max": float(fd["rmsd_max"])},
71
+ tm_score_range={"min": float(fd["tm_min"]), "max": float(fd["tm_max"])},
72
+ length_range={"min": int(fd["length_min"]), "max": int(fd["length_max"])},
73
+ clusters=[
74
+ "cluster_ultra_high", "cluster_very_high", "cluster_high",
75
+ "cluster_medium", "cluster_low",
76
+ ],
77
+ total_records=fd["total_records"],
78
+ )
79
+ except HTTPException:
80
+ raise
81
+ except Exception as e:
82
+ logger.error(f"Error getting filters: {e}")
83
+ raise HTTPException(status_code=500, detail=str(e))
84
+
85
+
86
+ @router.post("/data", response_model=DataResponse)
87
+ async def get_data(filters: FilterParams):
88
+ """Get filtered protein data. Searches only by pdb_id_A and auth_asym_id_A."""
89
+ try:
90
+ manager = _require_data_manager()
91
+ limit = filters.limit or 1000
92
+ fetch_limit = limit * 10 if filters.cluster_id else limit
93
+
94
+ df = manager.query_edge(
95
+ pdb_id_a=filters.pdb_id,
96
+ auth_asym_id_a=filters.auth_asym_id,
97
+ chain_ids=filters.chain_ids,
98
+ rmsd_min=filters.rmsd_min,
99
+ rmsd_max=filters.rmsd_max,
100
+ tm_min=filters.tm_score_min,
101
+ tm_max=filters.tm_score_max,
102
+ limit=fetch_limit,
103
+ )
104
+
105
+ if filters.cluster_id:
106
+ df = df[df["cluster_id"] == filters.cluster_id]
107
+
108
+ filtered_total = len(df)
109
+ df = df.head(limit)
110
+
111
+ def _safe_float(val):
112
+ try:
113
+ v = float(val)
114
+ return None if math.isnan(v) else round(v, 6)
115
+ except (TypeError, ValueError):
116
+ return None
117
+
118
+ records = []
119
+ for row in df.itertuples(index=False):
120
+ raw_ph = getattr(row, "pH", None)
121
+ raw_temp = getattr(row, "temp_K", None)
122
+ records.append(DataRecord(
123
+ pdb_id_a=row.pdb_id_A,
124
+ auth_asym_id_a=row.auth_asym_id_A,
125
+ pdb_id_b=row.pdb_id_B,
126
+ auth_asym_id_b=row.auth_asym_id_B,
127
+ tm_score=float(row.TM1) if row.TM1 is not None else 0.0,
128
+ rmsd=float(row.RMSD) if row.RMSD is not None else 0.0,
129
+ structure_sim=_safe_float(row.structure_sim),
130
+ length_a=int(row.length_a) if row.length_a is not None else None,
131
+ length_b=int(row.length_b) if row.length_b is not None else None,
132
+ cluster_id=row.cluster_id,
133
+ exptl_method=getattr(row, "experimental_method", None) or None,
134
+ pH=_safe_float(raw_ph) if raw_ph else None,
135
+ temp=_safe_float(raw_temp) if raw_temp else None,
136
+ delta_rosetta=_safe_float(getattr(row, "delta_Rosetta", None)),
137
+ delta_foldx=_safe_float(getattr(row, "delta_FoldX", None)),
138
+ delta_evoef2=_safe_float(getattr(row, "delta_EvoEF2", None)),
139
+ delta_rm=_safe_float(getattr(row, "delta_RM", None)),
140
+ delta_rm_plus=_safe_float(getattr(row, "delta_RM_plus", None)),
141
+ state_id_b=getattr(row, "state_id_B", None) or None,
142
+ state_fidelity=getattr(row, "state_fidelity", None) or None,
143
+ avg_sim=getattr(row, "avg_sim", None) or None,
144
+ pair_fidelity=getattr(row, "pair_fidelity", None) or None,
145
+ ))
146
+
147
+ return DataResponse(
148
+ data=records,
149
+ total=manager.get_total(),
150
+ filtered=filtered_total,
151
+ )
152
+
153
+ except HTTPException:
154
+ raise
155
+ except Exception as e:
156
+ logger.error(f"Error filtering data: {e}")
157
+ raise HTTPException(status_code=500, detail=str(e))
158
+
159
+
160
+ @router.get("/summary", response_model=SummaryStats)
161
+ async def get_summary(
162
+ chain_ids: List[str] = Query(default=None),
163
+ rmsd_min: float = Query(default=None),
164
+ rmsd_max: float = Query(default=None),
165
+ tm_score_min: float = Query(default=None),
166
+ tm_score_max: float = Query(default=None),
167
+ ):
168
+ """Get aggregate statistics for the dataset or filtered subset."""
169
+ try:
170
+ manager = _require_data_manager()
171
+ stats = manager.get_summary_stats(
172
+ chain_ids=chain_ids,
173
+ rmsd_min=rmsd_min,
174
+ rmsd_max=rmsd_max,
175
+ tm_min=tm_score_min,
176
+ tm_max=tm_score_max,
177
+ )
178
+ return SummaryStats(
179
+ total_records=stats["total_records"],
180
+ unique_chains=stats["unique_chains"],
181
+ avg_rmsd=float(stats["avg_rmsd"]),
182
+ avg_tm_score=float(stats["avg_tm_score"]),
183
+ avg_sequence_length=float(stats["avg_sequence_length"]),
184
+ rmsd_distribution={str(k): int(v) for k, v in stats["rmsd_distribution"].items()},
185
+ tm_score_distribution={str(k): int(v) for k, v in stats["tm_score_distribution"].items()},
186
+ length_distribution={str(k): int(v) for k, v in stats["length_distribution"].items()},
187
+ )
188
+ except HTTPException:
189
+ raise
190
+ except Exception as e:
191
+ logger.error(f"Error computing summary: {e}")
192
+ raise HTTPException(status_code=500, detail=str(e))
193
+
194
+
195
+ @router.get("/chain/resolve", response_model=ChainMetadata)
196
+ async def resolve_chain_endpoint(
197
+ pdb_id: str = Query(..., description="4-character PDB ID"),
198
+ auth_asym_id: str = Query(..., description="Chain identifier (e.g., 'A')"),
199
+ use_cache: bool = Query(True, description="Use cached data if available")
200
+ ):
201
+ """Resolve chain metadata from RCSB web API."""
202
+ metadata = await resolve_chain_async(pdb_id, auth_asym_id, use_cache=use_cache)
203
+ return metadata.model_copy(update={
204
+ "binding_status": tsv_loader.get_binding_status(pdb_id, auth_asym_id),
205
+ "cath_id": tsv_loader.get_cath_id(pdb_id, auth_asym_id),
206
+ "cath_superfamily": tsv_loader.get_cath_superfamily(pdb_id, auth_asym_id),
207
+ "state_id": tsv_loader.get_state_id(pdb_id, auth_asym_id),
208
+ })
209
+
210
+
211
+ @router.post("/chain/batch-resolve")
212
+ async def batch_resolve_chains_endpoint(
213
+ chains: List[Dict[str, str]],
214
+ use_cache: bool = Query(True, description="Use cached data if available")
215
+ ):
216
+ """Batch resolve multiple chains at once."""
217
+ chain_tuples = []
218
+ for chain in chains:
219
+ pdb_id = chain.get("pdb_id")
220
+ auth_asym_id = chain.get("auth_asym_id")
221
+ if pdb_id and auth_asym_id:
222
+ chain_tuples.append((pdb_id, auth_asym_id))
223
+
224
+ result = await batch_resolve_chains_async(chain_tuples, use_cache=use_cache)
225
+ return {
226
+ key: metadata.model_copy(update={
227
+ "binding_status": tsv_loader.get_binding_status(*key.split("|")),
228
+ "cath_id": tsv_loader.get_cath_id(*key.split("|")),
229
+ "cath_superfamily": tsv_loader.get_cath_superfamily(*key.split("|")),
230
+ "state_id": tsv_loader.get_state_id(*key.split("|")),
231
+ })
232
+ for key, metadata in result.items()
233
+ }
234
+
235
+
236
+ @router.get("/chain/cache/stats")
237
+ async def cache_stats():
238
+ """Get cache statistics."""
239
+ cache = get_cache()
240
+ return {
241
+ "cached_chains": len(cache.cache),
242
+ "ttl_hours": cache.ttl.total_seconds() / 3600
243
+ }
244
+
245
+
246
+ @router.get("/chain/functions")
247
+ async def get_chain_functions(
248
+ pdb_id: str = Query(..., description="4-character PDB ID"),
249
+ auth_asym_id: str = Query(..., description="Chain identifier (e.g., 'A')"),
250
+ ):
251
+ """Return ranked functional annotations for a chain from node table."""
252
+ functions = tsv_loader.get_functions(pdb_id, auth_asym_id)
253
+ if functions is None:
254
+ raise HTTPException(status_code=404, detail=f"No annotation found for {pdb_id}:{auth_asym_id}")
255
+ return {"pdb_id": pdb_id.lower(), "chain_id": auth_asym_id.upper(), "functions": functions}
256
+
257
+
258
+ @router.post("/chain/cache/clear")
259
+ async def clear_cache():
260
+ """Clear the chain metadata cache."""
261
+ cache = get_cache()
262
+ cache.clear()
263
+ return {"message": "Cache cleared successfully"}
backend/app/protein/chain_resolver.py ADDED
@@ -0,0 +1,519 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chain metadata resolution service with caching.
3
+ """
4
+ import asyncio
5
+ import logging
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Dict, Tuple, Optional, List
9
+ from datetime import datetime, timedelta
10
+ from fastapi import HTTPException
11
+
12
+ # Add parent directory to path to import fetch_chain_info_from_pdb
13
+ sys.path.insert(0, str(Path(__file__).parent))
14
+
15
+ try:
16
+ from app.protein.fetch_chain_info_from_pdb import build_query, _safe_get, extract_ph_from_details
17
+ from rcsbapi.data import DataQuery
18
+ HAS_RCSB_API = True
19
+ except ImportError:
20
+ logging.warning("fetch_chain_info_from_pdb or rcsbapi not found, using mock implementation")
21
+ HAS_RCSB_API = False
22
+
23
+ from app.protein.models import ChainMetadata, EntryInfo, CrystalGrow, PolymerEntity, PolymerEntityInstance, NonpolymerEntity
24
+ from app.protein import tsv_loader
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ async def fetch_chain_record_async(pdb_id: str, auth_asym_id: str) -> dict:
30
+ """
31
+ Async version of fetch_chain_record that safely executes DataQuery.exec()
32
+ in a separate thread to avoid asyncio.run() conflicts.
33
+
34
+ Args:
35
+ pdb_id: 4-character PDB ID
36
+ auth_asym_id: Chain identifier
37
+
38
+ Returns:
39
+ dict with entry, polymer_entity, polymer_entity_instance, meta
40
+
41
+ Raises:
42
+ ValueError: if chain not found or ambiguous
43
+ """
44
+ if not HAS_RCSB_API:
45
+ # Mock implementation for testing
46
+ return {
47
+ "entry": {
48
+ "rcsb_id": pdb_id,
49
+ "title": f"Mock entry for {pdb_id}",
50
+ "exptl_method": "X-RAY DIFFRACTION",
51
+ "resolution_combined": [1.5],
52
+ "crystal_grow": {
53
+ "pH": 7.0,
54
+ "temp": 298.0,
55
+ "pdbx_details": "Mock crystal growth conditions"
56
+ }
57
+ },
58
+ "polymer_entity": {
59
+ "rcsb_id": f"{pdb_id}_1",
60
+ "entity_id": "1",
61
+ "sequence_length": 300,
62
+ },
63
+ "polymer_entity_instance": {
64
+ "rcsb_id": f"{pdb_id}.{auth_asym_id}",
65
+ "auth_asym_id": auth_asym_id,
66
+ },
67
+ "meta": {
68
+ "pdb_id": pdb_id,
69
+ "auth_asym_id": auth_asym_id,
70
+ }
71
+ }
72
+
73
+ # Build query
74
+ dq = build_query(pdb_id)
75
+
76
+ # Execute in separate thread to avoid asyncio.run() conflict
77
+ # DataQuery.exec() internally uses asyncio.run() which can't be called
78
+ # from within FastAPI's event loop
79
+ def _exec_query():
80
+ return dq.exec()
81
+
82
+ result = await asyncio.to_thread(_exec_query)
83
+ data = result["data"]["entries"][0]
84
+
85
+ if not data:
86
+ raise ValueError(f"No data returned for entry {pdb_id}")
87
+
88
+ # Parse entry-level data (same logic as fetch_chain_record)
89
+ ph_struct = _safe_get(data, "exptl_crystal_grow.pH")
90
+ pdbx_details = _safe_get(data, "exptl_crystal_grow.pdbx_details")
91
+ xray_temp = _safe_get(data, "exptl_crystal_grow.temp")
92
+
93
+ # Cryo-EM fallbacks (used when crystal grow fields are absent)
94
+ em_temp = _safe_get(data, "em_vitrification.temp")
95
+ em_cryogen = _safe_get(data, "em_vitrification.cryogen_name")
96
+ em_assembly_details = _safe_get(data, "em_entity_assembly.details")
97
+
98
+ # Compose EM details from available fields
99
+ em_details_parts = []
100
+ if em_assembly_details:
101
+ em_details_parts.append(em_assembly_details)
102
+ if em_cryogen:
103
+ em_details_parts.append(f"Vitrified in {em_cryogen}")
104
+ em_details = "; ".join(em_details_parts) if em_details_parts else None
105
+
106
+ entry_pack = {
107
+ "rcsb_id": data.get("rcsb_id"),
108
+ "title": _safe_get(data, "struct.title"),
109
+ "initial_release_date": _safe_get(data, "rcsb_accession_info.initial_release_date"),
110
+ "exptl_method": _safe_get(data, "exptl.method"),
111
+ "resolution_combined": _safe_get(data, "rcsb_entry_info.resolution_combined"),
112
+ "crystal_grow": {
113
+ "pH": (
114
+ ph_struct if ph_struct is not None
115
+ else extract_ph_from_details(pdbx_details)
116
+ ),
117
+ "pH_structured": ph_struct,
118
+ "pH_from_details": extract_ph_from_details(pdbx_details),
119
+ "temp": xray_temp if xray_temp is not None else em_temp,
120
+ "pdbx_details": pdbx_details if pdbx_details is not None else em_details,
121
+ },
122
+ "refine": {
123
+ "ls_d_res_high": _safe_get(data, "refine.ls_d_res_high"),
124
+ "details": _safe_get(data, "refine.details"),
125
+ },
126
+ }
127
+
128
+ # Find matching chain instance
129
+ polymer_entities = data.get("polymer_entities") or []
130
+ nonpolymer_entities = data.get("nonpolymer_entities") or []
131
+
132
+ matches: List[Tuple[dict, dict]] = []
133
+ for ent in polymer_entities:
134
+ instances = ent.get("polymer_entity_instances") or []
135
+ for inst in instances:
136
+ ids = inst.get("rcsb_polymer_entity_instance_container_identifiers") or {}
137
+ if ids.get("auth_asym_id") == auth_asym_id:
138
+ matches.append((ent, inst))
139
+
140
+ if len(matches) == 0:
141
+ raise ValueError(
142
+ f"No polymer_entity_instance found with auth_asym_id='{auth_asym_id}' in entry {pdb_id}"
143
+ )
144
+
145
+ if len(matches) > 1:
146
+ amb = []
147
+ for e, i in matches:
148
+ ids = i.get("rcsb_polymer_entity_instance_container_identifiers") or {}
149
+ amb.append({
150
+ "entity_rcsb_id": e.get("rcsb_id"),
151
+ "instance_rcsb_id": i.get("rcsb_id"),
152
+ "asym_id": ids.get("asym_id"),
153
+ "auth_asym_id": ids.get("auth_asym_id"),
154
+ })
155
+ raise ValueError(
156
+ f"Ambiguous: {len(matches)} instances match auth_asym_id='{auth_asym_id}' in entry {pdb_id}. "
157
+ f"Candidates: {amb}"
158
+ )
159
+
160
+ ent, inst = matches[0]
161
+
162
+ # Build polymer entity pack
163
+ polymer_entity_pack = {
164
+ "rcsb_id": ent.get("rcsb_id"),
165
+ "entity_id": _safe_get(ent, "rcsb_polymer_entity_container_identifiers.entity_id"),
166
+ "sequence_length": _safe_get(ent, "entity_poly.rcsb_sample_sequence_length"),
167
+ "seq_can": _safe_get(ent, "entity_poly.pdbx_seq_one_letter_code_can"),
168
+ "reference_sequences": _safe_get(
169
+ ent,
170
+ "rcsb_polymer_entity_container_identifiers.reference_sequence_identifiers",
171
+ default=[],
172
+ ),
173
+ }
174
+
175
+ # Build polymer instance pack
176
+ ids = inst.get("rcsb_polymer_entity_instance_container_identifiers") or {}
177
+ polymer_instance_pack = {
178
+ "rcsb_id": inst.get("rcsb_id"),
179
+ "asym_id": ids.get("asym_id"),
180
+ "auth_asym_id": ids.get("auth_asym_id"),
181
+ "binding_affinity": inst.get("rcsb_binding_affinity"),
182
+ "instance_annotations": inst.get("rcsb_polymer_entity_instance_annotation"),
183
+ }
184
+
185
+ # Build nonpolymer entities list (ligands)
186
+ nonpolymer_list = []
187
+ for npe in nonpolymer_entities:
188
+ nonpolymer_list.append({
189
+ "rcsb_nonpolymer_entity_container_identifiers": npe.get(
190
+ "rcsb_nonpolymer_entity_container_identifiers"
191
+ ),
192
+ "nonpolymer_comp": npe.get("nonpolymer_comp"),
193
+ })
194
+
195
+ return {
196
+ "entry": entry_pack,
197
+ "polymer_entity": polymer_entity_pack,
198
+ "polymer_entity_instance": polymer_instance_pack,
199
+ "nonpolymer_entities": nonpolymer_list,
200
+ "meta": {
201
+ "pdb_id": pdb_id,
202
+ "auth_asym_id": auth_asym_id,
203
+ "matched_instance_rcsb_id": inst.get("rcsb_id"),
204
+ },
205
+ }
206
+
207
+
208
+ class ChainCache:
209
+ """In-memory cache for chain metadata with TTL and negative caching."""
210
+
211
+ def __init__(self, ttl_hours: int = 24, negative_ttl_hours: int = 1):
212
+ self.cache: Dict[Tuple[str, str], Tuple[ChainMetadata, datetime]] = {}
213
+ self.negative_cache: Dict[Tuple[str, str], Tuple[str, datetime]] = {} # Store error message + timestamp
214
+ self.ttl = timedelta(hours=ttl_hours)
215
+ self.negative_ttl = timedelta(hours=negative_ttl_hours)
216
+
217
+ def get(self, pdb_id: str, auth_asym_id: str) -> Optional[ChainMetadata]:
218
+ """Get cached metadata if not expired."""
219
+ key = (pdb_id.lower(), auth_asym_id.upper())
220
+ if key in self.cache:
221
+ metadata, timestamp = self.cache[key]
222
+ if datetime.now() - timestamp < self.ttl:
223
+ logger.info(f"Cache hit for {pdb_id}:{auth_asym_id}")
224
+ return metadata
225
+ else:
226
+ # Expired, remove
227
+ del self.cache[key]
228
+ logger.info(f"Cache expired for {pdb_id}:{auth_asym_id}")
229
+ return None
230
+
231
+ def get_negative(self, pdb_id: str, auth_asym_id: str) -> Optional[str]:
232
+ """Check if chain is in negative cache (known to not exist)."""
233
+ key = (pdb_id.lower(), auth_asym_id.upper())
234
+ if key in self.negative_cache:
235
+ error_msg, timestamp = self.negative_cache[key]
236
+ if datetime.now() - timestamp < self.negative_ttl:
237
+ logger.info(f"Negative cache hit for {pdb_id}:{auth_asym_id}")
238
+ return error_msg
239
+ else:
240
+ # Expired, remove
241
+ del self.negative_cache[key]
242
+ logger.info(f"Negative cache expired for {pdb_id}:{auth_asym_id}")
243
+ return None
244
+
245
+ def set(self, pdb_id: str, auth_asym_id: str, metadata: ChainMetadata):
246
+ """Store metadata in cache."""
247
+ key = (pdb_id.lower(), auth_asym_id.upper())
248
+ self.cache[key] = (metadata, datetime.now())
249
+ # Remove from negative cache if it was there
250
+ self.negative_cache.pop(key, None)
251
+ logger.info(f"Cached metadata for {pdb_id}:{auth_asym_id}")
252
+
253
+ def set_negative(self, pdb_id: str, auth_asym_id: str, error_msg: str):
254
+ """Store negative cache entry (chain not found)."""
255
+ key = (pdb_id.lower(), auth_asym_id.upper())
256
+ self.negative_cache[key] = (error_msg, datetime.now())
257
+ logger.info(f"Negative cached {pdb_id}:{auth_asym_id} - {error_msg}")
258
+
259
+ def clear(self):
260
+ """Clear all cache."""
261
+ self.cache.clear()
262
+ self.negative_cache.clear()
263
+ logger.info("Cache cleared")
264
+
265
+
266
+ # Global cache instance (24h positive cache, 1h negative cache)
267
+ _chain_cache = ChainCache(ttl_hours=24, negative_ttl_hours=1)
268
+
269
+
270
+ async def resolve_chain_async(pdb_id: str, auth_asym_id: str, use_cache: bool = True) -> ChainMetadata:
271
+ """
272
+ Async version of resolve_chain that works in FastAPI context.
273
+
274
+ Args:
275
+ pdb_id: 4-character PDB ID (case-insensitive)
276
+ auth_asym_id: Chain identifier (case-insensitive)
277
+ use_cache: Whether to use cache (default True)
278
+
279
+ Returns:
280
+ ChainMetadata object
281
+
282
+ Raises:
283
+ HTTPException: 400 for invalid params, 404 if not found, 502 if upstream fails
284
+ """
285
+ # Validate inputs
286
+ if not pdb_id or len(pdb_id) != 4:
287
+ raise HTTPException(
288
+ status_code=400,
289
+ detail=f"Invalid PDB ID: '{pdb_id}'. Must be 4 characters."
290
+ )
291
+
292
+ if not auth_asym_id:
293
+ raise HTTPException(
294
+ status_code=400,
295
+ detail="auth_asym_id is required"
296
+ )
297
+
298
+ pdb_id = pdb_id.lower()
299
+ auth_asym_id = auth_asym_id.upper()
300
+
301
+ # Check cache
302
+ if use_cache:
303
+ cached = _chain_cache.get(pdb_id, auth_asym_id)
304
+ if cached:
305
+ return cached
306
+
307
+ # Fetch from web
308
+ logger.info(f"Fetching chain metadata from web: {pdb_id}:{auth_asym_id}")
309
+
310
+ try:
311
+ raw_data = await fetch_chain_record_async(pdb_id, auth_asym_id)
312
+ except ValueError as e:
313
+ # Chain not found or ambiguous - add to negative cache
314
+ error_msg = str(e)
315
+ logger.warning(f"Chain resolution failed for {pdb_id}:{auth_asym_id} - {error_msg}")
316
+
317
+ # Cache 404 responses to prevent repeated upstream queries
318
+ if "No polymer_entity_instance found" in error_msg and use_cache:
319
+ _chain_cache.set_negative(pdb_id, auth_asym_id, error_msg)
320
+
321
+ raise HTTPException(status_code=404, detail=error_msg)
322
+ except Exception as e:
323
+ # Upstream API failure
324
+ error_msg = f"Failed to fetch chain metadata from RCSB: {str(e)}"
325
+ logger.error(f"Upstream error for {pdb_id}:{auth_asym_id} - {error_msg}")
326
+ raise HTTPException(status_code=502, detail=error_msg)
327
+
328
+ # Parse into our models
329
+ try:
330
+ entry_data = raw_data.get("entry", {})
331
+ crystal_grow_data = entry_data.get("crystal_grow", {})
332
+
333
+ # Extract pH with fallback logic (prefer structured, then extracted from details)
334
+ pH_value = crystal_grow_data.get("pH")
335
+ if pH_value is None:
336
+ pH_value = crystal_grow_data.get("pH_from_details")
337
+ if pH_value is None:
338
+ pH_value = crystal_grow_data.get("pH_structured")
339
+
340
+ crystal_grow = CrystalGrow(
341
+ pH=pH_value,
342
+ pH_structured=crystal_grow_data.get("pH_structured"),
343
+ pH_from_details=crystal_grow_data.get("pH_from_details"),
344
+ temp=crystal_grow_data.get("temp"),
345
+ pdbx_details=crystal_grow_data.get("pdbx_details")
346
+ )
347
+
348
+ entry = EntryInfo(
349
+ rcsb_id=entry_data.get("rcsb_id"),
350
+ title=entry_data.get("title"),
351
+ initial_release_date=entry_data.get("initial_release_date"),
352
+ exptl_method=entry_data.get("exptl_method"),
353
+ resolution_combined=entry_data.get("resolution_combined"),
354
+ crystal_grow=crystal_grow
355
+ )
356
+
357
+ polymer_entity_data = raw_data.get("polymer_entity", {})
358
+ polymer_entity = PolymerEntity(
359
+ rcsb_id=polymer_entity_data.get("rcsb_id"),
360
+ entity_id=polymer_entity_data.get("entity_id"),
361
+ sequence_length=polymer_entity_data.get("sequence_length"),
362
+ seq_can=polymer_entity_data.get("seq_can"),
363
+ reference_sequences=polymer_entity_data.get("reference_sequences")
364
+ )
365
+
366
+ instance_data = raw_data.get("polymer_entity_instance", {})
367
+ polymer_instance = PolymerEntityInstance(
368
+ rcsb_id=instance_data.get("rcsb_id"),
369
+ asym_id=instance_data.get("asym_id"),
370
+ auth_asym_id=instance_data.get("auth_asym_id"),
371
+ binding_affinity=instance_data.get("binding_affinity")
372
+ )
373
+
374
+ # Parse nonpolymer entities (ligands/binders) - ASYNC VERSION
375
+ nonpolymer_list = []
376
+ nonpolymer_entities_raw = raw_data.get("nonpolymer_entities", [])
377
+ for npe in nonpolymer_entities_raw:
378
+ identifiers = npe.get("rcsb_nonpolymer_entity_container_identifiers", {})
379
+ comp = npe.get("nonpolymer_comp", {})
380
+ chem_comp = comp.get("chem_comp", {})
381
+ descriptors = comp.get("rcsb_chem_comp_descriptor", {})
382
+
383
+ nonpolymer_list.append(NonpolymerEntity(
384
+ rcsb_id=identifiers.get("rcsb_id"),
385
+ name=chem_comp.get("name"),
386
+ formula_weight=chem_comp.get("formula_weight"),
387
+ smiles=descriptors.get("SMILES"),
388
+ inchi=descriptors.get("InChI")
389
+ ))
390
+
391
+ # Prefer TSV sequence over RCSB (faster, always available for dataset chains)
392
+ tsv_seq, tsv_len = tsv_loader.get_sequence(pdb_id, auth_asym_id)
393
+ if tsv_seq is not None:
394
+ polymer_entity = PolymerEntity(
395
+ rcsb_id=polymer_entity.rcsb_id,
396
+ entity_id=polymer_entity.entity_id,
397
+ sequence_length=tsv_len if tsv_len is not None else polymer_entity.sequence_length,
398
+ seq_can=tsv_seq,
399
+ reference_sequences=polymer_entity.reference_sequences,
400
+ )
401
+
402
+ metadata = ChainMetadata(
403
+ pdb_id=pdb_id,
404
+ chain_id=auth_asym_id,
405
+ entry=entry,
406
+ polymer_entity=polymer_entity,
407
+ polymer_entity_instance=polymer_instance,
408
+ nonpolymer_entities=nonpolymer_list if nonpolymer_list else None
409
+ )
410
+
411
+ # Cache it
412
+ if use_cache:
413
+ _chain_cache.set(pdb_id, auth_asym_id, metadata)
414
+
415
+ return metadata
416
+
417
+ except Exception as e:
418
+ logger.error(f"Failed to parse chain metadata: {e}")
419
+ raise HTTPException(
420
+ status_code=500,
421
+ detail=f"Failed to parse chain metadata: {str(e)}"
422
+ )
423
+
424
+
425
+ def resolve_chain(pdb_id: str, auth_asym_id: str, use_cache: bool = True) -> ChainMetadata:
426
+ """
427
+ Synchronous wrapper for resolve_chain_async - deprecated, use resolve_chain_async instead.
428
+
429
+ Args:
430
+ pdb_id: 4-character PDB ID (case-insensitive)
431
+ auth_asym_id: Chain identifier (case-insensitive)
432
+ use_cache: Whether to use cache (default True)
433
+
434
+ Returns:
435
+ ChainMetadata object
436
+
437
+ Raises:
438
+ HTTPException: 400 for invalid params, 404 if not found, 502 if upstream fails
439
+ """
440
+ import asyncio
441
+ # Run the async version in a sync context
442
+ try:
443
+ loop = asyncio.get_event_loop()
444
+ except RuntimeError:
445
+ loop = asyncio.new_event_loop()
446
+ asyncio.set_event_loop(loop)
447
+
448
+ return loop.run_until_complete(resolve_chain_async(pdb_id, auth_asym_id, use_cache))
449
+
450
+
451
+ def batch_resolve_chains(chains: list[Tuple[str, str]], use_cache: bool = True) -> Dict[str, ChainMetadata]:
452
+ """
453
+ Resolve multiple chains in batch (synchronous version - deprecated).
454
+
455
+ Args:
456
+ chains: List of (pdb_id, auth_asym_id) tuples
457
+ use_cache: Whether to use cache
458
+
459
+ Returns:
460
+ Dict mapping "pdb_id|auth_asym_id" to ChainMetadata (only successful resolutions)
461
+ """
462
+ results = {}
463
+
464
+ for pdb_id, auth_asym_id in chains:
465
+ key = f"{pdb_id.lower()}|{auth_asym_id.upper()}"
466
+ try:
467
+ metadata = resolve_chain(pdb_id, auth_asym_id, use_cache=use_cache)
468
+ results[key] = metadata
469
+ except HTTPException as e:
470
+ logger.warning(f"Failed to resolve {key}: {e.detail}")
471
+ continue
472
+ except Exception as e:
473
+ logger.error(f"Unexpected error resolving {key}: {e}")
474
+ continue
475
+
476
+ return results
477
+
478
+
479
+ async def batch_resolve_chains_async(chains: list[Tuple[str, str]], use_cache: bool = True, max_concurrent: int = 10) -> Dict[str, ChainMetadata]:
480
+ """
481
+ Resolve multiple chains in batch with controlled concurrency.
482
+
483
+ Args:
484
+ chains: List of (pdb_id, auth_asym_id) tuples
485
+ use_cache: Whether to use cache
486
+ max_concurrent: Maximum number of concurrent requests to RCSB (default 10)
487
+
488
+ Returns:
489
+ Dict mapping "pdb_id|auth_asym_id" to ChainMetadata (only successful resolutions)
490
+ """
491
+ results = {}
492
+ semaphore = asyncio.Semaphore(max_concurrent)
493
+
494
+ async def resolve_with_semaphore(pdb_id: str, auth_asym_id: str):
495
+ async with semaphore:
496
+ key = f"{pdb_id.lower()}|{auth_asym_id.upper()}"
497
+ try:
498
+ metadata = await resolve_chain_async(pdb_id, auth_asym_id, use_cache=use_cache)
499
+ return key, metadata
500
+ except HTTPException as e:
501
+ logger.warning(f"Failed to resolve {key}: {e.detail}")
502
+ return key, None
503
+ except Exception as e:
504
+ logger.error(f"Unexpected error resolving {key}: {e}")
505
+ return key, None
506
+
507
+ # Execute all requests concurrently with semaphore limit
508
+ tasks = [resolve_with_semaphore(pdb_id, auth_asym_id) for pdb_id, auth_asym_id in chains]
509
+ results_list = await asyncio.gather(*tasks)
510
+
511
+ # Filter out None results (failed resolutions)
512
+ results = {key: metadata for key, metadata in results_list if metadata is not None}
513
+
514
+ return results
515
+
516
+
517
+ def get_cache() -> ChainCache:
518
+ """Get the global cache instance (for testing/admin)."""
519
+ return _chain_cache
backend/app/protein/config.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runtime configuration for the MuSProt application."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from functools import lru_cache
6
+ from pathlib import Path
7
+
8
+ from huggingface_hub import hf_hub_download
9
+
10
+
11
+ DEFAULT_VOLUME_DIR = Path("/data")
12
+ LOCAL_DATA_DIR = Path(__file__).resolve().parents[2] / "data" / "data_protein"
13
+ ASSETS_DIR = Path(__file__).resolve().parents[2] / "assets"
14
+
15
+
16
+ def _existing_path(*candidates: Path) -> Path | None:
17
+ return next((path for path in candidates if path.exists()), None)
18
+
19
+
20
+ @lru_cache(maxsize=1)
21
+ def get_database_path() -> Path:
22
+ """Resolve MuSProt.db from an explicit path, mounted volume, local data, or Hub."""
23
+ explicit_path = os.getenv("MUSPROT_DB_PATH")
24
+ if explicit_path:
25
+ path = Path(explicit_path).expanduser().resolve()
26
+ if not path.exists():
27
+ raise FileNotFoundError(f"MUSPROT_DB_PATH does not exist: {path}")
28
+ return path
29
+
30
+ local_path = _existing_path(
31
+ DEFAULT_VOLUME_DIR / "MuSProt.db",
32
+ LOCAL_DATA_DIR / "json" / "MuSProt.db",
33
+ )
34
+ if local_path:
35
+ return local_path.resolve()
36
+
37
+ repo_id = os.getenv("MUSPROT_DATASET_REPO")
38
+ if not repo_id:
39
+ raise FileNotFoundError(
40
+ "MuSProt.db was not found. Set MUSPROT_DB_PATH, mount it at "
41
+ "/data/MuSProt.db, or set MUSPROT_DATASET_REPO."
42
+ )
43
+
44
+ return Path(
45
+ hf_hub_download(
46
+ repo_id=repo_id,
47
+ repo_type="dataset",
48
+ filename=os.getenv("MUSPROT_DB_FILENAME", "MuSProt.db"),
49
+ revision=os.getenv("MUSPROT_DATASET_REVISION", "main"),
50
+ token=os.getenv("HF_TOKEN"),
51
+ )
52
+ )
53
+
54
+
55
+ def get_summary_path() -> Path | None:
56
+ """Return an optional precomputed summary JSON path."""
57
+ explicit_path = os.getenv("MUSPROT_SUMMARY_PATH")
58
+ if explicit_path:
59
+ path = Path(explicit_path).expanduser().resolve()
60
+ return path if path.exists() else None
61
+
62
+ return _existing_path(
63
+ DEFAULT_VOLUME_DIR / "musprot_summary.json",
64
+ ASSETS_DIR / "musprot_summary.json",
65
+ LOCAL_DATA_DIR / "musprot_summary.json",
66
+ )
67
+
68
+
69
+ def get_docs_path() -> Path | None:
70
+ """Return the dataset documentation path when available."""
71
+ explicit_path = os.getenv("MUSPROT_DOCS_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_documentation.md",
78
+ ASSETS_DIR / "MuSProt_documentation.md",
79
+ LOCAL_DATA_DIR / "MuSProt_documentation.md",
80
+ )
81
+
82
+
83
+ def get_plots_dir() -> Path | None:
84
+ """Return a directory containing only public MuSProt plot assets."""
85
+ explicit_path = os.getenv("MUSPROT_PLOTS_DIR")
86
+ if explicit_path:
87
+ path = Path(explicit_path).expanduser().resolve()
88
+ return path if path.is_dir() else None
89
+
90
+ return _existing_path(
91
+ DEFAULT_VOLUME_DIR / "plots",
92
+ ASSETS_DIR / "plots",
93
+ LOCAL_DATA_DIR / "plots",
94
+ )
backend/app/protein/data_loader.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLite-based data loader for protein structure data.
3
+ Reads from MuSProt.db: edge table (pairwise comparisons from CSV)
4
+ and node table (chain annotations from TSV).
5
+ """
6
+ import json
7
+ import pandas as pd
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Set
10
+ import logging
11
+
12
+ from app.protein.config import get_database_path, get_summary_path
13
+ from app.protein.database import connect_readonly
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _assign_cluster(tm: float) -> str:
19
+ if tm >= 0.99:
20
+ return "cluster_ultra_high"
21
+ if tm >= 0.95:
22
+ return "cluster_very_high"
23
+ if tm >= 0.90:
24
+ return "cluster_high"
25
+ if tm >= 0.80:
26
+ return "cluster_medium"
27
+ return "cluster_low"
28
+
29
+
30
+ class DataManager:
31
+ """Manages SQL queries against MuSProt.db edge and node tables."""
32
+
33
+ def __init__(self, db_path: Optional[Path] = None, **_kwargs):
34
+ self.db_path = db_path or get_database_path()
35
+ self._loaded = False
36
+ self._total_records = 0
37
+ self._summary: Dict[str, Any] = {}
38
+
39
+ def load_data(self) -> None:
40
+ """Verify DB is accessible and load optional precomputed metadata."""
41
+ if self._loaded:
42
+ return
43
+ summary_path = get_summary_path()
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:
50
+ cur = conn.cursor()
51
+ cur.execute("SELECT 1 FROM edge LIMIT 1")
52
+ if not self._total_records:
53
+ logger.warning(
54
+ "No musprot_summary.json found; counting edge rows during startup. "
55
+ "Publish the summary sidecar to avoid this scan."
56
+ )
57
+ cur.execute("SELECT COUNT(*) FROM edge")
58
+ self._total_records = cur.fetchone()[0]
59
+ finally:
60
+ conn.close()
61
+ self._loaded = True
62
+ logger.info(f"DataManager ready: {self._total_records:,} edge records in {self.db_path}")
63
+
64
+ def _connect(self):
65
+ if not self._loaded:
66
+ raise RuntimeError("Call load_data() first.")
67
+ return connect_readonly(self.db_path)
68
+
69
+ def get_all_chains(self) -> Set[str]:
70
+ """Return unique A-side chain identifiers as 'PDBID_ChainID' from node table."""
71
+ from app.protein import tsv_loader
72
+ index = tsv_loader._get_index()
73
+ return {f"{pdb.upper()}_{chain}" for pdb, chain in index.keys()}
74
+
75
+ def get_filters_data(self) -> Dict[str, Any]:
76
+ """Return aggregated ranges for filter UI."""
77
+ if self._summary:
78
+ return {
79
+ "tm_min": self._summary["tm_score_range"]["min"],
80
+ "tm_max": self._summary["tm_score_range"]["max"],
81
+ "rmsd_min": self._summary["rmsd_range"]["min"],
82
+ "rmsd_max": self._summary["rmsd_range"]["max"],
83
+ "length_min": self._summary["length_range"]["min"],
84
+ "length_max": self._summary["length_range"]["max"],
85
+ "total_records": self._total_records,
86
+ }
87
+
88
+ conn = self._connect()
89
+ try:
90
+ cur = conn.cursor()
91
+ cur.execute(
92
+ "SELECT MIN(CAST(TM1 AS REAL)), MAX(CAST(TM1 AS REAL)),"
93
+ " MIN(CAST(RMSD AS REAL)), MAX(CAST(RMSD AS REAL))"
94
+ " FROM edge"
95
+ )
96
+ tm_min, tm_max, rmsd_min, rmsd_max = cur.fetchone()
97
+ finally:
98
+ conn.close()
99
+
100
+ from app.protein import tsv_loader
101
+ lengths = []
102
+ for row in tsv_loader._get_index().values():
103
+ try:
104
+ lengths.append(int(row["sequence_length"]))
105
+ except (ValueError, TypeError):
106
+ pass
107
+
108
+ return {
109
+ "tm_min": tm_min or 0.0,
110
+ "tm_max": tm_max or 1.0,
111
+ "rmsd_min": rmsd_min or 0.0,
112
+ "rmsd_max": rmsd_max or 10.0,
113
+ "length_min": min(lengths) if lengths else 0,
114
+ "length_max": max(lengths) if lengths else 0,
115
+ "total_records": self._total_records,
116
+ }
117
+
118
+ def query_edge(
119
+ self,
120
+ pdb_id_a: Optional[str] = None,
121
+ auth_asym_id_a: Optional[str] = None,
122
+ chain_ids: Optional[List[str]] = None,
123
+ rmsd_min: Optional[float] = None,
124
+ rmsd_max: Optional[float] = None,
125
+ tm_min: Optional[float] = None,
126
+ tm_max: Optional[float] = None,
127
+ limit: int = 1000,
128
+ ) -> pd.DataFrame:
129
+ """Query edge table with filters. Returns DataFrame with cluster_id and lengths."""
130
+ where_clauses: List[str] = []
131
+ params: List[Any] = []
132
+
133
+ if pdb_id_a and auth_asym_id_a:
134
+ where_clauses.append("LOWER(pdb_id_A) = LOWER(?) AND LOWER(auth_asym_id_A) = LOWER(?)")
135
+ params.extend([pdb_id_a, auth_asym_id_a])
136
+ elif chain_ids:
137
+ sub: List[str] = []
138
+ for cid in chain_ids:
139
+ parts = cid.split("_", 1)
140
+ if len(parts) == 2:
141
+ sub.append("(LOWER(pdb_id_A) = LOWER(?) AND LOWER(auth_asym_id_A) = LOWER(?))")
142
+ params.extend(parts)
143
+ if sub:
144
+ where_clauses.append("(" + " OR ".join(sub) + ")")
145
+
146
+ if rmsd_min is not None:
147
+ where_clauses.append("CAST(RMSD AS REAL) >= ?")
148
+ params.append(rmsd_min)
149
+ if rmsd_max is not None:
150
+ where_clauses.append("CAST(RMSD AS REAL) <= ?")
151
+ params.append(rmsd_max)
152
+ if tm_min is not None:
153
+ where_clauses.append("CAST(TM1 AS REAL) >= ?")
154
+ params.append(tm_min)
155
+ if tm_max is not None:
156
+ where_clauses.append("CAST(TM1 AS REAL) <= ?")
157
+ params.append(tm_max)
158
+
159
+ where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
160
+ sql = f"""
161
+ SELECT e.pdb_id_A, e.auth_asym_id_A, e.pdb_id_B, e.auth_asym_id_B,
162
+ CAST(e.TM1 AS REAL) AS TM1,
163
+ CAST(e.RMSD AS REAL) AS RMSD,
164
+ CAST(e.structure_sim AS REAL) AS structure_sim,
165
+ e."delta_Rosetta", e."delta_FoldX", e."delta_EvoEF2",
166
+ e."delta_RM", e."delta_RM+",
167
+ e.state_id_B, e.state_fidelity, e.avg_sim, e.pair_fidelity
168
+ FROM edge e
169
+ {where_sql}
170
+ LIMIT ?
171
+ """
172
+ params.append(limit)
173
+
174
+ conn = self._connect()
175
+ try:
176
+ df = pd.read_sql_query(sql, conn, params=params)
177
+ finally:
178
+ conn.close()
179
+
180
+ # Rename columns with special characters so itertuples works cleanly
181
+ df = df.rename(columns={"delta_RM+": "delta_RM_plus"})
182
+
183
+ df["cluster_id"] = df["TM1"].apply(
184
+ lambda t: _assign_cluster(t) if pd.notna(t) else "cluster_low"
185
+ )
186
+
187
+ from app.protein import tsv_loader
188
+ df["length_a"] = [
189
+ tsv_loader.get_sequence(r.pdb_id_A, r.auth_asym_id_A)[1]
190
+ for r in df.itertuples(index=False)
191
+ ]
192
+ df["length_b"] = [
193
+ tsv_loader.get_sequence(r.pdb_id_B, r.auth_asym_id_B)[1]
194
+ for r in df.itertuples(index=False)
195
+ ]
196
+ node_rows = [
197
+ tsv_loader.get_node_row(r.pdb_id_B, r.auth_asym_id_B)
198
+ for r in df.itertuples(index=False)
199
+ ]
200
+ df["experimental_method"] = [row.get("experimental_method") if row else None for row in node_rows]
201
+ df["pH"] = [row.get("pH") if row else None for row in node_rows]
202
+ df["temp_K"] = [row.get("temp_K") if row else None for row in node_rows]
203
+
204
+ return df
205
+
206
+ def get_summary_stats(
207
+ self,
208
+ chain_ids: Optional[List[str]] = None,
209
+ rmsd_min: Optional[float] = None,
210
+ rmsd_max: Optional[float] = None,
211
+ tm_min: Optional[float] = None,
212
+ tm_max: Optional[float] = None,
213
+ ) -> Dict[str, Any]:
214
+ """Compute aggregate statistics via SQL, with optional filters."""
215
+ if not any([chain_ids, rmsd_min, rmsd_max, tm_min, tm_max]) and self._summary:
216
+ return self._summary
217
+
218
+ where_clauses: List[str] = []
219
+ params: List[Any] = []
220
+
221
+ if chain_ids:
222
+ sub: List[str] = []
223
+ for cid in chain_ids:
224
+ parts = cid.split("_", 1)
225
+ if len(parts) == 2:
226
+ sub.append("(LOWER(pdb_id_A) = LOWER(?) AND LOWER(auth_asym_id_A) = LOWER(?))")
227
+ params.extend(parts)
228
+ if sub:
229
+ where_clauses.append("(" + " OR ".join(sub) + ")")
230
+
231
+ if rmsd_min is not None:
232
+ where_clauses.append("CAST(RMSD AS REAL) >= ?")
233
+ params.append(rmsd_min)
234
+ if rmsd_max is not None:
235
+ where_clauses.append("CAST(RMSD AS REAL) <= ?")
236
+ params.append(rmsd_max)
237
+ if tm_min is not None:
238
+ where_clauses.append("CAST(TM1 AS REAL) >= ?")
239
+ params.append(tm_min)
240
+ if tm_max is not None:
241
+ where_clauses.append("CAST(TM1 AS REAL) <= ?")
242
+ params.append(tm_max)
243
+
244
+ where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else ""
245
+
246
+ conn = self._connect()
247
+ try:
248
+ cur = conn.cursor()
249
+ cur.execute(
250
+ f"SELECT COUNT(*), AVG(CAST(RMSD AS REAL)), AVG(CAST(TM1 AS REAL))"
251
+ f" FROM edge {where_sql}",
252
+ params,
253
+ )
254
+ total, avg_rmsd, avg_tm = cur.fetchone()
255
+
256
+ cur.execute(
257
+ f"SELECT COUNT(DISTINCT LOWER(pdb_id_A) || '_' || LOWER(auth_asym_id_A))"
258
+ f" FROM edge {where_sql}",
259
+ params,
260
+ )
261
+ unique_chains = cur.fetchone()[0]
262
+
263
+ def _dist(col, bins, labels):
264
+ cases = " ".join(
265
+ f"WHEN CAST({col} AS REAL) >= {lo} AND CAST({col} AS REAL) < {hi} THEN '{lbl}'"
266
+ for (lo, hi), lbl in zip(zip(bins, bins[1:]), labels)
267
+ )
268
+ sql = (
269
+ f"SELECT CASE {cases} ELSE '{labels[-1]}' END AS bucket, COUNT(*)"
270
+ f" FROM edge {where_sql} GROUP BY bucket"
271
+ )
272
+ cur.execute(sql, params)
273
+ return {r[0]: r[1] for r in cur.fetchall()}
274
+
275
+ rmsd_dist = _dist(
276
+ "RMSD",
277
+ [0, 0.5, 1.0, 1.5, 2.0, 5.0, 1e9],
278
+ ["0-0.5", "0.5-1.0", "1.0-1.5", "1.5-2.0", "2.0-5.0", ">5.0"],
279
+ )
280
+ tm_dist = _dist(
281
+ "TM1",
282
+ [0, 0.5, 0.7, 0.85, 0.95, 1.01],
283
+ ["0-0.5", "0.5-0.7", "0.7-0.85", "0.85-0.95", "0.95-1.0"],
284
+ )
285
+ finally:
286
+ conn.close()
287
+
288
+ from app.protein import tsv_loader
289
+ lengths = []
290
+ for row in tsv_loader._get_index().values():
291
+ try:
292
+ lengths.append(int(row["sequence_length"]))
293
+ except (ValueError, TypeError):
294
+ pass
295
+ avg_len = sum(lengths) / len(lengths) if lengths else 0.0
296
+ len_dist = {}
297
+ for length in lengths:
298
+ if length <= 100:
299
+ bucket = "0-100"
300
+ elif length <= 200:
301
+ bucket = "100-200"
302
+ elif length <= 300:
303
+ bucket = "200-300"
304
+ elif length <= 500:
305
+ bucket = "300-500"
306
+ elif length <= 1000:
307
+ bucket = "500-1000"
308
+ else:
309
+ bucket = ">1000"
310
+ len_dist[bucket] = len_dist.get(bucket, 0) + 1
311
+
312
+ return {
313
+ "total_records": total or 0,
314
+ "unique_chains": unique_chains or 0,
315
+ "avg_rmsd": avg_rmsd or 0.0,
316
+ "avg_tm_score": avg_tm or 0.0,
317
+ "avg_sequence_length": avg_len,
318
+ "rmsd_distribution": rmsd_dist,
319
+ "tm_score_distribution": tm_dist,
320
+ "length_distribution": len_dist,
321
+ }
322
+
323
+ def get_total(self) -> int:
324
+ return self._total_records
backend/app/protein/database.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Read-only SQLite connection helpers."""
2
+ from __future__ import annotations
3
+
4
+ import sqlite3
5
+ from pathlib import Path
6
+
7
+
8
+ def connect_readonly(db_path: Path) -> sqlite3.Connection:
9
+ """Open an immutable, query-only SQLite connection."""
10
+ uri = f"file:{db_path.resolve()}?mode=ro&immutable=1"
11
+ connection = sqlite3.connect(uri, uri=True)
12
+ connection.execute("PRAGMA query_only = ON")
13
+ return connection
backend/app/protein/fetch_chain_info_from_pdb.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, Optional, List, Tuple
2
+ import json
3
+ from rcsbapi.data import DataQuery
4
+
5
+ def build_query(pdb_id: str) -> DataQuery:
6
+ return DataQuery(
7
+ input_type="entry",
8
+ input_ids=[pdb_id],
9
+ return_data_list=[
10
+ # Entry basics
11
+ "rcsb_id",
12
+ "struct.title",
13
+ "exptl.method",
14
+ "rcsb_entry_info.resolution_combined",
15
+ "rcsb_accession_info.initial_release_date",
16
+
17
+ # Experimental info
18
+ "exptl.method",
19
+ "rcsb_entry_info.resolution_combined",
20
+ "exptl_crystal_grow.pH",
21
+ "exptl_crystal_grow.temp",
22
+ "exptl_crystal_grow.pdbx_details",
23
+ "refine.ls_d_res_high",
24
+ "refine.details",
25
+
26
+ # Polymer entities (sequence-level)
27
+ "polymer_entities.rcsb_id",
28
+ "polymer_entities.rcsb_polymer_entity_container_identifiers.entity_id",
29
+ "polymer_entities.entity_poly.rcsb_sample_sequence_length",
30
+ "polymer_entities.entity_poly.pdbx_seq_one_letter_code_can",
31
+ "polymer_entities.rcsb_polymer_entity_container_identifiers."
32
+ "reference_sequence_identifiers.database_name",
33
+ "polymer_entities.rcsb_polymer_entity_container_identifiers."
34
+ "reference_sequence_identifiers.database_accession",
35
+
36
+ # Polymer entity instances (chain-level)
37
+ "polymer_entities.polymer_entity_instances.rcsb_id",
38
+ "polymer_entities.polymer_entity_instances.rcsb_polymer_entity_instance_container_identifiers.asym_id",
39
+ "polymer_entities.polymer_entity_instances.rcsb_polymer_entity_instance_container_identifiers.auth_asym_id",
40
+ "rcsb_binding_affinity",
41
+
42
+ # Instance annotations (chain-level)
43
+ # "polymer_entities.polymer_entity_instances."
44
+ # "rcsb_polymer_entity_instance_annotation.annotation_id",
45
+ # "polymer_entities.polymer_entity_instances."
46
+ # "rcsb_polymer_entity_instance_annotation.description",
47
+ # "polymer_entities.polymer_entity_instances."
48
+ # "rcsb_polymer_entity_instance_annotation.source",
49
+
50
+ # Cryo-EM specific conditions (fallback for non-crystallography structures)
51
+ "em_vitrification.temp",
52
+ "em_vitrification.cryogen_name",
53
+ "em_entity_assembly.details",
54
+
55
+ # Nonpolymer (ligands)
56
+ "nonpolymer_entities.rcsb_nonpolymer_entity_container_identifiers.nonpolymer_comp_id",
57
+ "nonpolymer_entities.nonpolymer_comp.chem_comp.id",
58
+ "nonpolymer_entities.nonpolymer_comp.chem_comp.name",
59
+ "nonpolymer_entities.nonpolymer_comp.rcsb_chem_comp_descriptor.InChIKey",
60
+ "nonpolymer_entities.nonpolymer_comp.rcsb_chem_comp_descriptor.SMILES",
61
+ ],
62
+ )
63
+
64
+
65
+ from typing import Any, Dict, Optional, List, Tuple
66
+
67
+ from typing import Any, Dict, List, Union
68
+
69
+ def _safe_get(
70
+ obj: Union[Dict[str, Any], List[Any]],
71
+ path: str,
72
+ default=None,
73
+ *,
74
+ take_first: bool = True,
75
+ ):
76
+ """
77
+ Safe getter for nested RCSB-style data.
78
+
79
+ Supports:
80
+ - dict
81
+ - list[dict]
82
+ - dict[list[dict]]
83
+
84
+ Rules:
85
+ - If encountering a list:
86
+ * take_first=True -> use the first element
87
+ * take_first=False -> return the whole list
88
+ """
89
+ cur = obj
90
+ for p in path.split("."):
91
+ if cur is None:
92
+ return default
93
+
94
+ # Case 1: list encountered
95
+ if isinstance(cur, list):
96
+ if len(cur) == 0:
97
+ return default
98
+ if take_first:
99
+ cur = cur[0]
100
+ else:
101
+ return cur
102
+
103
+ # Case 2: dict expected
104
+ if not isinstance(cur, dict):
105
+ return default
106
+
107
+ if p not in cur:
108
+ return default
109
+
110
+ cur = cur[p]
111
+
112
+ return cur
113
+
114
+ import re
115
+ from typing import Optional, Union
116
+
117
+ def extract_ph_from_details(details: Union[str, list, None]) -> Optional[float]:
118
+ """
119
+ Extract pH value from pdbx_details free text.
120
+ Supports patterns like:
121
+ - "pH 7.5"
122
+ - "PH=6.8"
123
+ - "ph: 8.0"
124
+ - "crystallized at pH/ph/PH xxx"
125
+
126
+ Returns:
127
+ float pH if found, else None
128
+ """
129
+ if details is None:
130
+ return None
131
+
132
+ # If list (RCSB sometimes returns list[dict or str]), flatten
133
+ if isinstance(details, list):
134
+ texts = []
135
+ for d in details:
136
+ if isinstance(d, dict):
137
+ texts.extend(str(v) for v in d.values())
138
+ else:
139
+ texts.append(str(d))
140
+ text = " ".join(texts)
141
+ else:
142
+ text = str(details)
143
+
144
+ # Normalize
145
+ text = text.replace(",", " ")
146
+
147
+ # Regex: pH / PH / ph followed by number
148
+ # Examples matched:
149
+ # pH 7.0
150
+ # PH=6.8
151
+ # ph:8.5
152
+ pattern = re.compile(
153
+ r"\b(?:pH|PH|ph)\s*[:=]?\s*([0-9]+(?:\.[0-9]+)?)"
154
+ )
155
+
156
+ match = pattern.search(text)
157
+ if match:
158
+ try:
159
+ return float(match.group(1))
160
+ except ValueError:
161
+ return None
162
+
163
+ return None
164
+
165
+
166
+
167
+ def fetch_chain_record(
168
+ pdb_id: str,
169
+ auth_asym_id: str,
170
+ query_builder=build_query,
171
+ ) -> Dict[str, Any]:
172
+ """
173
+ Fetch entry-level data and return ONLY the chain instance matching auth_asym_id,
174
+ plus its parent polymer_entity (sequence-level) and entry metadata.
175
+
176
+ Raises:
177
+ - ValueError if no chain or multiple chains match (ambiguous).
178
+ """
179
+ dq = query_builder(pdb_id)
180
+ data = dq.exec()["data"]["entries"][0]
181
+
182
+ if not data:
183
+ raise ValueError(f"No data returned for entry {pdb_id}")
184
+
185
+ # ---- entry-level pack
186
+ ph_struct = _safe_get(data, "exptl_crystal_grow.pH")
187
+ pdbx_details = _safe_get(data, "exptl_crystal_grow.pdbx_details")
188
+ xray_temp = _safe_get(data, "exptl_crystal_grow.temp")
189
+
190
+ # Cryo-EM fallbacks (used when crystal grow fields are absent)
191
+ em_temp = _safe_get(data, "em_vitrification.temp")
192
+ em_cryogen = _safe_get(data, "em_vitrification.cryogen_name")
193
+ em_assembly_details = _safe_get(data, "em_entity_assembly.details")
194
+
195
+ em_details_parts = []
196
+ if em_assembly_details:
197
+ em_details_parts.append(em_assembly_details)
198
+ if em_cryogen:
199
+ em_details_parts.append(f"Vitrified in {em_cryogen}")
200
+ em_details = "; ".join(em_details_parts) if em_details_parts else None
201
+
202
+ entry_pack = {
203
+ "rcsb_id": data.get("rcsb_id"),
204
+ "title": _safe_get(data, "struct.title"),
205
+ "initial_release_date": _safe_get(data, "rcsb_accession_info.initial_release_date"),
206
+ "exptl_method": _safe_get(data, "exptl.method"),
207
+ "resolution_combined": _safe_get(data, "rcsb_entry_info.resolution_combined"),
208
+ "crystal_grow": {
209
+ "pH": (
210
+ ph_struct if ph_struct is not None
211
+ else extract_ph_from_details(pdbx_details)
212
+ ),
213
+ "pH_structured": ph_struct,
214
+ "pH_from_details": extract_ph_from_details(pdbx_details),
215
+ "temp": xray_temp if xray_temp is not None else em_temp,
216
+ "pdbx_details": pdbx_details if pdbx_details is not None else em_details,
217
+ },
218
+ "refine": {
219
+ "ls_d_res_high": _safe_get(data, "refine.ls_d_res_high"),
220
+ "details": _safe_get(data, "refine.details"),
221
+ },
222
+ }
223
+
224
+ polymer_entities = data.get("polymer_entities") or []
225
+ nonpolymer_entities = data.get("nonpolymer_entities") or []
226
+
227
+ matches: List[Tuple[Dict[str, Any], Dict[str, Any]]] = []
228
+ for ent in polymer_entities:
229
+ instances = ent.get("polymer_entity_instances") or []
230
+ for inst in instances:
231
+ ids = inst.get("rcsb_polymer_entity_instance_container_identifiers") or {}
232
+ if ids.get("auth_asym_id") == auth_asym_id:
233
+ matches.append((ent, inst))
234
+
235
+ if len(matches) == 0:
236
+ # Helpful debug info: list available auth_asym_id
237
+ available = []
238
+ for ent in polymer_entities:
239
+ instances = ent.get("polymer_entity_instances") or []
240
+ for inst in instances:
241
+ ids = inst.get("rcsb_polymer_entity_instance_container_identifiers") or {}
242
+ if ids.get("auth_asym_id") is not None:
243
+ available.append(ids.get("auth_asym_id"))
244
+ raise ValueError(
245
+ f"No chain instance matches auth_asym_id='{auth_asym_id}' in entry {pdb_id}. "
246
+ f"Available auth_asym_id: {sorted(set(available))}"
247
+ )
248
+
249
+ if len(matches) > 1:
250
+ amb = []
251
+ for e, i in matches:
252
+ ids = i.get("rcsb_polymer_entity_instance_container_identifiers") or {}
253
+ amb.append({
254
+ "entity_rcsb_id": e.get("rcsb_id"),
255
+ "instance_rcsb_id": i.get("rcsb_id"),
256
+ "asym_id": ids.get("asym_id"),
257
+ "auth_asym_id": ids.get("auth_asym_id"),
258
+ })
259
+ raise ValueError(
260
+ f"Ambiguous: {len(matches)} instances match auth_asym_id='{auth_asym_id}' in entry {pdb_id}. "
261
+ f"Candidates: {amb}"
262
+ )
263
+
264
+ ent, inst = matches[0]
265
+
266
+ polymer_entity_pack = {
267
+ "rcsb_id": ent.get("rcsb_id"),
268
+ "entity_id": _safe_get(ent, "rcsb_polymer_entity_container_identifiers.entity_id"),
269
+ "sequence_length": _safe_get(ent, "entity_poly.rcsb_sample_sequence_length"),
270
+ "seq_can": _safe_get(ent, "entity_poly.pdbx_seq_one_letter_code_can"),
271
+ "reference_sequences": _safe_get(
272
+ ent,
273
+ "rcsb_polymer_entity_container_identifiers.reference_sequence_identifiers",
274
+ default=[],
275
+ ),
276
+ }
277
+
278
+ ids = inst.get("rcsb_polymer_entity_instance_container_identifiers") or {}
279
+ polymer_instance_pack = {
280
+ "rcsb_id": inst.get("rcsb_id"),
281
+ "asym_id": ids.get("asym_id"),
282
+ "auth_asym_id": ids.get("auth_asym_id"),
283
+ "binding_affinity": inst.get("rcsb_binding_affinity"),
284
+ "instance_annotations": inst.get("rcsb_polymer_entity_instance_annotation"),
285
+ }
286
+
287
+ return {
288
+ "entry": entry_pack,
289
+ "polymer_entity": polymer_entity_pack,
290
+ "polymer_entity_instance": polymer_instance_pack,
291
+ "nonpolymer_entities": nonpolymer_entities,
292
+ "meta": {
293
+ "pdb_id": pdb_id,
294
+ "auth_asym_id": auth_asym_id,
295
+ "matched_instance_rcsb_id": inst.get("rcsb_id"),
296
+ },
297
+ }
298
+
299
+
300
+ if __name__=="__main__":
301
+ rec = fetch_chain_record("2D4F", "A")
302
+ with open("data.json", "w") as file:
303
+ json.dump(rec, file, indent=2)
backend/app/protein/models.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ cath_id: Optional[str] = None
63
+ cath_superfamily: Optional[str] = None
64
+ state_id: Optional[str] = None
65
+
66
+
67
+ class FilterParams(BaseModel):
68
+ """Request model for filtering protein data."""
69
+ chain_ids: Optional[List[str]] = Field(default=None, description="List of chain IDs to filter")
70
+ pdb_id: Optional[str] = Field(default=None, description="PDB ID for single chain search")
71
+ auth_asym_id: Optional[str] = Field(default=None, description="Chain ID for single chain search")
72
+ rmsd_min: Optional[float] = Field(default=None, ge=0, description="Minimum RMSD value")
73
+ rmsd_max: Optional[float] = Field(default=None, ge=0, description="Maximum RMSD value")
74
+ tm_score_min: Optional[float] = Field(default=None, ge=0, le=1, description="Minimum TM-score")
75
+ tm_score_max: Optional[float] = Field(default=None, ge=0, le=1, description="Maximum TM-score")
76
+ length_min: Optional[int] = Field(default=None, ge=0, description="Minimum sequence length")
77
+ length_max: Optional[int] = Field(default=None, ge=0, description="Maximum sequence length")
78
+ include_duplicates: Optional[bool] = Field(default=None, description="Include duplicate structures")
79
+ cluster_id: Optional[str] = Field(default=None, description="Structural similarity cluster ID")
80
+ limit: Optional[int] = Field(default=1000, ge=1, le=10000, description="Maximum results to return")
81
+
82
+
83
+ class FilterOptions(BaseModel):
84
+ """Available filter options and ranges."""
85
+ chain_ids: List[str] = Field(description="Available chain IDs")
86
+ rmsd_range: Dict[str, float] = Field(description="Min and max RMSD values")
87
+ tm_score_range: Dict[str, float] = Field(description="Min and max TM-score values")
88
+ length_range: Dict[str, int] = Field(description="Min and max sequence lengths")
89
+ clusters: List[str] = Field(description="Available similarity clusters")
90
+ total_records: int = Field(description="Total number of records")
91
+
92
+
93
+ class DataRecord(BaseModel):
94
+ """Single protein comparison record."""
95
+ pdb_id_a: str
96
+ auth_asym_id_a: str
97
+ pdb_id_b: str
98
+ auth_asym_id_b: str
99
+ tm_score: float
100
+ rmsd: float
101
+ structure_sim: Optional[float] = None
102
+ sequence_identity: Optional[float] = None
103
+ length_a: Optional[int] = None
104
+ length_b: Optional[int] = None
105
+ aligned_length: Optional[int] = None
106
+ status: Optional[str] = None
107
+ cluster_id: Optional[str] = None
108
+ exptl_method: Optional[str] = None
109
+ temp: Optional[float] = None
110
+ pH: Optional[float] = None
111
+ # Energy score deltas fetched directly from the edge table
112
+ delta_rosetta: Optional[float] = None
113
+ delta_foldx: Optional[float] = None
114
+ delta_evoef2: Optional[float] = None
115
+ delta_rm: Optional[float] = None
116
+ delta_rm_plus: Optional[float] = None
117
+ # Alternative state similarity fields from edge table
118
+ state_id_b: Optional[str] = None
119
+ state_fidelity: Optional[str] = None
120
+ avg_sim: Optional[str] = None
121
+ pair_fidelity: Optional[str] = None
122
+
123
+
124
+ class DataResponse(BaseModel):
125
+ """Response model for filtered data."""
126
+ data: List[DataRecord]
127
+ total: int
128
+ filtered: int
129
+
130
+
131
+ class SummaryStats(BaseModel):
132
+ """Aggregate statistics for the dataset."""
133
+ total_records: int
134
+ unique_chains: int
135
+ avg_rmsd: float
136
+ avg_tm_score: float
137
+ avg_sequence_length: float
138
+ rmsd_distribution: Dict[str, int]
139
+ tm_score_distribution: Dict[str, int]
140
+ length_distribution: Dict[str, int]
backend/app/protein/tsv_loader.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Node table lookup for chain annotations.
3
+
4
+ Loads the node table from MuSProt.db once on first use and indexes rows
5
+ by (pdb_id_lower, auth_asym_id_upper). When multiple rows share the same
6
+ key the first occurrence is kept.
7
+ """
8
+ import ast
9
+ import sqlite3
10
+ import logging
11
+ from typing import Dict, List, Optional, Tuple, Any
12
+
13
+ from app.protein.config import get_database_path
14
+ from app.protein.database import connect_readonly
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _Row = Dict[str, str]
19
+
20
+ # (pdb_id_lower, auth_asym_id_upper) -> relevant fields
21
+ _index: Optional[Dict[Tuple[str, str], _Row]] = None
22
+
23
+
24
+ def _load() -> Dict[Tuple[str, str], _Row]:
25
+ index: Dict[Tuple[str, str], _Row] = {}
26
+ db_path = get_database_path()
27
+ conn = connect_readonly(db_path)
28
+ try:
29
+ conn.row_factory = sqlite3.Row
30
+ cur = conn.cursor()
31
+ cur.execute(
32
+ 'SELECT pdb_id, auth_asym_id, base_label, sequence, sequence_length,'
33
+ ' CATH_ID, cath_superfamily, Rosetta, FoldX, EvoEF2, RM, "RM+", ranked_functions,'
34
+ ' state_id, experimental_method, pH, temp_K'
35
+ ' FROM node'
36
+ )
37
+ for row in cur:
38
+ pdb_id = row["pdb_id"] or ""
39
+ auth_asym_id = row["auth_asym_id"] or ""
40
+ key = (pdb_id.lower(), auth_asym_id.upper())
41
+ if key not in index:
42
+ index[key] = {
43
+ "base_label": row["base_label"] or "",
44
+ "sequence": row["sequence"] or "",
45
+ "sequence_length": row["sequence_length"] or "",
46
+ "CATH_ID": row["CATH_ID"] or "",
47
+ "cath_superfamily": row["cath_superfamily"] or "",
48
+ "Rosetta": row["Rosetta"] or "",
49
+ "FoldX": row["FoldX"] or "",
50
+ "EvoEF2": row["EvoEF2"] or "",
51
+ "RM": row["RM"] or "",
52
+ "RM+": row["RM+"] or "",
53
+ "ranked_functions": row["ranked_functions"] or "",
54
+ "state_id": row["state_id"] or "",
55
+ "experimental_method": row["experimental_method"] or "",
56
+ "pH": row["pH"] or "",
57
+ "temp_K": row["temp_K"] or "",
58
+ }
59
+ finally:
60
+ conn.close()
61
+
62
+ logger.info(f"Node index loaded: {len(index):,} entries from {db_path}")
63
+ return index
64
+
65
+
66
+ def _get_index() -> Dict[Tuple[str, str], _Row]:
67
+ global _index
68
+ if _index is None:
69
+ _index = _load()
70
+ return _index
71
+
72
+
73
+ def reset_index() -> None:
74
+ """Force reload of node index."""
75
+ global _index
76
+ _index = None
77
+
78
+
79
+ def _parse_functions(raw: str) -> List[str]:
80
+ raw = raw.strip()
81
+ if not raw:
82
+ return []
83
+ try:
84
+ parsed = ast.literal_eval(raw)
85
+ if isinstance(parsed, list):
86
+ return [str(f).strip() for f in parsed if str(f).strip()]
87
+ except (ValueError, SyntaxError):
88
+ pass
89
+ return [f.strip() for f in raw.split(";") if f.strip()]
90
+
91
+
92
+ def _parse_float(val: str) -> Optional[float]:
93
+ val = val.strip()
94
+ if not val:
95
+ return None
96
+ try:
97
+ return float(val)
98
+ except ValueError:
99
+ return None
100
+
101
+
102
+ def get_energy_scores(pdb_id: str, auth_asym_id: str) -> Optional[Dict[str, Any]]:
103
+ """Return energy scores (Rosetta, FoldX, EvoEF2, RM, RM+) or None if not found."""
104
+ key = (pdb_id.lower(), auth_asym_id.upper())
105
+ row = _get_index().get(key)
106
+ if row is None:
107
+ return None
108
+ return {
109
+ "Rosetta": _parse_float(row.get("Rosetta", "")),
110
+ "FoldX": _parse_float(row.get("FoldX", "")),
111
+ "EvoEF2": _parse_float(row.get("EvoEF2", "")),
112
+ "RM": _parse_float(row.get("RM", "")),
113
+ "RM+": _parse_float(row.get("RM+", "")),
114
+ }
115
+
116
+
117
+ def get_node_row(pdb_id: str, auth_asym_id: str) -> Optional[_Row]:
118
+ """Return the cached node row for a chain."""
119
+ return _get_index().get((pdb_id.lower(), auth_asym_id.upper()))
120
+
121
+
122
+ def get_binding_status(pdb_id: str, auth_asym_id: str) -> Optional[str]:
123
+ """Return binding status (base_label: apo/holo) or None if not found."""
124
+ key = (pdb_id.lower(), auth_asym_id.upper())
125
+ row = _get_index().get(key)
126
+ if row is None:
127
+ return None
128
+ return row.get("base_label") or None
129
+
130
+
131
+ def get_cath_id(pdb_id: str, auth_asym_id: str) -> Optional[str]:
132
+ """Return CATH_ID or 'uncategorized' if not found."""
133
+ key = (pdb_id.lower(), auth_asym_id.upper())
134
+ row = _get_index().get(key)
135
+ if row is None:
136
+ return "uncategorized"
137
+ val = row.get("CATH_ID", "").strip()
138
+ return val if val else "uncategorized"
139
+
140
+
141
+ def get_cath_superfamily(pdb_id: str, auth_asym_id: str) -> Optional[str]:
142
+ """Return CATH superfamily code (e.g. '3.40.190.10') or None if not found."""
143
+ key = (pdb_id.lower(), auth_asym_id.upper())
144
+ row = _get_index().get(key)
145
+ if row is None:
146
+ return None
147
+ val = row.get("cath_superfamily", "").strip()
148
+ return val if val else None
149
+
150
+
151
+ def get_sequence(pdb_id: str, auth_asym_id: str) -> Tuple[Optional[str], Optional[int]]:
152
+ """Return (seq_can, sequence_length) from node table, or (None, None) if not found."""
153
+ key = (pdb_id.lower(), auth_asym_id.upper())
154
+ row = _get_index().get(key)
155
+ if row is None:
156
+ return None, None
157
+ seq = row["sequence"] or None
158
+ try:
159
+ length = int(row["sequence_length"]) if row["sequence_length"].strip() else None
160
+ except ValueError:
161
+ length = None
162
+ return seq, length
163
+
164
+
165
+ def get_functions(pdb_id: str, auth_asym_id: str) -> Optional[List[str]]:
166
+ """Return ranked function list for a chain, or None if not in node table."""
167
+ key = (pdb_id.lower(), auth_asym_id.upper())
168
+ row = _get_index().get(key)
169
+ if row is None:
170
+ return None
171
+ return _parse_functions(row["ranked_functions"])
172
+
173
+
174
+ def get_state_id(pdb_id: str, auth_asym_id: str) -> Optional[str]:
175
+ """Return state_id for a chain, or None if not found."""
176
+ key = (pdb_id.lower(), auth_asym_id.upper())
177
+ row = _get_index().get(key)
178
+ if row is None:
179
+ return None
180
+ val = row.get("state_id", "").strip()
181
+ return val if val else None
backend/assets/MuSProt_documentation.md ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MuSProt Dataset Documentation
2
+
3
+ **MuSProt** (Multistate Protein Database) is a large-scale structural database capturing conformational diversity across protein chains derived from the Protein Data Bank (PDB). It encodes pairwise structural relationships between chain instances sharing the same UniProt annotation, enabling systematic analysis of protein flexibility, binding effects, and functional variation.
4
+
5
+ ---
6
+
7
+ ## Download
8
+
9
+ The full dataset is distributed as a single SQLite database file (`MuSProt.db`, ~6.3 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
+ | `uniprot_id` | TEXT | UniProt accession number |
28
+ | `pdb_id` | TEXT | 4-character PDB entry ID (lowercase) |
29
+ | `auth_asym_id` | TEXT | Author chain identifier (e.g. `A`) |
30
+ | `base_label` | TEXT | Canonical label combining UniProt ID and chain state |
31
+ | `sequence` | TEXT | SEQRES amino acid sequence |
32
+ | `sequence_length` | INT | Number of residues in the chain |
33
+ | `original_metals` | TEXT | Metal ions present in the structure |
34
+ | `original_ligands` | TEXT | Small-molecule ligands present in the structure |
35
+ | `sequence_id` | TEXT | Internal sequence cluster identifier |
36
+ | `CATH_ID` | TEXT | CATH domain assignment |
37
+ | `cath_class` | TEXT | CATH class (e.g. `1` = Mainly Alpha) |
38
+ | `cath_arch` | TEXT | CATH architecture |
39
+ | `cath_topo` | TEXT | CATH topology |
40
+ | `cath_homology` | TEXT | CATH homology superfamily |
41
+ | `cath_superfamily` | TEXT | Full CATH superfamily code (e.g. `1.10.10.10`) |
42
+ | `domain_length` | INT | Length of the matched CATH domain |
43
+ | `Rosetta` | FLOAT | Rosetta total energy score |
44
+ | `FoldX` | FLOAT | FoldX total energy score |
45
+ | `EvoEF2` | FLOAT | EvoEF2 total energy score |
46
+ | `RM` | FLOAT | RosettaMembrane energy score |
47
+ | `RM+` | FLOAT | RosettaMembrane+ energy score |
48
+ | `ranked_functions` | TEXT | JSON-encoded list of ranked GO/functional annotations |
49
+
50
+ ---
51
+
52
+ ### Table: `edge`
53
+
54
+ Each row represents a pairwise structural comparison between two chain instances (A and B) that share the same UniProt identity.
55
+
56
+ | Column | Type | Description |
57
+ |---|---|---|
58
+ | `pdb_id_A` | TEXT | PDB ID of chain A |
59
+ | `auth_asym_id_A` | TEXT | Chain identifier of chain A |
60
+ | `pdb_id_B` | TEXT | PDB ID of chain B |
61
+ | `auth_asym_id_B` | TEXT | Chain identifier of chain B |
62
+ | `TM1` | FLOAT | TM-score of the alignment (chain A as reference) |
63
+ | `RMSD` | FLOAT | Root-mean-square deviation of Cα atoms (Å) |
64
+ | `structure_sim` | FLOAT | Composite structural similarity score |
65
+ | `delta_Rosetta` | FLOAT | Rosetta energy difference (B − A) |
66
+ | `delta_FoldX` | FLOAT | FoldX energy difference (B − A) |
67
+ | `delta_EvoEF2` | FLOAT | EvoEF2 energy difference (B − A) |
68
+ | `delta_RM` | FLOAT | RosettaMembrane energy difference (B − A) |
69
+ | `delta_RM+` | FLOAT | RosettaMembrane+ energy difference (B − A) |
70
+
71
+ ---
72
+
73
+ ## Usage Examples
74
+
75
+ ### Python (sqlite3)
76
+
77
+ ```python
78
+ import sqlite3
79
+ import pandas as pd
80
+
81
+ conn = sqlite3.connect("MuSProt.db")
82
+
83
+ # Load all chains for a UniProt entry
84
+ df_nodes = pd.read_sql(
85
+ "SELECT * FROM node WHERE uniprot_id = 'P00533'",
86
+ conn
87
+ )
88
+
89
+ # Find all structural neighbours of a given chain
90
+ df_edges = pd.read_sql(
91
+ "SELECT * FROM edge WHERE pdb_id_A = '1ivo' AND auth_asym_id_A = 'A'",
92
+ conn
93
+ )
94
+
95
+ conn.close()
96
+ ```
97
+
98
+ ### Filter by structural similarity
99
+
100
+ ```python
101
+ # Retrieve pairs with high TM-score and low RMSD
102
+ df = pd.read_sql("""
103
+ SELECT *
104
+ FROM edge
105
+ WHERE CAST(TM1 AS REAL) > 0.8
106
+ AND CAST(RMSD AS REAL) < 2.0
107
+ LIMIT 1000
108
+ """, conn)
109
+ ```
110
+
111
+ ### Join nodes and edges
112
+
113
+ ```python
114
+ # Get full info for both chains in each pair
115
+ df = pd.read_sql("""
116
+ SELECT
117
+ e.pdb_id_A, e.auth_asym_id_A,
118
+ e.pdb_id_B, e.auth_asym_id_B,
119
+ e.TM1, e.RMSD,
120
+ nA.sequence_length AS len_A,
121
+ nB.sequence_length AS len_B,
122
+ nA.cath_superfamily
123
+ FROM edge e
124
+ JOIN node nA ON e.pdb_id_A = nA.pdb_id AND e.auth_asym_id_A = nA.auth_asym_id
125
+ JOIN node nB ON e.pdb_id_B = nB.pdb_id AND e.auth_asym_id_B = nB.auth_asym_id
126
+ WHERE nA.uniprot_id = 'P00533'
127
+ LIMIT 500
128
+ """, conn)
129
+ ```
130
+
131
+ ---
132
+
133
+ ## Notes
134
+
135
+ - 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.
136
+ - `ranked_functions` in the `node` table is a JSON string. Parse it with `json.loads()`.
137
+ - The `edge` table is directional: (A→B) and (B→A) are separate rows and may differ slightly in TM-score.
138
+ - Energy delta values represent B − A; a negative delta means chain B is lower energy than chain A.
backend/assets/musprot_summary.json ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,
32
+ "max": 1.0
33
+ },
34
+ "rmsd_range": {
35
+ "min": 0.0,
36
+ "max": 9.83
37
+ },
38
+ "length_range": {
39
+ "min": 1,
40
+ "max": 4542
41
+ }
42
+ }
backend/requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi==0.109.0
2
+ uvicorn[standard]==0.27.0
3
+ pandas==2.2.0
4
+ pydantic==2.5.3
5
+ rcsb-api>=1.7.3
6
+ huggingface_hub>=0.32.0
backend/scripts/generate_summary.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate the small summary sidecar used by the deployed MuSProt app."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
10
+
11
+ from app.protein.data_loader import DataManager
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser()
16
+ parser.add_argument("database", type=Path)
17
+ parser.add_argument("-o", "--output", type=Path, default=Path("musprot_summary.json"))
18
+ args = parser.parse_args()
19
+
20
+ manager = DataManager(args.database)
21
+ manager.load_data()
22
+ summary = manager.get_summary_stats()
23
+ filters = manager.get_filters_data()
24
+ summary.update(
25
+ {
26
+ "tm_score_range": {"min": filters["tm_min"], "max": filters["tm_max"]},
27
+ "rmsd_range": {"min": filters["rmsd_min"], "max": filters["rmsd_max"]},
28
+ "length_range": {"min": filters["length_min"], "max": filters["length_max"]},
29
+ }
30
+ )
31
+ args.output.write_text(json.dumps(summary, indent=2), encoding="utf-8")
32
+ print(f"Wrote {args.output}")
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
backend/space_app.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MuSProt-only FastAPI application for Hugging Face Docker Spaces."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ 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
18
+
19
+
20
+ logging.basicConfig(
21
+ level=os.getenv("LOG_LEVEL", "INFO"),
22
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
23
+ )
24
+ logger = logging.getLogger(__name__)
25
+
26
+ BACKEND_DIR = Path(__file__).resolve().parent
27
+ FRONTEND_DIR = Path(os.getenv("MUSPROT_FRONTEND_DIR", BACKEND_DIR / "frontend"))
28
+
29
+
30
+ @asynccontextmanager
31
+ async def lifespan(_: FastAPI):
32
+ """Initialize the shared read-only database manager."""
33
+ db_path = get_database_path()
34
+ logger.info("Using read-only MuSProt database: %s", db_path)
35
+ manager = DataManager(db_path)
36
+ manager.load_data()
37
+ set_data_manager(manager)
38
+
39
+ if os.getenv("MUSPROT_PRELOAD_NODE_INDEX", "1") == "1":
40
+ tsv_loader._get_index()
41
+
42
+ yield
43
+ set_data_manager(None)
44
+
45
+
46
+ app = FastAPI(
47
+ title="MuSProt",
48
+ description="Multistate protein structure dataset explorer",
49
+ version="1.0.0",
50
+ lifespan=lifespan,
51
+ docs_url="/api/docs",
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")
59
+ async def health():
60
+ return {"status": "healthy", "database": str(get_database_path())}
61
+
62
+
63
+ @app.get("/api/protein/docs", response_class=PlainTextResponse)
64
+ async def protein_docs():
65
+ path = get_docs_path()
66
+ if not path:
67
+ raise HTTPException(status_code=404, detail="MuSProt documentation is not available")
68
+ return path.read_text(encoding="utf-8")
69
+
70
+
71
+ @app.get("/api/protein/download/db")
72
+ async def download_database():
73
+ repo_id = os.getenv("MUSPROT_DATASET_REPO")
74
+ if repo_id:
75
+ revision = os.getenv("MUSPROT_DATASET_REVISION", "main")
76
+ filename = os.getenv("MUSPROT_DB_FILENAME", "MuSProt.db")
77
+ return RedirectResponse(
78
+ f"https://huggingface.co/datasets/{repo_id}/resolve/{revision}/{filename}?download=true"
79
+ )
80
+
81
+ return FileResponse(
82
+ get_database_path(),
83
+ media_type="application/x-sqlite3",
84
+ filename="MuSProt.db",
85
+ )
86
+
87
+
88
+ plots_dir = get_plots_dir()
89
+ if plots_dir:
90
+ app.mount("/api/protein/plots", StaticFiles(directory=plots_dir), name="protein-plots")
91
+
92
+ assets_dir = FRONTEND_DIR / "assets"
93
+ if assets_dir.is_dir():
94
+ app.mount("/assets", StaticFiles(directory=assets_dir), name="frontend-assets")
95
+
96
+
97
+ @app.get("/{path:path}", include_in_schema=False)
98
+ async def frontend(path: str):
99
+ """Serve the React SPA and its pre-rendered routes."""
100
+ requested = FRONTEND_DIR / path
101
+ if path and requested.is_file():
102
+ return FileResponse(requested)
103
+
104
+ prerendered = requested / "index.html"
105
+ if path and prerendered.is_file():
106
+ return FileResponse(prerendered)
107
+
108
+ index = FRONTEND_DIR / "index.html"
109
+ if not index.is_file():
110
+ raise HTTPException(status_code=503, detail="Frontend build is not available")
111
+ return FileResponse(index)
frontend/.prettierignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ node_modules
2
+ build
3
+ coverage
4
+ package-lock.json
frontend/.prettierrc ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "singleQuote": true,
3
+ "semi": true,
4
+ "trailingComma": "es5",
5
+ "printWidth": 100
6
+ }
frontend/eslint.config.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js';
2
+ import globals from 'globals';
3
+ import tseslint from '@typescript-eslint/eslint-plugin';
4
+ import tsparser from '@typescript-eslint/parser';
5
+ import reactHooks from 'eslint-plugin-react-hooks';
6
+ import reactRefresh from 'eslint-plugin-react-refresh';
7
+
8
+ export default [
9
+ {
10
+ ignores: [
11
+ 'build/**',
12
+ 'node_modules/**',
13
+ 'coverage/**',
14
+ 'tests/**',
15
+ 'src/components/benchmarks/**',
16
+ 'src/components/datasets/materials/**',
17
+ 'src/components/evaluation/**',
18
+ 'src/components/home/**',
19
+ 'src/components/ui/**',
20
+ 'src/data/**',
21
+ 'src/pages/benchmarks/**',
22
+ ],
23
+ },
24
+ js.configs.recommended,
25
+ {
26
+ files: ['**/*.{js,mjs,cjs}'],
27
+ languageOptions: {
28
+ globals: {
29
+ ...globals.node,
30
+ },
31
+ },
32
+ },
33
+ {
34
+ files: ['**/*.{ts,tsx}'],
35
+ languageOptions: {
36
+ parser: tsparser,
37
+ parserOptions: {
38
+ ecmaVersion: 'latest',
39
+ sourceType: 'module',
40
+ },
41
+ globals: {
42
+ ...globals.browser,
43
+ ...globals.node,
44
+ },
45
+ },
46
+ plugins: {
47
+ '@typescript-eslint': tseslint,
48
+ 'react-hooks': reactHooks,
49
+ 'react-refresh': reactRefresh,
50
+ },
51
+ rules: {
52
+ ...tseslint.configs.recommended.rules,
53
+ 'react-hooks/rules-of-hooks': 'error',
54
+ 'react-hooks/exhaustive-deps': 'warn',
55
+ 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
56
+ '@typescript-eslint/no-explicit-any': 'off',
57
+ '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
58
+ 'no-undef': 'off',
59
+ },
60
+ },
61
+ ];
frontend/index.html ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "musprot",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://huggingface.co/spaces/wenruifan/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",
13
+ "react-helmet-async": "^3.0.0",
14
+ "react-markdown": "^10.1.0",
15
+ "react-router-dom": "^7.13.0",
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",
22
+ "@typescript-eslint/eslint-plugin": "^8.56.0",
23
+ "@typescript-eslint/parser": "^8.56.0",
24
+ "@vitejs/plugin-react-swc": "^3.10.2",
25
+ "eslint": "^9.39.2",
26
+ "eslint-plugin-react-hooks": "^7.0.1",
27
+ "eslint-plugin-react-refresh": "^0.5.0",
28
+ "globals": "^17.3.0",
29
+ "prettier": "^3.8.1",
30
+ "typescript": "^5.9.3",
31
+ "vite": "^6.4.3"
32
+ },
33
+ "scripts": {
34
+ "dev": "vite",
35
+ "build": "vite build && node scripts/prerender.mjs",
36
+ "lint": "eslint .",
37
+ "lint:fix": "eslint . --fix",
38
+ "format": "prettier --write .",
39
+ "format:check": "prettier --check .",
40
+ "typecheck": "tsc --noEmit -p tsconfig.typecheck.json"
41
+ }
42
+ }
frontend/public/robots.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ User-agent: *
2
+ Allow: /
3
+
4
+ Disallow: /api/
5
+
6
+ Sitemap: https://wenruifan-musprot.hf.space/sitemap.xml
frontend/public/sitemap.xml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3
+ <url>
4
+ <loc>https://wenruifan-musprot.hf.space/</loc>
5
+ <changefreq>monthly</changefreq>
6
+ <priority>1.0</priority>
7
+ </url>
8
+ <url>
9
+ <loc>https://wenruifan-musprot.hf.space/docs</loc>
10
+ <changefreq>monthly</changefreq>
11
+ <priority>0.8</priority>
12
+ </url>
13
+ </urlset>
frontend/scripts/prerender.mjs ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ const BUILD_DIR = 'build';
5
+ const BASE_URL = 'https://wenruifan-musprot.hf.space';
6
+ const template = readFileSync(join(BUILD_DIR, 'index.html'), 'utf-8');
7
+
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
+
22
+ for (const route of routes) {
23
+ const canonical = `${BASE_URL}/${route.path}`;
24
+ const head = [
25
+ `<title>${route.title}</title>`,
26
+ `<meta name="description" content="${route.description}" />`,
27
+ `<link rel="canonical" href="${canonical}" />`,
28
+ `<meta property="og:title" content="${route.title}" />`,
29
+ `<meta property="og:description" content="${route.description}" />`,
30
+ `<meta property="og:url" content="${canonical}" />`,
31
+ `<meta property="og:site_name" content="MuSProt" />`,
32
+ ].join('\n ');
33
+
34
+ const html = template
35
+ .replace(/<title>[^<]*<\/title>/, '')
36
+ .replace(/<meta\s+name="description"[^>]*\/?>/, '')
37
+ .replace('</head>', ` ${head}\n </head>`);
38
+
39
+ const outputDir = route.path ? join(BUILD_DIR, route.path) : BUILD_DIR;
40
+ mkdirSync(outputDir, { recursive: true });
41
+ writeFileSync(join(outputDir, 'index.html'), html, 'utf-8');
42
+ }
frontend/src/App.tsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 />
21
+ </div>
22
+ );
23
+ }
frontend/src/api/protein.ts ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 } 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(pdb_id: string, auth_asym_id: string): Promise<string[]> {
114
+ const response = await api.get<{ functions: string[] }>('/protein/chain/functions', {
115
+ params: { pdb_id, auth_asym_id },
116
+ });
117
+ return response.data.functions;
118
+ },
119
+ };
frontend/src/components/datasets/protein/BindersPanel.tsx ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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">
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">
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">
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">
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">
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">
111
+ <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">CATH ID</div>
112
+ <div className="text-sm font-semibold text-slate-900 font-mono">{metadata?.cath_id || 'uncategorized'}</div>
113
+ </div>
114
+
115
+ <div className="break-inside-avoid mb-3">
116
+ <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">CATH Domain</div>
117
+ <div className="text-sm font-semibold text-slate-900 font-mono">
118
+ {metadata?.cath_superfamily ? (
119
+ <a
120
+ href={`https://www.cathdb.info/version/latest/superfamily/${metadata.cath_superfamily}`}
121
+ target="_blank"
122
+ rel="noopener noreferrer"
123
+ className="text-blue-600 hover:text-blue-800 hover:underline inline-flex items-center gap-1 transition-colors"
124
+ >
125
+ {metadata.cath_superfamily}
126
+ <span className="text-xs">↗</span>
127
+ </a>
128
+ ) : '—'}
129
+ </div>
130
+ </div>
131
+
132
+ <div className="break-inside-avoid mb-3">
133
+ <div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-1">State ID</div>
134
+ <div className="text-sm font-semibold text-slate-900 font-mono">{metadata?.state_id || '—'}</div>
135
+ </div>
136
+ </div>
137
+ </div>
138
+ );
139
+ };
140
+
141
+ export default ChainBasicInfoCards;
frontend/src/components/datasets/protein/ChainSearchInput.tsx ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: "3vgm.A", "3VGM:A", "3VGM_A", "3vgm 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., "3vgm.A", "3VGM:A", "3VGM_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., 3vgm.A, 3VGM:A, 3VGM_A, 3vgm 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., 3vgm.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 ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 <span className="table-hint">(Matched)</span> {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('delta_rosetta')}>
106
+ ΔRosetta {getSortIcon('delta_rosetta')}
107
+ </th>
108
+ <th onClick={() => handleSort('delta_foldx')}>
109
+ ΔFoldX {getSortIcon('delta_foldx')}
110
+ </th>
111
+ <th onClick={() => handleSort('delta_evoef2')}>
112
+ ΔEvoEF2 {getSortIcon('delta_evoef2')}
113
+ </th>
114
+ <th onClick={() => handleSort('delta_rm')}>
115
+ ΔRM {getSortIcon('delta_rm')}
116
+ </th>
117
+ <th onClick={() => handleSort('delta_rm_plus')}>
118
+ ΔRM+ {getSortIcon('delta_rm_plus')}
119
+ </th>
120
+ <th onClick={() => handleSort('state_id_b')}>
121
+ State ID {getSortIcon('state_id_b')}
122
+ </th>
123
+ <th onClick={() => handleSort('state_fidelity')}>
124
+ State fidelity {getSortIcon('state_fidelity')}
125
+ </th>
126
+ <th onClick={() => handleSort('avg_sim')}>
127
+ State avg. similarity {getSortIcon('avg_sim')}
128
+ </th>
129
+ <th onClick={() => handleSort('pair_fidelity')}>
130
+ Observation fidelity {getSortIcon('pair_fidelity')}
131
+ </th>
132
+ <th onClick={() => handleSort('structure_sim')}>
133
+ Observation similarity {getSortIcon('structure_sim')}
134
+ </th>
135
+ </tr>
136
+ </thead>
137
+ <tbody>
138
+ {paginatedData.map((record, index) => (
139
+ <tr key={`${record.pdb_id_a}-${record.auth_asym_id_a}-${record.pdb_id_b}-${record.auth_asym_id_b}-${index}`}>
140
+ <td>
141
+ <span className="chain-id">{record.pdb_id_b.toUpperCase()}</span>
142
+ </td>
143
+ <td>
144
+ <span className="asym-id">{record.auth_asym_id_b}</span>
145
+ </td>
146
+ <td className="numeric">{record.tm_score.toFixed(3)}</td>
147
+ <td className="numeric">{record.rmsd.toFixed(2)}</td>
148
+ <td className="experimental-method">
149
+ {record.exptl_method || <span className="placeholder">—</span>}
150
+ </td>
151
+ <td className="numeric">
152
+ {formatTemperature(record.temp)}
153
+ </td>
154
+ <td className="numeric">
155
+ {formatPH(record.pH)}
156
+ </td>
157
+ {(['delta_rosetta', 'delta_foldx', 'delta_evoef2', 'delta_rm', 'delta_rm_plus'] as const).map(field => {
158
+ const val = record[field];
159
+ return (
160
+ <td key={field} className={`numeric delta-cell${val == null ? '' : val > 0 ? ' delta-pos' : val < 0 ? ' delta-neg' : ''}`}>
161
+ {val == null ? <span className="placeholder">—</span> : `${val > 0 ? '+' : ''}${val.toFixed(2)}`}
162
+ </td>
163
+ );
164
+ })}
165
+ <td className="font-mono">{record.state_id_b ?? <span className="placeholder">—</span>}</td>
166
+ <td>{record.state_fidelity ?? <span className="placeholder">—</span>}</td>
167
+ <td>{record.avg_sim ?? <span className="placeholder">—</span>}</td>
168
+ <td>{record.pair_fidelity ?? <span className="placeholder">—</span>}</td>
169
+ <td className="numeric">{record.structure_sim != null ? record.structure_sim.toFixed(3) : <span className="placeholder">—</span>}</td>
170
+ </tr>
171
+ ))}
172
+ </tbody>
173
+ </table>
174
+ </div>
175
+
176
+ {totalPages > 1 && (
177
+ <div className="pagination">
178
+ <button
179
+ onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
180
+ disabled={currentPage === 1}
181
+ >
182
+ Previous
183
+ </button>
184
+ <span className="page-info">
185
+ Page {currentPage} of {totalPages}
186
+ </span>
187
+ <button
188
+ onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
189
+ disabled={currentPage === totalPages}
190
+ >
191
+ Next
192
+ </button>
193
+ </div>
194
+ )}
195
+ </div>
196
+ );
197
+ };
frontend/src/components/datasets/protein/ErrorBoundary.css ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="flex justify-between items-center py-2 border-b border-slate-100">
93
+ {formattedDetails && (
94
+ <div className="experimental-item experimental-details">
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">
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 ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react';
2
+ import { proteinApi } from '../../../api/protein';
3
+
4
+ interface FunctionPanelProps {
5
+ pdbId: string;
6
+ chainId: string;
7
+ }
8
+
9
+ const FunctionPanel: React.FC<FunctionPanelProps> = ({ pdbId, chainId }) => {
10
+ const [functions, setFunctions] = useState<string[] | null>(null);
11
+ const [loading, setLoading] = useState(true);
12
+ const [notFound, setNotFound] = useState(false);
13
+
14
+ useEffect(() => {
15
+ let cancelled = false;
16
+ setLoading(true);
17
+ setNotFound(false);
18
+ setFunctions(null);
19
+
20
+ proteinApi.getChainFunctions(pdbId, chainId)
21
+ .then(fns => { if (!cancelled) setFunctions(fns); })
22
+ .catch(() => { if (!cancelled) setNotFound(true); })
23
+ .finally(() => { if (!cancelled) setLoading(false); });
24
+
25
+ return () => { cancelled = true; };
26
+ }, [pdbId, chainId]);
27
+
28
+ return (
29
+ <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">
30
+ {/* Header */}
31
+ <div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
32
+ <h3 className="text-xl font-semibold text-slate-900">Ranked Functions</h3>
33
+ {functions && (
34
+ <span className="text-sm text-slate-500">
35
+ {functions.length} annotation{functions.length !== 1 ? 's' : ''}
36
+ </span>
37
+ )}
38
+ </div>
39
+
40
+ {/* Scrollable body */}
41
+ <div className="px-6 py-4" style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '14rem' }}>
42
+ {loading && (
43
+ <p className="text-sm text-slate-500 text-center py-4">Loading functions…</p>
44
+ )}
45
+
46
+ {!loading && (notFound || (functions && functions.length === 0)) && (
47
+ <p className="text-sm text-slate-500 text-center py-4">No functional annotation available</p>
48
+ )}
49
+
50
+ {!loading && functions && functions.length > 0 && (
51
+ <ol className="space-y-3">
52
+ {functions.map((fn, idx) => (
53
+ <li key={idx} className="flex gap-3 text-sm text-slate-700" style={{ minWidth: 0 }}>
54
+ <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">
55
+ {idx + 1}
56
+ </span>
57
+ <span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{fn}</span>
58
+ </li>
59
+ ))}
60
+ </ol>
61
+ )}
62
+ </div>
63
+ </div>
64
+ );
65
+ };
66
+
67
+ export default FunctionPanel;
frontend/src/components/datasets/protein/ProteinDashboard.tsx ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
2
+ import { Link } from 'react-router-dom';
3
+ import { Home, Download, BookOpen } from 'lucide-react';
4
+ import { FilterPanel } from './FilterPanel';
5
+ import { StaticDistributionPlots } from './StaticDistributionPlots';
6
+ import { DataTable } from './DataTable';
7
+ import { StatsPanel } from './StatsPanel';
8
+ import ChainBasicInfoCards from './ChainBasicInfoCards';
9
+ import SequencePanel from './SequencePanel';
10
+ import ExperimentalConditionsPanel from './ExperimentalConditionsPanel';
11
+ import BindersPanel from './BindersPanel';
12
+ import FunctionPanel from './FunctionPanel';
13
+ import ErrorBoundary from './ErrorBoundary';
14
+ import { proteinApi } from '../../../api/protein';
15
+ import type { FilterParams, FilterOptions, DataResponse, SummaryStats, ChainMetadata } from '../../../types/protein';
16
+ import { PageSEO } from '../../seo/PageSEO';
17
+ import { MuSProtJsonLd } from '../../seo/MuSProtJsonLd';
18
+
19
+ interface CacheEntry {
20
+ status: 'success' | 'not_found' | 'error';
21
+ timestamp: number;
22
+ message?: string;
23
+ metadata?: ChainMetadata;
24
+ }
25
+
26
+ const NEGATIVE_CACHE_TTL = 10 * 60 * 1000; // 10 minutes
27
+
28
+ export function ProteinDashboard() {
29
+ // State management
30
+ const [filterOptions, setFilterOptions] = useState<FilterOptions | null>(null);
31
+ const [filters, setFilters] = useState<FilterParams>({});
32
+ const [dataResponse, setDataResponse] = useState<DataResponse | null>(null);
33
+ const [summaryStats, setSummaryStats] = useState<SummaryStats | null>(null);
34
+
35
+ // Search input state (controlled)
36
+ const [searchInputValue, setSearchInputValue] = useState('');
37
+ const [searchStatus, setSearchStatus] = useState<'idle' | 'typing' | 'loading' | 'success' | 'not_found' | 'error'>('idle');
38
+ const [searchError, setSearchError] = useState<string | null>(null);
39
+
40
+ // Chain metadata cache (client-side) — used for the searched chain's detail panels
41
+ const [chainMetadataCache, setChainMetadataCache] = useState<Record<string, ChainMetadata>>({});
42
+
43
+ // Request control refs
44
+ const negativeCache = useRef<Map<string, CacheEntry>>(new Map());
45
+ const inFlightResolve = useRef<{ key: string; controller: AbortController } | null>(null);
46
+ const lastAttemptedKey = useRef<string | null>(null);
47
+
48
+ const [, setLoadingFilters] = useState(true);
49
+ const [loadingData, setLoadingData] = useState(false);
50
+ const [loadingStats, setLoadingStats] = useState(false);
51
+
52
+ const [error, setError] = useState<string | null>(null);
53
+
54
+ // Load filter options on mount
55
+ useEffect(() => {
56
+ const loadFilters = async () => {
57
+ try {
58
+ setLoadingFilters(true);
59
+ const options = await proteinApi.getFilters();
60
+ setFilterOptions(options);
61
+ } catch (err) {
62
+ setError('Failed to load filter options. The MuSProt database may still be initializing.');
63
+ console.error('Error loading filters:', err);
64
+ } finally {
65
+ setLoadingFilters(false);
66
+ }
67
+ };
68
+
69
+ loadFilters();
70
+ }, []);
71
+
72
+ const fetchData = useCallback(async () => {
73
+ try {
74
+ setLoadingData(true);
75
+ setError(null);
76
+ const response = await proteinApi.getData(filters);
77
+ setDataResponse(response);
78
+ } catch (err) {
79
+ setError('Failed to fetch data. Please check your filters and try again.');
80
+ console.error('Error fetching data:', err);
81
+ } finally {
82
+ setLoadingData(false);
83
+ }
84
+ }, [filters]);
85
+
86
+ const fetchStats = useCallback(async () => {
87
+ try {
88
+ setLoadingStats(true);
89
+ const stats = await proteinApi.getSummary(filters);
90
+ setSummaryStats(stats);
91
+ } catch (err) {
92
+ console.error('Error fetching stats:', err);
93
+ } finally {
94
+ setLoadingStats(false);
95
+ }
96
+ }, [filters]);
97
+
98
+ // Debounced data fetching — skip fetchData when there's no active chain search
99
+ useEffect(() => {
100
+ const timeoutId = setTimeout(() => {
101
+ if (filters.pdb_id && filters.auth_asym_id) {
102
+ fetchData();
103
+ }
104
+ fetchStats();
105
+ }, 500);
106
+
107
+ return () => clearTimeout(timeoutId);
108
+ }, [fetchData, fetchStats, filters.pdb_id, filters.auth_asym_id]);
109
+
110
+ const handleFiltersChange = (newFilters: FilterParams) => {
111
+ setFilters(newFilters);
112
+ };
113
+
114
+ const handleSearchInputChange = useCallback((value: string) => {
115
+ setSearchInputValue(value);
116
+ if (value && searchStatus !== 'loading') {
117
+ setSearchStatus('typing');
118
+ // Clear last attempted key when user starts typing new value
119
+ lastAttemptedKey.current = null;
120
+ } else if (!value) {
121
+ setSearchStatus('idle');
122
+ setSearchError(null);
123
+ }
124
+ }, [searchStatus]);
125
+
126
+ const handleSearchClear = useCallback(() => {
127
+ setSearchInputValue('');
128
+ setSearchStatus('idle');
129
+ setSearchError(null);
130
+ setFilters({});
131
+ lastAttemptedKey.current = null;
132
+ // Abort any in-flight request
133
+ if (inFlightResolve.current) {
134
+ inFlightResolve.current.controller.abort();
135
+ inFlightResolve.current = null;
136
+ }
137
+ }, []);
138
+
139
+ // Cleanup on unmount
140
+ useEffect(() => {
141
+ return () => {
142
+ // Abort any in-flight requests
143
+ if (inFlightResolve.current) {
144
+ inFlightResolve.current.controller.abort();
145
+ }
146
+ };
147
+ }, []);
148
+
149
+ const handleChainMetadataLoaded = useCallback((metadata: ChainMetadata) => {
150
+ const key = `${metadata.pdb_id.toLowerCase()}|${metadata.chain_id.toUpperCase()}`;
151
+ setChainMetadataCache(prev => ({ ...prev, [key]: metadata }));
152
+ setSearchStatus('success');
153
+ setSearchError(null);
154
+
155
+ negativeCache.current.set(key, {
156
+ status: 'success',
157
+ timestamp: Date.now(),
158
+ metadata
159
+ });
160
+ }, []);
161
+
162
+ const resolveChainWrapper = useCallback(async (pdb_id: string, auth_asym_id: string): Promise<ChainMetadata> => {
163
+ const key = `${pdb_id.toLowerCase()}|${auth_asym_id.toUpperCase()}`;
164
+
165
+ // Check if same key as last attempt (prevent duplicate requests)
166
+ if (key === lastAttemptedKey.current && inFlightResolve.current?.key === key) {
167
+ throw new Error('Request already in flight');
168
+ }
169
+
170
+ // Check positive cache first
171
+ if (chainMetadataCache[key]) {
172
+ setSearchStatus('success');
173
+ return chainMetadataCache[key];
174
+ }
175
+
176
+ // Check negative cache
177
+ const cachedEntry = negativeCache.current.get(key);
178
+ if (cachedEntry) {
179
+ const age = Date.now() - cachedEntry.timestamp;
180
+ if (age < NEGATIVE_CACHE_TTL) {
181
+ if (cachedEntry.status === 'not_found') {
182
+ setSearchStatus('not_found');
183
+ setSearchError(cachedEntry.message || `No chain '${auth_asym_id}' found in PDB ${pdb_id.toUpperCase()}. Check the chain ID or entry.`);
184
+ const error: any = new Error('Chain not found');
185
+ error.code = 'NOT_FOUND';
186
+ throw error;
187
+ } else if (cachedEntry.status === 'error') {
188
+ setSearchStatus('error');
189
+ setSearchError(cachedEntry.message || 'Failed to resolve chain');
190
+ throw new Error(cachedEntry.message || 'Failed to resolve chain');
191
+ }
192
+ } else {
193
+ // TTL expired, remove from cache
194
+ negativeCache.current.delete(key);
195
+ }
196
+ }
197
+
198
+ // Abort previous request if different key
199
+ if (inFlightResolve.current && inFlightResolve.current.key !== key) {
200
+ inFlightResolve.current.controller.abort();
201
+ inFlightResolve.current = null;
202
+ }
203
+
204
+ // Start new request
205
+ lastAttemptedKey.current = key;
206
+ const controller = new AbortController();
207
+ inFlightResolve.current = { key, controller };
208
+
209
+ setSearchStatus('loading');
210
+ setSearchError(null);
211
+
212
+ try {
213
+ const metadata = await proteinApi.resolveChain(pdb_id, auth_asym_id, { signal: controller.signal });
214
+
215
+ // Only update state if not aborted
216
+ if (inFlightResolve.current?.key === key) {
217
+ inFlightResolve.current = null;
218
+ }
219
+
220
+ return metadata;
221
+ } catch (err: any) {
222
+ // Ignore aborted requests
223
+ if (err.name === 'AbortError' || err.name === 'CanceledError') {
224
+ return Promise.reject(err);
225
+ }
226
+
227
+ // Clear in-flight if this was our request
228
+ if (inFlightResolve.current?.key === key) {
229
+ inFlightResolve.current = null;
230
+ }
231
+
232
+ // Handle 404 not found
233
+ if (err.code === 'NOT_FOUND' || err.status === 404) {
234
+ const message = err.detail || `No chain '${auth_asym_id}' found in PDB ${pdb_id.toUpperCase()}. Check the chain ID or entry.`;
235
+ negativeCache.current.set(key, {
236
+ status: 'not_found',
237
+ timestamp: Date.now(),
238
+ message
239
+ });
240
+ setSearchStatus('not_found');
241
+ setSearchError(message);
242
+ } else {
243
+ // Other errors
244
+ const message = err.response?.data?.detail || err.message || 'Failed to resolve chain';
245
+ negativeCache.current.set(key, {
246
+ status: 'error',
247
+ timestamp: Date.now(),
248
+ message
249
+ });
250
+ setSearchStatus('error');
251
+ setSearchError(message);
252
+ }
253
+
254
+ throw err;
255
+ }
256
+ }, [chainMetadataCache]);
257
+
258
+ // exptl_method, temp, pH are now included directly in each record from the DB
259
+ const enrichedData = useMemo(() => dataResponse?.data ?? [], [dataResponse?.data]);
260
+
261
+ // Determine if we have an active chain search
262
+ const hasActiveChainSearch = filters.pdb_id && filters.auth_asym_id;
263
+ const activeChainKey = hasActiveChainSearch
264
+ ? `${filters.pdb_id!.toLowerCase()}|${filters.auth_asym_id!.toUpperCase()}`
265
+ : null;
266
+ const activeChainMetadata = activeChainKey ? chainMetadataCache[activeChainKey] : null;
267
+
268
+ // Generate resolved label for success message
269
+ const resolvedLabel = hasActiveChainSearch
270
+ ? `Found ${filters.pdb_id!.toUpperCase()} Chain ${filters.auth_asym_id!.toUpperCase()}`
271
+ : undefined;
272
+
273
+ return (
274
+ <div className="protein-dashboard min-h-screen bg-white">
275
+ <PageSEO
276
+ title="MuSProt: Multistate Protein Structure Database"
277
+ 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."
278
+ path="/"
279
+ />
280
+ <MuSProtJsonLd />
281
+ {/* Breadcrumb */}
282
+ <section className="bg-slate-50 py-4 border-b border-gray-200">
283
+ <div className="max-w-7xl mx-auto px-6 flex items-center justify-between">
284
+ <nav className="flex items-center gap-2 text-sm">
285
+ <Link
286
+ to="/"
287
+ className="text-slate-600 hover:text-slate-900 transition-colors flex items-center gap-1"
288
+ >
289
+ <Home className="w-4 h-4" />
290
+ Home
291
+ </Link>
292
+ <span className="text-slate-900 font-medium">MuSProt: Multistate Protein Database</span>
293
+ </nav>
294
+ <div className="flex items-center gap-3">
295
+ <Link
296
+ to="/docs"
297
+ className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium text-slate-700 border border-gray-200 rounded-lg hover:border-slate-900 hover:text-slate-900 transition-colors bg-white"
298
+ >
299
+ <BookOpen className="w-4 h-4" />
300
+ Docs
301
+ </Link>
302
+ <a
303
+ href="/api/protein/download/db"
304
+ download="MuSProt.db"
305
+ className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium text-white bg-slate-900 rounded-lg hover:bg-slate-700 transition-colors"
306
+ >
307
+ <Download className="w-4 h-4" />
308
+ Download DB
309
+ </a>
310
+ </div>
311
+ </div>
312
+ </section>
313
+
314
+ {error && (
315
+ <div className="max-w-7xl mx-auto px-6 mt-4">
316
+ <div className="protein-error-banner">
317
+ <span>⚠️ {error}</span>
318
+ <button onClick={() => setError(null)}>×</button>
319
+ </div>
320
+ </div>
321
+ )}
322
+
323
+ <div className="max-w-7xl mx-auto px-6 py-6">
324
+ <main className="space-y-6">
325
+ {hasActiveChainSearch && activeChainMetadata ? (
326
+ <>
327
+ {/* First Row: Search + Chain Information (left-right 3:7 split) */}
328
+ <div className="grid gap-6 items-stretch" style={{ gridTemplateColumns: 'minmax(0, 3fr) minmax(0, 7fr)' }}>
329
+ <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">
330
+ <FilterPanel
331
+ filterOptions={filterOptions}
332
+ filters={filters}
333
+ searchInputValue={searchInputValue}
334
+ onSearchInputChange={handleSearchInputChange}
335
+ onSearchClear={handleSearchClear}
336
+ searchStatus={searchStatus}
337
+ searchError={searchError}
338
+ resolvedLabel={resolvedLabel}
339
+ onFiltersChange={handleFiltersChange}
340
+ onChainMetadataLoaded={handleChainMetadataLoaded}
341
+ resolveChain={resolveChainWrapper}
342
+ loading={loadingData}
343
+ showResetButton={true}
344
+ />
345
+ </section>
346
+
347
+ <section className="min-w-0 h-full flex">
348
+ <ErrorBoundary componentName="Chain Information">
349
+ <ChainBasicInfoCards metadata={activeChainMetadata} />
350
+ </ErrorBoundary>
351
+ </section>
352
+ </div>
353
+
354
+ {/* Second Row: Sequence, Experimental Conditions, Binders (3 columns, equal height) */}
355
+ <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
356
+ <div 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">
357
+ <ErrorBoundary componentName="Sequence Panel">
358
+ <SequencePanel metadata={activeChainMetadata} />
359
+ </ErrorBoundary>
360
+ </div>
361
+ <div 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">
362
+ <ErrorBoundary componentName="Experimental Conditions">
363
+ <ExperimentalConditionsPanel metadata={activeChainMetadata} />
364
+ </ErrorBoundary>
365
+ </div>
366
+ {activeChainMetadata.nonpolymer_entities && activeChainMetadata.nonpolymer_entities.length > 0 && (
367
+ <div 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
+ <ErrorBoundary componentName="Binders Panel">
369
+ <BindersPanel metadata={activeChainMetadata} />
370
+ </ErrorBoundary>
371
+ </div>
372
+ )}
373
+ </div>
374
+
375
+ {/* Functions block — full width, between panels and Results */}
376
+ <ErrorBoundary componentName="Function Panel">
377
+ <FunctionPanel pdbId={filters.pdb_id!} chainId={filters.auth_asym_id!} />
378
+ </ErrorBoundary>
379
+ </>
380
+ ) : (
381
+ <>
382
+ {/* Search Panel Full Width */}
383
+ <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">
384
+ <FilterPanel
385
+ filterOptions={filterOptions}
386
+ filters={filters}
387
+ searchInputValue={searchInputValue}
388
+ onSearchInputChange={handleSearchInputChange}
389
+ onSearchClear={handleSearchClear}
390
+ searchStatus={searchStatus}
391
+ searchError={searchError}
392
+ resolvedLabel={resolvedLabel}
393
+ onFiltersChange={handleFiltersChange}
394
+ onChainMetadataLoaded={handleChainMetadataLoaded}
395
+ resolveChain={resolveChainWrapper}
396
+ loading={loadingData}
397
+ showResetButton={false}
398
+ />
399
+ </section>
400
+
401
+ {/* Default Dataset Summary View */}
402
+ <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">
403
+ <StatsPanel stats={summaryStats} loading={loadingStats} />
404
+ </section>
405
+
406
+ <section>
407
+ <StaticDistributionPlots />
408
+ </section>
409
+ </>
410
+ )}
411
+
412
+ {hasActiveChainSearch && (
413
+ <section className="protein-table-section">
414
+ <DataTable
415
+ data={enrichedData}
416
+ loading={loadingData}
417
+ total={dataResponse?.total || 0}
418
+ filtered={dataResponse?.filtered || 0}
419
+ />
420
+ </section>
421
+ )}
422
+ </main>
423
+ </div>
424
+ </div>
425
+ );
426
+ }
frontend/src/components/datasets/protein/SequencePanel.tsx ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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">
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] text-right select-none">{idx * 60 + 1}</span>
118
+ <span className="text-gray-800 tracking-wide break-all">{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 ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ const IMG_BASE = '/api/protein/plots';
4
+ const CACHE_BUST = '?v=20260527';
5
+
6
+ const plots = [
7
+ { name: 'Structure Similarity', file: 'TM-RMSD.png' },
8
+ { name: 'TM-Align Distribution', file: 'TM-Dis.png' },
9
+ { name: 'RMSD Distribution', file: 'RMSD-Dis.png' },
10
+ { name: 'State Distribution', file: 'States-Dis.png' },
11
+ { name: 'Sequence Length Distribution', file: 'Seq-Len.png' },
12
+ { name: 'Function Rank Correlation vs. Structure Similarity', file: 'Func_rank.png' },
13
+ ];
14
+
15
+ export const StaticDistributionPlots: React.FC = () => {
16
+ return (
17
+ <div style={{ columns: '400px', columnGap: '1.5rem' }}>
18
+ {plots.map(({ name, file }) => (
19
+ <div
20
+ key={file}
21
+ style={{ breakInside: 'avoid', marginBottom: '1.5rem' }}
22
+ 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"
23
+ >
24
+ <div className="px-4 pt-5 pb-2 text-xl font-semibold text-slate-900">{name}</div>
25
+ <div className="px-4 pb-4">
26
+ <img
27
+ src={`${IMG_BASE}/${file}${CACHE_BUST}`}
28
+ alt={name}
29
+ style={{ width: '100%', height: 'auto', display: 'block' }}
30
+ />
31
+ </div>
32
+ </div>
33
+ ))}
34
+ </div>
35
+ );
36
+ };
frontend/src/components/datasets/protein/StatsPanel.css ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .stats-panel {
2
+ display: grid;
3
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
4
+ gap: 15px;
5
+ padding: 0;
6
+ }
7
+
8
+ .stat-card {
9
+ background: white;
10
+ padding: 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: 2rem;
24
+ font-weight: 700;
25
+ color: #2196f3;
26
+ margin-bottom: 5px;
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 ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import type { SummaryStats } from '../../../types/protein';
3
+ import './StatsPanel.css';
4
+
5
+ interface StatsPanelProps {
6
+ stats: SummaryStats | null;
7
+ loading: boolean;
8
+ }
9
+
10
+ export const StatsPanel: React.FC<StatsPanelProps> = ({ stats, loading }) => {
11
+ if (loading || !stats) {
12
+ return null;
13
+ }
14
+
15
+ return (
16
+ <div className="stats-panel">
17
+ <div className="stat-card">
18
+ <div className="stat-value">{stats.total_records.toLocaleString()}</div>
19
+ <div className="stat-label">Total Records</div>
20
+ </div>
21
+
22
+ <div className="stat-card">
23
+ <div className="stat-value">{stats.unique_chains.toLocaleString()}</div>
24
+ <div className="stat-label">Unique Chains</div>
25
+ </div>
26
+
27
+ <div className="stat-card">
28
+ <div className="stat-value">{stats.avg_rmsd.toFixed(2)} Å</div>
29
+ <div className="stat-label">Avg RMSD</div>
30
+ </div>
31
+
32
+ <div className="stat-card">
33
+ <div className="stat-value">{stats.avg_tm_score.toFixed(3)}</div>
34
+ <div className="stat-label">Avg TM-score</div>
35
+ </div>
36
+
37
+ <div className="stat-card">
38
+ <div className="stat-value">{Math.round(stats.avg_sequence_length)}</div>
39
+ <div className="stat-label">Avg Length</div>
40
+ </div>
41
+ </div>
42
+ );
43
+ };
frontend/src/components/layout/Footer.tsx ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Database } from 'lucide-react';
2
+
3
+ export function Footer() {
4
+ return (
5
+ <footer className="bg-slate-900 text-white mt-12">
6
+ <div className="max-w-7xl mx-auto px-6 py-8 flex flex-col md:flex-row gap-4 items-center justify-between">
7
+ <div className="flex items-center gap-2">
8
+ <Database className="w-5 h-5" />
9
+ <span className="font-semibold">MuSProt</span>
10
+ </div>
11
+ <p className="text-sm text-slate-400">
12
+ Multistate protein structures, pairwise comparisons, energy scores, and functional annotations.
13
+ </p>
14
+ <a
15
+ href="https://huggingface.co/spaces/wenruifan/MuSProt"
16
+ target="_blank"
17
+ rel="noopener noreferrer"
18
+ className="text-sm text-slate-400 hover:text-white transition-colors"
19
+ >
20
+ Hosted on Hugging Face
21
+ </a>
22
+ </div>
23
+ </footer>
24
+ );
25
+ }
frontend/src/components/layout/Header.tsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { BookOpen, Database, Download } from 'lucide-react';
2
+ import { Link, useLocation } from 'react-router-dom';
3
+
4
+ export function Header() {
5
+ const location = useLocation();
6
+
7
+ return (
8
+ <header className="border-b border-gray-200 bg-white sticky top-0 z-50 shadow-sm">
9
+ <div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between gap-6">
10
+ <Link to="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
11
+ <div className="w-9 h-9 bg-slate-900 rounded-lg flex items-center justify-center">
12
+ <Database className="w-5 h-5 text-white" />
13
+ </div>
14
+ <div>
15
+ <div className="text-xl font-semibold text-slate-900">MuSProt</div>
16
+ <div className="text-xs text-slate-500">Multistate Protein Structure Database</div>
17
+ </div>
18
+ </Link>
19
+
20
+ <nav className="flex items-center gap-2">
21
+ <Link
22
+ to="/"
23
+ className={`flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg transition-colors ${
24
+ location.pathname === '/' ? 'bg-slate-100 text-slate-900 font-semibold' : 'text-slate-600 hover:bg-slate-50'
25
+ }`}
26
+ >
27
+ <Database className="w-4 h-4" />
28
+ Explorer
29
+ </Link>
30
+ <Link
31
+ to="/docs"
32
+ className={`flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg transition-colors ${
33
+ location.pathname === '/docs' ? 'bg-slate-100 text-slate-900 font-semibold' : 'text-slate-600 hover:bg-slate-50'
34
+ }`}
35
+ >
36
+ <BookOpen className="w-4 h-4" />
37
+ Docs
38
+ </Link>
39
+ <a
40
+ href="/api/protein/download/db"
41
+ className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-slate-900 rounded-lg hover:bg-slate-700 transition-colors"
42
+ >
43
+ <Download className="w-4 h-4" />
44
+ Download DB
45
+ </a>
46
+ </nav>
47
+ </div>
48
+ </header>
49
+ );
50
+ }