syntheogenesis / dee /static /structcard.js
github-actions[bot]
Deploy 38008be
0ab82d0
Raw
History Blame Contribute Delete
14.3 kB
/* 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 };
})();