Commit ·
4e1ae88
1
Parent(s): c46f2c2
Show binding partners and merge them with binders into an Interactions section
Browse files- backend/app/protein/api/routes/explore.py +7 -0
- backend/app/protein/catalog.py +31 -0
- backend/app/protein/records.py +62 -0
- backend/assets/MuSProt_documentation.md +40 -2
- backend/scripts/build_index.py +129 -4
- frontend/src/components/Partners.tsx +324 -0
- frontend/src/components/StructureDiff.tsx +14 -4
- frontend/src/components/Transitions.tsx +41 -7
- frontend/src/lib/api.ts +42 -0
- frontend/src/pages/ChainPage.tsx +124 -48
- frontend/src/pages/HomePage.tsx +1 -1
- frontend/src/pages/SequencePage.tsx +65 -13
- frontend/src/pages/StatisticsPage.tsx +57 -0
- frontend/src/styles/pages.css +289 -0
backend/app/protein/api/routes/explore.py
CHANGED
|
@@ -79,6 +79,7 @@ async def get_chain(pdb_id: str, chain_id: str):
|
|
| 79 |
raise HTTPException(status_code=404, detail=f"{pdb_id.upper()}.{chain_id} is not in MuSProt")
|
| 80 |
node["family"] = await run_in_threadpool(catalog.sequence_summary, node["sequence_id"])
|
| 81 |
node["ecod_names"] = await run_in_threadpool(catalog.ecod_names, None, node["ecod_fids"])
|
|
|
|
| 82 |
return node
|
| 83 |
|
| 84 |
|
|
@@ -100,12 +101,18 @@ async def get_chain_transitions(pdb_id: str, chain_id: str):
|
|
| 100 |
chain_composition=m["chain_composition"],
|
| 101 |
binders=m["binders"],
|
| 102 |
release_date=m["release_date"],
|
|
|
|
| 103 |
)
|
|
|
|
|
|
|
|
|
|
| 104 |
return {
|
| 105 |
"pdb_id": node["pdb_id"],
|
| 106 |
"auth_asym_id": node["auth_asym_id"],
|
| 107 |
"state_id": node["state_id"],
|
| 108 |
"sequence_id": node["sequence_id"],
|
|
|
|
|
|
|
| 109 |
"transitions": edges,
|
| 110 |
}
|
| 111 |
|
|
|
|
| 79 |
raise HTTPException(status_code=404, detail=f"{pdb_id.upper()}.{chain_id} is not in MuSProt")
|
| 80 |
node["family"] = await run_in_threadpool(catalog.sequence_summary, node["sequence_id"])
|
| 81 |
node["ecod_names"] = await run_in_threadpool(catalog.ecod_names, None, node["ecod_fids"])
|
| 82 |
+
node["binding_partners"] = await run_in_threadpool(records.get_partners, node["pdb_id"], node["auth_asym_id"])
|
| 83 |
return node
|
| 84 |
|
| 85 |
|
|
|
|
| 101 |
chain_composition=m["chain_composition"],
|
| 102 |
binders=m["binders"],
|
| 103 |
release_date=m["release_date"],
|
| 104 |
+
partners=m["partners"],
|
| 105 |
)
|
| 106 |
+
home = members.get((node["pdb_id"], node["auth_asym_id"]))
|
| 107 |
+
keys = {k for m in members.values() for k in m["partners"]}
|
| 108 |
+
names = await run_in_threadpool(catalog.partner_names, None, keys)
|
| 109 |
return {
|
| 110 |
"pdb_id": node["pdb_id"],
|
| 111 |
"auth_asym_id": node["auth_asym_id"],
|
| 112 |
"state_id": node["state_id"],
|
| 113 |
"sequence_id": node["sequence_id"],
|
| 114 |
+
"partners": home["partners"] if home else [],
|
| 115 |
+
"partner_names": names,
|
| 116 |
"transitions": edges,
|
| 117 |
}
|
| 118 |
|
backend/app/protein/catalog.py
CHANGED
|
@@ -104,9 +104,39 @@ def _member_dict(r: sqlite3.Row) -> Dict[str, Any]:
|
|
| 104 |
"pfam_id": r["pfam_id"],
|
| 105 |
"ecod_id": r["ecod_id"],
|
| 106 |
"ecod_fid": r["ecod_fid"],
|
|
|
|
| 107 |
}
|
| 108 |
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
def ecod_names(conn: Optional[sqlite3.Connection], f_ids: List[str] = (), h_ids: List[str] = ()) -> Dict[str, Any]:
|
| 111 |
"""Names for ECOD family ids (x.h.t.f) and homology ids (x.h)."""
|
| 112 |
own = conn is None
|
|
@@ -261,6 +291,7 @@ def get_sequence(sequence_id: str) -> Optional[Dict[str, Any]]:
|
|
| 261 |
)
|
| 262 |
if r["state_a"] in shown and r["state_b"] in shown
|
| 263 |
]
|
|
|
|
| 264 |
seq["ecod_group"] = group_info(conn, seq["ecod_hset"])
|
| 265 |
seq["ecod_names"] = ecod_names(conn, h_ids=seq["ecod_hset"])
|
| 266 |
finally:
|
|
|
|
| 104 |
"pfam_id": r["pfam_id"],
|
| 105 |
"ecod_id": r["ecod_id"],
|
| 106 |
"ecod_fid": r["ecod_fid"],
|
| 107 |
+
"partners": json.loads(r["partners"]) if "partners" in r.keys() and r["partners"] else [],
|
| 108 |
}
|
| 109 |
|
| 110 |
|
| 111 |
+
def partner_names(conn: Optional[sqlite3.Connection], keys) -> Dict[str, Dict[str, Any]]:
|
| 112 |
+
"""Display names for binding-partner keys (see build_index.partner_key)."""
|
| 113 |
+
keys = sorted({k for k in keys if k and k != "self"})
|
| 114 |
+
if not keys:
|
| 115 |
+
return {}
|
| 116 |
+
own = conn is None
|
| 117 |
+
conn = conn or _connect()
|
| 118 |
+
if conn is None:
|
| 119 |
+
return {}
|
| 120 |
+
out: Dict[str, Dict[str, Any]] = {}
|
| 121 |
+
try:
|
| 122 |
+
for i in range(0, len(keys), 500):
|
| 123 |
+
chunk = keys[i:i + 500]
|
| 124 |
+
try:
|
| 125 |
+
rows = conn.execute(
|
| 126 |
+
f"SELECT key, type, description, uniprot, n_chains FROM partner_name "
|
| 127 |
+
f"WHERE key IN ({','.join('?' * len(chunk))})",
|
| 128 |
+
chunk,
|
| 129 |
+
).fetchall()
|
| 130 |
+
except sqlite3.OperationalError: # index built before binding partners existed
|
| 131 |
+
return {}
|
| 132 |
+
for r in rows:
|
| 133 |
+
out[r[0]] = {"type": r[1], "description": r[2], "uniprot": r[3], "n_chains": r[4]}
|
| 134 |
+
finally:
|
| 135 |
+
if own:
|
| 136 |
+
conn.close()
|
| 137 |
+
return out
|
| 138 |
+
|
| 139 |
+
|
| 140 |
def ecod_names(conn: Optional[sqlite3.Connection], f_ids: List[str] = (), h_ids: List[str] = ()) -> Dict[str, Any]:
|
| 141 |
"""Names for ECOD family ids (x.h.t.f) and homology ids (x.h)."""
|
| 142 |
own = conn is None
|
|
|
|
| 291 |
)
|
| 292 |
if r["state_a"] in shown and r["state_b"] in shown
|
| 293 |
]
|
| 294 |
+
seq["partner_names"] = partner_names(conn, (k for m in members for k in m["partners"]))
|
| 295 |
seq["ecod_group"] = group_info(conn, seq["ecod_hset"])
|
| 296 |
seq["ecod_names"] = ecod_names(conn, h_ids=seq["ecod_hset"])
|
| 297 |
finally:
|
backend/app/protein/records.py
CHANGED
|
@@ -275,3 +275,65 @@ def list_entry_chains(pdb_id: str) -> List[Dict[str, Any]]:
|
|
| 275 |
}
|
| 276 |
for r in rows
|
| 277 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
}
|
| 276 |
for r in rows
|
| 277 |
]
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
# Binding partners (pipeline step 12). A contact counts as a biological partner
|
| 281 |
+
# when the target side has at least PARTNER_MIN_RES interface residues and the
|
| 282 |
+
# contact exists in biological assembly 1 (entries without assembly records:
|
| 283 |
+
# not only via a symmetry mate). Must match backend/scripts/build_index.py.
|
| 284 |
+
PARTNER_MIN_RES = 5
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _parse_semicolon_ranges(raw: Optional[str]) -> List[List[int]]:
|
| 288 |
+
"""'12-15;40' -> [[12, 15], [40, 40]]."""
|
| 289 |
+
return _parse_ranges((raw or "").replace(";", ","))
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def get_partners(pdb_id: str, chain: str) -> List[Dict[str, Any]]:
|
| 293 |
+
"""Polymer chains in contact with one chain, largest interface first."""
|
| 294 |
+
conn = _connect()
|
| 295 |
+
try:
|
| 296 |
+
try:
|
| 297 |
+
rows = conn.execute(
|
| 298 |
+
"SELECT * FROM partner WHERE pdb_id = ? COLLATE NOCASE AND auth_asym_id = ?",
|
| 299 |
+
(pdb_id, chain),
|
| 300 |
+
).fetchall()
|
| 301 |
+
except sqlite3.OperationalError: # DB without the partner table
|
| 302 |
+
return []
|
| 303 |
+
observed = {
|
| 304 |
+
r[0]: (r[1], integer(r[2]))
|
| 305 |
+
for r in conn.execute(
|
| 306 |
+
"SELECT auth_asym_id, sequence_id, state_id FROM node WHERE pdb_id = ? COLLATE NOCASE",
|
| 307 |
+
(pdb_id,),
|
| 308 |
+
)
|
| 309 |
+
}
|
| 310 |
+
finally:
|
| 311 |
+
conn.close()
|
| 312 |
+
|
| 313 |
+
out = []
|
| 314 |
+
for r in rows:
|
| 315 |
+
n_res = integer(r["n_res_target"]) or 0
|
| 316 |
+
asm = text(r["in_assembly1"])
|
| 317 |
+
via_sym = text(r["via_symmetry"]) == "1"
|
| 318 |
+
in_asm = None if asm is None else asm == "1"
|
| 319 |
+
biological = n_res >= PARTNER_MIN_RES and (in_asm if in_asm is not None else not via_sym)
|
| 320 |
+
seen = observed.get(r["partner_chain"])
|
| 321 |
+
out.append({
|
| 322 |
+
"chain": r["partner_chain"],
|
| 323 |
+
"entity": text(r["partner_entity"]),
|
| 324 |
+
"type": text(r["partner_type"]) or "other",
|
| 325 |
+
"description": text(r["partner_description"]),
|
| 326 |
+
"uniprot": text(r["partner_uniprot"]),
|
| 327 |
+
"n_res_target": n_res,
|
| 328 |
+
"n_res_partner": integer(r["n_res_partner"]),
|
| 329 |
+
"n_contacts": integer(r["n_contacts"]),
|
| 330 |
+
"interface": _parse_semicolon_ranges(r["target_interface"]),
|
| 331 |
+
"same_entity": text(r["same_entity"]) == "1",
|
| 332 |
+
"via_symmetry": via_sym,
|
| 333 |
+
"in_assembly1": in_asm,
|
| 334 |
+
"biological": biological,
|
| 335 |
+
"observation": {"sequence_id": seen[0], "state_id": seen[1]} if seen else None,
|
| 336 |
+
})
|
| 337 |
+
out.sort(key=lambda p: (not p["biological"], -p["n_res_target"]))
|
| 338 |
+
return out
|
| 339 |
+
|
backend/assets/MuSProt_documentation.md
CHANGED
|
@@ -14,6 +14,8 @@
|
|
| 14 |
| State clusters | 162,601 |
|
| 15 |
| Transition triplets (`edge` rows, ordered observation pairs) | 113,581,394 |
|
| 16 |
| Transitions between distinct states | 25,530,666 |
|
|
|
|
|
|
|
| 17 |
|
| 18 |
---
|
| 19 |
|
|
@@ -59,7 +61,7 @@ coverage = aligned_length / max(L_A, L_B)
|
|
| 59 |
|
| 60 |
## Download
|
| 61 |
|
| 62 |
-
The full dataset is a single SQLite database file (`MuSProt.db`, ~37 GB) with
|
| 63 |
|
| 64 |
Atomic coordinates are not included: fetch mmCIF files from the [RCSB PDB](https://www.rcsb.org) or [PDBe](https://www.ebi.ac.uk/pdbe/) using `pdb_id` and `auth_asym_id`.
|
| 65 |
|
|
@@ -157,6 +159,27 @@ One row per ordered pair of observations (A → B) of the same sequence.
|
|
| 157 |
|
| 158 |
When A and B share no resolved residues, `tm_aln`, `pair_similarity` and `pair_fidelity` are empty and `RMSD` / `coverage` are stored as `0.0`.
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
### Order / disorder labels
|
| 161 |
|
| 162 |
These columns describe the *observed* resolved state (residues with or without modelled coordinates), not intrinsic disorder: a residue can lack density because it is flexible, or because of crystal contacts, occupancy or modelling choices. They are only computed when both structures use the same experimental method and at least one is not NMR (NMR models build every residue); otherwise `delta_order_valid` is `False` and the counts are empty.
|
|
@@ -176,7 +199,7 @@ MuSProt is organised as a hierarchical graph. In the **outer layer**, each seque
|
|
| 176 |
|
| 177 |
### Indexes
|
| 178 |
|
| 179 |
-
|
| 180 |
|
| 181 |
```sql
|
| 182 |
SELECT * FROM edge WHERE pdb_id_A = '3vgk' COLLATE NOCASE AND auth_asym_id_A = 'A';
|
|
@@ -237,6 +260,21 @@ df = pd.read_sql("""
|
|
| 237 |
""", conn)
|
| 238 |
```
|
| 239 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
Scanning `node` by `uniprot_id` or `sequence_id` reads the whole table; for repeated queries create an index first (`CREATE INDEX idx_node_seq ON node(sequence_id)`).
|
| 241 |
|
| 242 |
---
|
|
|
|
| 14 |
| State clusters | 162,601 |
|
| 15 |
| Transition triplets (`edge` rows, ordered observation pairs) | 113,581,394 |
|
| 16 |
| Transitions between distinct states | 25,530,666 |
|
| 17 |
+
| Polymer contacts (`partner` rows, observation × contacting chain) | 3,481,852 |
|
| 18 |
+
| Observations with a bound polymer partner (≥ 5 interface residues, in assembly 1) | 637,022 (393,476 with a hetero partner) |
|
| 19 |
|
| 20 |
---
|
| 21 |
|
|
|
|
| 61 |
|
| 62 |
## Download
|
| 63 |
|
| 64 |
+
The full dataset is a single SQLite database file (`MuSProt.db`, ~37 GB) with three tables: **`node`**, **`edge`** and **`partner`**. Download it with the **Download** button at the top of the site.
|
| 65 |
|
| 66 |
Atomic coordinates are not included: fetch mmCIF files from the [RCSB PDB](https://www.rcsb.org) or [PDBe](https://www.ebi.ac.uk/pdbe/) using `pdb_id` and `auth_asym_id`.
|
| 67 |
|
|
|
|
| 159 |
|
| 160 |
When A and B share no resolved residues, `tm_aln`, `pair_similarity` and `pair_fidelity` are empty and `RMSD` / `coverage` are stored as `0.0`.
|
| 161 |
|
| 162 |
+
### Table: `partner`
|
| 163 |
+
|
| 164 |
+
One row per (observation, contacting polymer chain) of the same PDB entry: every protein, DNA or RNA chain with a heavy atom within 4.5 Å of the observation (first model, hydrogens removed), including contacts to crystallographic symmetry copies. Small-molecule binders are in `node.binders`. All values are stored as TEXT.
|
| 165 |
+
|
| 166 |
+
| Column | Type | Description |
|
| 167 |
+
|---|---|---|
|
| 168 |
+
| `pdb_id`, `auth_asym_id` | TEXT | The observation (same keys as `node`) |
|
| 169 |
+
| `partner_chain` | TEXT | `auth_asym_id` of the contacting chain; equal to `auth_asym_id` when the chain touches its own symmetry copy |
|
| 170 |
+
| `partner_entity` | TEXT | Entity id of the partner in the PDB entry |
|
| 171 |
+
| `partner_type` | TEXT | `protein`, `dna`, `rna`, `hybrid` or `other` |
|
| 172 |
+
| `partner_description` | TEXT | Entity description (`_entity.pdbx_description`) |
|
| 173 |
+
| `partner_uniprot` | TEXT | UniProt accession(s) of the partner chain from SIFTS (`;`-joined), falling back to the entry's `_struct_ref` |
|
| 174 |
+
| `n_res_target`, `n_res_partner` | TEXT | Interface residues on the observation / on the partner |
|
| 175 |
+
| `n_contacts` | TEXT | Atom pairs within 4.5 Å |
|
| 176 |
+
| `target_interface` | TEXT | Interface residues on the observation, `label_seq_id` ranges (`12-15;40`) |
|
| 177 |
+
| `same_entity` | TEXT | `1` when the partner is another copy of the same entity (homo-oligomer contact) |
|
| 178 |
+
| `via_symmetry` | TEXT | `1` when the two chains only touch through a crystallographic symmetry copy |
|
| 179 |
+
| `in_assembly1` | TEXT | `1` when the contact exists in biological assembly 1, `0` when it does not (typical of crystal packing), empty when the entry has no assembly records |
|
| 180 |
+
|
| 181 |
+
No interface-size cut is applied in the table. The website counts a **bound partner** when `n_res_target ≥ 5` and `in_assembly1 = 1` (for entries without assembly records: `via_symmetry = 0`); all other rows are shown as crystal or marginal contacts.
|
| 182 |
+
|
| 183 |
### Order / disorder labels
|
| 184 |
|
| 185 |
These columns describe the *observed* resolved state (residues with or without modelled coordinates), not intrinsic disorder: a residue can lack density because it is flexible, or because of crystal contacts, occupancy or modelling choices. They are only computed when both structures use the same experimental method and at least one is not NMR (NMR models build every residue); otherwise `delta_order_valid` is `False` and the counts are empty.
|
|
|
|
| 199 |
|
| 200 |
### Indexes
|
| 201 |
|
| 202 |
+
All three tables are indexed for per-chain lookup: `node(pdb_id COLLATE NOCASE, auth_asym_id)`, `edge(pdb_id_A COLLATE NOCASE, auth_asym_id_A)` and `partner(pdb_id COLLATE NOCASE, auth_asym_id)`. Use the same collation in queries to hit the index:
|
| 203 |
|
| 204 |
```sql
|
| 205 |
SELECT * FROM edge WHERE pdb_id_A = '3vgk' COLLATE NOCASE AND auth_asym_id_A = 'A';
|
|
|
|
| 260 |
""", conn)
|
| 261 |
```
|
| 262 |
|
| 263 |
+
### Observations of one sequence and what they are bound to
|
| 264 |
+
|
| 265 |
+
```python
|
| 266 |
+
df = pd.read_sql("""
|
| 267 |
+
SELECT n.pdb_id, n.auth_asym_id, n.state_id,
|
| 268 |
+
p.partner_chain, p.partner_description, p.partner_uniprot,
|
| 269 |
+
CAST(p.n_res_target AS INTEGER) AS interface_residues
|
| 270 |
+
FROM node n
|
| 271 |
+
JOIN partner p ON p.pdb_id = n.pdb_id COLLATE NOCASE AND p.auth_asym_id = n.auth_asym_id
|
| 272 |
+
WHERE n.uniprot_id = 'P0DP23'
|
| 273 |
+
AND p.same_entity = '0' AND p.in_assembly1 = '1'
|
| 274 |
+
AND CAST(p.n_res_target AS INTEGER) >= 5
|
| 275 |
+
""", conn)
|
| 276 |
+
```
|
| 277 |
+
|
| 278 |
Scanning `node` by `uniprot_id` or `sequence_id` reads the whole table; for repeated queries create an index first (`CREATE INDEX idx_node_seq ON node(sequence_id)`).
|
| 279 |
|
| 280 |
---
|
backend/scripts/build_index.py
CHANGED
|
@@ -5,7 +5,9 @@ Build the website sidecars from MuSProt.db.
|
|
| 5 |
Outputs (into --out-dir):
|
| 6 |
MuSProt-index.db compact, indexed lookup tables the website queries directly
|
| 7 |
sequence one row per sequence_id (catalog: counts, representative, function)
|
| 8 |
-
member one row per chain observation (light columns only)
|
|
|
|
|
|
|
| 9 |
state one row per (sequence_id, state_id)
|
| 10 |
state_pair mean similarity / fidelity between two states of a sequence
|
| 11 |
function_fts FTS5 over UniProt / CATH / ECOD / Pfam / top ranked functions per sequence
|
|
@@ -127,6 +129,95 @@ def load_ecod_names(path: Path | None) -> tuple[dict, dict]:
|
|
| 127 |
return fam, hom
|
| 128 |
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
# ─────────────────────────────────────────────────────────────── node pass
|
| 131 |
NODE_SQL = """
|
| 132 |
SELECT sequence_id, uniprot_id, pdb_id, auth_asym_id, sequence_length,
|
|
@@ -161,16 +252,20 @@ def build_members(src: sqlite3.Connection, dst: sqlite3.Connection, limit: int |
|
|
| 161 |
cath_id TEXT,
|
| 162 |
pfam_id TEXT,
|
| 163 |
ecod_id TEXT,
|
| 164 |
-
ecod_fid TEXT
|
|
|
|
| 165 |
);
|
| 166 |
"""
|
| 167 |
)
|
|
|
|
| 168 |
seqs: dict[str, dict] = {}
|
| 169 |
functions: dict[str, list[str]] = {}
|
| 170 |
ov = {
|
| 171 |
"method": Counter(), "binding": Counter(), "composition": Counter(),
|
| 172 |
"npp": Counter(), "year": Counter(), "cath": Counter(),
|
| 173 |
"resolution": [], "length": {}, "uniprot": set(), "entries": set(),
|
|
|
|
|
|
|
| 174 |
}
|
| 175 |
sql = NODE_SQL + (f" LIMIT {int(limit)}" if limit else "")
|
| 176 |
batch = []
|
|
@@ -181,11 +276,24 @@ def build_members(src: sqlite3.Connection, dst: sqlite3.Connection, limit: int |
|
|
| 181 |
pfam, ecod_ids, ecod_fid) = row
|
| 182 |
state_i = to_int(state)
|
| 183 |
res_f = to_float(resolution)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
batch.append((
|
| 185 |
seq_id, state_i, pdb, chain, uniprot or None, method or None, res_f,
|
| 186 |
binding or None, comp or None, binders or None, date or None,
|
| 187 |
to_float(res_cov), to_int(n_res), to_float(ph), to_float(temp),
|
| 188 |
cath_id or None, pfam or None, ecod_ids or None, ecod_fid or None,
|
|
|
|
| 189 |
))
|
| 190 |
s = seqs.get(seq_id)
|
| 191 |
if s is None:
|
|
@@ -237,12 +345,28 @@ def build_members(src: sqlite3.Connection, dst: sqlite3.Connection, limit: int |
|
|
| 237 |
|
| 238 |
n += 1
|
| 239 |
if len(batch) >= 50000:
|
| 240 |
-
dst.executemany(f"INSERT INTO member VALUES ({','.join('?' *
|
| 241 |
batch.clear()
|
| 242 |
log(f" node rows: {n:,}")
|
| 243 |
if batch:
|
| 244 |
-
dst.executemany(f"INSERT INTO member VALUES ({','.join('?' *
|
| 245 |
log(f" node rows total: {n:,}; sequences: {len(seqs):,}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
dst.executescript(
|
| 247 |
"""
|
| 248 |
CREATE INDEX idx_member_seq ON member(sequence_id, state_id);
|
|
@@ -681,6 +805,7 @@ def build_overview(seqs, ov, n_nodes, edges, state_fid) -> dict:
|
|
| 681 |
"chain_composition": [{"label": k, "value": v} for k, v in ov["composition"].most_common()],
|
| 682 |
"nucleic_acid_binding": [{"label": k, "value": v} for k, v in ov["npp"].most_common()],
|
| 683 |
"release_year": [{"label": k, "value": ov["year"][k]} for k in sorted(ov["year"])],
|
|
|
|
| 684 |
"pair_fidelity": [
|
| 685 |
{"label": k, "value": edges["pair_fidelity"].get(k, 0),
|
| 686 |
"cross_state": edges["pair_fidelity_cross"].get(k, 0)}
|
|
|
|
| 5 |
Outputs (into --out-dir):
|
| 6 |
MuSProt-index.db compact, indexed lookup tables the website queries directly
|
| 7 |
sequence one row per sequence_id (catalog: counts, representative, function)
|
| 8 |
+
member one row per chain observation (light columns only), incl. its
|
| 9 |
+
biological binding partners (JSON list of partner keys)
|
| 10 |
+
partner_name partner key -> type, description, UniProt (from the partner table)
|
| 11 |
state one row per (sequence_id, state_id)
|
| 12 |
state_pair mean similarity / fidelity between two states of a sequence
|
| 13 |
function_fts FTS5 over UniProt / CATH / ECOD / Pfam / top ranked functions per sequence
|
|
|
|
| 129 |
return fam, hom
|
| 130 |
|
| 131 |
|
| 132 |
+
# ─────────────────────────────────────────────────────────────── binding partners
|
| 133 |
+
# partner table (pipeline step 12): one row per (target chain, partner chain).
|
| 134 |
+
# A contact counts as a biological partner when the target side has at least
|
| 135 |
+
# PARTNER_MIN_RES interface residues and the contact exists in biological
|
| 136 |
+
# assembly 1 (entries without assembly records: not only via a symmetry mate).
|
| 137 |
+
# Must match app/protein/records.py.
|
| 138 |
+
PARTNER_MIN_RES = 5
|
| 139 |
+
NUCLEIC_LABEL = {"dna": "DNA", "rna": "RNA", "hybrid": "DNA/RNA hybrid"}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def partner_is_biological(n_res_target, via_symmetry, in_assembly1) -> bool:
|
| 143 |
+
if (to_int(n_res_target) or 0) < PARTNER_MIN_RES:
|
| 144 |
+
return False
|
| 145 |
+
if in_assembly1 in ("0", "1"):
|
| 146 |
+
return in_assembly1 == "1"
|
| 147 |
+
return via_symmetry == "0"
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _sequence_like(desc: str) -> bool:
|
| 151 |
+
"""Nucleic-acid descriptions that just spell the sequence, e.g. DNA (5'-D(*CP*GP*...)-3')."""
|
| 152 |
+
d = desc.strip().upper()
|
| 153 |
+
return "*" in d or d.startswith(("5'", "DNA (", "RNA (", "DNA(", "RNA(")) or d in ("DNA", "RNA")
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def partner_key(ptype: str, desc: str, uniprot: str) -> str:
|
| 157 |
+
"""UniProt accession(s) when known; otherwise '<type>:<description>', or just
|
| 158 |
+
the polymer type for nucleic acids whose description only spells the sequence."""
|
| 159 |
+
if uniprot:
|
| 160 |
+
return uniprot
|
| 161 |
+
if not desc or (ptype in NUCLEIC_LABEL and _sequence_like(desc)):
|
| 162 |
+
return ptype or "other"
|
| 163 |
+
d = " ".join(desc.lower().split()).replace("ribosomal rna", "rrna")
|
| 164 |
+
return f"{ptype}:{d}"
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def load_partners(src: sqlite3.Connection):
|
| 168 |
+
"""(pdb_lower, chain) -> [(key, n_res_target)] biological partners, largest
|
| 169 |
+
interface first; 'self' marks another copy of the same entity. Also returns
|
| 170 |
+
key -> Counter of (type, description, uniprot) for naming."""
|
| 171 |
+
has = src.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='partner'").fetchone()
|
| 172 |
+
if not has:
|
| 173 |
+
log(" no partner table in the DB; skipping binding partners")
|
| 174 |
+
return {}, {}
|
| 175 |
+
per_chain: dict = defaultdict(dict)
|
| 176 |
+
names: dict = defaultdict(Counter)
|
| 177 |
+
n = 0
|
| 178 |
+
for pdb, chain, ptype, desc, unp, nres, same, sym, asm in src.execute(
|
| 179 |
+
"SELECT pdb_id, auth_asym_id, partner_type, partner_description, partner_uniprot, "
|
| 180 |
+
"n_res_target, same_entity, via_symmetry, in_assembly1 FROM partner"
|
| 181 |
+
):
|
| 182 |
+
n += 1
|
| 183 |
+
if not partner_is_biological(nres, sym, asm):
|
| 184 |
+
continue
|
| 185 |
+
if same == "1":
|
| 186 |
+
key = "self"
|
| 187 |
+
else:
|
| 188 |
+
key = partner_key(ptype or "", desc or "", unp or "")
|
| 189 |
+
names[key][(ptype or "", desc or "", unp or "")] += 1
|
| 190 |
+
d = per_chain[(pdb.lower(), chain)]
|
| 191 |
+
d[key] = max(d.get(key, 0), to_int(nres) or 0)
|
| 192 |
+
log(f" partner rows: {n:,}; chains with a biological partner: {len(per_chain):,}")
|
| 193 |
+
out = {k: sorted(v.items(), key=lambda kv: -kv[1]) for k, v in per_chain.items()}
|
| 194 |
+
return out, names
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def write_partner_names(dst: sqlite3.Connection, names: dict, used: Counter) -> None:
|
| 198 |
+
dst.executescript(
|
| 199 |
+
"""
|
| 200 |
+
DROP TABLE IF EXISTS partner_name;
|
| 201 |
+
CREATE TABLE partner_name (
|
| 202 |
+
key TEXT PRIMARY KEY,
|
| 203 |
+
type TEXT,
|
| 204 |
+
description TEXT,
|
| 205 |
+
uniprot TEXT,
|
| 206 |
+
n_chains INTEGER
|
| 207 |
+
);
|
| 208 |
+
"""
|
| 209 |
+
)
|
| 210 |
+
rows = []
|
| 211 |
+
for key, n_chains in used.items():
|
| 212 |
+
if key == "self":
|
| 213 |
+
continue
|
| 214 |
+
(ptype, desc, unp), _ = names[key].most_common(1)[0]
|
| 215 |
+
if key in NUCLEIC_LABEL:
|
| 216 |
+
desc = NUCLEIC_LABEL[key]
|
| 217 |
+
rows.append((key, ptype, desc, unp or None, n_chains))
|
| 218 |
+
dst.executemany("INSERT INTO partner_name VALUES (?,?,?,?,?)", rows)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
# ─────────────────────────────────────────────────────────────── node pass
|
| 222 |
NODE_SQL = """
|
| 223 |
SELECT sequence_id, uniprot_id, pdb_id, auth_asym_id, sequence_length,
|
|
|
|
| 252 |
cath_id TEXT,
|
| 253 |
pfam_id TEXT,
|
| 254 |
ecod_id TEXT,
|
| 255 |
+
ecod_fid TEXT,
|
| 256 |
+
partners TEXT
|
| 257 |
);
|
| 258 |
"""
|
| 259 |
)
|
| 260 |
+
partners, partner_names = load_partners(src)
|
| 261 |
seqs: dict[str, dict] = {}
|
| 262 |
functions: dict[str, list[str]] = {}
|
| 263 |
ov = {
|
| 264 |
"method": Counter(), "binding": Counter(), "composition": Counter(),
|
| 265 |
"npp": Counter(), "year": Counter(), "cath": Counter(),
|
| 266 |
"resolution": [], "length": {}, "uniprot": set(), "entries": set(),
|
| 267 |
+
"partner_used": Counter(), "partner_type": Counter(),
|
| 268 |
+
"with_partner": 0, "with_hetero_partner": 0,
|
| 269 |
}
|
| 270 |
sql = NODE_SQL + (f" LIMIT {int(limit)}" if limit else "")
|
| 271 |
batch = []
|
|
|
|
| 276 |
pfam, ecod_ids, ecod_fid) = row
|
| 277 |
state_i = to_int(state)
|
| 278 |
res_f = to_float(resolution)
|
| 279 |
+
# another entity with the same UniProt accession is still a homo contact
|
| 280 |
+
pkeys = []
|
| 281 |
+
for key, _ in partners.get((pdb.lower(), chain), ()):
|
| 282 |
+
key = "self" if uniprot and key == uniprot else key
|
| 283 |
+
if key not in pkeys:
|
| 284 |
+
pkeys.append(key)
|
| 285 |
+
if pkeys:
|
| 286 |
+
ov["with_partner"] += 1
|
| 287 |
+
hetero = [k for k in pkeys if k != "self"]
|
| 288 |
+
ov["with_hetero_partner"] += bool(hetero)
|
| 289 |
+
ov["partner_used"].update(pkeys)
|
| 290 |
+
ov["partner_type"].update({partner_names[k].most_common(1)[0][0][0] for k in hetero})
|
| 291 |
batch.append((
|
| 292 |
seq_id, state_i, pdb, chain, uniprot or None, method or None, res_f,
|
| 293 |
binding or None, comp or None, binders or None, date or None,
|
| 294 |
to_float(res_cov), to_int(n_res), to_float(ph), to_float(temp),
|
| 295 |
cath_id or None, pfam or None, ecod_ids or None, ecod_fid or None,
|
| 296 |
+
json.dumps(pkeys, separators=(",", ":")) if pkeys else None,
|
| 297 |
))
|
| 298 |
s = seqs.get(seq_id)
|
| 299 |
if s is None:
|
|
|
|
| 345 |
|
| 346 |
n += 1
|
| 347 |
if len(batch) >= 50000:
|
| 348 |
+
dst.executemany(f"INSERT INTO member VALUES ({','.join('?' * 20)})", batch)
|
| 349 |
batch.clear()
|
| 350 |
log(f" node rows: {n:,}")
|
| 351 |
if batch:
|
| 352 |
+
dst.executemany(f"INSERT INTO member VALUES ({','.join('?' * 20)})", batch)
|
| 353 |
log(f" node rows total: {n:,}; sequences: {len(seqs):,}")
|
| 354 |
+
write_partner_names(dst, partner_names, ov["partner_used"])
|
| 355 |
+
top = []
|
| 356 |
+
for key, c in ov["partner_used"].most_common(41):
|
| 357 |
+
if key == "self":
|
| 358 |
+
continue
|
| 359 |
+
(ptype, desc, unp), _ = partner_names[key].most_common(1)[0]
|
| 360 |
+
top.append({"key": key, "type": ptype, "label": NUCLEIC_LABEL.get(key, desc),
|
| 361 |
+
"uniprot": unp or None, "value": c})
|
| 362 |
+
ov["partners"] = {
|
| 363 |
+
"observations_with_partner": ov["with_partner"],
|
| 364 |
+
"observations_with_hetero_partner": ov["with_hetero_partner"],
|
| 365 |
+
"observations_homo_only": ov["with_partner"] - ov["with_hetero_partner"],
|
| 366 |
+
"distinct_partners": len([k for k in ov["partner_used"] if k != "self"]),
|
| 367 |
+
"by_type": [{"label": k, "value": v} for k, v in ov["partner_type"].most_common()],
|
| 368 |
+
"top": top[:40],
|
| 369 |
+
}
|
| 370 |
dst.executescript(
|
| 371 |
"""
|
| 372 |
CREATE INDEX idx_member_seq ON member(sequence_id, state_id);
|
|
|
|
| 805 |
"chain_composition": [{"label": k, "value": v} for k, v in ov["composition"].most_common()],
|
| 806 |
"nucleic_acid_binding": [{"label": k, "value": v} for k, v in ov["npp"].most_common()],
|
| 807 |
"release_year": [{"label": k, "value": ov["year"][k]} for k in sorted(ov["year"])],
|
| 808 |
+
"partners": ov.get("partners"),
|
| 809 |
"pair_fidelity": [
|
| 810 |
{"label": k, "value": edges["pair_fidelity"].get(k, 0),
|
| 811 |
"cross_state": edges["pair_fidelity_cross"].get(k, 0)}
|
frontend/src/components/Partners.tsx
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo, useState } from 'react';
|
| 2 |
+
import { Link } from 'react-router-dom';
|
| 3 |
+
import type { BindingPartner, Member, PartnerName, StateInfo } from '../lib/api';
|
| 4 |
+
import { chainPath, fmtInt } from '../lib/format';
|
| 5 |
+
import { EmptyState, ExternalLink } from './ui';
|
| 6 |
+
|
| 7 |
+
/** Key of an observation's biological binding partners that are not copies of itself. */
|
| 8 |
+
export const SELF = 'self';
|
| 9 |
+
export const NO_PARTNER = '__none__';
|
| 10 |
+
|
| 11 |
+
const TYPE_LABEL: Record<string, string> = { protein: 'Protein', dna: 'DNA', rna: 'RNA', hybrid: 'DNA/RNA', other: 'Polymer' };
|
| 12 |
+
|
| 13 |
+
/** Display label for a partner key ('self', a UniProt accession, 'dna', 'protein:<description>'). */
|
| 14 |
+
export function partnerLabel(key: string, names?: Record<string, PartnerName>): string {
|
| 15 |
+
if (key === SELF) return 'Homo-oligomer';
|
| 16 |
+
if (key === NO_PARTNER) return 'No partner';
|
| 17 |
+
const n = names?.[key];
|
| 18 |
+
if (n?.description) return n.description;
|
| 19 |
+
const i = key.indexOf(':');
|
| 20 |
+
if (i > 0) return key.slice(i + 1);
|
| 21 |
+
return TYPE_LABEL[key] ?? key;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export function partnerType(key: string, names?: Record<string, PartnerName>): string {
|
| 25 |
+
if (key === SELF) return 'self';
|
| 26 |
+
return names?.[key]?.type ?? (key.includes(':') ? key.split(':')[0] : TYPE_LABEL[key] ? key : 'protein');
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/** Readable name for a partner chain; oligonucleotides described by their sequence become "DNA strand (21 nt)". */
|
| 30 |
+
export function polymerName(description: string | null, type: string, entity?: string | null): string {
|
| 31 |
+
if (!description) return entity ? `Entity ${entity}` : TYPE_LABEL[type] ?? type;
|
| 32 |
+
if ((type === 'dna' || type === 'rna' || type === 'hybrid') && /\*|^5'|\(5'/.test(description)) {
|
| 33 |
+
const n = (description.match(/\*/g) ?? []).length;
|
| 34 |
+
return `${TYPE_LABEL[type]} strand${n ? ` (${n} nt)` : ''}`;
|
| 35 |
+
}
|
| 36 |
+
return description;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function short(s: string, max = 28) {
|
| 40 |
+
return s.length > max ? `${s.slice(0, max - 1)}…` : s;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export function sameSet(a: string[] = [], b: string[] = []) {
|
| 44 |
+
if (a.length !== b.length) return false;
|
| 45 |
+
const s = new Set(a);
|
| 46 |
+
return b.every((k) => s.has(k));
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/** Compact partner chips; with `home`, partners absent from it are marked as gained and missing ones as lost. */
|
| 50 |
+
export function PartnerChips({
|
| 51 |
+
keys = [],
|
| 52 |
+
names,
|
| 53 |
+
home,
|
| 54 |
+
max = 2,
|
| 55 |
+
}: {
|
| 56 |
+
keys?: string[];
|
| 57 |
+
names?: Record<string, PartnerName>;
|
| 58 |
+
home?: string[];
|
| 59 |
+
max?: number;
|
| 60 |
+
}) {
|
| 61 |
+
const homeSet = home ? new Set(home) : null;
|
| 62 |
+
const lost = home ? home.filter((k) => !keys.includes(k)) : [];
|
| 63 |
+
const title = [
|
| 64 |
+
keys.length ? `Partners: ${keys.map((k) => partnerLabel(k, names)).join(', ')}` : 'No biological polymer partner',
|
| 65 |
+
lost.length ? `Not bound here (bound in this chain): ${lost.map((k) => partnerLabel(k, names)).join(', ')}` : '',
|
| 66 |
+
]
|
| 67 |
+
.filter(Boolean)
|
| 68 |
+
.join('\n');
|
| 69 |
+
if (!keys.length && !lost.length) return <span className="muted">—</span>;
|
| 70 |
+
return (
|
| 71 |
+
<span className="pchips" title={title}>
|
| 72 |
+
{keys.slice(0, max).map((k) => (
|
| 73 |
+
<span
|
| 74 |
+
key={k}
|
| 75 |
+
className={`pchip pchip--${partnerType(k, names)} ${homeSet && !homeSet.has(k) ? 'is-gained' : ''}`}
|
| 76 |
+
>
|
| 77 |
+
{short(partnerLabel(k, names), 22)}
|
| 78 |
+
</span>
|
| 79 |
+
))}
|
| 80 |
+
{keys.length > max && <span className="pchip pchip--more">+{keys.length - max}</span>}
|
| 81 |
+
{!keys.length && <span className="muted small">none</span>}
|
| 82 |
+
{lost.length > 0 && <span className="pchip pchip--lost">−{lost.length}</span>}
|
| 83 |
+
</span>
|
| 84 |
+
);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/* ───────────────────────────── chain page: every contacting polymer chain */
|
| 88 |
+
|
| 89 |
+
function kindOf(p: BindingPartner): { label: string; tone: string; hint: string } {
|
| 90 |
+
if (p.biological)
|
| 91 |
+
return p.same_entity
|
| 92 |
+
? { label: 'Homo-oligomer', tone: 'homo', hint: 'Another copy of the same entity, in biological assembly 1' }
|
| 93 |
+
: { label: 'Bound partner', tone: 'bio', hint: 'A different polymer, in contact within biological assembly 1' };
|
| 94 |
+
if (p.n_res_target < 5)
|
| 95 |
+
return { label: 'Marginal', tone: 'weak', hint: 'Fewer than 5 interface residues on this chain' };
|
| 96 |
+
return {
|
| 97 |
+
label: 'Crystal contact',
|
| 98 |
+
tone: 'weak',
|
| 99 |
+
hint: p.via_symmetry
|
| 100 |
+
? 'Touches only a symmetry mate and is not part of biological assembly 1 (crystal packing)'
|
| 101 |
+
: 'In the deposited coordinates but not in biological assembly 1 (likely crystal packing)',
|
| 102 |
+
};
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function InterfaceStrip({ ranges, length }: { ranges: Array<[number, number]>; length: number }) {
|
| 106 |
+
if (!length) return null;
|
| 107 |
+
return (
|
| 108 |
+
<span className="istrip" role="img" aria-label={`Interface residues ${ranges.map(([a, b]) => (a === b ? a : `${a}-${b}`)).join(', ')}`}>
|
| 109 |
+
{ranges.map(([a, b]) => (
|
| 110 |
+
<span
|
| 111 |
+
key={a}
|
| 112 |
+
className="istrip__seg"
|
| 113 |
+
style={{ left: `${((a - 1) / length) * 100}%`, width: `max(2px, ${((b - a + 1) / length) * 100}%)` }}
|
| 114 |
+
/>
|
| 115 |
+
))}
|
| 116 |
+
</span>
|
| 117 |
+
);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
function fmtRanges(r: Array<[number, number]>) {
|
| 121 |
+
return r.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
export function BindingPartners({
|
| 125 |
+
partners,
|
| 126 |
+
pdb,
|
| 127 |
+
length,
|
| 128 |
+
}: {
|
| 129 |
+
partners: BindingPartner[];
|
| 130 |
+
pdb: string;
|
| 131 |
+
length: number;
|
| 132 |
+
}) {
|
| 133 |
+
const nWeak = partners.filter((p) => !p.biological).length;
|
| 134 |
+
const [showAll, setShowAll] = useState(false);
|
| 135 |
+
const rows = showAll ? partners : partners.filter((p) => p.biological);
|
| 136 |
+
|
| 137 |
+
if (!partners.length)
|
| 138 |
+
return <EmptyState title="No polymer contacts">This chain touches no other protein, DNA or RNA chain within 4.5 Å.</EmptyState>;
|
| 139 |
+
|
| 140 |
+
return (
|
| 141 |
+
<>
|
| 142 |
+
{rows.length === 0 ? (
|
| 143 |
+
<p className="muted">No biological partner: every contact is a crystal contact or marginal.</p>
|
| 144 |
+
) : (
|
| 145 |
+
<div className="table-wrap">
|
| 146 |
+
<table className="table table--dense">
|
| 147 |
+
<thead>
|
| 148 |
+
<tr>
|
| 149 |
+
<th>Partner</th>
|
| 150 |
+
<th>Chain</th>
|
| 151 |
+
<th>Kind</th>
|
| 152 |
+
<th className="num" title="Interface residues on this chain / on the partner">
|
| 153 |
+
Interface
|
| 154 |
+
</th>
|
| 155 |
+
<th>Interface on this chain (SEQRES)</th>
|
| 156 |
+
</tr>
|
| 157 |
+
</thead>
|
| 158 |
+
<tbody>
|
| 159 |
+
{rows.map((p) => {
|
| 160 |
+
const k = kindOf(p);
|
| 161 |
+
return (
|
| 162 |
+
<tr key={p.chain} className={p.biological ? '' : 'is-dim'}>
|
| 163 |
+
<td>
|
| 164 |
+
<span className="partner-name">
|
| 165 |
+
<span className={`ptype ptype--${p.type}`}>{TYPE_LABEL[p.type] ?? p.type}</span>
|
| 166 |
+
<span className="partner-name__text" title={p.description ?? undefined}>
|
| 167 |
+
{polymerName(p.description, p.type, p.entity)}
|
| 168 |
+
</span>
|
| 169 |
+
</span>
|
| 170 |
+
{p.uniprot && (
|
| 171 |
+
<span className="partner-name__ids">
|
| 172 |
+
{p.uniprot.split(';').map((u) => (
|
| 173 |
+
<ExternalLink key={u} href={`https://www.uniprot.org/uniprotkb/${u}`}>
|
| 174 |
+
{u}
|
| 175 |
+
</ExternalLink>
|
| 176 |
+
))}
|
| 177 |
+
</span>
|
| 178 |
+
)}
|
| 179 |
+
</td>
|
| 180 |
+
<td className="mono">
|
| 181 |
+
{p.observation ? (
|
| 182 |
+
<Link to={chainPath(pdb, p.chain)} title={`${p.observation.sequence_id}, state ${p.observation.state_id ?? '?'}`}>
|
| 183 |
+
{pdb.toUpperCase()}.{p.chain}
|
| 184 |
+
</Link>
|
| 185 |
+
) : (
|
| 186 |
+
`${pdb.toUpperCase()}.${p.chain}`
|
| 187 |
+
)}
|
| 188 |
+
{p.via_symmetry && (
|
| 189 |
+
<span className="muted small" title="Contact is with a crystallographic symmetry copy of this chain">
|
| 190 |
+
{' '}
|
| 191 |
+
(sym)
|
| 192 |
+
</span>
|
| 193 |
+
)}
|
| 194 |
+
</td>
|
| 195 |
+
<td>
|
| 196 |
+
<span className={`pkind pkind--${k.tone}`} title={k.hint}>
|
| 197 |
+
{k.label}
|
| 198 |
+
</span>
|
| 199 |
+
</td>
|
| 200 |
+
<td className="num tabular" title={`${fmtInt(p.n_contacts)} atom contacts within 4.5 Å`}>
|
| 201 |
+
{p.n_res_target} / {p.n_res_partner ?? '—'}
|
| 202 |
+
</td>
|
| 203 |
+
<td title={fmtRanges(p.interface)}>
|
| 204 |
+
<InterfaceStrip ranges={p.interface} length={length} />
|
| 205 |
+
</td>
|
| 206 |
+
</tr>
|
| 207 |
+
);
|
| 208 |
+
})}
|
| 209 |
+
</tbody>
|
| 210 |
+
</table>
|
| 211 |
+
</div>
|
| 212 |
+
)}
|
| 213 |
+
{nWeak > 0 && (
|
| 214 |
+
<button type="button" className="btn btn--ghost btn--sm partners-toggle" onClick={() => setShowAll((s) => !s)}>
|
| 215 |
+
{showAll ? 'Hide' : 'Show'} {fmtInt(nWeak)} crystal / marginal contact{nWeak === 1 ? '' : 's'}
|
| 216 |
+
</button>
|
| 217 |
+
)}
|
| 218 |
+
</>
|
| 219 |
+
);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
/* ───────────────────────────── sequence page: partners × states */
|
| 223 |
+
|
| 224 |
+
const MAX_ROWS = 12;
|
| 225 |
+
const MAX_COLS = 14;
|
| 226 |
+
|
| 227 |
+
export function PartnersByState({
|
| 228 |
+
members,
|
| 229 |
+
states,
|
| 230 |
+
names,
|
| 231 |
+
selectedState,
|
| 232 |
+
onSelectState,
|
| 233 |
+
selectedPartner,
|
| 234 |
+
onSelectPartner,
|
| 235 |
+
}: {
|
| 236 |
+
members: Member[];
|
| 237 |
+
states: StateInfo[];
|
| 238 |
+
names?: Record<string, PartnerName>;
|
| 239 |
+
selectedState: number | null;
|
| 240 |
+
onSelectState: (s: number | null) => void;
|
| 241 |
+
selectedPartner: string | null;
|
| 242 |
+
onSelectPartner: (k: string | null) => void;
|
| 243 |
+
}) {
|
| 244 |
+
const { rows, cols, cell, perState } = useMemo(() => {
|
| 245 |
+
const total = new Map<string, number>();
|
| 246 |
+
const cell = new Map<string, number>();
|
| 247 |
+
const perState = new Map<number, number>();
|
| 248 |
+
for (const m of members) {
|
| 249 |
+
const keys = m.partners?.length ? m.partners : [NO_PARTNER];
|
| 250 |
+
perState.set(m.state_id, (perState.get(m.state_id) ?? 0) + 1);
|
| 251 |
+
for (const k of keys) {
|
| 252 |
+
total.set(k, (total.get(k) ?? 0) + 1);
|
| 253 |
+
cell.set(`${k}|${m.state_id}`, (cell.get(`${k}|${m.state_id}`) ?? 0) + 1);
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
const rows = [...total.entries()].sort((a, b) => b[1] - a[1]).slice(0, MAX_ROWS);
|
| 257 |
+
const cols = states.slice(0, MAX_COLS);
|
| 258 |
+
return { rows, cols, cell, perState };
|
| 259 |
+
}, [members, states]);
|
| 260 |
+
|
| 261 |
+
if (rows.length === 1 && rows[0][0] === NO_PARTNER)
|
| 262 |
+
return <p className="muted">No observation of this sequence has a biological polymer partner.</p>;
|
| 263 |
+
|
| 264 |
+
return (
|
| 265 |
+
<div className="pmatrix-wrap">
|
| 266 |
+
<table className="pmatrix">
|
| 267 |
+
<thead>
|
| 268 |
+
<tr>
|
| 269 |
+
<th className="pmatrix__corner">Partner \ state</th>
|
| 270 |
+
{cols.map((s) => (
|
| 271 |
+
<th key={s.state_id}>
|
| 272 |
+
<button
|
| 273 |
+
type="button"
|
| 274 |
+
className={`pmatrix__state ${selectedState === s.state_id ? 'is-on' : ''}`}
|
| 275 |
+
onClick={() => onSelectState(selectedState === s.state_id ? null : s.state_id)}
|
| 276 |
+
title={`State ${s.state_id}: ${fmtInt(perState.get(s.state_id))} observations`}
|
| 277 |
+
>
|
| 278 |
+
{s.state_id}
|
| 279 |
+
</button>
|
| 280 |
+
</th>
|
| 281 |
+
))}
|
| 282 |
+
<th className="num">All</th>
|
| 283 |
+
</tr>
|
| 284 |
+
</thead>
|
| 285 |
+
<tbody>
|
| 286 |
+
{rows.map(([k, n]) => (
|
| 287 |
+
<tr key={k}>
|
| 288 |
+
<th scope="row">
|
| 289 |
+
<button
|
| 290 |
+
type="button"
|
| 291 |
+
className={`pmatrix__partner ${selectedPartner === k ? 'is-on' : ''}`}
|
| 292 |
+
onClick={() => onSelectPartner(selectedPartner === k ? null : k)}
|
| 293 |
+
title={`${partnerLabel(k, names)}${names?.[k]?.uniprot ? ` (${names[k].uniprot})` : ''} — click to list these observations`}
|
| 294 |
+
>
|
| 295 |
+
<span className={`ptype-dot ptype-dot--${partnerType(k, names)}`} aria-hidden />
|
| 296 |
+
<span className="pmatrix__label">{partnerLabel(k, names)}</span>
|
| 297 |
+
{names?.[k]?.uniprot && <span className="pmatrix__acc mono">{names[k].uniprot}</span>}
|
| 298 |
+
</button>
|
| 299 |
+
</th>
|
| 300 |
+
{cols.map((s) => {
|
| 301 |
+
const c = cell.get(`${k}|${s.state_id}`) ?? 0;
|
| 302 |
+
const frac = c / (perState.get(s.state_id) || 1);
|
| 303 |
+
const level = c === 0 ? 0 : Math.min(5, 1 + Math.floor(frac * 5));
|
| 304 |
+
return (
|
| 305 |
+
<td
|
| 306 |
+
key={s.state_id}
|
| 307 |
+
className={`pmatrix__cell seq-bg-${level}`}
|
| 308 |
+
title={`${partnerLabel(k, names)} in state ${s.state_id}: ${c} of ${perState.get(s.state_id)} observations`}
|
| 309 |
+
>
|
| 310 |
+
{c > 0 ? c : ''}
|
| 311 |
+
</td>
|
| 312 |
+
);
|
| 313 |
+
})}
|
| 314 |
+
<td className="num tabular">{fmtInt(n)}</td>
|
| 315 |
+
</tr>
|
| 316 |
+
))}
|
| 317 |
+
</tbody>
|
| 318 |
+
</table>
|
| 319 |
+
{(states.length > MAX_COLS) && (
|
| 320 |
+
<p className="muted small">Showing the {MAX_COLS} largest of {states.length} states.</p>
|
| 321 |
+
)}
|
| 322 |
+
</div>
|
| 323 |
+
);
|
| 324 |
+
}
|
frontend/src/components/StructureDiff.tsx
CHANGED
|
@@ -15,14 +15,17 @@ export const DIFF_COLORS: Record<CompareClass, string> = {
|
|
| 15 |
missing: 'transparent',
|
| 16 |
};
|
| 17 |
|
|
|
|
|
|
|
|
|
|
| 18 |
type Show = 'both' | 'A' | 'B';
|
| 19 |
|
| 20 |
-
function runsFor(residues: StructureCompare['residues'], chain: 'A' | 'B'): ResidueColorRun[] {
|
| 21 |
const absent: CompareClass = chain === 'A' ? 'only_b' : 'only_a';
|
| 22 |
const runs: ResidueColorRun[] = [];
|
| 23 |
for (const r of residues) {
|
| 24 |
if (r.class === absent || r.class === 'missing') continue;
|
| 25 |
-
const color = DIFF_COLORS[r.class];
|
| 26 |
const last = runs[runs.length - 1];
|
| 27 |
if (last && last.color === color && last.end === r.pos - 1) last.end = r.pos;
|
| 28 |
else runs.push({ chain, start: r.pos, end: r.pos, color });
|
|
@@ -61,7 +64,8 @@ export function StructureDiff({
|
|
| 61 |
|
| 62 |
const runs = useMemo(() => {
|
| 63 |
if (!d) return [];
|
| 64 |
-
|
|
|
|
| 65 |
}, [d, show]);
|
| 66 |
|
| 67 |
const url = api.compareStructureUrl(a.pdb_id, a.auth_asym_id, b.pdb_id, b.auth_asym_id, show === 'both' ? undefined : show);
|
|
@@ -83,10 +87,11 @@ export function StructureDiff({
|
|
| 83 |
options={[
|
| 84 |
{ value: 'both', label: 'Both' },
|
| 85 |
{ value: 'A', label: `${la} (this)` },
|
| 86 |
-
{ value: 'B', label: `${lb} (
|
| 87 |
]}
|
| 88 |
/>
|
| 89 |
<p className="muted small sdiff__stats">
|
|
|
|
| 90 |
{lb} superposed onto {la} on their largest rigid core: {fmtInt(d.counts.low)} of {fmtInt(d.n_common)} shared
|
| 91 |
residues within {t.low} Å (core RMSD {fmtNum(d.rmsd_core, 2)} Å), maximum deviation {fmtNum(d.max_dev, 1)} Å.
|
| 92 |
</p>
|
|
@@ -98,6 +103,11 @@ export function StructureDiff({
|
|
| 98 |
label={show === 'both' ? `chain A = ${la} · chain B = ${lb}` : show === 'A' ? la : lb}
|
| 99 |
/>
|
| 100 |
<ul className="legend sdiff__legend">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
<li>
|
| 102 |
<Swatch color={DIFF_COLORS.low} /> < {t.low} Å <b className="tabular">{fmtInt(d.counts.low)}</b>
|
| 103 |
</li>
|
|
|
|
| 15 |
missing: 'transparent',
|
| 16 |
};
|
| 17 |
|
| 18 |
+
/* In the superposed "both" view the alternative is drawn in one colour so it stands apart from this chain. */
|
| 19 |
+
export const ALT_COLOR = '#9b7fd4';
|
| 20 |
+
|
| 21 |
type Show = 'both' | 'A' | 'B';
|
| 22 |
|
| 23 |
+
function runsFor(residues: StructureCompare['residues'], chain: 'A' | 'B', uniform?: string): ResidueColorRun[] {
|
| 24 |
const absent: CompareClass = chain === 'A' ? 'only_b' : 'only_a';
|
| 25 |
const runs: ResidueColorRun[] = [];
|
| 26 |
for (const r of residues) {
|
| 27 |
if (r.class === absent || r.class === 'missing') continue;
|
| 28 |
+
const color = uniform ?? DIFF_COLORS[r.class];
|
| 29 |
const last = runs[runs.length - 1];
|
| 30 |
if (last && last.color === color && last.end === r.pos - 1) last.end = r.pos;
|
| 31 |
else runs.push({ chain, start: r.pos, end: r.pos, color });
|
|
|
|
| 64 |
|
| 65 |
const runs = useMemo(() => {
|
| 66 |
if (!d) return [];
|
| 67 |
+
if (show === 'both') return [...runsFor(d.residues, 'A'), ...runsFor(d.residues, 'B', ALT_COLOR)];
|
| 68 |
+
return runsFor(d.residues, show);
|
| 69 |
}, [d, show]);
|
| 70 |
|
| 71 |
const url = api.compareStructureUrl(a.pdb_id, a.auth_asym_id, b.pdb_id, b.auth_asym_id, show === 'both' ? undefined : show);
|
|
|
|
| 87 |
options={[
|
| 88 |
{ value: 'both', label: 'Both' },
|
| 89 |
{ value: 'A', label: `${la} (this)` },
|
| 90 |
+
{ value: 'B', label: `${lb} (alternative)` },
|
| 91 |
]}
|
| 92 |
/>
|
| 93 |
<p className="muted small sdiff__stats">
|
| 94 |
+
{show === 'both' ? <>{la} is coloured by deviation; {lb} is violet. </> : null}
|
| 95 |
{lb} superposed onto {la} on their largest rigid core: {fmtInt(d.counts.low)} of {fmtInt(d.n_common)} shared
|
| 96 |
residues within {t.low} Å (core RMSD {fmtNum(d.rmsd_core, 2)} Å), maximum deviation {fmtNum(d.max_dev, 1)} Å.
|
| 97 |
</p>
|
|
|
|
| 103 |
label={show === 'both' ? `chain A = ${la} · chain B = ${lb}` : show === 'A' ? la : lb}
|
| 104 |
/>
|
| 105 |
<ul className="legend sdiff__legend">
|
| 106 |
+
{show === 'both' && (
|
| 107 |
+
<li>
|
| 108 |
+
<Swatch color={ALT_COLOR} /> {lb} (alternative, superposed)
|
| 109 |
+
</li>
|
| 110 |
+
)}
|
| 111 |
<li>
|
| 112 |
<Swatch color={DIFF_COLORS.low} /> < {t.low} Å <b className="tabular">{fmtInt(d.counts.low)}</b>
|
| 113 |
</li>
|
frontend/src/components/Transitions.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
import { useMemo, useState } from 'react';
|
| 2 |
import { Box } from 'lucide-react';
|
| 3 |
-
import { ENERGY_TERMS, type EnergyTerm, type OrderChange, type Transition } from '../lib/api';
|
| 4 |
import { FIDELITY_INFO, FIDELITY_ORDER, fidelityColor, fidelityKey } from '../lib/fidelity';
|
| 5 |
import { chainLabel, fmtNum, fmtSigned, methodShort } from '../lib/format';
|
| 6 |
import { niceTicks, Tooltip, useWidth, type TipState } from './charts';
|
| 7 |
import { ChainLink, EmptyState, FidelityBadge, LigandBadge, Pagination, Segmented, Swatch } from './ui';
|
|
|
|
| 8 |
|
| 9 |
export type Scope = 'cross' | 'same' | 'all';
|
| 10 |
|
|
@@ -41,7 +42,7 @@ export function TransitionScatter({
|
|
| 41 |
return (
|
| 42 |
<div className="chart scatter" ref={ref}>
|
| 43 |
{width > 0 && (
|
| 44 |
-
<svg width={width} height={height} role="img" aria-label="Similarity versus RMSD for every
|
| 45 |
<g transform={`translate(${m.left},${m.top})`}>
|
| 46 |
{[0.5, 0.7, 0.95].filter((t) => t > xMin).map((t) => (
|
| 47 |
<g key={t}>
|
|
@@ -170,6 +171,8 @@ export function TransitionsTable({
|
|
| 170 |
scope,
|
| 171 |
onScope,
|
| 172 |
onView,
|
|
|
|
|
|
|
| 173 |
}: {
|
| 174 |
items: Transition[];
|
| 175 |
homeState: number | null;
|
|
@@ -179,8 +182,13 @@ export function TransitionsTable({
|
|
| 179 |
onScope: (s: Scope) => void;
|
| 180 |
/** open the superposed structure comparison for this partner */
|
| 181 |
onView?: (t: Transition) => void;
|
|
|
|
|
|
|
|
|
|
| 182 |
}) {
|
| 183 |
const [fid, setFid] = useState<string>('all');
|
|
|
|
|
|
|
| 184 |
const [sort, setSort] = useState<{ k: SortKey; dir: 1 | -1 }>({ k: 'similarity', dir: 1 });
|
| 185 |
const [page, setPage] = useState(0);
|
| 186 |
|
|
@@ -192,8 +200,13 @@ export function TransitionsTable({
|
|
| 192 |
),
|
| 193 |
[items, scope, homeState],
|
| 194 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
const rows = useMemo(() => {
|
| 196 |
-
|
|
|
|
| 197 |
return [...r].sort((a, b) => {
|
| 198 |
const va = sortValue(a, sort.k);
|
| 199 |
const vb = sortValue(b, sort.k);
|
|
@@ -201,7 +214,7 @@ export function TransitionsTable({
|
|
| 201 |
if (vb == null) return -1;
|
| 202 |
return (va < vb ? -1 : va > vb ? 1 : 0) * sort.dir;
|
| 203 |
});
|
| 204 |
-
}, [scoped, fid, sort]);
|
| 205 |
const fidCounts = useMemo(() => {
|
| 206 |
const c: Record<string, number> = {};
|
| 207 |
for (const t of scoped) c[fidelityKey(t.fidelity)] = (c[fidelityKey(t.fidelity)] ?? 0) + 1;
|
|
@@ -237,7 +250,7 @@ export function TransitionsTable({
|
|
| 237 |
<>
|
| 238 |
<div className="table-toolbar table-toolbar--wrap">
|
| 239 |
<Segmented
|
| 240 |
-
label="
|
| 241 |
value={scope}
|
| 242 |
onChange={(v) => {
|
| 243 |
onScope(v);
|
|
@@ -259,15 +272,30 @@ export function TransitionsTable({
|
|
| 259 |
}}
|
| 260 |
options={fidOptions}
|
| 261 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
</div>
|
| 263 |
{rows.length === 0 ? (
|
| 264 |
-
<EmptyState title="No
|
| 265 |
) : (
|
| 266 |
<div className="table-wrap">
|
| 267 |
<table className="table table--dense table--rows table--sticky-first">
|
| 268 |
<thead>
|
| 269 |
<tr>
|
| 270 |
-
<th>
|
| 271 |
{th('state', 'State')}
|
| 272 |
<th>Fidelity</th>
|
| 273 |
{th('similarity', 'Similarity')}
|
|
@@ -279,6 +307,7 @@ export function TransitionsTable({
|
|
| 279 |
<th>Method</th>
|
| 280 |
{th('resolution', 'Res. Å')}
|
| 281 |
<th>Ligand</th>
|
|
|
|
| 282 |
{th('date', 'Released')}
|
| 283 |
</tr>
|
| 284 |
</thead>
|
|
@@ -336,6 +365,11 @@ export function TransitionsTable({
|
|
| 336 |
<td>
|
| 337 |
<LigandBadge status={t.binding_status} />
|
| 338 |
</td>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
<td className="num tabular">{t.release_date ?? '—'}</td>
|
| 340 |
|
| 341 |
</tr>
|
|
|
|
| 1 |
import { useMemo, useState } from 'react';
|
| 2 |
import { Box } from 'lucide-react';
|
| 3 |
+
import { ENERGY_TERMS, type EnergyTerm, type OrderChange, type PartnerName, type Transition } from '../lib/api';
|
| 4 |
import { FIDELITY_INFO, FIDELITY_ORDER, fidelityColor, fidelityKey } from '../lib/fidelity';
|
| 5 |
import { chainLabel, fmtNum, fmtSigned, methodShort } from '../lib/format';
|
| 6 |
import { niceTicks, Tooltip, useWidth, type TipState } from './charts';
|
| 7 |
import { ChainLink, EmptyState, FidelityBadge, LigandBadge, Pagination, Segmented, Swatch } from './ui';
|
| 8 |
+
import { PartnerChips, sameSet } from './Partners';
|
| 9 |
|
| 10 |
export type Scope = 'cross' | 'same' | 'all';
|
| 11 |
|
|
|
|
| 42 |
return (
|
| 43 |
<div className="chart scatter" ref={ref}>
|
| 44 |
{width > 0 && (
|
| 45 |
+
<svg width={width} height={height} role="img" aria-label="Similarity versus RMSD for every alternative">
|
| 46 |
<g transform={`translate(${m.left},${m.top})`}>
|
| 47 |
{[0.5, 0.7, 0.95].filter((t) => t > xMin).map((t) => (
|
| 48 |
<g key={t}>
|
|
|
|
| 171 |
scope,
|
| 172 |
onScope,
|
| 173 |
onView,
|
| 174 |
+
homePartners,
|
| 175 |
+
partnerNames,
|
| 176 |
}: {
|
| 177 |
items: Transition[];
|
| 178 |
homeState: number | null;
|
|
|
|
| 182 |
onScope: (s: Scope) => void;
|
| 183 |
/** open the superposed structure comparison for this partner */
|
| 184 |
onView?: (t: Transition) => void;
|
| 185 |
+
/** this chain's biological binding partners, to flag alternatives bound to something else */
|
| 186 |
+
homePartners?: string[];
|
| 187 |
+
partnerNames?: Record<string, PartnerName>;
|
| 188 |
}) {
|
| 189 |
const [fid, setFid] = useState<string>('all');
|
| 190 |
+
const [bound, setBound] = useState<'all' | 'same' | 'diff'>('all');
|
| 191 |
+
const hasPartners = homePartners != null && items.some((t) => t.partners != null);
|
| 192 |
const [sort, setSort] = useState<{ k: SortKey; dir: 1 | -1 }>({ k: 'similarity', dir: 1 });
|
| 193 |
const [page, setPage] = useState(0);
|
| 194 |
|
|
|
|
| 200 |
),
|
| 201 |
[items, scope, homeState],
|
| 202 |
);
|
| 203 |
+
const nSamePartners = useMemo(
|
| 204 |
+
() => scoped.filter((t) => sameSet(t.partners, homePartners)).length,
|
| 205 |
+
[scoped, homePartners],
|
| 206 |
+
);
|
| 207 |
const rows = useMemo(() => {
|
| 208 |
+
let r = fid === 'all' ? scoped : scoped.filter((t) => fidelityKey(t.fidelity) === fid);
|
| 209 |
+
if (bound !== 'all') r = r.filter((t) => sameSet(t.partners, homePartners) === (bound === 'same'));
|
| 210 |
return [...r].sort((a, b) => {
|
| 211 |
const va = sortValue(a, sort.k);
|
| 212 |
const vb = sortValue(b, sort.k);
|
|
|
|
| 214 |
if (vb == null) return -1;
|
| 215 |
return (va < vb ? -1 : va > vb ? 1 : 0) * sort.dir;
|
| 216 |
});
|
| 217 |
+
}, [scoped, fid, sort, bound, homePartners]);
|
| 218 |
const fidCounts = useMemo(() => {
|
| 219 |
const c: Record<string, number> = {};
|
| 220 |
for (const t of scoped) c[fidelityKey(t.fidelity)] = (c[fidelityKey(t.fidelity)] ?? 0) + 1;
|
|
|
|
| 250 |
<>
|
| 251 |
<div className="table-toolbar table-toolbar--wrap">
|
| 252 |
<Segmented
|
| 253 |
+
label="Alternative scope"
|
| 254 |
value={scope}
|
| 255 |
onChange={(v) => {
|
| 256 |
onScope(v);
|
|
|
|
| 272 |
}}
|
| 273 |
options={fidOptions}
|
| 274 |
/>
|
| 275 |
+
{hasPartners && (
|
| 276 |
+
<Segmented
|
| 277 |
+
label="Binding partners"
|
| 278 |
+
value={bound}
|
| 279 |
+
onChange={(v) => {
|
| 280 |
+
setBound(v);
|
| 281 |
+
setPage(0);
|
| 282 |
+
}}
|
| 283 |
+
options={[
|
| 284 |
+
{ value: 'all', label: 'Any partners', count: scoped.length },
|
| 285 |
+
{ value: 'same', label: 'Same as this chain', count: nSamePartners },
|
| 286 |
+
{ value: 'diff', label: 'Different', count: scoped.length - nSamePartners },
|
| 287 |
+
]}
|
| 288 |
+
/>
|
| 289 |
+
)}
|
| 290 |
</div>
|
| 291 |
{rows.length === 0 ? (
|
| 292 |
+
<EmptyState title="No alternatives in this view" />
|
| 293 |
) : (
|
| 294 |
<div className="table-wrap">
|
| 295 |
<table className="table table--dense table--rows table--sticky-first">
|
| 296 |
<thead>
|
| 297 |
<tr>
|
| 298 |
+
<th>Alternative</th>
|
| 299 |
{th('state', 'State')}
|
| 300 |
<th>Fidelity</th>
|
| 301 |
{th('similarity', 'Similarity')}
|
|
|
|
| 307 |
<th>Method</th>
|
| 308 |
{th('resolution', 'Res. Å')}
|
| 309 |
<th>Ligand</th>
|
| 310 |
+
{hasPartners && <th title="Biological polymer partners of the alternative; outlined = not bound by this chain, −n = partners of this chain missing here">Binding partners</th>}
|
| 311 |
{th('date', 'Released')}
|
| 312 |
</tr>
|
| 313 |
</thead>
|
|
|
|
| 365 |
<td>
|
| 366 |
<LigandBadge status={t.binding_status} />
|
| 367 |
</td>
|
| 368 |
+
{hasPartners && (
|
| 369 |
+
<td>
|
| 370 |
+
<PartnerChips keys={t.partners} names={partnerNames} home={homePartners} />
|
| 371 |
+
</td>
|
| 372 |
+
)}
|
| 373 |
<td className="num tabular">{t.release_date ?? '—'}</td>
|
| 374 |
|
| 375 |
</tr>
|
frontend/src/lib/api.ts
CHANGED
|
@@ -45,6 +45,14 @@ export interface Overview {
|
|
| 45 |
group_sizes: LabelValue[];
|
| 46 |
largest: Array<{ hset: string; label: string; n_sequences: number; n_obs: number; n_multistate: number }>;
|
| 47 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
order?: {
|
| 49 |
validity: LabelValue[];
|
| 50 |
evidence: Array<LabelValue & { cross_state: number }>;
|
|
@@ -109,6 +117,35 @@ export interface Member {
|
|
| 109 |
pfam_id: string | null;
|
| 110 |
ecod_id: string | null;
|
| 111 |
ecod_fid: string | null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
}
|
| 113 |
|
| 114 |
export interface StatePair {
|
|
@@ -134,6 +171,7 @@ export interface SequenceDetail extends SequenceSummary {
|
|
| 134 |
cath_superfamilies?: string[];
|
| 135 |
ecod_group: EcodGroup | null;
|
| 136 |
ecod_names: EcodNames;
|
|
|
|
| 137 |
}
|
| 138 |
|
| 139 |
export const ENERGY_TERMS = ['Rosetta', 'FoldX', 'EvoEF2', 'RW', 'RW+'] as const;
|
|
@@ -177,6 +215,7 @@ export interface ChainDetail {
|
|
| 177 |
};
|
| 178 |
functions: RankedFunction[];
|
| 179 |
family: (SequenceSummary & { states: StateInfo[] }) | null;
|
|
|
|
| 180 |
}
|
| 181 |
|
| 182 |
export interface Transition {
|
|
@@ -198,6 +237,7 @@ export interface Transition {
|
|
| 198 |
chain_composition?: string | null;
|
| 199 |
binders?: string[];
|
| 200 |
release_date?: string | null;
|
|
|
|
| 201 |
}
|
| 202 |
|
| 203 |
/** Observed order/disorder change from A to B (resolved-state sense, not intrinsic disorder). */
|
|
@@ -250,6 +290,8 @@ export interface TransitionsResponse {
|
|
| 250 |
auth_asym_id: string;
|
| 251 |
state_id: number | null;
|
| 252 |
sequence_id: string;
|
|
|
|
|
|
|
| 253 |
transitions: Transition[];
|
| 254 |
}
|
| 255 |
|
|
|
|
| 45 |
group_sizes: LabelValue[];
|
| 46 |
largest: Array<{ hset: string; label: string; n_sequences: number; n_obs: number; n_multistate: number }>;
|
| 47 |
};
|
| 48 |
+
partners?: {
|
| 49 |
+
observations_with_partner: number;
|
| 50 |
+
observations_with_hetero_partner: number;
|
| 51 |
+
observations_homo_only: number;
|
| 52 |
+
distinct_partners: number;
|
| 53 |
+
by_type: LabelValue[];
|
| 54 |
+
top: Array<LabelValue & { key: string; type: string; uniprot: string | null }>;
|
| 55 |
+
} | null;
|
| 56 |
order?: {
|
| 57 |
validity: LabelValue[];
|
| 58 |
evidence: Array<LabelValue & { cross_state: number }>;
|
|
|
|
| 117 |
pfam_id: string | null;
|
| 118 |
ecod_id: string | null;
|
| 119 |
ecod_fid: string | null;
|
| 120 |
+
/** Biological binding-partner keys, largest interface first; 'self' = homo-oligomer contact. */
|
| 121 |
+
partners: string[];
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/** Display info for a binding-partner key (UniProt accession, 'dna', 'rna', or 'type:description'). */
|
| 125 |
+
export interface PartnerName {
|
| 126 |
+
type: string;
|
| 127 |
+
description: string | null;
|
| 128 |
+
uniprot: string | null;
|
| 129 |
+
n_chains: number;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/** One polymer chain in contact with an observation (pipeline step 12). */
|
| 133 |
+
export interface BindingPartner {
|
| 134 |
+
chain: string;
|
| 135 |
+
entity: string | null;
|
| 136 |
+
type: string;
|
| 137 |
+
description: string | null;
|
| 138 |
+
uniprot: string | null;
|
| 139 |
+
n_res_target: number;
|
| 140 |
+
n_res_partner: number | null;
|
| 141 |
+
n_contacts: number | null;
|
| 142 |
+
/** Interface residues on this chain, label_seq_id ranges. */
|
| 143 |
+
interface: Array<[number, number]>;
|
| 144 |
+
same_entity: boolean;
|
| 145 |
+
via_symmetry: boolean;
|
| 146 |
+
in_assembly1: boolean | null;
|
| 147 |
+
biological: boolean;
|
| 148 |
+
observation: { sequence_id: string; state_id: number | null } | null;
|
| 149 |
}
|
| 150 |
|
| 151 |
export interface StatePair {
|
|
|
|
| 171 |
cath_superfamilies?: string[];
|
| 172 |
ecod_group: EcodGroup | null;
|
| 173 |
ecod_names: EcodNames;
|
| 174 |
+
partner_names?: Record<string, PartnerName>;
|
| 175 |
}
|
| 176 |
|
| 177 |
export const ENERGY_TERMS = ['Rosetta', 'FoldX', 'EvoEF2', 'RW', 'RW+'] as const;
|
|
|
|
| 215 |
};
|
| 216 |
functions: RankedFunction[];
|
| 217 |
family: (SequenceSummary & { states: StateInfo[] }) | null;
|
| 218 |
+
binding_partners?: BindingPartner[];
|
| 219 |
}
|
| 220 |
|
| 221 |
export interface Transition {
|
|
|
|
| 237 |
chain_composition?: string | null;
|
| 238 |
binders?: string[];
|
| 239 |
release_date?: string | null;
|
| 240 |
+
partners?: string[];
|
| 241 |
}
|
| 242 |
|
| 243 |
/** Observed order/disorder change from A to B (resolved-state sense, not intrinsic disorder). */
|
|
|
|
| 290 |
auth_asym_id: string;
|
| 291 |
state_id: number | null;
|
| 292 |
sequence_id: string;
|
| 293 |
+
partners?: string[];
|
| 294 |
+
partner_names?: Record<string, PartnerName>;
|
| 295 |
transitions: Transition[];
|
| 296 |
}
|
| 297 |
|
frontend/src/pages/ChainPage.tsx
CHANGED
|
@@ -28,6 +28,8 @@ import { StructureDiff } from '../components/StructureDiff';
|
|
| 28 |
import { DomainList } from '../components/Domains';
|
| 29 |
import { OrderChangeView } from '../components/Transitions';
|
| 30 |
import { fidelityColor } from '../lib/fidelity';
|
|
|
|
|
|
|
| 31 |
|
| 32 |
function Binder({ id }: { id: string }) {
|
| 33 |
const name = usePromise(() => rcsbComponentName(id), [id]);
|
|
@@ -45,18 +47,76 @@ function Binder({ id }: { id: string }) {
|
|
| 45 |
);
|
| 46 |
}
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
function ComparePanel({
|
| 49 |
a,
|
| 50 |
t,
|
| 51 |
onClose,
|
| 52 |
show3d,
|
| 53 |
onShow3d,
|
|
|
|
|
|
|
| 54 |
}: {
|
| 55 |
a: ChainDetail;
|
| 56 |
t: Transition;
|
| 57 |
onClose: () => void;
|
| 58 |
show3d: boolean;
|
| 59 |
onShow3d: (open: boolean) => void;
|
|
|
|
|
|
|
| 60 |
}) {
|
| 61 |
const rows: Array<[string, React.ReactNode, React.ReactNode]> = [
|
| 62 |
['State', a.state_id ?? '—', t.state_id ?? '—'],
|
|
@@ -64,6 +124,15 @@ function ComparePanel({
|
|
| 64 |
['Resolution', a.resolution != null ? `${fmtNum(a.resolution, 2)} Å` : '—', t.resolution != null ? `${fmtNum(t.resolution, 2)} Å` : '—'],
|
| 65 |
['Ligand state', <LigandBadge key="a" status={a.binding_status} />, <LigandBadge key="b" status={t.binding_status} />],
|
| 66 |
['Binders', a.binders.join(' ') || '—', (t.binders ?? []).join(' ') || '—'],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
['Assembly', capitalize(a.chain_composition), capitalize(t.chain_composition)],
|
| 68 |
['Released', a.release_date ?? '—', t.release_date ?? '—'],
|
| 69 |
];
|
|
@@ -83,7 +152,7 @@ function ComparePanel({
|
|
| 83 |
<Box aria-hidden /> {show3d ? 'Hide structure' : 'View structure'}
|
| 84 |
</button>
|
| 85 |
<Link to={chainPath(t.pdb_id, t.auth_asym_id)} className="btn btn--ghost btn--sm">
|
| 86 |
-
Open
|
| 87 |
</Link>
|
| 88 |
<button type="button" className="icon-btn" aria-label="Close comparison" onClick={onClose}>
|
| 89 |
<X aria-hidden />
|
|
@@ -138,9 +207,9 @@ function ComparePanel({
|
|
| 138 |
</tbody>
|
| 139 |
</table>
|
| 140 |
<div>
|
| 141 |
-
<h3 className="compare__sub">Energy change (
|
| 142 |
<DeltaBars delta={t.delta} />
|
| 143 |
-
<p className="muted small">Negative (green) means the
|
| 144 |
</div>
|
| 145 |
</div>
|
| 146 |
<OrderChangeView
|
|
@@ -257,7 +326,21 @@ export function ChainPage() {
|
|
| 257 |
</Badge>
|
| 258 |
<Badge>{methodShort(c.experimental_method)}{c.resolution != null ? ` · ${fmtNum(c.resolution, 2)} Å` : ''}</Badge>
|
| 259 |
{c.chain_composition && <Badge>{capitalize(c.chain_composition)}</Badge>}
|
| 260 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
</div>
|
| 262 |
<div className="entity-head__links">
|
| 263 |
<ExternalLink href={`https://www.rcsb.org/structure/${c.pdb_id.toUpperCase()}`}>RCSB PDB</ExternalLink>
|
|
@@ -300,13 +383,13 @@ export function ChainPage() {
|
|
| 300 |
title="Alternative conformations"
|
| 301 |
kicker={
|
| 302 |
tr.data
|
| 303 |
-
? `${fmtInt(transitions.length)} other observations of this sequence; ${fmtInt(nCross)} are in a different state. Each point is one
|
| 304 |
: 'Loading transitions…'
|
| 305 |
}
|
| 306 |
>
|
| 307 |
{tr.loading && <Spinner label="Loading transitions…" />}
|
| 308 |
{tr.error && <ErrorState message={tr.error.message} />}
|
| 309 |
-
{tr.data && transitions.length === 0 && <EmptyState title="No
|
| 310 |
{tr.data && transitions.length > 0 && (
|
| 311 |
<div className="neighbourhood">
|
| 312 |
<TransitionScatter
|
|
@@ -364,11 +447,13 @@ export function ChainPage() {
|
|
| 364 |
onClose={() => setSelected(null)}
|
| 365 |
show3d={compare3d}
|
| 366 |
onShow3d={setCompare3d}
|
|
|
|
|
|
|
| 367 |
/>
|
| 368 |
)}
|
| 369 |
|
| 370 |
{tr.data && transitions.length > 0 && (
|
| 371 |
-
<Section title="Transitions" kicker="Energy deltas are
|
| 372 |
<TransitionsTable
|
| 373 |
items={transitions}
|
| 374 |
homeState={homeState}
|
|
@@ -385,11 +470,41 @@ export function ChainPage() {
|
|
| 385 |
}}
|
| 386 |
scope={scope}
|
| 387 |
onScope={setScope}
|
|
|
|
|
|
|
| 388 |
/>
|
| 389 |
</Section>
|
| 390 |
)}
|
| 391 |
|
| 392 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
<Section title="Identifiers & domains" kicker="Cross-references for this chain; every identifier links out.">
|
| 394 |
<DomainList
|
| 395 |
uniprot={c.uniprot_id}
|
|
@@ -403,42 +518,6 @@ export function ChainPage() {
|
|
| 403 |
pfamIds={c.pfam_ids}
|
| 404 |
/>
|
| 405 |
</Section>
|
| 406 |
-
<Section
|
| 407 |
-
title="Binders & context"
|
| 408 |
-
kicker="Binders are small molecules on this chain; nucleic acid and assembly describe the whole PDB entry."
|
| 409 |
-
>
|
| 410 |
-
<dl className="facts">
|
| 411 |
-
<Fact label="Small-molecule binders (this chain)">
|
| 412 |
-
{c.binders.length ? `${c.binders.length}` : 'None'}
|
| 413 |
-
</Fact>
|
| 414 |
-
<Fact label="Apo / holo" hint="Holo = at least one small-molecule binder on this chain; DNA/RNA are not counted">
|
| 415 |
-
<span className="fact-note">
|
| 416 |
-
<LigandBadge status={c.binding_status} />
|
| 417 |
-
<small className="muted">by small molecules only</small>
|
| 418 |
-
</span>
|
| 419 |
-
</Fact>
|
| 420 |
-
<Fact label="Nucleic acid in entry" hint="Non-protein polymer present anywhere in the PDB entry, not necessarily bound to this chain">
|
| 421 |
-
{c.nucleic_acid_binding && c.nucleic_acid_binding !== 'None' ? c.nucleic_acid_binding : 'None'}
|
| 422 |
-
</Fact>
|
| 423 |
-
<Fact label="Assembly (entry)" hint="Protein chain composition of the PDB entry">
|
| 424 |
-
{capitalize(c.chain_composition)}
|
| 425 |
-
</Fact>
|
| 426 |
-
</dl>
|
| 427 |
-
{c.binders.length > 0 ? (
|
| 428 |
-
<div className="binders">
|
| 429 |
-
{c.binders.map((b) => (
|
| 430 |
-
<Binder key={b} id={b} />
|
| 431 |
-
))}
|
| 432 |
-
</div>
|
| 433 |
-
) : (
|
| 434 |
-
<p className="muted small" style={{ marginTop: 12 }}>
|
| 435 |
-
No ligands, metals or cofactors on this chain.
|
| 436 |
-
</p>
|
| 437 |
-
)}
|
| 438 |
-
</Section>
|
| 439 |
-
</div>
|
| 440 |
-
|
| 441 |
-
<div className="grid-2">
|
| 442 |
<Section title="Experiment">
|
| 443 |
<dl className="facts">
|
| 444 |
<Fact label="Method">{c.experimental_method ? capitalize(c.experimental_method.toLowerCase()) : '—'}</Fact>
|
|
@@ -464,9 +543,6 @@ export function ChainPage() {
|
|
| 464 |
</Fact>
|
| 465 |
</dl>
|
| 466 |
</Section>
|
| 467 |
-
</div>
|
| 468 |
-
|
| 469 |
-
<div className="grid-2">
|
| 470 |
<Section title="Ranked functions" kicker="Functional annotations ranked by matching score for this observation.">
|
| 471 |
<FunctionList functions={c.functions} />
|
| 472 |
</Section>
|
|
@@ -492,7 +568,7 @@ export function ChainPage() {
|
|
| 492 |
</div>
|
| 493 |
|
| 494 |
<p className="muted small page-foot-note">
|
| 495 |
-
Other chains of this entry: <Link to={`/search?q=${c.pdb_id}`}>{c.pdb_id.toUpperCase()}</Link>.
|
| 496 |
each observation; <ChainLink pdb={c.pdb_id} chain={c.auth_asym_id} /> is this page.
|
| 497 |
</p>
|
| 498 |
</div>
|
|
|
|
| 28 |
import { DomainList } from '../components/Domains';
|
| 29 |
import { OrderChangeView } from '../components/Transitions';
|
| 30 |
import { fidelityColor } from '../lib/fidelity';
|
| 31 |
+
import { BindingPartners, PartnerChips, polymerName } from '../components/Partners';
|
| 32 |
+
import type { PartnerName } from '../lib/api';
|
| 33 |
|
| 34 |
function Binder({ id }: { id: string }) {
|
| 35 |
const name = usePromise(() => rcsbComponentName(id), [id]);
|
|
|
|
| 47 |
);
|
| 48 |
}
|
| 49 |
|
| 50 |
+
function BindingSummary({ c }: { c: ChainDetail }) {
|
| 51 |
+
const partners = c.binding_partners;
|
| 52 |
+
const bound = partners?.filter((p) => p.biological && !p.same_entity) ?? [];
|
| 53 |
+
const homo = partners?.filter((p) => p.biological && p.same_entity) ?? [];
|
| 54 |
+
const weak = partners?.filter((p) => !p.biological) ?? [];
|
| 55 |
+
const types = bound.reduce<Record<string, number>>((acc, p) => {
|
| 56 |
+
const t = p.type === 'protein' ? 'protein' : p.type === 'dna' ? 'DNA' : p.type === 'rna' ? 'RNA' : p.type;
|
| 57 |
+
acc[t] = (acc[t] ?? 0) + 1;
|
| 58 |
+
return acc;
|
| 59 |
+
}, {});
|
| 60 |
+
const nucleic = c.nucleic_acid_binding && c.nucleic_acid_binding !== 'None' ? c.nucleic_acid_binding : null;
|
| 61 |
+
return (
|
| 62 |
+
<div className="compare__metrics binding__summary">
|
| 63 |
+
<div className="metric" title="Holo = at least one small-molecule binder on this chain; polymers are not counted">
|
| 64 |
+
<span>Small molecules</span>
|
| 65 |
+
<b className="tabular">{c.binders.length}</b>
|
| 66 |
+
<small>
|
| 67 |
+
<LigandBadge status={c.binding_status} />
|
| 68 |
+
</small>
|
| 69 |
+
</div>
|
| 70 |
+
{partners && (
|
| 71 |
+
<>
|
| 72 |
+
<div className="metric" title="Different protein, DNA or RNA chains bound in biological assembly 1">
|
| 73 |
+
<span>Bound macromolecules</span>
|
| 74 |
+
<b className="tabular">{bound.length}</b>
|
| 75 |
+
<small>
|
| 76 |
+
{bound.length
|
| 77 |
+
? Object.entries(types)
|
| 78 |
+
.map(([t, n]) => `${n} ${t}`)
|
| 79 |
+
.join(' · ')
|
| 80 |
+
: 'none'}
|
| 81 |
+
</small>
|
| 82 |
+
</div>
|
| 83 |
+
<div className="metric" title="Other copies of the same entity in contact, in biological assembly 1">
|
| 84 |
+
<span>Homo-oligomer contacts</span>
|
| 85 |
+
<b className="tabular">{homo.length}</b>
|
| 86 |
+
<small>copies of this entity</small>
|
| 87 |
+
</div>
|
| 88 |
+
<div className="metric" title="Crystal packing or fewer than 5 interface residues; not counted as binding">
|
| 89 |
+
<span>Crystal / marginal</span>
|
| 90 |
+
<b className="tabular">{weak.length}</b>
|
| 91 |
+
<small>not counted</small>
|
| 92 |
+
</div>
|
| 93 |
+
</>
|
| 94 |
+
)}
|
| 95 |
+
<div className="metric" title="Protein chain composition of the whole PDB entry">
|
| 96 |
+
<span>Entry assembly</span>
|
| 97 |
+
<b className="binding__text">{capitalize(c.chain_composition)}</b>
|
| 98 |
+
<small>{nucleic ? `${nucleic} in entry` : 'no nucleic acid in entry'}</small>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
function ComparePanel({
|
| 105 |
a,
|
| 106 |
t,
|
| 107 |
onClose,
|
| 108 |
show3d,
|
| 109 |
onShow3d,
|
| 110 |
+
homePartners,
|
| 111 |
+
partnerNames,
|
| 112 |
}: {
|
| 113 |
a: ChainDetail;
|
| 114 |
t: Transition;
|
| 115 |
onClose: () => void;
|
| 116 |
show3d: boolean;
|
| 117 |
onShow3d: (open: boolean) => void;
|
| 118 |
+
homePartners?: string[];
|
| 119 |
+
partnerNames?: Record<string, PartnerName>;
|
| 120 |
}) {
|
| 121 |
const rows: Array<[string, React.ReactNode, React.ReactNode]> = [
|
| 122 |
['State', a.state_id ?? '—', t.state_id ?? '—'],
|
|
|
|
| 124 |
['Resolution', a.resolution != null ? `${fmtNum(a.resolution, 2)} Å` : '—', t.resolution != null ? `${fmtNum(t.resolution, 2)} Å` : '—'],
|
| 125 |
['Ligand state', <LigandBadge key="a" status={a.binding_status} />, <LigandBadge key="b" status={t.binding_status} />],
|
| 126 |
['Binders', a.binders.join(' ') || '—', (t.binders ?? []).join(' ') || '—'],
|
| 127 |
+
...(homePartners
|
| 128 |
+
? ([
|
| 129 |
+
[
|
| 130 |
+
'Binding partners',
|
| 131 |
+
<PartnerChips key="a" keys={homePartners} names={partnerNames} max={4} />,
|
| 132 |
+
<PartnerChips key="b" keys={t.partners} names={partnerNames} home={homePartners} max={4} />,
|
| 133 |
+
],
|
| 134 |
+
] as Array<[string, React.ReactNode, React.ReactNode]>)
|
| 135 |
+
: []),
|
| 136 |
['Assembly', capitalize(a.chain_composition), capitalize(t.chain_composition)],
|
| 137 |
['Released', a.release_date ?? '—', t.release_date ?? '—'],
|
| 138 |
];
|
|
|
|
| 152 |
<Box aria-hidden /> {show3d ? 'Hide structure' : 'View structure'}
|
| 153 |
</button>
|
| 154 |
<Link to={chainPath(t.pdb_id, t.auth_asym_id)} className="btn btn--ghost btn--sm">
|
| 155 |
+
Open alternative <ArrowRight aria-hidden />
|
| 156 |
</Link>
|
| 157 |
<button type="button" className="icon-btn" aria-label="Close comparison" onClick={onClose}>
|
| 158 |
<X aria-hidden />
|
|
|
|
| 207 |
</tbody>
|
| 208 |
</table>
|
| 209 |
<div>
|
| 210 |
+
<h3 className="compare__sub">Energy change (alternative − this chain)</h3>
|
| 211 |
<DeltaBars delta={t.delta} />
|
| 212 |
+
<p className="muted small">Negative (green) means the alternative conformation scores lower, i.e. more favourable.</p>
|
| 213 |
</div>
|
| 214 |
</div>
|
| 215 |
<OrderChangeView
|
|
|
|
| 326 |
</Badge>
|
| 327 |
<Badge>{methodShort(c.experimental_method)}{c.resolution != null ? ` · ${fmtNum(c.resolution, 2)} Å` : ''}</Badge>
|
| 328 |
{c.chain_composition && <Badge>{capitalize(c.chain_composition)}</Badge>}
|
| 329 |
+
{(() => {
|
| 330 |
+
const bound = (c.binding_partners ?? [])
|
| 331 |
+
.filter((p) => p.biological && !p.same_entity)
|
| 332 |
+
.sort((x, y) => Number(y.type === 'protein') - Number(x.type === 'protein'));
|
| 333 |
+
if (!c.binding_partners)
|
| 334 |
+
return c.nucleic_acid_binding && c.nucleic_acid_binding !== 'None' ? <Badge>Binds {c.nucleic_acid_binding}</Badge> : null;
|
| 335 |
+
if (!bound.length) return null;
|
| 336 |
+
const name = polymerName(bound[0].description, bound[0].type, bound[0].entity);
|
| 337 |
+
return (
|
| 338 |
+
<Badge title={bound.map((p) => `${polymerName(p.description, p.type, p.entity)} (${p.chain})`).join('\n')}>
|
| 339 |
+
Bound to {name.length > 32 ? `${name.slice(0, 31)}…` : name}
|
| 340 |
+
{bound.length > 1 ? ` +${bound.length - 1}` : ''}
|
| 341 |
+
</Badge>
|
| 342 |
+
);
|
| 343 |
+
})()}
|
| 344 |
</div>
|
| 345 |
<div className="entity-head__links">
|
| 346 |
<ExternalLink href={`https://www.rcsb.org/structure/${c.pdb_id.toUpperCase()}`}>RCSB PDB</ExternalLink>
|
|
|
|
| 383 |
title="Alternative conformations"
|
| 384 |
kicker={
|
| 385 |
tr.data
|
| 386 |
+
? `${fmtInt(transitions.length)} other observations of this sequence; ${fmtInt(nCross)} are in a different state. Each point is one alternative — click to compare.`
|
| 387 |
: 'Loading transitions…'
|
| 388 |
}
|
| 389 |
>
|
| 390 |
{tr.loading && <Spinner label="Loading transitions…" />}
|
| 391 |
{tr.error && <ErrorState message={tr.error.message} />}
|
| 392 |
+
{tr.data && transitions.length === 0 && <EmptyState title="No alternatives" />}
|
| 393 |
{tr.data && transitions.length > 0 && (
|
| 394 |
<div className="neighbourhood">
|
| 395 |
<TransitionScatter
|
|
|
|
| 447 |
onClose={() => setSelected(null)}
|
| 448 |
show3d={compare3d}
|
| 449 |
onShow3d={setCompare3d}
|
| 450 |
+
homePartners={tr.data?.partners}
|
| 451 |
+
partnerNames={tr.data?.partner_names}
|
| 452 |
/>
|
| 453 |
)}
|
| 454 |
|
| 455 |
{tr.data && transitions.length > 0 && (
|
| 456 |
+
<Section title="Transitions" kicker="Energy deltas are alternative − this chain; sort any column." flush>
|
| 457 |
<TransitionsTable
|
| 458 |
items={transitions}
|
| 459 |
homeState={homeState}
|
|
|
|
| 470 |
}}
|
| 471 |
scope={scope}
|
| 472 |
onScope={setScope}
|
| 473 |
+
homePartners={tr.data?.partners}
|
| 474 |
+
partnerNames={tr.data?.partner_names}
|
| 475 |
/>
|
| 476 |
</Section>
|
| 477 |
)}
|
| 478 |
|
| 479 |
+
<Section
|
| 480 |
+
title="Interactions"
|
| 481 |
+
kicker={`What ${label} interacts with in ${c.pdb_id.toUpperCase()}: small molecules on this chain, and protein, DNA or RNA chains within 4.5 Å of it.`}
|
| 482 |
+
>
|
| 483 |
+
<BindingSummary c={c} />
|
| 484 |
+
<h3 className="binding__sub">
|
| 485 |
+
Small molecules <span>ligands, metals and cofactors on this chain</span>
|
| 486 |
+
</h3>
|
| 487 |
+
{c.binders.length > 0 ? (
|
| 488 |
+
<div className="binders">
|
| 489 |
+
{c.binders.map((b) => (
|
| 490 |
+
<Binder key={b} id={b} />
|
| 491 |
+
))}
|
| 492 |
+
</div>
|
| 493 |
+
) : (
|
| 494 |
+
<p className="muted small">No ligands, metals or cofactors on this chain (apo).</p>
|
| 495 |
+
)}
|
| 496 |
+
{c.binding_partners && (
|
| 497 |
+
<>
|
| 498 |
+
<h3 className="binding__sub">
|
| 499 |
+
Macromolecules{' '}
|
| 500 |
+
<span>bound partners need ≥ 5 interface residues and must be in biological assembly 1; the rest are crystal or marginal contacts</span>
|
| 501 |
+
</h3>
|
| 502 |
+
<BindingPartners partners={c.binding_partners} pdb={c.pdb_id} length={c.sequence_length ?? c.sequence.length} />
|
| 503 |
+
</>
|
| 504 |
+
)}
|
| 505 |
+
</Section>
|
| 506 |
+
|
| 507 |
+
<div className="masonry">
|
| 508 |
<Section title="Identifiers & domains" kicker="Cross-references for this chain; every identifier links out.">
|
| 509 |
<DomainList
|
| 510 |
uniprot={c.uniprot_id}
|
|
|
|
| 518 |
pfamIds={c.pfam_ids}
|
| 519 |
/>
|
| 520 |
</Section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
<Section title="Experiment">
|
| 522 |
<dl className="facts">
|
| 523 |
<Fact label="Method">{c.experimental_method ? capitalize(c.experimental_method.toLowerCase()) : '—'}</Fact>
|
|
|
|
| 543 |
</Fact>
|
| 544 |
</dl>
|
| 545 |
</Section>
|
|
|
|
|
|
|
|
|
|
| 546 |
<Section title="Ranked functions" kicker="Functional annotations ranked by matching score for this observation.">
|
| 547 |
<FunctionList functions={c.functions} />
|
| 548 |
</Section>
|
|
|
|
| 568 |
</div>
|
| 569 |
|
| 570 |
<p className="muted small page-foot-note">
|
| 571 |
+
Other chains of this entry: <Link to={`/search?q=${c.pdb_id}`}>{c.pdb_id.toUpperCase()}</Link>. Chain links open
|
| 572 |
each observation; <ChainLink pdb={c.pdb_id} chain={c.auth_asym_id} /> is this page.
|
| 573 |
</p>
|
| 574 |
</div>
|
frontend/src/pages/HomePage.tsx
CHANGED
|
@@ -302,7 +302,7 @@ export function HomePage() {
|
|
| 302 |
<div className="cta__text">
|
| 303 |
<h2>Take the whole database with you</h2>
|
| 304 |
<p>
|
| 305 |
-
One SQLite file with
|
| 306 |
it with any SQLite client, pandas or DuckDB.
|
| 307 |
</p>
|
| 308 |
</div>
|
|
|
|
| 302 |
<div className="cta__text">
|
| 303 |
<h2>Take the whole database with you</h2>
|
| 304 |
<p>
|
| 305 |
+
One SQLite file with three tables — <code>node</code> (observations), <code>edge</code> (transitions) and <code>partner</code> (binding partners). Query
|
| 306 |
it with any SQLite client, pandas or DuckDB.
|
| 307 |
</p>
|
| 308 |
</div>
|
frontend/src/pages/SequencePage.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { useMemo, useState } from 'react';
|
| 2 |
import { Link, useParams } from 'react-router-dom';
|
| 3 |
-
import { api, type Member } from '../lib/api';
|
| 4 |
import { useApi } from '../lib/hooks';
|
| 5 |
import { cleanFunction, fmtInt, fmtNum, fmtPct, methodShort, capitalize } from '../lib/format';
|
| 6 |
import { ChainLink, EmptyState, ErrorState, ExternalLink, LigandBadge, Pagination, Section, Segmented, Spinner, Meter, UniProtLinks } from '../components/ui';
|
|
@@ -8,12 +8,25 @@ import { StateHeatmap, StateLandscape } from '../components/StateViews';
|
|
| 8 |
import { FunctionList, SequenceTrack } from '../components/Panels';
|
| 9 |
import { PageMeta } from '../components/PageMeta';
|
| 10 |
import { DomainList } from '../components/Domains';
|
|
|
|
| 11 |
|
| 12 |
const PAGE = 25;
|
| 13 |
|
| 14 |
type MemberSort = 'state' | 'resolution' | 'date' | 'coverage';
|
| 15 |
|
| 16 |
-
function MembersTable({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
const [ligand, setLigand] = useState<'all' | 'apo' | 'holo'>('all');
|
| 18 |
const [sort, setSort] = useState<MemberSort>('state');
|
| 19 |
const [page, setPage] = useState(0);
|
|
@@ -21,6 +34,8 @@ function MembersTable({ members, state }: { members: Member[]; state: number | n
|
|
| 21 |
const rows = useMemo(() => {
|
| 22 |
let r = members;
|
| 23 |
if (state != null) r = r.filter((m) => m.state_id === state);
|
|
|
|
|
|
|
| 24 |
if (ligand !== 'all') r = r.filter((m) => m.binding_status === ligand);
|
| 25 |
const cmp: Record<MemberSort, (a: Member, b: Member) => number> = {
|
| 26 |
state: (a, b) => a.state_id - b.state_id || (a.resolution ?? 99) - (b.resolution ?? 99),
|
|
@@ -29,10 +44,14 @@ function MembersTable({ members, state }: { members: Member[]; state: number | n
|
|
| 29 |
coverage: (a, b) => (b.resolved_coverage ?? 0) - (a.resolved_coverage ?? 0),
|
| 30 |
};
|
| 31 |
return [...r].sort(cmp[sort]);
|
| 32 |
-
}, [members, state, ligand, sort]);
|
| 33 |
|
| 34 |
const pageRows = rows.slice(page * PAGE, (page + 1) * PAGE);
|
| 35 |
-
const scoped =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
const nHolo = scoped.filter((m) => m.binding_status === 'holo').length;
|
| 37 |
|
| 38 |
return (
|
|
@@ -60,6 +79,11 @@ function MembersTable({ members, state }: { members: Member[]; state: number | n
|
|
| 60 |
<option value="coverage">Coverage</option>
|
| 61 |
</select>
|
| 62 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
</div>
|
| 64 |
{rows.length === 0 ? (
|
| 65 |
<EmptyState title="No observations match" />
|
|
@@ -74,6 +98,7 @@ function MembersTable({ members, state }: { members: Member[]; state: number | n
|
|
| 74 |
<th className="num">Res. (Å)</th>
|
| 75 |
<th>Ligand</th>
|
| 76 |
<th>Binders</th>
|
|
|
|
| 77 |
<th>Assembly</th>
|
| 78 |
<th>Modelled</th>
|
| 79 |
<th className="num">Released</th>
|
|
@@ -94,6 +119,9 @@ function MembersTable({ members, state }: { members: Member[]; state: number | n
|
|
| 94 |
<td className="mono binders-cell" title={m.binders.join(', ')}>
|
| 95 |
{m.binders.length ? m.binders.slice(0, 4).join(' ') + (m.binders.length > 4 ? ` +${m.binders.length - 4}` : '') : '—'}
|
| 96 |
</td>
|
|
|
|
|
|
|
|
|
|
| 97 |
<td>{capitalize(m.chain_composition)}</td>
|
| 98 |
<td>
|
| 99 |
<span className="meter-cell">
|
|
@@ -117,6 +145,7 @@ export function SequencePage() {
|
|
| 117 |
const { id = '' } = useParams();
|
| 118 |
const res = useApi(`sequence:${id}`, (sig) => api.sequence(id, sig));
|
| 119 |
const [selected, setSelected] = useState<number | null>(null);
|
|
|
|
| 120 |
const s = res.data;
|
| 121 |
|
| 122 |
if (res.loading) return <div className="page"><Spinner label="Loading sequence…" /></div>;
|
|
@@ -229,15 +258,38 @@ export function SequencePage() {
|
|
| 229 |
)}
|
| 230 |
</div>
|
| 231 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
<Section
|
| 233 |
title={selected != null ? `Observations in state ${selected}` : 'All observations'}
|
| 234 |
kicker="Open a chain to see its transitions to every other observation."
|
| 235 |
flush
|
| 236 |
>
|
| 237 |
-
<MembersTable
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
</Section>
|
| 239 |
|
| 240 |
-
<div className="
|
| 241 |
<Section
|
| 242 |
title="Identifiers & domains"
|
| 243 |
kicker="Consensus assignments across this sequence's chains; per-chain domain IDs are on each chain page."
|
|
@@ -259,17 +311,17 @@ export function SequencePage() {
|
|
| 259 |
</Link>
|
| 260 |
)}
|
| 261 |
</Section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
<Section title="Ranked functions" kicker={`From the representative chain ${s.rep_pdb.toUpperCase()}.${s.rep_chain}`}>
|
| 263 |
<FunctionList functions={s.functions ?? []} />
|
| 264 |
</Section>
|
| 265 |
</div>
|
| 266 |
-
<Section title="Sequence" kicker={`${fmtInt(s.length)} residues (SEQRES)`}>
|
| 267 |
-
<SequenceTrack
|
| 268 |
-
sequence={s.sequence ?? ''}
|
| 269 |
-
fastaHeader={`${s.sequence_id}${s.uniprot_id ? ` ${s.uniprot_id}` : ''}`}
|
| 270 |
-
fileName={`${s.sequence_id}.fasta`}
|
| 271 |
-
/>
|
| 272 |
-
</Section>
|
| 273 |
</div>
|
| 274 |
);
|
| 275 |
}
|
|
|
|
| 1 |
import { useMemo, useState } from 'react';
|
| 2 |
import { Link, useParams } from 'react-router-dom';
|
| 3 |
+
import { api, type Member, type PartnerName } from '../lib/api';
|
| 4 |
import { useApi } from '../lib/hooks';
|
| 5 |
import { cleanFunction, fmtInt, fmtNum, fmtPct, methodShort, capitalize } from '../lib/format';
|
| 6 |
import { ChainLink, EmptyState, ErrorState, ExternalLink, LigandBadge, Pagination, Section, Segmented, Spinner, Meter, UniProtLinks } from '../components/ui';
|
|
|
|
| 8 |
import { FunctionList, SequenceTrack } from '../components/Panels';
|
| 9 |
import { PageMeta } from '../components/PageMeta';
|
| 10 |
import { DomainList } from '../components/Domains';
|
| 11 |
+
import { NO_PARTNER, PartnerChips, PartnersByState, partnerLabel } from '../components/Partners';
|
| 12 |
|
| 13 |
const PAGE = 25;
|
| 14 |
|
| 15 |
type MemberSort = 'state' | 'resolution' | 'date' | 'coverage';
|
| 16 |
|
| 17 |
+
function MembersTable({
|
| 18 |
+
members,
|
| 19 |
+
state,
|
| 20 |
+
partner,
|
| 21 |
+
names,
|
| 22 |
+
onClearPartner,
|
| 23 |
+
}: {
|
| 24 |
+
members: Member[];
|
| 25 |
+
state: number | null;
|
| 26 |
+
partner: string | null;
|
| 27 |
+
names?: Record<string, PartnerName>;
|
| 28 |
+
onClearPartner: () => void;
|
| 29 |
+
}) {
|
| 30 |
const [ligand, setLigand] = useState<'all' | 'apo' | 'holo'>('all');
|
| 31 |
const [sort, setSort] = useState<MemberSort>('state');
|
| 32 |
const [page, setPage] = useState(0);
|
|
|
|
| 34 |
const rows = useMemo(() => {
|
| 35 |
let r = members;
|
| 36 |
if (state != null) r = r.filter((m) => m.state_id === state);
|
| 37 |
+
if (partner != null)
|
| 38 |
+
r = r.filter((m) => (partner === NO_PARTNER ? !m.partners?.length : m.partners?.includes(partner)));
|
| 39 |
if (ligand !== 'all') r = r.filter((m) => m.binding_status === ligand);
|
| 40 |
const cmp: Record<MemberSort, (a: Member, b: Member) => number> = {
|
| 41 |
state: (a, b) => a.state_id - b.state_id || (a.resolution ?? 99) - (b.resolution ?? 99),
|
|
|
|
| 44 |
coverage: (a, b) => (b.resolved_coverage ?? 0) - (a.resolved_coverage ?? 0),
|
| 45 |
};
|
| 46 |
return [...r].sort(cmp[sort]);
|
| 47 |
+
}, [members, state, partner, ligand, sort]);
|
| 48 |
|
| 49 |
const pageRows = rows.slice(page * PAGE, (page + 1) * PAGE);
|
| 50 |
+
const scoped = members.filter(
|
| 51 |
+
(m) =>
|
| 52 |
+
(state == null || m.state_id === state) &&
|
| 53 |
+
(partner == null || (partner === NO_PARTNER ? !m.partners?.length : m.partners?.includes(partner))),
|
| 54 |
+
);
|
| 55 |
const nHolo = scoped.filter((m) => m.binding_status === 'holo').length;
|
| 56 |
|
| 57 |
return (
|
|
|
|
| 79 |
<option value="coverage">Coverage</option>
|
| 80 |
</select>
|
| 81 |
</label>
|
| 82 |
+
{partner != null && (
|
| 83 |
+
<button type="button" className="btn btn--ghost btn--sm" onClick={onClearPartner}>
|
| 84 |
+
Partner: {partnerLabel(partner, names)} ✕
|
| 85 |
+
</button>
|
| 86 |
+
)}
|
| 87 |
</div>
|
| 88 |
{rows.length === 0 ? (
|
| 89 |
<EmptyState title="No observations match" />
|
|
|
|
| 98 |
<th className="num">Res. (Å)</th>
|
| 99 |
<th>Ligand</th>
|
| 100 |
<th>Binders</th>
|
| 101 |
+
<th>Binding partners</th>
|
| 102 |
<th>Assembly</th>
|
| 103 |
<th>Modelled</th>
|
| 104 |
<th className="num">Released</th>
|
|
|
|
| 119 |
<td className="mono binders-cell" title={m.binders.join(', ')}>
|
| 120 |
{m.binders.length ? m.binders.slice(0, 4).join(' ') + (m.binders.length > 4 ? ` +${m.binders.length - 4}` : '') : '—'}
|
| 121 |
</td>
|
| 122 |
+
<td>
|
| 123 |
+
<PartnerChips keys={m.partners} names={names} />
|
| 124 |
+
</td>
|
| 125 |
<td>{capitalize(m.chain_composition)}</td>
|
| 126 |
<td>
|
| 127 |
<span className="meter-cell">
|
|
|
|
| 145 |
const { id = '' } = useParams();
|
| 146 |
const res = useApi(`sequence:${id}`, (sig) => api.sequence(id, sig));
|
| 147 |
const [selected, setSelected] = useState<number | null>(null);
|
| 148 |
+
const [partner, setPartner] = useState<string | null>(null);
|
| 149 |
const s = res.data;
|
| 150 |
|
| 151 |
if (res.loading) return <div className="page"><Spinner label="Loading sequence…" /></div>;
|
|
|
|
| 258 |
)}
|
| 259 |
</div>
|
| 260 |
|
| 261 |
+
{s.partner_names && (
|
| 262 |
+
<Section
|
| 263 |
+
title="Binding partners by state"
|
| 264 |
+
kicker="Observations per state bound to each protein, DNA or RNA partner (in biological assembly 1, ≥ 5 interface residues). Click a partner or state to filter the observations below."
|
| 265 |
+
>
|
| 266 |
+
<PartnersByState
|
| 267 |
+
members={s.members}
|
| 268 |
+
states={s.states}
|
| 269 |
+
names={s.partner_names}
|
| 270 |
+
selectedState={selected}
|
| 271 |
+
onSelectState={setSelected}
|
| 272 |
+
selectedPartner={partner}
|
| 273 |
+
onSelectPartner={setPartner}
|
| 274 |
+
/>
|
| 275 |
+
</Section>
|
| 276 |
+
)}
|
| 277 |
+
|
| 278 |
<Section
|
| 279 |
title={selected != null ? `Observations in state ${selected}` : 'All observations'}
|
| 280 |
kicker="Open a chain to see its transitions to every other observation."
|
| 281 |
flush
|
| 282 |
>
|
| 283 |
+
<MembersTable
|
| 284 |
+
members={s.members}
|
| 285 |
+
state={selected}
|
| 286 |
+
partner={partner}
|
| 287 |
+
names={s.partner_names}
|
| 288 |
+
onClearPartner={() => setPartner(null)}
|
| 289 |
+
/>
|
| 290 |
</Section>
|
| 291 |
|
| 292 |
+
<div className="masonry">
|
| 293 |
<Section
|
| 294 |
title="Identifiers & domains"
|
| 295 |
kicker="Consensus assignments across this sequence's chains; per-chain domain IDs are on each chain page."
|
|
|
|
| 311 |
</Link>
|
| 312 |
)}
|
| 313 |
</Section>
|
| 314 |
+
<Section title="Sequence" kicker={`${fmtInt(s.length)} residues (SEQRES)`}>
|
| 315 |
+
<SequenceTrack
|
| 316 |
+
sequence={s.sequence ?? ''}
|
| 317 |
+
fastaHeader={`${s.sequence_id}${s.uniprot_id ? ` ${s.uniprot_id}` : ''}`}
|
| 318 |
+
fileName={`${s.sequence_id}.fasta`}
|
| 319 |
+
/>
|
| 320 |
+
</Section>
|
| 321 |
<Section title="Ranked functions" kicker={`From the representative chain ${s.rep_pdb.toUpperCase()}.${s.rep_chain}`}>
|
| 322 |
<FunctionList functions={s.functions ?? []} />
|
| 323 |
</Section>
|
| 324 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
</div>
|
| 326 |
);
|
| 327 |
}
|
frontend/src/pages/StatisticsPage.tsx
CHANGED
|
@@ -234,6 +234,63 @@ export function StatisticsPage() {
|
|
| 234 |
</>
|
| 235 |
)}
|
| 236 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
<h2 className="section-title">Observations</h2>
|
| 238 |
<div className="chart-grid">
|
| 239 |
<ChartCard title="Experimental method">
|
|
|
|
| 234 |
</>
|
| 235 |
)}
|
| 236 |
|
| 237 |
+
{o.partners && (
|
| 238 |
+
<>
|
| 239 |
+
<h2 className="section-title">Binding partners</h2>
|
| 240 |
+
<div className="chart-grid">
|
| 241 |
+
<ChartCard
|
| 242 |
+
title="Partners of each observation"
|
| 243 |
+
kicker="Protein, DNA or RNA chains within 4.5 Å with ≥ 5 interface residues, in biological assembly 1. Crystal contacts are excluded."
|
| 244 |
+
>
|
| 245 |
+
<StackedBar
|
| 246 |
+
height={16}
|
| 247 |
+
parts={[
|
| 248 |
+
{ key: 'hetero', label: 'Bound to another polymer', value: o.partners.observations_with_hetero_partner, color: 'var(--accent)' },
|
| 249 |
+
{ key: 'homo', label: 'Homo-oligomer contacts only', value: o.partners.observations_homo_only, color: 'var(--fid-identical)' },
|
| 250 |
+
{
|
| 251 |
+
key: 'none',
|
| 252 |
+
label: 'No polymer partner',
|
| 253 |
+
value: o.counts.observations - o.partners.observations_with_partner,
|
| 254 |
+
color: 'var(--apo)',
|
| 255 |
+
},
|
| 256 |
+
]}
|
| 257 |
+
/>
|
| 258 |
+
<h3 className="mini-title">Observations by partner type (hetero partners)</h3>
|
| 259 |
+
<BarList
|
| 260 |
+
items={o.partners.by_type.map((t) => ({
|
| 261 |
+
key: t.label,
|
| 262 |
+
label: ({ protein: 'Protein', dna: 'DNA', rna: 'RNA', hybrid: 'DNA/RNA hybrid' } as Record<string, string>)[t.label] ?? t.label,
|
| 263 |
+
value: t.value,
|
| 264 |
+
}))}
|
| 265 |
+
/>
|
| 266 |
+
<p className="muted small">{fmtInt(o.partners.distinct_partners)} distinct partners (UniProt accessions or named polymers).</p>
|
| 267 |
+
</ChartCard>
|
| 268 |
+
<ChartCard
|
| 269 |
+
title="Most frequent protein partners"
|
| 270 |
+
kicker="Observations bound to each protein (by UniProt accession), excluding homo-oligomer contacts."
|
| 271 |
+
>
|
| 272 |
+
<div className="barlist--wide">
|
| 273 |
+
<BarList
|
| 274 |
+
items={o.partners.top
|
| 275 |
+
.filter((t) => t.type === 'protein')
|
| 276 |
+
.slice(0, 15)
|
| 277 |
+
.map((t) => ({
|
| 278 |
+
key: t.key,
|
| 279 |
+
label: (
|
| 280 |
+
<span title={`${t.label}${t.uniprot ? ` (${t.uniprot})` : ''}`}>
|
| 281 |
+
{t.label}
|
| 282 |
+
{t.uniprot && <span className="muted mono small"> {t.uniprot}</span>}
|
| 283 |
+
</span>
|
| 284 |
+
),
|
| 285 |
+
value: t.value,
|
| 286 |
+
}))}
|
| 287 |
+
/>
|
| 288 |
+
</div>
|
| 289 |
+
</ChartCard>
|
| 290 |
+
</div>
|
| 291 |
+
</>
|
| 292 |
+
)}
|
| 293 |
+
|
| 294 |
<h2 className="section-title">Observations</h2>
|
| 295 |
<div className="chart-grid">
|
| 296 |
<ChartCard title="Experimental method">
|
frontend/src/styles/pages.css
CHANGED
|
@@ -2081,3 +2081,292 @@
|
|
| 2081 |
.order-legend {
|
| 2082 |
margin: -8px 0 6px;
|
| 2083 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2081 |
.order-legend {
|
| 2082 |
margin: -8px 0 6px;
|
| 2083 |
}
|
| 2084 |
+
|
| 2085 |
+
/* ───────────────────────── binding partners */
|
| 2086 |
+
.pchips {
|
| 2087 |
+
display: inline-flex;
|
| 2088 |
+
flex-wrap: nowrap;
|
| 2089 |
+
gap: 4px;
|
| 2090 |
+
align-items: center;
|
| 2091 |
+
}
|
| 2092 |
+
|
| 2093 |
+
.pchip {
|
| 2094 |
+
display: inline-flex;
|
| 2095 |
+
align-items: center;
|
| 2096 |
+
height: 20px;
|
| 2097 |
+
padding: 0 7px;
|
| 2098 |
+
border-radius: 999px;
|
| 2099 |
+
font-size: 11.5px;
|
| 2100 |
+
white-space: nowrap;
|
| 2101 |
+
background: var(--surface-2);
|
| 2102 |
+
color: var(--ink-2);
|
| 2103 |
+
border: 1px solid var(--line);
|
| 2104 |
+
}
|
| 2105 |
+
|
| 2106 |
+
.pchip--self {
|
| 2107 |
+
background: transparent;
|
| 2108 |
+
color: var(--muted);
|
| 2109 |
+
border-style: dashed;
|
| 2110 |
+
}
|
| 2111 |
+
|
| 2112 |
+
.pchip--protein {
|
| 2113 |
+
background: var(--accent-soft);
|
| 2114 |
+
color: var(--accent-ink);
|
| 2115 |
+
border-color: transparent;
|
| 2116 |
+
}
|
| 2117 |
+
|
| 2118 |
+
.pchip--dna,
|
| 2119 |
+
.pchip--rna,
|
| 2120 |
+
.pchip--hybrid {
|
| 2121 |
+
background: color-mix(in srgb, var(--holo) 14%, transparent);
|
| 2122 |
+
color: var(--ink);
|
| 2123 |
+
border-color: transparent;
|
| 2124 |
+
}
|
| 2125 |
+
|
| 2126 |
+
.pchip.is-gained {
|
| 2127 |
+
box-shadow: inset 0 0 0 1.5px var(--accent);
|
| 2128 |
+
font-weight: 600;
|
| 2129 |
+
}
|
| 2130 |
+
|
| 2131 |
+
.pchip--more {
|
| 2132 |
+
background: transparent;
|
| 2133 |
+
}
|
| 2134 |
+
|
| 2135 |
+
.pchip--lost {
|
| 2136 |
+
background: transparent;
|
| 2137 |
+
color: var(--pos);
|
| 2138 |
+
border-color: color-mix(in srgb, var(--pos) 40%, transparent);
|
| 2139 |
+
}
|
| 2140 |
+
|
| 2141 |
+
.partner-name {
|
| 2142 |
+
display: inline-flex;
|
| 2143 |
+
align-items: baseline;
|
| 2144 |
+
gap: 8px;
|
| 2145 |
+
}
|
| 2146 |
+
|
| 2147 |
+
.partner-name__text {
|
| 2148 |
+
font-weight: 550;
|
| 2149 |
+
color: var(--ink);
|
| 2150 |
+
}
|
| 2151 |
+
|
| 2152 |
+
.partner-name__ids {
|
| 2153 |
+
display: flex;
|
| 2154 |
+
gap: 10px;
|
| 2155 |
+
margin-top: 2px;
|
| 2156 |
+
font-size: 12px;
|
| 2157 |
+
}
|
| 2158 |
+
|
| 2159 |
+
.ptype {
|
| 2160 |
+
font-size: 10.5px;
|
| 2161 |
+
font-weight: 650;
|
| 2162 |
+
letter-spacing: 0.04em;
|
| 2163 |
+
text-transform: uppercase;
|
| 2164 |
+
padding: 1px 6px;
|
| 2165 |
+
border-radius: 4px;
|
| 2166 |
+
background: var(--accent-soft);
|
| 2167 |
+
color: var(--accent-ink);
|
| 2168 |
+
white-space: nowrap;
|
| 2169 |
+
}
|
| 2170 |
+
|
| 2171 |
+
.ptype--dna,
|
| 2172 |
+
.ptype--rna,
|
| 2173 |
+
.ptype--hybrid {
|
| 2174 |
+
background: color-mix(in srgb, var(--holo) 16%, transparent);
|
| 2175 |
+
color: var(--ink);
|
| 2176 |
+
}
|
| 2177 |
+
|
| 2178 |
+
.ptype-dot {
|
| 2179 |
+
display: inline-block;
|
| 2180 |
+
width: 8px;
|
| 2181 |
+
height: 8px;
|
| 2182 |
+
border-radius: 50%;
|
| 2183 |
+
margin-right: 6px;
|
| 2184 |
+
flex: none;
|
| 2185 |
+
background: var(--accent);
|
| 2186 |
+
}
|
| 2187 |
+
|
| 2188 |
+
.ptype-dot--self {
|
| 2189 |
+
background: transparent;
|
| 2190 |
+
border: 1.5px dashed var(--muted);
|
| 2191 |
+
}
|
| 2192 |
+
|
| 2193 |
+
.ptype-dot--dna,
|
| 2194 |
+
.ptype-dot--rna,
|
| 2195 |
+
.ptype-dot--hybrid {
|
| 2196 |
+
background: var(--holo);
|
| 2197 |
+
}
|
| 2198 |
+
|
| 2199 |
+
.pkind {
|
| 2200 |
+
font-size: 12px;
|
| 2201 |
+
font-weight: 550;
|
| 2202 |
+
white-space: nowrap;
|
| 2203 |
+
}
|
| 2204 |
+
|
| 2205 |
+
.pkind--bio {
|
| 2206 |
+
color: var(--accent-strong);
|
| 2207 |
+
}
|
| 2208 |
+
|
| 2209 |
+
.pkind--homo {
|
| 2210 |
+
color: var(--ink-2);
|
| 2211 |
+
}
|
| 2212 |
+
|
| 2213 |
+
.pkind--weak {
|
| 2214 |
+
color: var(--faint);
|
| 2215 |
+
}
|
| 2216 |
+
|
| 2217 |
+
tr.is-dim td {
|
| 2218 |
+
color: var(--muted);
|
| 2219 |
+
}
|
| 2220 |
+
|
| 2221 |
+
.istrip {
|
| 2222 |
+
position: relative;
|
| 2223 |
+
display: block;
|
| 2224 |
+
min-width: 220px;
|
| 2225 |
+
height: 12px;
|
| 2226 |
+
border-radius: 3px;
|
| 2227 |
+
background: var(--surface-3);
|
| 2228 |
+
overflow: hidden;
|
| 2229 |
+
}
|
| 2230 |
+
|
| 2231 |
+
.istrip__seg {
|
| 2232 |
+
position: absolute;
|
| 2233 |
+
top: 0;
|
| 2234 |
+
bottom: 0;
|
| 2235 |
+
background: var(--accent);
|
| 2236 |
+
}
|
| 2237 |
+
|
| 2238 |
+
tr.is-dim .istrip__seg {
|
| 2239 |
+
background: var(--faint);
|
| 2240 |
+
}
|
| 2241 |
+
|
| 2242 |
+
.partners-toggle {
|
| 2243 |
+
margin-top: 10px;
|
| 2244 |
+
}
|
| 2245 |
+
|
| 2246 |
+
.pmatrix-wrap {
|
| 2247 |
+
overflow-x: auto;
|
| 2248 |
+
}
|
| 2249 |
+
|
| 2250 |
+
.pmatrix {
|
| 2251 |
+
border-collapse: separate;
|
| 2252 |
+
border-spacing: 2px;
|
| 2253 |
+
font-size: 12.5px;
|
| 2254 |
+
}
|
| 2255 |
+
|
| 2256 |
+
.pmatrix th {
|
| 2257 |
+
font-weight: 550;
|
| 2258 |
+
color: var(--muted);
|
| 2259 |
+
text-align: center;
|
| 2260 |
+
}
|
| 2261 |
+
|
| 2262 |
+
.pmatrix tbody th {
|
| 2263 |
+
text-align: left;
|
| 2264 |
+
}
|
| 2265 |
+
|
| 2266 |
+
.pmatrix__corner {
|
| 2267 |
+
text-align: left !important;
|
| 2268 |
+
font-size: 11.5px;
|
| 2269 |
+
}
|
| 2270 |
+
|
| 2271 |
+
.pmatrix__partner,
|
| 2272 |
+
.pmatrix__state {
|
| 2273 |
+
border: 0;
|
| 2274 |
+
background: transparent;
|
| 2275 |
+
font: inherit;
|
| 2276 |
+
color: var(--ink-2);
|
| 2277 |
+
cursor: pointer;
|
| 2278 |
+
padding: 3px 6px;
|
| 2279 |
+
border-radius: 6px;
|
| 2280 |
+
white-space: nowrap;
|
| 2281 |
+
display: inline-flex;
|
| 2282 |
+
align-items: center;
|
| 2283 |
+
}
|
| 2284 |
+
|
| 2285 |
+
.pmatrix__partner:hover,
|
| 2286 |
+
.pmatrix__state:hover {
|
| 2287 |
+
background: var(--surface-2);
|
| 2288 |
+
}
|
| 2289 |
+
|
| 2290 |
+
.pmatrix__partner.is-on,
|
| 2291 |
+
.pmatrix__state.is-on {
|
| 2292 |
+
background: var(--accent-soft);
|
| 2293 |
+
color: var(--accent-ink);
|
| 2294 |
+
font-weight: 650;
|
| 2295 |
+
}
|
| 2296 |
+
|
| 2297 |
+
.pmatrix__cell {
|
| 2298 |
+
min-width: 34px;
|
| 2299 |
+
height: 26px;
|
| 2300 |
+
text-align: center;
|
| 2301 |
+
border-radius: 4px;
|
| 2302 |
+
font-variant-numeric: tabular-nums;
|
| 2303 |
+
color: var(--ink);
|
| 2304 |
+
}
|
| 2305 |
+
|
| 2306 |
+
.seq-bg-0 { background: var(--surface-2); }
|
| 2307 |
+
.seq-bg-1 { background: var(--seq-1); }
|
| 2308 |
+
.seq-bg-2 { background: var(--seq-2); }
|
| 2309 |
+
.seq-bg-3 { background: var(--seq-3); color: #fff; }
|
| 2310 |
+
.seq-bg-4 { background: var(--seq-4); color: #fff; }
|
| 2311 |
+
.seq-bg-5 { background: var(--seq-5); color: #fff; }
|
| 2312 |
+
|
| 2313 |
+
.pmatrix__label {
|
| 2314 |
+
display: inline-block;
|
| 2315 |
+
max-width: 300px;
|
| 2316 |
+
overflow: hidden;
|
| 2317 |
+
text-overflow: ellipsis;
|
| 2318 |
+
vertical-align: bottom;
|
| 2319 |
+
}
|
| 2320 |
+
|
| 2321 |
+
.pmatrix__acc {
|
| 2322 |
+
margin-left: 6px;
|
| 2323 |
+
font-size: 11px;
|
| 2324 |
+
color: var(--muted);
|
| 2325 |
+
}
|
| 2326 |
+
|
| 2327 |
+
.barlist--wide .barlist__row {
|
| 2328 |
+
grid-template-columns: minmax(120px, 260px) minmax(0, 1fr) auto;
|
| 2329 |
+
}
|
| 2330 |
+
|
| 2331 |
+
.binding__summary {
|
| 2332 |
+
margin-top: 4px;
|
| 2333 |
+
}
|
| 2334 |
+
|
| 2335 |
+
.binding__text {
|
| 2336 |
+
font-size: 15px !important;
|
| 2337 |
+
}
|
| 2338 |
+
|
| 2339 |
+
.binding__sub {
|
| 2340 |
+
margin: 22px 0 10px;
|
| 2341 |
+
font-size: 14px;
|
| 2342 |
+
font-weight: 650;
|
| 2343 |
+
color: var(--ink);
|
| 2344 |
+
}
|
| 2345 |
+
|
| 2346 |
+
.binding__sub span {
|
| 2347 |
+
margin-left: 8px;
|
| 2348 |
+
font-size: 12.5px;
|
| 2349 |
+
font-weight: 400;
|
| 2350 |
+
color: var(--muted);
|
| 2351 |
+
}
|
| 2352 |
+
|
| 2353 |
+
/* Two-column card flow: each column stacks independently, so a short card does
|
| 2354 |
+
not leave a gap beside a tall one. */
|
| 2355 |
+
.masonry {
|
| 2356 |
+
columns: 2;
|
| 2357 |
+
column-gap: 20px;
|
| 2358 |
+
margin-bottom: -20px;
|
| 2359 |
+
}
|
| 2360 |
+
|
| 2361 |
+
.masonry > * {
|
| 2362 |
+
break-inside: avoid;
|
| 2363 |
+
margin: 0 0 20px;
|
| 2364 |
+
display: block;
|
| 2365 |
+
width: 100%;
|
| 2366 |
+
}
|
| 2367 |
+
|
| 2368 |
+
@media (max-width: 800px) {
|
| 2369 |
+
.masonry {
|
| 2370 |
+
columns: 1;
|
| 2371 |
+
}
|
| 2372 |
+
}
|