WinslowFan Claude Opus 5.5 commited on
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 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 two tables, **`node`** and **`edge`**. Download it with the **Download** button at the top of the site.
63
 
64
  Atomic coordinates are not included: fetch mmCIF files from the [RCSB PDB](https://www.rcsb.org) or [PDBe](https://www.ebi.ac.uk/pdbe/) using `pdb_id` and `auth_asym_id`.
65
 
@@ -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
- Both tables are indexed for per-chain lookup: `node(pdb_id COLLATE NOCASE, auth_asym_id)` and `edge(pdb_id_A COLLATE NOCASE, auth_asym_id_A)`. Use the same collation in queries to hit the index:
180
 
181
  ```sql
182
  SELECT * FROM edge WHERE pdb_id_A = '3vgk' COLLATE NOCASE AND auth_asym_id_A = 'A';
@@ -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('?' * 19)})", batch)
241
  batch.clear()
242
  log(f" node rows: {n:,}")
243
  if batch:
244
- dst.executemany(f"INSERT INTO member VALUES ({','.join('?' * 19)})", batch)
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
- return [...(show !== 'B' ? runsFor(d.residues, 'A') : []), ...(show !== 'A' ? runsFor(d.residues, 'B') : [])];
 
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} (partner)` },
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} /> &lt; {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} /> &lt; {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 partner observation">
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
- const r = fid === 'all' ? scoped : scoped.filter((t) => fidelityKey(t.fidelity) === fid);
 
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="Partner scope"
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 partner observations in this view" />
265
  ) : (
266
  <div className="table-wrap">
267
  <table className="table table--dense table--rows table--sticky-first">
268
  <thead>
269
  <tr>
270
- <th>Partner</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 partner <ArrowRight aria-hidden />
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 (partner − this chain)</h3>
142
  <DeltaBars delta={t.delta} />
143
- <p className="muted small">Negative (green) means the partner conformation scores lower, i.e. more favourable.</p>
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
- {c.nucleic_acid_binding && c.nucleic_acid_binding !== 'None' && <Badge>Binds {c.nucleic_acid_binding}</Badge>}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 partner — click to compare.`
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 partner observations" />}
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 partner − this chain; sort any column." flush>
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
- <div className="grid-2">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>. Partner links open
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 two tables — <code>node</code> (observations) and <code>edge</code> (transitions). Query
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({ members, state }: { members: Member[]; state: number | null }) {
 
 
 
 
 
 
 
 
 
 
 
 
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 = state != null ? members.filter((m) => m.state_id === state) : members;
 
 
 
 
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 members={s.members} state={selected} />
 
 
 
 
 
 
238
  </Section>
239
 
240
- <div className="grid-2">
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
+ }