MuSProt / frontend /src /components /Transitions.tsx
WinslowFan's picture
Claude Opus 5.5
Energy z-scores, modified residues, node columns in place of em_specimen
fe94734
Raw History Blame Contribute Delete
27.5 kB
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<HTMLDivElement>();
const [tip, setTip] = useState<TipState | null>(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 (
<div className="chart scatter" ref={ref}>
{width > 0 && (
<svg width={width} height={height} role="img" aria-label="Similarity versus RMSD for every alternative">
<g transform={`translate(${m.left},${m.top})`}>
{[0.5, 0.7, 0.95].filter((t) => t > xMin).map((t) => (
<g key={t}>
<line x1={x(t)} x2={x(t)} y1={0} y2={ih} className="threshold" />
</g>
))}
{yTicks.map((t) => (
<g key={t}>
<line x1={0} x2={iw} y1={y(t)} y2={y(t)} className={t === 0 ? 'axis-line' : 'grid-line'} />
<text x={-8} y={y(t)} dy="0.32em" textAnchor="end" className="tick">
{Number.isInteger(t) ? t : t.toFixed(1)}
</text>
</g>
))}
{xTicks.map((t) => (
<text key={t} x={x(t)} y={ih + 16} textAnchor="middle" className="tick">
{t.toFixed(1)}
</text>
))}
<text x={iw / 2} y={ih + 36} textAnchor="middle" className="axis-title">
Pair similarity (aligned core) β†’ more similar
</text>
<text transform={`translate(${-38},${ih / 2}) rotate(-90)`} textAnchor="middle" className="axis-title">
RMSD (Γ…)
</text>
{ordered.map((p) => {
const key = chainLabel(p.pdb_id, p.auth_asym_id);
const same = p.state_id === homeState;
const isSel = key === selected;
return (
<circle
key={key}
cx={x(p.similarity!)}
cy={y(p.rmsd!)}
r={isSel ? 7 : 4.5}
fill={same ? 'var(--surface)' : fidelityColor(p.fidelity)}
stroke={same ? 'var(--faint)' : 'var(--surface)'}
strokeWidth={same ? 1.5 : 1.5}
className={`scatter__pt ${isSel ? 'is-sel' : ''}`}
onClick={() => onSelect(p)}
onMouseMove={(e) => {
const r = (e.currentTarget.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
setTip({
x: e.clientX - r.left,
y: e.clientY - r.top,
content: (
<>
<div className="chart-tip__title mono">{key}</div>
<div className="chart-tip__row">
<span>State</span>
<b>{p.state_id ?? 'β€”'}{same ? ' (same)' : ''}</b>
</div>
<div className="chart-tip__row">
<span>Similarity</span>
<b className="tabular">{fmtNum(p.similarity, 3)}</b>
</div>
<div className="chart-tip__row">
<span>RMSD</span>
<b className="tabular">{fmtNum(p.rmsd, 2)} Γ…</b>
</div>
<div className="chart-tip__row">
<span>Fidelity</span>
<b>{FIDELITY_INFO[fidelityKey(p.fidelity)].label}</b>
</div>
<div className="chart-tip__foot">Click to compare</div>
</>
),
});
}}
onMouseLeave={() => setTip(null)}
/>
);
})}
</g>
</svg>
)}
<Tooltip tip={tip} />
<ul className="legend">
<li>
<span className="swatch swatch--dot swatch--ring" /> Same state
</li>
{FIDELITY_ORDER.filter((f) => f !== 'identical').map((f) => (
<li key={f}>
<Swatch color={fidelityColor(f)} shape="dot" /> {FIDELITY_INFO[f].label} fidelity
</li>
))}
<li className="muted">Lines at 0.50 / 0.70 / 0.95 mark the fidelity bins</li>
</ul>
</div>
);
}
/* ───────────────────────────── 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<string, PartnerName>;
}) {
const [fid, setFid] = useState<string>('all');
const hasZ = items.some((t) => t.z != null);
const [energy, setEnergy] = useState<EnergyMode>('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<string, number> = {};
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) => (
<th className={num ? 'num' : ''} aria-sort={sort.k === k ? (sort.dir === 1 ? 'ascending' : 'descending') : 'none'}>
<button
type="button"
className={`th-sort ${sort.k === k ? 'is-active' : ''}`}
onClick={() => {
setSort((s) => ({ k, dir: s.k === k ? ((-s.dir) as 1 | -1) : 1 }));
setPage(0);
}}
>
{label}
<span className="th-sort__arrow" aria-hidden>
{sort.k === k ? (sort.dir === 1 ? '↑' : '↓') : '↕'}
</span>
</button>
</th>
);
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 (
<>
<div className="table-toolbar table-toolbar--wrap">
<Segmented
label="Alternative scope"
value={scope}
onChange={(v) => {
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 },
]}
/>
<Segmented
label="Fidelity"
value={fid}
onChange={(v) => {
setFid(v);
setPage(0);
}}
options={fidOptions}
/>
{hasZ && (
<Segmented<EnergyMode>
label="Energy change"
value={energy}
onChange={setEnergy}
options={[
{ value: 'z', label: 'z-score' },
{ value: 'delta', label: 'Raw Ξ”' },
]}
/>
)}
{hasPartners && (
<Segmented
label="Binding partners"
value={bound}
onChange={(v) => {
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 },
]}
/>
)}
</div>
{rows.length === 0 ? (
<EmptyState title="No alternatives in this view" />
) : (
<div className="table-wrap">
<table className="table table--dense table--rows table--sticky-first">
<thead>
<tr>
<th>Alternative</th>
{th('state', 'State')}
<th>Fidelity</th>
{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>Method</th>
{th('resolution', 'Res. Γ…')}
<th>Ligand</th>
{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>}
{th('date', 'Released')}
</tr>
</thead>
<tbody>
{rows.slice(page * PAGE, (page + 1) * PAGE).map((t) => {
const key = chainLabel(t.pdb_id, t.auth_asym_id);
return (
<tr
key={key}
className={`is-clickable ${key === selected ? 'is-selected' : ''}`}
onClick={(e) => {
if ((e.target as HTMLElement).closest('a')) return;
onSelect(t);
}}
>
<td>
<span className="partner-cell">
<ChainLink pdb={t.pdb_id} chain={t.auth_asym_id} strong />
{onView && (
<button
type="button"
className="btn-3d"
onClick={(e) => {
e.stopPropagation();
onView(t);
}}
title="View both structures superposed, coloured by per-residue deviation"
>
<Box aria-hidden /> 3D
</button>
)}
</span>
</td>
<td className="num tabular">{t.state_id ?? 'β€”'}</td>
<td>
<FidelityBadge value={t.fidelity} />
</td>
<td className="num tabular strong">{fmtNum(t.similarity, 3)}</td>
<td className="num tabular">{fmtNum(t.tm_aln, 3)}</td>
<td className="num tabular">{fmtNum(t.rmsd, 2)}</td>
<td className="num tabular">{fmtNum(t.coverage, 2)}</td>
<td className="num">
<OrderCell order={t.order} />
</td>
{ENERGY_TERMS.map((e) => {
const v = t.delta[e];
if (mode === 'z') {
const z = t.z?.[e] ?? null;
return (
<td key={e} className="num tabular" title={v == null ? undefined : `Ξ”${e} ${fmtSigned(v, 1)}`}>
<span className={`zval ${zClass(z)}`}>{fmtSigned(z, 1)}</span>
</td>
);
}
return (
<td key={e} className={`num tabular delta ${v == null ? '' : v > 0 ? 'delta--up' : v < 0 ? 'delta--down' : ''}`}>
{fmtSigned(v, 1)}
</td>
);
})}
<td>{methodShort(methodOf(t))}</td>
<td className="num tabular">{fmtNum(t.resolution, 2)}</td>
<td>
<LigandBadge status={t.binding_status} />
</td>
{hasPartners && (
<td>
<PartnerChips keys={t.partners} names={partnerNames} home={homePartners} />
</td>
)}
<td className="num tabular">{t.release_date ?? 'β€”'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<Pagination page={page} pageSize={PAGE} total={rows.length} pageCount={Math.ceil(rows.length / PAGE)} onPage={setPage} />
</>
);
}
/* ───────────────────────────── 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<EnergyTerm, number | null>; z?: Record<EnergyTerm, number | null> | null }) {
if (z) {
return (
<ul className="deltas deltas--z">
{ENERGY_TERMS.map((e) => {
const v = z[e];
const w = v == null ? 0 : (Math.min(Math.abs(v), Z_AXIS) / Z_AXIS) * 50;
return (
<li key={e}>
<span className="deltas__label">{e}</span>
<span className="deltas__track">
{[-3, -2, 2, 3].map((g) => (
<span key={g} className={`deltas__guide ${Math.abs(g) === 3 ? 'is-3' : ''}`} style={{ left: `${50 + (g / Z_AXIS) * 50}%` }} />
))}
<span className="deltas__mid" />
{v != null && (
<span
className={`deltas__bar ${v > 0 ? 'is-up' : 'is-down'} ${Math.abs(v) < 2 ? 'is-faint' : ''}`}
style={v > 0 ? { left: '50%', width: `${w}%` } : { right: '50%', width: `${w}%` }}
/>
)}
</span>
<span className="deltas__val tabular">
<span className={`zval ${zClass(v)}`}>{v == null ? 'β€”' : `z ${fmtSigned(v, 1)}`}</span>
<small className="muted">{delta[e] == null ? '' : `Ξ” ${fmtSigned(delta[e], 1)}`}</small>
</span>
</li>
);
})}
</ul>
);
}
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 (
<ul className="deltas">
{ENERGY_TERMS.map((e) => {
const v = delta[e];
const w = v == null ? 0 : (Math.abs(v) / max) * 50;
return (
<li key={e}>
<span className="deltas__label">Ξ”{e}</span>
<span className="deltas__track">
<span className="deltas__mid" />
{v != null && (
<span
className={`deltas__bar ${v > 0 ? 'is-up' : 'is-down'}`}
style={v > 0 ? { left: '50%', width: `${w}%` } : { right: '50%', width: `${w}%` }}
/>
)}
</span>
<span className={`deltas__val tabular ${v == null ? '' : v > 0 ? 'delta--up' : 'delta--down'}`}>
{fmtSigned(v, 1)}
</span>
</li>
);
})}
</ul>
);
}
/* ───────────────────────────── observed order / disorder */
export const ORDER_REASON: Record<string, string> = {
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<string, string> = {
high: 'High evidence',
medium: 'Medium evidence',
low: 'Low evidence',
not_applicable: 'No ordering',
};
function OrderCell({ order }: { order: OrderChange }) {
if (!order.valid) {
return (
<span className="muted small" title={`Not comparable: ${ORDER_REASON[order.reason ?? ''] ?? order.reason ?? 'n/a'}`}>
n/a
</span>
);
}
const plus = order.n_ordered ?? 0;
const minus = order.n_disordered ?? 0;
if (!plus && !minus) return <span className="muted">0</span>;
return (
<span className="order-cell" title={EVIDENCE_LABEL[order.evidence ?? ''] ?? ''}>
{plus > 0 && <span className="order-cell__plus tabular">+{plus}</span>}
{minus > 0 && <span className="order-cell__minus tabular">βˆ’{minus}</span>}
{order.evidence && order.evidence !== 'not_applicable' && (
<span className={`evid evid--${order.evidence}`}>{order.evidence}</span>
)}
</span>
);
}
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<ResState>(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 (
<div className="orderview">
<h3 className="compare__sub">Order ↔ disorder (resolved residues)</h3>
{!order.valid ? (
<p className="muted small">
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.
</p>
) : !order.n_ordered && !order.n_disordered ? (
<p className="muted small">
Both structures resolve the same residues ({order.n_shared_resolved?.toLocaleString() ?? 'β€”'} shared).
</p>
) : (
<>
<div className="orderview__stats">
<span>
<span className="swatch" style={{ background: 'var(--ord)' }} /> <b className="tabular">{order.n_ordered ?? 0}</b> residues
ordered in {to}
{order.longest_ordered ? <span className="muted"> (longest {order.longest_ordered})</span> : null}
</span>
<span>
<span className="swatch" style={{ background: 'var(--disord)' }} /> <b className="tabular">{order.n_disordered ?? 0}</b>{' '}
residues resolved only in {from}
{order.longest_disordered ? <span className="muted"> (longest {order.longest_disordered})</span> : null}
</span>
{order.evidence && order.evidence !== 'not_applicable' && (
<span>
<span className={`evid evid--${order.evidence}`}>{EVIDENCE_LABEL[order.evidence]}</span>
<span className="muted">
{' '}
{order.n_internal_ordered ?? 0} internal Β· {order.n_terminal_ordered ?? 0} terminal
</span>
</span>
)}
{order.with_ligand_change && <span className="badge badge--outline">Co-occurs with a ligand change</span>}
</div>
<div className={`order-track ${runs ? 'order-track--full' : ''}`} aria-label="Resolved residues along the sequence">
{runs?.map((r) => (
<span
key={`${r.state}${r.start}`}
className={`order-track__seg order-track__seg--${r.state}`}
style={{ left: `${((r.start - 1) / L) * 100}%`, width: `${((r.end - r.start + 1) / L) * 100}%` }}
title={runTitle(r.state, r.start, r.end)}
/>
))}
{!runs && order.ordered_ranges.map(([a, b]) => (
<span
key={`o${a}`}
className="order-track__seg order-track__seg--ord"
style={{ left: `${((a - 1) / L) * 100}%`, width: `${Math.max(0.4, ((b - a + 1) / L) * 100)}%` }}
title={`Ordered in ${to}: ${a}–${b}`}
/>
))}
{!runs && order.disordered_ranges.map(([a, b]) => (
<span
key={`d${a}`}
className="order-track__seg order-track__seg--dis"
style={{ left: `${((a - 1) / L) * 100}%`, width: `${Math.max(0.4, ((b - a + 1) / L) * 100)}%` }}
title={`Resolved only in ${from}: ${a}–${b}`}
/>
))}
<span className="order-track__labels">
<span>1</span>
<span>{L}</span>
</span>
</div>
{runs && (
<ul className="legend order-legend">
<li>
<span className="swatch order-key order-key--both" /> Resolved in both
</li>
<li>
<span className="swatch" style={{ background: 'var(--ord)' }} /> Ordered in {to}
</li>
<li>
<span className="swatch" style={{ background: 'var(--disord)' }} /> Resolved only in {from}
</li>
<li>
<span className="swatch order-key order-key--none" /> Not resolved in either
</li>
</ul>
)}
<p className="muted small orderview__ranges">
{order.ordered_ranges.length > 0 && (
<>
Ordered: <span className="mono">{fmtRanges(order.ordered_ranges)}</span>
</>
)}
{order.ordered_ranges.length > 0 && order.disordered_ranges.length > 0 && ' Β· '}
{order.disordered_ranges.length > 0 && (
<>
Only in {from}: <span className="mono">{fmtRanges(order.disordered_ranges)}</span>
</>
)}
</p>
</>
)}
</div>
);
}