Deploy MuSProt React and FastAPI application
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +13 -0
- .env.example +11 -0
- .gitignore +18 -0
- Dockerfile +21 -13
- README.md +74 -10
- backend/app/__init__.py +0 -0
- backend/app/protein/__init__.py +1 -0
- backend/app/protein/api/__init__.py +1 -0
- backend/app/protein/api/routes/__init__.py +1 -0
- backend/app/protein/api/routes/protein.py +263 -0
- backend/app/protein/chain_resolver.py +519 -0
- backend/app/protein/config.py +94 -0
- backend/app/protein/data_loader.py +324 -0
- backend/app/protein/database.py +13 -0
- backend/app/protein/fetch_chain_info_from_pdb.py +303 -0
- backend/app/protein/models.py +140 -0
- backend/app/protein/tsv_loader.py +181 -0
- backend/assets/MuSProt_documentation.md +138 -0
- backend/assets/musprot_summary.json +42 -0
- backend/requirements.txt +6 -0
- backend/scripts/generate_summary.py +36 -0
- backend/space_app.py +111 -0
- frontend/.prettierignore +4 -0
- frontend/.prettierrc +6 -0
- frontend/eslint.config.js +61 -0
- frontend/index.html +16 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +42 -0
- frontend/public/robots.txt +6 -0
- frontend/public/sitemap.xml +13 -0
- frontend/scripts/prerender.mjs +42 -0
- frontend/src/App.tsx +23 -0
- frontend/src/api/protein.ts +119 -0
- frontend/src/components/datasets/protein/BindersPanel.tsx +108 -0
- frontend/src/components/datasets/protein/ChainBasicInfoCards.tsx +141 -0
- frontend/src/components/datasets/protein/ChainSearchInput.tsx +204 -0
- frontend/src/components/datasets/protein/DataTable.css +177 -0
- frontend/src/components/datasets/protein/DataTable.tsx +197 -0
- frontend/src/components/datasets/protein/ErrorBoundary.css +93 -0
- frontend/src/components/datasets/protein/ErrorBoundary.tsx +105 -0
- frontend/src/components/datasets/protein/ExperimentalConditionsPanel.tsx +117 -0
- frontend/src/components/datasets/protein/FilterPanel.tsx +75 -0
- frontend/src/components/datasets/protein/FunctionPanel.tsx +67 -0
- frontend/src/components/datasets/protein/ProteinDashboard.tsx +426 -0
- frontend/src/components/datasets/protein/SequencePanel.tsx +137 -0
- frontend/src/components/datasets/protein/StaticDistributionPlots.tsx +36 -0
- frontend/src/components/datasets/protein/StatsPanel.css +52 -0
- frontend/src/components/datasets/protein/StatsPanel.tsx +43 -0
- frontend/src/components/layout/Footer.tsx +25 -0
- 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 |
-
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
|
| 11 |
-
|
| 12 |
-
COPY
|
|
|
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
EXPOSE 8501
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
app_port: 8501
|
| 8 |
-
tags:
|
| 9 |
-
- streamlit
|
| 10 |
pinned: false
|
| 11 |
-
short_description: A
|
| 12 |
license: mit
|
| 13 |
---
|
| 14 |
|
| 15 |
-
#
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|