import { useMemo, useState } from 'react'; import { Box } from 'lucide-react'; import { ENERGY_TERMS, type EnergyTerm, type OrderChange, type PartnerName, type Transition } from '../lib/api'; import { FIDELITY_INFO, FIDELITY_ORDER, fidelityColor, fidelityKey } from '../lib/fidelity'; import { chainLabel, fmtNum, fmtSigned, methodOf, methodShort } from '../lib/format'; import { niceTicks, Tooltip, useWidth, type TipState } from './charts'; import { ChainLink, EmptyState, FidelityBadge, LigandBadge, Pagination, Segmented, Swatch } from './ui'; import { PartnerChips, sameSet } from './Partners'; export type Scope = 'cross' | 'same' | 'all'; /* ───────────────────────────── neighbourhood scatter */ export function TransitionScatter({ items, homeState, selected, onSelect, }: { items: Transition[]; homeState: number | null; selected: string | null; onSelect: (t: Transition) => void; }) { const [ref, width] = useWidth(); const [tip, setTip] = useState(null); const pts = items.filter((t) => t.similarity != null && t.rmsd != null); const height = 300; const m = { top: 16, right: 16, bottom: 44, left: 52 }; const iw = Math.max(0, width - m.left - m.right); const ih = height - m.top - m.bottom; const xMin = Math.min(0.3, ...pts.map((p) => p.similarity!)) - 0.02; const xMax = 1; const yTicks = niceTicks(Math.max(2, ...pts.map((p) => p.rmsd!))); const yMax = yTicks[yTicks.length - 1]; const x = (v: number) => ((v - xMin) / (xMax - xMin)) * iw; const y = (v: number) => ih - (v / yMax) * ih; const xTicks = [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1].filter((t) => t >= xMin); // draw larger changes last so they sit on top const ordered = [...pts].sort((a, b) => (b.similarity ?? 0) - (a.similarity ?? 0)); return (
{width > 0 && ( {[0.5, 0.7, 0.95].filter((t) => t > xMin).map((t) => ( ))} {yTicks.map((t) => ( {Number.isInteger(t) ? t : t.toFixed(1)} ))} {xTicks.map((t) => ( {t.toFixed(1)} ))} Pair similarity (aligned core) → more similar RMSD (Å) {ordered.map((p) => { const key = chainLabel(p.pdb_id, p.auth_asym_id); const same = p.state_id === homeState; const isSel = key === selected; return ( onSelect(p)} onMouseMove={(e) => { const r = (e.currentTarget.ownerSVGElement as SVGSVGElement).getBoundingClientRect(); setTip({ x: e.clientX - r.left, y: e.clientY - r.top, content: ( <>
{key}
State {p.state_id ?? '—'}{same ? ' (same)' : ''}
Similarity {fmtNum(p.similarity, 3)}
RMSD {fmtNum(p.rmsd, 2)} Å
Fidelity {FIDELITY_INFO[fidelityKey(p.fidelity)].label}
Click to compare
), }); }} onMouseLeave={() => setTip(null)} /> ); })} )}
  • Same state
  • {FIDELITY_ORDER.filter((f) => f !== 'identical').map((f) => (
  • {FIDELITY_INFO[f].label} fidelity
  • ))}
  • Lines at 0.50 / 0.70 / 0.95 mark the fidelity bins
); } /* ───────────────────────────── table */ type SortKey = 'similarity' | 'tm_aln' | 'rmsd' | 'coverage' | 'state' | 'resolution' | 'date' | 'order' | EnergyTerm; type EnergyMode = 'z' | 'delta'; const PAGE = 25; /** |z| class: noise (< 1.5), possible (~2), real (>= 3). */ export function zClass(z: number | null | undefined): string { if (z == null) return ''; const a = Math.abs(z); const dir = z > 0 ? 'up' : 'down'; return a >= 3 ? `z--${dir} z--strong` : a >= 2 ? `z--${dir} z--weak` : a >= 1.5 ? `z--${dir} z--hint` : 'z--noise'; } function sortValue(t: Transition, k: SortKey, mode: EnergyMode = 'delta'): number | string | null { switch (k) { case 'similarity': return t.similarity; case 'tm_aln': return t.tm_aln; case 'rmsd': return t.rmsd; case 'coverage': return t.coverage; case 'state': return t.state_id; case 'resolution': return t.resolution ?? null; case 'date': return t.release_date ?? null; case 'order': return t.order.valid ? (t.order.n_ordered ?? 0) + (t.order.n_disordered ?? 0) : null; default: return mode === 'z' ? t.z?.[k] ?? null : t.delta[k]; } } export function TransitionsTable({ items, homeState, selected, onSelect, scope, onScope, onView, homePartners, partnerNames, }: { items: Transition[]; homeState: number | null; selected: string | null; onSelect: (t: Transition) => void; scope: Scope; onScope: (s: Scope) => void; /** open the superposed structure comparison for this partner */ onView?: (t: Transition) => void; /** this chain's biological binding partners, to flag alternatives bound to something else */ homePartners?: string[]; partnerNames?: Record; }) { const [fid, setFid] = useState('all'); const hasZ = items.some((t) => t.z != null); const [energy, setEnergy] = useState('z'); const mode: EnergyMode = hasZ ? energy : 'delta'; const [bound, setBound] = useState<'all' | 'same' | 'diff'>('all'); const hasPartners = homePartners != null && items.some((t) => t.partners != null); const [sort, setSort] = useState<{ k: SortKey; dir: 1 | -1 }>({ k: 'similarity', dir: 1 }); const [page, setPage] = useState(0); const nSame = items.filter((t) => t.state_id === homeState).length; const scoped = useMemo( () => items.filter((t) => scope === 'all' ? true : scope === 'same' ? t.state_id === homeState : t.state_id !== homeState, ), [items, scope, homeState], ); const nSamePartners = useMemo( () => scoped.filter((t) => sameSet(t.partners, homePartners)).length, [scoped, homePartners], ); const rows = useMemo(() => { let r = fid === 'all' ? scoped : scoped.filter((t) => fidelityKey(t.fidelity) === fid); if (bound !== 'all') r = r.filter((t) => sameSet(t.partners, homePartners) === (bound === 'same')); return [...r].sort((a, b) => { const va = sortValue(a, sort.k, mode); const vb = sortValue(b, sort.k, mode); if (va == null) return 1; if (vb == null) return -1; return (va < vb ? -1 : va > vb ? 1 : 0) * sort.dir; }); }, [scoped, fid, sort, bound, homePartners, mode]); const fidCounts = useMemo(() => { const c: Record = {}; for (const t of scoped) c[fidelityKey(t.fidelity)] = (c[fidelityKey(t.fidelity)] ?? 0) + 1; return c; }, [scoped]); const th = (k: SortKey, label: string, num = true) => ( ); const fidOptions = [ { value: 'all', label: 'Any fidelity', count: scoped.length }, ...[...FIDELITY_ORDER, 'NA'] .filter((f) => fidCounts[f]) .map((f) => ({ value: f, label: FIDELITY_INFO[f].label, count: fidCounts[f] })), ]; return ( <>
{ onScope(v); setFid('all'); setPage(0); }} options={[ { value: 'cross', label: 'Other states', count: items.length - nSame }, { value: 'same', label: 'Same state', count: nSame }, { value: 'all', label: 'All', count: items.length }, ]} /> { setFid(v); setPage(0); }} options={fidOptions} /> {hasZ && ( label="Energy change" value={energy} onChange={setEnergy} options={[ { value: 'z', label: 'z-score' }, { value: 'delta', label: 'Raw Δ' }, ]} /> )} {hasPartners && ( { setBound(v); setPage(0); }} options={[ { value: 'all', label: 'Any partners', count: scoped.length }, { value: 'same', label: 'Same as this chain', count: nSamePartners }, { value: 'diff', label: 'Different', count: scoped.length - nSamePartners }, ]} /> )}
{rows.length === 0 ? ( ) : (
{th('state', 'State')} {th('similarity', 'Similarity')} {th('tm_aln', 'TM (aln)')} {th('rmsd', 'RMSD Å')} {th('coverage', 'Coverage')} {th('order', 'Order Δ')} {ENERGY_TERMS.map((e) => th(e, mode === 'z' ? `z ${e}` : `Δ${e}`))} {th('resolution', 'Res. Å')} {hasPartners && } {th('date', 'Released')} {rows.slice(page * PAGE, (page + 1) * PAGE).map((t) => { const key = chainLabel(t.pdb_id, t.auth_asym_id); return ( { if ((e.target as HTMLElement).closest('a')) return; onSelect(t); }} > {ENERGY_TERMS.map((e) => { const v = t.delta[e]; if (mode === 'z') { const z = t.z?.[e] ?? null; return ( ); } return ( ); })} {hasPartners && ( )} ); })}
AlternativeFidelityMethodLigandBinding partners
{onView && ( )} {t.state_id ?? '—'} {fmtNum(t.similarity, 3)} {fmtNum(t.tm_aln, 3)} {fmtNum(t.rmsd, 2)} {fmtNum(t.coverage, 2)} {fmtSigned(z, 1)} 0 ? 'delta--up' : v < 0 ? 'delta--down' : ''}`}> {fmtSigned(v, 1)} {methodShort(methodOf(t))} {fmtNum(t.resolution, 2)} {t.release_date ?? '—'}
)} ); } /* ───────────────────────────── energy deltas (diverging bars) */ const Z_AXIS = 6; // bars span |z| <= 6, longer ones are clipped /** * Energy change per score. With z scores the bars share one scale (the * protein's own noise floor), with guides at |z| = 2 and 3; otherwise each raw * delta is scaled to the largest one. */ export function DeltaBars({ delta, z }: { delta: Record; z?: Record | null }) { if (z) { return (
    {ENERGY_TERMS.map((e) => { const v = z[e]; const w = v == null ? 0 : (Math.min(Math.abs(v), Z_AXIS) / Z_AXIS) * 50; return (
  • {e} {[-3, -2, 2, 3].map((g) => ( ))} {v != null && ( 0 ? 'is-up' : 'is-down'} ${Math.abs(v) < 2 ? 'is-faint' : ''}`} style={v > 0 ? { left: '50%', width: `${w}%` } : { right: '50%', width: `${w}%` }} /> )} {v == null ? '—' : `z ${fmtSigned(v, 1)}`} {delta[e] == null ? '' : `Δ ${fmtSigned(delta[e], 1)}`}
  • ); })}
); } const vals = ENERGY_TERMS.map((e) => delta[e]).filter((v): v is number => v != null); const max = Math.max(1, ...vals.map(Math.abs)); return (
    {ENERGY_TERMS.map((e) => { const v = delta[e]; const w = v == null ? 0 : (Math.abs(v) / max) * 50; return (
  • Δ{e} {v != null && ( 0 ? 'is-up' : 'is-down'}`} style={v > 0 ? { left: '50%', width: `${w}%` } : { right: '50%', width: `${w}%` }} /> )} 0 ? 'delta--up' : 'delta--down'}`}> {fmtSigned(v, 1)}
  • ); })}
); } /* ───────────────────────────── observed order / disorder */ export const ORDER_REASON: Record = { cross_method: 'different experimental methods', nmr: 'both are NMR ensembles', no_shared_residues: 'no shared resolved residues', missing_data: 'missing data', }; export const EVIDENCE_LABEL: Record = { high: 'High evidence', medium: 'Medium evidence', low: 'Low evidence', not_applicable: 'No ordering', }; function OrderCell({ order }: { order: OrderChange }) { if (!order.valid) { return ( n/a ); } const plus = order.n_ordered ?? 0; const minus = order.n_disordered ?? 0; if (!plus && !minus) return 0; return ( {plus > 0 && +{plus}} {minus > 0 && −{minus}} {order.evidence && order.evidence !== 'not_applicable' && ( {order.evidence} )} ); } function fmtRanges(r: Array<[number, number]>, max = 8): string { const parts = r.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)); return parts.length > max ? `${parts.slice(0, max).join(', ')} … (+${parts.length - max})` : parts.join(', '); } /** * Residues resolved in the partner but not in this chain ("ordered") and the reverse * ("disordered"), on the SEQRES axis. Resolved-state sense, not intrinsic disorder. */ type ResState = 'both' | 'ord' | 'dis' | 'none'; /** Contiguous runs of each residue's resolved state in this chain (A) vs the partner (B). */ function resolvedRuns(L: number, resolvedA: Array<[number, number]>, order: OrderChange) { const inA = new Uint8Array(L + 2); for (const [a, b] of resolvedA) for (let i = Math.max(1, a); i <= Math.min(L, b); i++) inA[i] = 1; const mark = new Array(L + 2).fill('none'); for (let i = 1; i <= L; i++) mark[i] = inA[i] ? 'both' : 'none'; for (const [a, b] of order.disordered_ranges) for (let i = a; i <= Math.min(L, b); i++) mark[i] = 'dis'; for (const [a, b] of order.ordered_ranges) for (let i = a; i <= Math.min(L, b); i++) mark[i] = 'ord'; const runs: Array<{ state: ResState; start: number; end: number }> = []; for (let i = 1; i <= L; i++) { const last = runs[runs.length - 1]; if (last && last.state === mark[i] && last.end === i - 1) last.end = i; else runs.push({ state: mark[i], start: i, end: i }); } return runs; } export function OrderChangeView({ order, length, from, to, resolvedFrom, }: { order: OrderChange; length: number; from: string; to: string; /** resolved SEQRES ranges of this chain (A); lets the track show every residue's state */ resolvedFrom?: Array<[number, number]>; }) { const L = Math.max(1, length); const runs = resolvedFrom?.length ? resolvedRuns(L, resolvedFrom, order) : null; const runTitle = (state: ResState, a: number, b: number) => { const span = a === b ? `${a}` : `${a}–${b}`; return state === 'both' ? `${span}: resolved in both` : state === 'ord' ? `${span}: ordered in ${to} (missing in ${from})` : state === 'dis' ? `${span}: resolved only in ${from}` : `${span}: not resolved in either structure`; }; return (

Order ↔ disorder (resolved residues)

{!order.valid ? (

Not comparable for this pair: {ORDER_REASON[order.reason ?? ''] ?? order.reason ?? 'unknown'}. Resolved-residue changes are only reported between structures solved with the same method.

) : !order.n_ordered && !order.n_disordered ? (

Both structures resolve the same residues ({order.n_shared_resolved?.toLocaleString() ?? '—'} shared).

) : ( <>
{order.n_ordered ?? 0} residues ordered in {to} {order.longest_ordered ? (longest {order.longest_ordered}) : null} {order.n_disordered ?? 0}{' '} residues resolved only in {from} {order.longest_disordered ? (longest {order.longest_disordered}) : null} {order.evidence && order.evidence !== 'not_applicable' && ( {EVIDENCE_LABEL[order.evidence]} {' '} {order.n_internal_ordered ?? 0} internal · {order.n_terminal_ordered ?? 0} terminal )} {order.with_ligand_change && Co-occurs with a ligand change}
{runs?.map((r) => ( ))} {!runs && order.ordered_ranges.map(([a, b]) => ( ))} {!runs && order.disordered_ranges.map(([a, b]) => ( ))} 1 {L}
{runs && (
  • Resolved in both
  • Ordered in {to}
  • Resolved only in {from}
  • Not resolved in either
)}

{order.ordered_ranges.length > 0 && ( <> Ordered: {fmtRanges(order.ordered_ranges)} )} {order.ordered_ranges.length > 0 && order.disordered_ranges.length > 0 && ' · '} {order.disordered_ranges.length > 0 && ( <> Only in {from}: {fmtRanges(order.disordered_ranges)} )}

)}
); }