File size: 6,820 Bytes
cb1600f
 
 
 
047712c
cb1600f
 
 
 
 
 
 
 
 
 
 
 
c77380d
 
4e1ae88
cb1600f
 
c77380d
cb1600f
 
 
 
c77380d
cb1600f
 
c77380d
cb1600f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c77380d
4e1ae88
cb1600f
 
047712c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cb1600f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4e1ae88
cb1600f
 
 
c77380d
cb1600f
 
 
 
 
 
 
047712c
cb1600f
 
 
 
4e1ae88
 
c77380d
 
 
 
 
4e1ae88
 
cb1600f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
import { useMemo, useState } from 'react';
import { api, type CompareClass, type StructureCompare } from '../lib/api';
import { useApi } from '../lib/hooks';
import { chainLabel, fmtInt, fmtNum } from '../lib/format';
import { CustomStructureViewer, type ResidueColorRun, type ResidueTooltip } from './StructureViewer';
import { ErrorState, Segmented, Spinner, Swatch } from './ui';

/* Cα deviation grades and the "resolved in one structure only" grey. */
export const DIFF_COLORS: Record<CompareClass, string> = {
  low: '#2fa84f',
  medium: '#f2b705',
  high: '#d93b3b',
  only_a: '#b6bcc6',
  only_b: '#b6bcc6',
  missing: 'transparent',
};

/* In the superposed "both" view the alternative keeps the same colours but is drawn semi-transparent. */
export const ALT_OPACITY = 0.35;

type Show = 'both' | 'A' | 'B';

function runsFor(residues: StructureCompare['residues'], chain: 'A' | 'B', opacity?: number): ResidueColorRun[] {
  const absent: CompareClass = chain === 'A' ? 'only_b' : 'only_a';
  const runs: ResidueColorRun[] = [];
  for (const r of residues) {
    if (r.class === absent || r.class === 'missing') continue;
    const color = DIFF_COLORS[r.class];
    const last = runs[runs.length - 1];
    if (last && last.color === color && last.end === r.pos - 1) last.end = r.pos;
    else runs.push({ chain, start: r.pos, end: r.pos, color, opacity });
  }
  return runs;
}

function describe(r: StructureCompare['residues'][number], la: string, lb: string): string {
  const head = `${r.res} ${r.pos}`;
  if (r.class === 'only_a') return `${head}: resolved only in ${la}`;
  if (r.class === 'only_b') return `${head}: resolved only in ${lb}`;
  return `${head}: Cα deviation ${fmtNum(r.dev, 2)} Å`;
}

/**
 * Superposed view of two observations of one sequence, coloured per residue by
 * Cα deviation (green / yellow / red) with order changes in grey, plus a
 * sequence track of the same classes.
 */
export function StructureDiff({
  a,
  b,
  length,
}: {
  a: { pdb_id: string; auth_asym_id: string };
  b: { pdb_id: string; auth_asym_id: string };
  length: number;
}) {
  const [show, setShow] = useState<Show>('both');
  const la = chainLabel(a.pdb_id, a.auth_asym_id);
  const lb = chainLabel(b.pdb_id, b.auth_asym_id);
  const res = useApi(`compare:${la}:${lb}`, (sig) =>
    api.compare(a.pdb_id, a.auth_asym_id, b.pdb_id, b.auth_asym_id, sig),
  );
  const d = res.data;

  const runs = useMemo(() => {
    if (!d) return [];
    if (show === 'both') return [...runsFor(d.residues, 'A'), ...runsFor(d.residues, 'B', ALT_OPACITY)];
    return runsFor(d.residues, show);
  }, [d, show]);

  // hover text names the real chains (the file calls them A and B)
  const tooltips = useMemo<ResidueTooltip[]>(() => {
    if (!d) return [];
    const out: ResidueTooltip[] = [];
    for (const r of d.residues) {
      const what =
        r.class === 'only_a' || r.class === 'only_b' ? 'ordered in one structure only' : r.dev != null ? `Cα Δ ${fmtNum(r.dev, 2)} Å` : '';
      if (show !== 'B' && r.class !== 'only_b' && r.class !== 'missing')
        out.push({ chain: 'A', pos: r.pos, text: `<b>${la}</b> (this) · ${r.res} ${r.pos} · ${what}` });
      if (show !== 'A' && r.class !== 'only_a' && r.class !== 'missing')
        out.push({ chain: 'B', pos: r.pos, text: `<b>${lb}</b> (alternative) · ${r.res} ${r.pos} · ${what}` });
    }
    return out;
  }, [d, show, la, lb]);

  const url = api.compareStructureUrl(a.pdb_id, a.auth_asym_id, b.pdb_id, b.auth_asym_id, show === 'both' ? undefined : show);
  const L = Math.max(length, d?.residues[d.residues.length - 1]?.pos ?? 0);
  const byPos = useMemo(() => new Map((d?.residues ?? []).map((r) => [r.pos, r])), [d]);

  if (res.loading) return <Spinner label="Fetching coordinates and superposing…" />;
  if (res.error) return <ErrorState title="Could not compare the structures" message={res.error.message} />;
  if (!d) return null;

  const t = d.thresholds;
  return (
    <div className="sdiff">
      <div className="sdiff__bar">
        <Segmented<Show>
          label="Chains shown"
          value={show}
          onChange={setShow}
          options={[
            { value: 'both', label: 'Both' },
            { value: 'A', label: `${la} (this)` },
            { value: 'B', label: `${lb} (alternative)` },
          ]}
        />
        <p className="muted small sdiff__stats">
          {show === 'both' ? <>{la} is solid and {lb} semi-transparent, both coloured by deviation. </> : null}
          {lb} superposed onto {la} on their largest rigid core: {fmtInt(d.counts.low)} of {fmtInt(d.n_common)} shared
          residues within {t.low} Å (core RMSD {fmtNum(d.rmsd_core, 2)} Å), maximum deviation {fmtNum(d.max_dev, 1)} Å.
        </p>
      </div>
      <CustomStructureViewer
        url={url}
        runs={runs}
        tooltips={tooltips}
        height={460}
        label={show === 'both' ? `chain A = ${la} · chain B = ${lb}` : show === 'A' ? la : lb}
      />
      <ul className="legend sdiff__legend">
        {show === 'both' && (
          <li>
            <Swatch color={DIFF_COLORS.low} /> solid: {la}
            <span style={{ opacity: ALT_OPACITY + 0.15 }}>
              <Swatch color={DIFF_COLORS.low} />
            </span>{' '}
            faded: {lb}
          </li>
        )}
        <li>
          <Swatch color={DIFF_COLORS.low} /> &lt; {t.low} Å <b className="tabular">{fmtInt(d.counts.low)}</b>
        </li>
        <li>
          <Swatch color={DIFF_COLORS.medium} /> {t.low}–{t.high} Å <b className="tabular">{fmtInt(d.counts.medium)}</b>
        </li>
        <li>
          <Swatch color={DIFF_COLORS.high} /> ≥ {t.high} Å <b className="tabular">{fmtInt(d.counts.high)}</b>
        </li>
        <li>
          <Swatch color={DIFF_COLORS.only_a} /> Ordered in only one structure{' '}
          <b className="tabular">
            {fmtInt(d.counts.only_a)} / {fmtInt(d.counts.only_b)}
          </b>
        </li>
      </ul>
      <div className="sdiff__track" aria-label="Per-residue comparison along the sequence">
        {Array.from({ length: L }, (_, i) => {
          const r = byPos.get(i + 1);
          return (
            <span
              key={i}
              className={`sdiff__cell ${r ? '' : 'is-empty'}`}
              style={r ? { background: DIFF_COLORS[r.class] } : undefined}
              title={r ? describe(r, la, lb) : `${i + 1}: not resolved in either structure`}
            />
          );
        })}
      </div>
      <div className="sdiff__axis">
        <span>1</span>
        <span className="muted small">
          grey: {fmtInt(d.counts.only_a)} residues ordered only in {la}, {fmtInt(d.counts.only_b)} only in {lb} · blank:
          disordered in both
        </span>
        <span>{L}</span>
      </div>
    </div>
  );
}