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