File size: 14,253 Bytes
0ab82d0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
/* structcard.js β€” a protein's fold, small enough to put on a card.
   ===========================================================================
   The catalog shows one card per conversation, and a scientist recognises a
   fold faster than they read a title. Mol* is the wrong tool for that: it is a
   full WebGL viewer, and ten of them on one screen would cost more than the
   rest of the app put together.

   So this draws the backbone itself. It fetches the same AlphaFold DB PDB file
   the Structure tab already loads, reads the alpha-carbon trace out of it, and
   paints it on a 2-D canvas β€” coloured by the model's own per-residue pLDDT,
   using AlphaFold's published confidence bands. That last part matters: the
   card is not decoration, it says how much of this model is trustworthy, in
   the colours a structural biologist already reads without a legend.

   Honesty rules this file follows:
     Β· A card with no UniProt accession gets a plain "no structure" plate. It
       never gets a generic squiggle that could be mistaken for a real fold.
     Β· A fetch that fails says so and offers a retry. It does not fall back to
       drawing something.
     Β· The projection is the real one β€” the two principal axes of the real
       coordinates β€” so two different proteins never come out looking alike.

   Public API:
     TDStructCard.paint(canvas, accession)  -> Promise<boolean>
     TDStructCard.observe(root)             -> lazily paints [data-uniprot]
                                               canvases as they scroll in
   =========================================================================== */
(function () {
    "use strict";

    /* AlphaFold's own confidence bands and colours (alphafold.ebi.ac.uk). Not
       invented here β€” a scientist reads these without being told. */
    var PLDDT_BANDS = [
        { min: 90, color: "#0053D6" },   // very high
        { min: 70, color: "#65CBF3" },   // confident
        { min: 50, color: "#FFDB13" },   // low
        { min: 0,  color: "#FF7D45" },   // very low
    ];
    function plddtColor(b) {
        for (var i = 0; i < PLDDT_BANDS.length; i++) {
            if (b >= PLDDT_BANDS[i].min) return PLDDT_BANDS[i].color;
        }
        return PLDDT_BANDS[PLDDT_BANDS.length - 1].color;
    }

    var ACC_RE = /^[A-Z0-9]{6,10}$/i;
    var _mem = {};          // accession -> Promise<{pts, plddt}>

    /* ── fetch + parse ──────────────────────────────────────────────────── */

    function pdbUrls(acc) {
        // Same version ladder app.js walks for the full viewer, newest first.
        return [6, 5, 4].map(function (v) {
            return "https://alphafold.ebi.ac.uk/files/AF-" + acc + "-F1-model_v" + v + ".pdb";
        });
    }

    function fetchTrace(acc) {
        if (_mem[acc]) return _mem[acc];
        var urls = pdbUrls(acc);
        var p = (function next(i) {
            if (i >= urls.length) return Promise.reject(new Error("no model"));
            return fetch(urls[i], { mode: "cors" })
                .then(function (r) { return r.ok ? r.text() : Promise.reject(new Error(String(r.status))); })
                .then(parsePdbCA)
                .catch(function () { return next(i + 1); });
        })(0);
        _mem[acc] = p;
        p.catch(function () { delete _mem[acc]; });   // a failure shouldn't be cached forever
        return p;
    }

    /* PDB is fixed-column, not delimited β€” splitting on whitespace breaks the
       moment a coordinate is wide enough to touch its neighbour, which happens
       on real files. Slice by the column positions in the format spec. */
    function parsePdbCA(text) {
        var pts = [], plddt = [];
        var lines = text.split("\n");
        for (var i = 0; i < lines.length; i++) {
            var L = lines[i];
            if (L.lastIndexOf("ATOM", 0) !== 0) continue;
            if (L.slice(12, 16).trim() !== "CA") continue;
            var x = parseFloat(L.slice(30, 38));
            var y = parseFloat(L.slice(38, 46));
            var z = parseFloat(L.slice(46, 54));
            if (!isFinite(x) || !isFinite(y) || !isFinite(z)) continue;
            var b = parseFloat(L.slice(60, 66));
            pts.push([x, y, z]);
            plddt.push(isFinite(b) ? b : 0);
        }
        if (pts.length < 3) throw new Error("no backbone");
        return { pts: pts, plddt: plddt };
    }

    /* ── projection ─────────────────────────────────────────────────────────
       Project onto the two principal axes of the coordinates, so the fold is
       seen along its widest face and fills the card. A fixed axis pair (say
       x/y) would show many proteins end-on as an uninformative blob.

       Jacobi rotation on the 3x3 covariance β€” small, exact enough, and no
       dependency. Eigenvectors come back as the columns of v. */
    function eigen3(m) {
        var a = [m[0].slice(), m[1].slice(), m[2].slice()];
        var v = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
        for (var sweep = 0; sweep < 24; sweep++) {
            var off = Math.abs(a[0][1]) + Math.abs(a[0][2]) + Math.abs(a[1][2]);
            if (off < 1e-12) break;
            for (var p = 0; p < 2; p++) {
                for (var q = p + 1; q < 3; q++) {
                    if (Math.abs(a[p][q]) < 1e-15) continue;
                    var theta = (a[q][q] - a[p][p]) / (2 * a[p][q]);
                    var sgn = theta >= 0 ? 1 : -1;
                    var t = sgn / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
                    var c = 1 / Math.sqrt(t * t + 1), s = t * c;
                    var k;
                    for (k = 0; k < 3; k++) {
                        var akp = a[k][p], akq = a[k][q];
                        a[k][p] = c * akp - s * akq;
                        a[k][q] = s * akp + c * akq;
                    }
                    for (k = 0; k < 3; k++) {
                        var apk = a[p][k], aqk = a[q][k];
                        a[p][k] = c * apk - s * aqk;
                        a[q][k] = s * apk + c * aqk;
                    }
                    for (k = 0; k < 3; k++) {
                        var vkp = v[k][p], vkq = v[k][q];
                        v[k][p] = c * vkp - s * vkq;
                        v[k][q] = s * vkp + c * vkq;
                    }
                }
            }
        }
        return [0, 1, 2]
            .sort(function (i, j) { return a[j][j] - a[i][i]; })
            .map(function (i) { return [v[0][i], v[1][i], v[2][i]]; });
    }

    function project(pts) {
        var n = pts.length, i, cx = 0, cy = 0, cz = 0;
        for (i = 0; i < n; i++) { cx += pts[i][0]; cy += pts[i][1]; cz += pts[i][2]; }
        cx /= n; cy /= n; cz /= n;

        var cov = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
        for (i = 0; i < n; i++) {
            var dx = pts[i][0] - cx, dy = pts[i][1] - cy, dz = pts[i][2] - cz;
            cov[0][0] += dx * dx; cov[0][1] += dx * dy; cov[0][2] += dx * dz;
            cov[1][1] += dy * dy; cov[1][2] += dy * dz; cov[2][2] += dz * dz;
        }
        cov[1][0] = cov[0][1]; cov[2][0] = cov[0][2]; cov[2][1] = cov[1][2];

        var ax = eigen3(cov);
        var u = ax[0], w = ax[1], d = ax[2];   // 3rd axis is the viewing depth
        var out = [];
        for (i = 0; i < n; i++) {
            var ex = pts[i][0] - cx, ey = pts[i][1] - cy, ez = pts[i][2] - cz;
            out.push([ex * u[0] + ey * u[1] + ez * u[2],
                      ex * w[0] + ey * w[1] + ez * w[2],
                      ex * d[0] + ey * d[1] + ez * d[2]]);
        }
        return out;
    }

    /* ── draw ───────────────────────────────────────────────────────────── */

    function draw(canvas, trace) {
        var dpr = Math.min(window.devicePixelRatio || 1, 2);
        var cssW = canvas.clientWidth || 148, cssH = canvas.clientHeight || 104;
        canvas.width = Math.round(cssW * dpr);
        canvas.height = Math.round(cssH * dpr);
        var ctx = canvas.getContext("2d");
        if (!ctx) return false;
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, cssW, cssH);

        var flat = project(trace.pts);

        /* Frame on the ordered core, not the extremes.
           Nearly every AlphaFold model trails a long, very-low-pLDDT tail β€”
           an intrinsically disordered terminus that the model places
           essentially arbitrarily. Scaling to min/max lets one such tail set
           the bounding box, which squashes the actual folded domain into a
           blob in the middle of the card. Percentile bounds ignore those few
           outlying residues for FRAMING while the tail is still drawn (and
           clipped), so the fold fills the space and the disorder still reads
           as the orange thread coming off it. */
        var i;
        var xs = [], ys = [];
        for (i = 0; i < flat.length; i++) { xs.push(flat[i][0]); ys.push(flat[i][1]); }
        var num = function (a, b) { return a - b; };
        xs.sort(num); ys.sort(num);
        var at = function (arr, p) {
            return arr[Math.min(arr.length - 1, Math.max(0, Math.round((arr.length - 1) * p)))];
        };
        var LO = 0.02, HI = 0.98;
        var minX = at(xs, LO), maxX = at(xs, HI);
        var minY = at(ys, LO), maxY = at(ys, HI);
        var pad = 7;
        var sx = (cssW - pad * 2) / Math.max(1e-6, maxX - minX);
        var sy = (cssH - pad * 2) / Math.max(1e-6, maxY - minY);
        var s = Math.min(sx, sy);
        var ox = (cssW - (maxX - minX) * s) / 2 - minX * s;
        var oy = (cssH - (maxY - minY) * s) / 2 - minY * s;

        // Percentile framing means the trailing residues can fall outside the
        // box; clip so they stop at the card edge instead of painting over it.
        ctx.beginPath();
        ctx.rect(0, 0, cssW, cssH);
        ctx.clip();

        ctx.lineWidth = Math.max(1.1, Math.min(2.4, 170 / flat.length + 0.9));
        ctx.lineCap = "round";
        ctx.lineJoin = "round";

        /* One stroke per segment so the colour can follow pLDDT along the
           chain. Long chains are decimated β€” past a few hundred segments the
           extra strokes cost more than they show at this size. */
        /* Depth cueing along the third principal axis. Without it a compact
           domain at this size is a solid tangle of one colour; near strands
           drawn brighter and slightly thicker than far ones is what makes it
           read as a three-dimensional object at 104x84 px. */
        var dLo = Infinity, dHi = -Infinity;
        for (i = 0; i < flat.length; i++) {
            if (flat[i][2] < dLo) dLo = flat[i][2];
            if (flat[i][2] > dHi) dHi = flat[i][2];
        }
        var dSpan = Math.max(1e-6, dHi - dLo);
        var base = ctx.lineWidth;

        var step = Math.max(1, Math.floor(flat.length / 420));
        for (i = step; i < flat.length; i += step) {
            var a = flat[i - step], b = flat[i];
            var depth = ((a[2] + b[2]) / 2 - dLo) / dSpan;     // 0 far, 1 near
            ctx.globalAlpha = 0.42 + 0.58 * depth;
            ctx.lineWidth = base * (0.72 + 0.52 * depth);
            ctx.beginPath();
            ctx.moveTo(a[0] * s + ox, a[1] * s + oy);
            ctx.lineTo(b[0] * s + ox, b[1] * s + oy);
            ctx.strokeStyle = plddtColor(trace.plddt[i]);
            ctx.stroke();
        }
        ctx.globalAlpha = 1;
        return true;
    }

    /* ── public ─────────────────────────────────────────────────────────── */

    function setState(canvas, s) {
        var host = canvas.closest ? canvas.closest("[data-sc-host]") : null;
        (host || canvas).setAttribute("data-sc", s);
    }

    function paint(canvas, accession) {
        var acc = String(accession || "").trim().toUpperCase();
        if (!canvas) return Promise.resolve(false);
        if (!ACC_RE.test(acc)) { setState(canvas, "none"); return Promise.resolve(false); }
        setState(canvas, "loading");
        return fetchTrace(acc).then(function (trace) {
            var ok = draw(canvas, trace);
            setState(canvas, ok ? "ok" : "error");
            if (ok) {
                canvas.setAttribute(
                    "aria-label",
                    "Predicted structure of " + acc + ", " + trace.pts.length +
                    " residues, coloured by AlphaFold confidence");
            }
            return ok;
        }).catch(function () {
            setState(canvas, "error");
            return false;
        });
    }

    /* Lazy paint. Ten cards means ten multi-hundred-kilobyte coordinate files;
       fetching them for rows nobody scrolled to is the difference between a
       catalog that opens instantly and one that doesn't. */
    var _io = null;
    function observe(root) {
        var nodes = (root || document).querySelectorAll("canvas[data-uniprot]:not([data-sc-seen])");
        if (!nodes.length) return;
        if (!("IntersectionObserver" in window)) {
            Array.prototype.forEach.call(nodes, function (c) {
                c.setAttribute("data-sc-seen", "1");
                paint(c, c.getAttribute("data-uniprot"));
            });
            return;
        }
        if (!_io) {
            _io = new IntersectionObserver(function (entries) {
                entries.forEach(function (e) {
                    if (!e.isIntersecting) return;
                    _io.unobserve(e.target);
                    paint(e.target, e.target.getAttribute("data-uniprot"));
                });
            }, { rootMargin: "120px" });
        }
        Array.prototype.forEach.call(nodes, function (c) {
            c.setAttribute("data-sc-seen", "1");
            _io.observe(c);
        });
    }

    window.TDStructCard = { paint: paint, observe: observe, plddtColor: plddtColor };
})();