Spaces:
Running
Running
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 };
})();
|