From 91cf32114866466e6aa704c70a0db728af53d339 Mon Sep 17 00:00:00 2001 From: younghoon Date: Tue, 8 Sep 2026 00:15:16 +0900 Subject: [PATCH 1/2] Refactor: isolate browser-independent helpers --- frontend/AllAtomPredictMixin.vue | 19 +- frontend/SelectToSendPanel.vue | 16 +- frontend/SelectToSendPanelFoldMason.vue | 11 +- frontend/Utilities.js | 874 +----------------- frontend/{lib => build-tools}/po-loader.js | 0 frontend/{lib => build-tools}/po-reader.js | 0 frontend/lib/.babelrc | 3 + frontend/lib/accession.js | 191 ++-- frontend/lib/alignmentColumns.js | 115 +++ frontend/lib/hitId.js | 53 ++ frontend/lib/msaTracks.js | 233 +++-- frontend/lib/package.json | 4 + frontend/lib/parseResults.js | 450 +++++++++ frontend/lib/pdbAssembly.js | 304 ++++++ frontend/lib/resultSort.js | 23 - frontend/lib/resultsApi.js | 360 -------- frontend/lib/structureRemark.js | 12 + frontend/lib/structureText.js | 447 +++++++++ frontend/lib/targetName.js | 48 + frontend/lib/taxonomyFilter.js | 7 +- frontend/lib/ticketRoute.js | 23 +- .../vue-simple-portal/components/Portal.js | 4 +- frontend/lib/vue-simple-portal/index.js | 4 +- frontend/webpack.frontend.config.js | 7 +- 24 files changed, 1782 insertions(+), 1426 deletions(-) rename frontend/{lib => build-tools}/po-loader.js (100%) rename frontend/{lib => build-tools}/po-reader.js (100%) create mode 100644 frontend/lib/.babelrc create mode 100644 frontend/lib/alignmentColumns.js create mode 100644 frontend/lib/hitId.js create mode 100644 frontend/lib/package.json create mode 100644 frontend/lib/parseResults.js create mode 100644 frontend/lib/pdbAssembly.js delete mode 100644 frontend/lib/resultsApi.js create mode 100644 frontend/lib/structureRemark.js create mode 100644 frontend/lib/structureText.js create mode 100644 frontend/lib/targetName.js diff --git a/frontend/AllAtomPredictMixin.vue b/frontend/AllAtomPredictMixin.vue index b58dfb7c..27d34ae2 100644 --- a/frontend/AllAtomPredictMixin.vue +++ b/frontend/AllAtomPredictMixin.vue @@ -1,5 +1,6 @@ \ No newline at end of file + diff --git a/frontend/SelectToSendPanel.vue b/frontend/SelectToSendPanel.vue index d36be905..baf6a856 100644 --- a/frontend/SelectToSendPanel.vue +++ b/frontend/SelectToSendPanel.vue @@ -91,6 +91,7 @@ import { StorageWrapper} from './lib/HistoryMixin.js'; import { encodeMultimer, getAccession, makeCgPDBFromText, mockPDB, sleep, storeChains } from './Utilities'; import { BlobDatabase } from './lib/BlobDatabase'; +import { structureRemarkLine, structureRemarkPrefix } from './lib/structureRemark.js'; const localDb = BlobDatabase() @@ -420,19 +421,8 @@ export default { this.$emit('clearAll') }, prependRemark(structure, accession, db) { - let is_cif = false - if (structure[0] == '#' || structure.startsWith('data_')) { - is_cif = true - } - - let prefix = is_cif ? '# ' : 'REMARK 99 ' - let firstline = prefix + 'Accession: ' + accession + ', DB: ' + db - if (!is_cif && firstline.length > 79) { - firstline = firstline.slice(76) + '... ' - } - - firstline = firstline.padEnd(80, ' ') + '\n' + prefix + this.remarkStr - return firstline + structure + const line = structureRemarkLine(structure, `Accession: ${accession}, DB: ${db}`, 99) + return line + '\n' + structureRemarkPrefix(structure, 99) + this.remarkStr + structure }, toggleIncludeQuery() { this.includeQuery = !this.includeQuery diff --git a/frontend/SelectToSendPanelFoldMason.vue b/frontend/SelectToSendPanelFoldMason.vue index b8168ccd..db83c7ed 100644 --- a/frontend/SelectToSendPanelFoldMason.vue +++ b/frontend/SelectToSendPanelFoldMason.vue @@ -68,6 +68,7 @@ import { StorageWrapper} from './lib/HistoryMixin.js'; import { BlobDatabase } from './lib/BlobDatabase.js'; import { pulchra } from 'pulchra-wasm'; import AllAtomPredictMixin from './AllAtomPredictMixin.vue'; +import { structureRemarkLine, structureRemarkPrefix } from './lib/structureRemark.js'; const localDb = BlobDatabase() @@ -184,14 +185,8 @@ export default { this.$emit('clearAll') }, prependInformation(structure, accession) { - let prefix = 'REMARK 99 ' - let firstline = prefix + 'Accession: ' + accession - if (firstline.length > 79) { - firstline = firstline.slice(76) + '... ' - } - - firstline = firstline.padEnd(80, ' ') + '\n' + prefix + this.remarkStr - return firstline + structure + const line = structureRemarkLine(structure, `Accession: ${accession}`, 99) + return line + '\n' + structureRemarkPrefix(structure, 99) + this.remarkStr + structure }, async getMockPdb(entry) { const mock = mockPDB(entry.ca, entry.aa.replace(/-/g, ''), 'A'); diff --git a/frontend/Utilities.js b/frontend/Utilities.js index c84def1f..66845fb4 100644 --- a/frontend/Utilities.js +++ b/frontend/Utilities.js @@ -1,194 +1,30 @@ import { ungzip } from "pako"; +import { parseResults, parseResultsRiboseek } from "./lib/parseResults.js"; + +export { + parseResults, + parseResultsFoldDisco, + parseResultsRiboseek, + tryFixName, + splitAlphaNum, +} from "./lib/parseResults.js"; + +export { + oneToThree, + threeToOne, + mockPDB, + mergePdbs, + concatenatePdbs, + encodeMultimer, + decodeMultimer, + splitMultimer, + mergeMultimer, + storeChains, + revertChainInfo, +} from "./lib/pdbAssembly.js"; +export { getChainName, getAccession } from "./lib/targetName.js"; +export { getResidueIndices, getResnoWithChain } from "./lib/alignmentColumns.js"; -function tryLinkTargetToDB(target, db) { - try { - var res = db.toLowerCase(); - if (res.startsWith("pfam")) { - return "https://www.ebi.ac.uk/interpro/entry/pfam/" + target; - } else if (res.startsWith("pdb")) { - return ( - "https://www.rcsb.org/structure/" + - target - .replaceAll(/-assembly[0-9]+/g, "") - .replaceAll(/\.(cif|pdb|ent)(\.gz)?/g, "") - .replaceAll(/[0-9]+DI_/g, "") // For interface cluster, should we link to cluster web? - .split("_")[0] - ); - } else if (res.startsWith("humanppi")) { - return ( - "http://prodata.swmed.edu/humanPPI/results/" + - target - .split("__") - .map((half) => half.split("_")[0]) - .join("_") - ); - } else if ( - res.startsWith("uniclust") || - res.startsWith("uniprot") || - res.startsWith("sprot") || - res.startsWith("swissprot") - ) { - return "https://www.uniprot.org/uniprot/" + target; - } else if (res.startsWith("eggnog_")) { - return "http://eggnogdb.embl.de/#/app/results?target_nogs=" + target; - } else if (res.startsWith("cdd")) { - return ( - "https://www.ncbi.nlm.nih.gov/Structure/cdd/cddsrv.cgi?uid=" + target - ); - } - - if (__APP__ == "foldseek") { - if (target.startsWith("AF-")) { - // Old: AF--F1-model_v4, entry keyed on the bare accession. - // New: AF--model_v1, entry keyed on the whole AF-, no UniProt. - // Interface entries append ___. - const stem = target.replaceAll( - /-(F[0-9]+-)?model_v[0-9]+(\.(cif|pdb))?(\.gz)?(_[A-Za-z0-9]+){0,3}$/g, - "", - ); - const isUniProt = /-F[0-9]+-model_v/.test(target); - const accession = isUniProt ? stem.substring(3) : stem; - const links = [ - { - label: "AFDB", - accession: accession, - href: "https://www.alphafold.ebi.ac.uk/entry/" + accession, - }, - ]; - if (isUniProt) { - links.push({ - label: "UniProt", - accession: accession, - href: "https://www.uniprot.org/uniprot/" + accession, - }); - } - return links; - } else if (target.startsWith("GMGC")) { - return ( - "https://gmgc.embl.de/search.cgi?search_id=" + - target.replaceAll(/\.(cif|pdb)(\.gz)?/g, "") - ); - } else if (target.startsWith("MGYP")) { - return ( - "https://esmatlas.com/explore/detail/" + - target.replaceAll(/\.(cif|pdb)(\.gz)?/g, "") - ); - } else if (target.startsWith("LevyLab_")) { - let accession = target.split("_")[1]; - return [ - { - label: "AFDB", - accession: accession, - href: "https://www.alphafold.ebi.ac.uk/entry/" + accession, - }, - { - label: "UniProt", - accession: accession, - href: "https://www.uniprot.org/uniprot/" + accession, - }, - ]; - } else if (target.startsWith("ProtVar_")) { - let accession1 = target.split("_")[1]; - let accession2 = target.split("_")[2]; - let result = [ - { - label: "AFDB", - accession: accession1, - href: "https://www.alphafold.ebi.ac.uk/entry/" + accession1, - }, - { - label: "UniProt", - accession: accession1, - href: "https://www.uniprot.org/uniprot/" + accession1, - }, - ]; - if (accession1 != accession2) { - result.push({ - label: "AFDB", - accession: accession2, - href: "https://www.alphafold.ebi.ac.uk/entry/" + accession2, - }); - result.push({ - label: "UniProt", - accession: accession2, - href: "https://www.uniprot.org/uniprot/" + accession2, - }); - } - return result; - } else if (target.startsWith("ModelArchive_")) { - return "https://modelarchive.org/doi/10.5452/" + target.split("_")[1]; - } else if (target.startsWith("Predictome_")) { - return "https://predictomes.org/summary/" + target.split("_")[1]; - } - if (res.startsWith("cath")) { - if (target.startsWith("af_")) { - const cath = target.substring(target.lastIndexOf("_") + 1); - return "https://www.cathdb.info/version/latest/superfamily/" + cath; - } else { - return "https://www.cathdb.info/version/latest/domain/" + target; - } - } else if (res.startsWith("bfvd")) { - const bfvd = target.replaceAll(/_.*/g, ""); - return [ - { - label: "BFVD", - accession: bfvd, - href: "https://bfvd.foldseek.com/cluster/" + bfvd, - }, - { - label: "UniRef", - accession: bfvd, - href: "https://www.uniprot.org/uniref/UniRef100_" + bfvd, - }, - ]; - } - } - return null; - } catch (e) { - return null; - } -} - -function tryFixTargetName(target, db) { - var res = db.toLowerCase(); - if (__APP__ == "foldseek") { - if (target.startsWith("AF-")) { - return target.replaceAll(/\.(cif|pdb)(\.gz)?(_[A-Z0-9]+)?$/g, ""); - } else if ( - res.startsWith("pdb") || - res.startsWith("gmgc") || - res.startsWith("mgyp") || - res.startsWith("mgnify") - ) { - return target.replaceAll(/\.(cif|pdb|ent)(\.gz)?/g, ""); - } else if (res.startsWith("bfvd")) { - return target.replaceAll(/_unrelaxed.*/g, ""); - } - if (res.startsWith("cath")) { - if (target.startsWith("af_")) { - const match = target.match( - /^af_([A-Z0-9]+)_(\d+)_(\d+)_(\d+\.\d+\.\d+\.\d+)$/, - ); - if (match && match.length == 5) { - return match[4] + " " + match[1] + " " + match[2] + "-" + match[3]; - } - } - } - } - return target; -} - -// Process e.g. AF-{uniprot ID}-F1_model_v4.cif.pdb.gz to just uniprot ID -export function tryFixName(name) { - if (/-_-_-_/.test(name)) { - name = name.split("-_-_-_")[0]; - } - - if (name.startsWith("AF-")) { - name = name.replaceAll(/(AF[-_]|[-_]F[0-9]+[-_]model[-_]v[0-9]+)/g, ""); - } - return name.replaceAll(/\.(cif|pdb|gz)/g, ""); -} export async function readUploadedText(file) { const bytes = new Uint8Array(await file.arrayBuffer()); @@ -199,262 +35,6 @@ export async function readUploadedText(file) { return new TextDecoder().decode(data); } -export function parseResults(data) { - let empty = 0; - let total = 0; - for (let i in data.results) { - let result = data.results[i]; - let db = result.db; - result.hasDescription = false; - result.hasTaxonomy = false; - if (result.alignments == null) { - empty++; - } - total++; - const isGrouped = - result.alignments != null && !Array.isArray(result.alignments); - const grouped = isGrouped ? result.alignments : {}; - for (let j in result.alignments) { - for (let k in result.alignments[j]) { - let item = result.alignments[j][k]; - if (item.description === undefined) { - let split = item.target.split(" "); - item.description = split.slice(1).join(" "); - item.href = tryLinkTargetToDB(split[0], db); - item.target = tryFixTargetName(split[0], db); - } - if (item.description.length > 1) { - result.hasDescription = true; - } - item.id = "result-" + i + "-" + j; - item.active = false; - if (__APP__ != "foldseek" || data.mode != "tmalign") { - item.eval = - typeof item.eval === "string" - ? item.eval - : item.eval.toExponential(2); - } - if (__APP__ == "foldseek") { - item.prob = - typeof item.prob === "string" ? item.prob : item.prob.toFixed(2); - if (data.mode == "tmalign") { - item.eval = - typeof item.eval === "string" ? item.eval : item.eval.toFixed(3); - } else if (data.mode == "lolalign") { - item.eval = item.eval * 100; - item.eval = parseFloat(item.eval.toFixed(2)).toString(); - } - } - if ("taxId" in item) { - result.hasTaxonomy = true; - } - if (!isGrouped) { - let groupId = item.complexid ?? k; - if (!grouped[groupId]) { - grouped[groupId] = []; - } - grouped[groupId].push(item); - } - } - } - result.alignments = grouped; - } - return total != 0 && empty / total == 1 - ? { results: [], mode: data.mode } - : data; -} - -export function splitAlphaNum(str) { - const len = str.length; - let i = 0; - - while (i < len) { - const cc = str.charCodeAt(i); - if (cc >= 48 && cc <= 57) { - break; - } - i++; - } - const alpha = str.slice(0, i); - - let j = i; - while (j < len) { - const cc = str.charCodeAt(j); - if (cc < 48 || cc > 57) { - break; - } - j++; - } - const numeric = str.slice(i, j); - - let substitution = ""; - if (j < len && str.charCodeAt(j) === 58 /* ':' */) { - substitution = str.slice(j); - } - - return [alpha, numeric, substitution]; -} - -function computeInterresidueDist(splitTarget) { - const dist = []; - let lastChain = null; - let lastPos = null; - - for (let i = 1; i < splitTarget.length; ++i) { - const curr = splitTarget[i]; - - if (curr === "_") { - dist.push(null); - continue; - } - - let [currChain, currPos] = splitAlphaNum(curr); - currPos = currPos | 0; - - // Find last valid residue before current - let j = i - 1; - while (j >= 0 && splitTarget[j] === "_") { - j--; - } - if (j < 0) { - dist.push(null); - lastChain = currChain; - lastPos = currPos; - continue; - } - - let [prevChain, prevPos] = splitAlphaNum(splitTarget[j]); - prevPos = prevPos | 0; - - const delta = currChain === prevChain ? currPos - prevPos : currPos; - dist.push(delta); - - // Update last valid residue - lastChain = currChain; - lastPos = currPos; - } - - return dist; -} - -export function parseResultsRiboseek(data) { - let empty = 0; - let total = 0; - for (let i in data.results) { - let result = data.results[i]; - let db = result.db; - result.hasDescription = false; - result.hasTaxonomy = false; - if (result.alignments == null) { - empty++; - } - total++; - const raw = result.alignments || []; - const groups = raw.length > 0 && Array.isArray(raw[0]) ? raw : [raw]; - const hits = []; - for (const group of groups) { - for (const item of group) { - if (item.description === undefined) { - const split = item.target.split(" "); - item.target = tryFixTargetName(split[0], db); - item.description = split.slice(1).join(" "); - item.href = tryLinkTargetToDB(split[0], db); - } - if (item.description.length > 1) { - result.hasDescription = true; - } - item.id = "result-" + i + "-" + hits.length; - item.evalStr = typeof item.eval === "string" ? item.eval : item.eval.toExponential(2); - item.strand = item.qStartPos > item.qEndPos ? "-" : "+"; - if ("taxId" in item) { - result.hasTaxonomy = true; - } - hits.push(item); - } - } - result.alignments = hits; - } - return total != 0 && empty / total == 1 - ? { results: [], mode: data.mode } - : data; -} - -export function parseResultsFoldDisco(data) { - let empty = 0; - let total = 0; - for (let i in data.results) { - let result = data.results[i]; - let db = result.db; - result.hasDescription = false; - result.hasTaxonomy = false; - if (result.alignments == null) { - empty++; - } - total++; - result.queryresidues = {}; - const grouped = {}; - let meta = null; - if ("meta" in result) { - meta = {}; - for (let j in result.meta) { - let entry = result.meta[j]; - meta[entry.key] = entry; - } - } - result.meta = null; - for (let j in result.alignments) { - let item = result.alignments[j]; - let split = item.target.split("/"); - item.target = split[split.length - 1]; - - const splitTarget = item.targetresidues.split(","); - item.gaps = splitTarget.reduce((acc, s) => { - return acc + (s == "_" ? "0" : "1"); - }, ""); - item.interresiduedist = computeInterresidueDist(splitTarget); - item.idfscore = item.idfscore.toFixed(3); - item.rmsd = item.rmsd.toFixed(3); - - item.queryresidues.split(",").map((r) => { - let [chain, pos, _] = splitAlphaNum(r); - if (!(chain in result.queryresidues)) { - result.queryresidues[chain] = new Set(); - } - result.queryresidues[chain].add(pos - 0); - }); - - item.href = tryLinkTargetToDB(item.target, db); - item.targetname = tryFixTargetName(item.target, db).toUpperCase(); - item.id = "result-" + i + "-" + j; - if (meta != null) { - let header = meta[item.dbkey].header; - let split = header.split(" "); - item.description = split.slice(1).join(" "); - if (item.description.length > 1) { - result.hasDescription = true; - } - if ("taxId" in meta[item.dbkey]) { - item.taxId = meta[item.dbkey].taxId; - item.taxName = meta[item.dbkey].taxName; - result.hasTaxonomy = true; - } - } - let groupId = j; - if (!grouped[groupId]) { - grouped[groupId] = []; - } - grouped[j].push(item); - } - result.alignments = grouped; - Object.keys(result.queryresidues).forEach(function (key, _) { - result.queryresidues[key] = Array.from(result.queryresidues[key]); - }); - } - return total != 0 && empty / total == 1 - ? { results: [], mode: data.mode } - : data; -} - export function dateTime() { // Generates current YYYY_MM_DD_HH_MM_SS timestamp return new Date() @@ -524,57 +104,6 @@ export function makePositionMap(realStart, alnString) { return map; } -export const oneToThree = { - A: "ALA", - R: "ARG", - N: "ASN", - D: "ASP", - C: "CYS", - E: "GLU", - Q: "GLN", - G: "GLY", - H: "HIS", - I: "ILE", - L: "LEU", - K: "LYS", - M: "MET", - F: "PHE", - P: "PRO", - S: "SER", - T: "THR", - W: "TRP", - Y: "TYR", - V: "VAL", - U: "SEC", - O: "PHL", - X: "XAA", -}; - -export const threeToOne = { - ALA: "A", - ARG: "R", - ASN: "N", - ASP: "D", - CYS: "C", - GLU: "E", - GLN: "Q", - GLY: "G", - HIS: "H", - ILE: "I", - LEU: "L", - LYS: "K", - MET: "M", - PHE: "F", - PRO: "P", - SER: "S", - THR: "T", - TRP: "W", - TYR: "Y", - VAL: "V", - SEC: "U", - PHL: "O", - XAA: "X", -}; export function makeCgPDBFromText(text) { const pdb = []; @@ -597,217 +126,6 @@ export function makeCgPDBFromText(text) { return pdb.join("\n"); } -/** - * Create a mock PDB from Ca data - * Follows the spacing spec from https://www.wwpdb.org/documentation/file-format-content/format33/sect9.html#ATOM - * Will have to change if/when swapping to fuller data - */ -export function mockPDB(ca, seq, chain) { - const atoms = ca.split(","); - const pdb = new Array(); - let j = 1; - for (let i = 0; i < atoms.length; i += 3, j++) { - let [x, y, z] = atoms.slice(i, i + 3).map((element) => parseFloat(element)); - // if (x == 0 && y == 0 && z == 0) continue; - pdb.push( - "ATOM " + - j.toString().padStart(5) + - " CA " + - oneToThree[ - seq != "" && atoms.length / 3 == seq.length ? seq[i / 3] : "A" - ] + - chain.toString().padStart(2) + - j.toString().padStart(4) + - " " + - x.toFixed(3).padStart(8) + - y.toFixed(3).padStart(8) + - z.toFixed(3).padStart(8) + - " 1.00 0.00 C ", - ); - } - return pdb.join("\n"); -} - -export function mergePdbs(chainPdbs /* [{pdb, chain}] */) { - let serial = 1; - const out = []; - - for (const { pdb, chain } of chainPdbs) { - const lines = pdb.split(/\r?\n/); - for (const line of lines) { - if (/^(ATOM |HETATM)/.test(line)) { - // reassign atom serial no. - let s = serial.toString().padStart(5, " "); - let l = line.padEnd(80, " "); - l = l.slice(0, 6) + s + l.slice(11); - - // change chain_id - l = l.substring(0, 21) + (chain[0] || "A") + l.substring(22); - - out.push(l); - serial++; - } - } - out.push("TER"); - } - - out.push("END"); - return out.join("\n"); -} - -/** - * - * @param {*} chainPdbs : Ca only pdb files with chain information in [{pdb, chain}] format - * @returns concatenated pdb string - * @abstract Concatenate multiple chains into one pdb files with single chain A - */ -export function concatenatePdbs(chainPdbs /* [{pdb, chain}] */) { - let serial = 1; - const out = []; - - for (const { pdb, chain } of chainPdbs) { - const lines = pdb.split(/\r?\n/); - for (const line of lines) { - if (/^(ATOM |HETATM)/.test(line)) { - // reassign atom serial no. and residue sequence no. - let s = serial.toString().padStart(5, " "); - let rs = serial.toString().padStart(4, " "); - let l = line.padEnd(80, " "); - l = l.slice(0, 6) + s + l.slice(11, 21) + "A" + rs + l.slice(26); - out.push(l); - serial++; - } - } - } - out.push("TER"); - out.push("END"); - return out.join("\n"); -} - -/** - * - * @param {*} chainPdbs : Ca only pdb files with chain information in [{pdb, chain}] format - * @returns concatenated pdb strings with suffix containing the chain informations - * @abstract Concatenate multiple chains into one pdb files with single chain A, preserving the chain information in the suffix as well - */ -export function encodeMultimer(chainPdbs /* [{pdb, chain}] */) { - const out = []; - const chainInfoArr = []; - const delimiter = "-_-_-_"; - let atomSerial = 1; - - for (const { pdb, chain } of chainPdbs) { - const lines = pdb.split(/\r?\n/); - const arr = []; - - for (const line of lines) { - if (/^(ATOM |HETATM)/.test(line)) { - // reassign atom serial no. and residue sequence no. - let l = line.padEnd(80, " "); - let s = atomSerial.toString().padStart(5, " "); - let rs = atomSerial.toString().padStart(4, " "); - l = l.slice(0, 6) + s + l.slice(11, 21) + "A" + rs + l.slice(26); - arr.push(l); - atomSerial++; - } - } - - let firstResn = Number(arr.at(0).slice(22, 26)); - let lastResn = Number(arr.at(-1).slice(22, 26)); - - const info = {}; - info.chain = chain; - info.end = lastResn; - info.offset = firstResn - 1; - chainInfoArr.push(info); - - out.push(arr.join("\n")); - } - out.push("TER"); - out.push("END"); - - let suffix = - delimiter + - chainInfoArr - .map((e) => { - return e.chain + "_" + e.end + "_" + e.offset; - }) - .join("-"); - - return { - pdb: out.join("\n"), - suffix: suffix, - }; -} - -/** - * - * @param {*} pdb : Ca only pdb file merged by encodeMultimer() - * @param {*} suffix : String containing chain information encoded by encodedMultimer() - * @returns Recovered pdb with original multimer information encoded beforehand - * @abstract Revert back the merged pdb into multimeric pdbs using multiple chain information encoded in suffix. - * Make sure to call this function with Ca only pdb, before running pulchra - */ -export function decodeMultimer(pdb, suffix) { - if (!suffix || suffix.length == 0) return pdb; - - const chainInfos = suffix.split("-").map((s) => { - const out = {}; - const info = s.split("_"); - - if (info.length != 3) return out; - - out.chain = info[0]; - out.end = Number(info[1]); - out.offset = Number(info[2]); - return out; - }); - - let index = 0; - const out = []; - - for (const line of pdb.split("\n")) { - if (line.startsWith("ATOM")) { - const rs = Number(line.slice(22, 26)); - const result = - line.slice(0, 21) + - chainInfos[index].chain + - (rs - chainInfos[index].offset).toString().padStart(4, " ") + - line.slice(26); - out.push(result); - if (rs == chainInfos[index].end) { - index++; - out.push("TER"); - } - } - } - out.push("END"); - - return out.join("\n"); -} - -export function splitMultimer(pdb) { - const arr = pdb.split("\nTER\n"); - const processed = arr.slice(0, -1).map((s) => s + "\nTER\nEND"); - return processed; -} - -export function mergeMultimer(arr) { - let serial = 1; - const merged = arr.map((s) => s.split("\nEND")[0]).join("\n") + "\nEND"; - const out = []; - for (const line of merged.split("\n")) { - if (line.startsWith("ATOM")) { - const result = - line.slice(0, 6) + serial.toString().padStart(5, " ") + line.slice(11); - out.push(result); - serial++; - } else if (line.startsWith("TER") || line.startsWith("END")) { - out.push(line); - } - } - return out.join("\n"); -} /* ------ The rotation matrix to rotate Chain_1 to Chain_2 ------ */ /* m t[m] u[m][0] u[m][1] u[m][2] */ @@ -953,42 +271,6 @@ export const calculateStrSize = (v) => { } }; -export function storeChains(pdb) { - const arr = []; - let c = ""; - for (let line of pdb.split("\n")) { - if (line.startsWith("ATOM")) { - c = line.charAt(21); - } else if (line.startsWith("TER")) { - arr.push(c); - } - } - if (arr.length == 0) { - arr.push(c); - } - return arr; -} - -export function revertChainInfo(pdb, chains) { - if (chains.length == 0 || chains[0] == "") { - return pdb; - } - - const arr = []; - let i = 0; - - for (let line of pdb.split("\n")) { - if (line.startsWith("ATOM")) { - line = line.slice(0, 21) + chains[i] + line.slice(22); - } else if (line.startsWith("TER")) { - i++; - } - - arr.push(line); - } - - return arr.join("\n"); -} export function getAbsOffsetTop($el) { var sum = 0; @@ -999,52 +281,6 @@ export function getAbsOffsetTop($el) { return sum; } -export const getChainName = (name) => { - if (/_v[0-9]+$/.test(name) || /^AF-\W+-/.test(name)) { - return "A"; - } - - if (name.includes(' ')) { - name = name.split(' ')[0] - } - - let pos = name.lastIndexOf("_"); - if (pos != -1) { - let match = name.substring(pos + 1); - return match.length >= 1 && isNaN(Number(match[0])) ? match[0] : "A"; - } - // fallback - return "A"; -}; - -export const getAccession = (name) => { - if (/-_-_-_/.test(name)) { - name = name.split("-_-_-_")[0]; - } - - if (name.includes(' ')) { - name = name.split(' ')[0] - } - - if (/^AF-\w+-/.test(name)) { - name = name.split("-")[1]; - } - - // name = name.replaceAll(/-assembly[0-9]/g, ""); - name = name.replaceAll(/\.(cif|pdb|gz)/g, ""); - - if (/_v[0-9]+$/.test(name)) { - return name; - } - - if (/_unrelaxed_rank_/.test(name)) { - let pos = name.indexOf("_unrelaxed_rank_"); - return pos != -1 ? name.substring(0, pos) : name; - } - - let pos = name.lastIndexOf("_"); - return pos != -1 ? name.substring(0, pos) : name; -}; /** * Throttle function @@ -1067,63 +303,3 @@ export function throttle(func, delay) { } export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -export function getResidueIndices( - seq, - alnPoses /* array of highlighted columns */, -) { - const result = []; - - if (alnPoses.length == 0) { - return result; - } - - const selectedColumns = new Set(alnPoses); - let resno = 0; - for (let i = 0; i < seq.length; i++) { - if (seq[i] == "-") { - continue; - } - if (selectedColumns.has(i)) { - result.push(resno); - } - resno++; - } - return result; -} - -export function getResnoWithChain( - seq, - alnPoses /* array of highlighted columns */, - chains, - offsets, -) { - const result = []; - - if (alnPoses.length == 0) { - return result; - } - - let resi = 0; - let startPos = 0; - const sorted = [...alnPoses].sort((a, b) => a - b); - const isMultimer = Object.keys(offsets).length > 1; - - for (let p of sorted) { - for (let i = startPos; i <= p && i < seq.length; i++) { - if (seq[i] != "-") { - if (i == p) { - const resno = resi + 1; - const chain = isMultimer ? chains[resno] : "A"; - const offset = isMultimer ? offsets[chain] : 0; - result.push(chain + String(resno - offset)); - resi++; - startPos = i + 1; - break; - } - resi++; - } - } - } - return result; -} diff --git a/frontend/lib/po-loader.js b/frontend/build-tools/po-loader.js similarity index 100% rename from frontend/lib/po-loader.js rename to frontend/build-tools/po-loader.js diff --git a/frontend/lib/po-reader.js b/frontend/build-tools/po-reader.js similarity index 100% rename from frontend/lib/po-reader.js rename to frontend/build-tools/po-reader.js diff --git a/frontend/lib/.babelrc b/frontend/lib/.babelrc new file mode 100644 index 00000000..46c7c220 --- /dev/null +++ b/frontend/lib/.babelrc @@ -0,0 +1,3 @@ +{ + "extends": "../.babelrc" +} diff --git a/frontend/lib/accession.js b/frontend/lib/accession.js index 32ba8925..4d6b4184 100644 --- a/frontend/lib/accession.js +++ b/frontend/lib/accession.js @@ -1,22 +1,22 @@ -// Fetch a structure by accession from a third-party service. -// -// Extracted from LoadAcessionButton.vue so the search-page API and the button share one -// definition — the same reason resultSort.js exists. The button imports from here. -// -// Note these are cross-origin fetches to services unrelated to the MMseqs2 server -// (files.rcsb.org, alphafold.ebi.ac.uk, bfvd.steineggerlab.workers.dev, yanglab.qd.sdu.edu.cn), -// so they fail independently of it. - -import { create } from 'axios'; - -/** Always available. */ +// Fetch structures with the platform API and reject non-2xx responses before parsing. + +async function request(url, { method = 'GET', body = null, headers = {} } = {}) { + const response = await fetch(url, { method, body, headers }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText || 'error'} from ${url}`); + } + return response; +} + +const getText = (url, opts) => request(url, opts).then(r => r.text()); +const getJson = (url, opts) => request(url, opts).then(r => r.json()); + export const BASE_SOURCES = [ { text: 'PDB (rcsb.org)', value: 'PDB' }, { text: 'AlphaFoldDB (ebi.ac.uk)', value: 'AlphaFoldDB' }, { text: 'BFVD (bfvd.foldseek.com)', value: 'BFVD' }, ]; -/** Opt-in per page via the button's `extraEnabled` prop. */ export const EXTRA_SOURCES = { AlphaFill: { text: 'AlphaFill (alphafill.eu)', value: 'AlphaFill' }, QBioLip: { text: 'Q-BioLiP (yanglab.qd.sdu.edu.cn)', value: 'QBioLip' }, @@ -32,38 +32,33 @@ export function sourcesFor(extraEnabled = []) { /** * Resolve one accession to `{ name, text }`, or reject with the accession. - * - * `AlphaFoldDB` is a *fuzzy search*, not a lookup: it queries `text:*ACC OR text:ACC*` and takes - * the first hit, so the entry returned can differ from the one asked for. Callers that care should - * compare the returned `name` against what they requested. */ export function fetchAccession(accession, source) { return new Promise((resolve, reject) => { - const axios = create(); const upper = String(accession).toUpperCase(); const fail = () => reject(accession); if (source === 'PDB') { - axios.get('https://files.rcsb.org/download/' + upper + '.cif') - .then(r => resolve({ name: upper + '.cif', text: r.data })) + getText('https://files.rcsb.org/download/' + upper + '.cif') + .then(text => resolve({ name: upper + '.cif', text })) .catch(fail); } else if (source === 'BFVD') { - axios.get('https://bfvd.steineggerlab.workers.dev/pdb/' + upper + '.pdb') - .then(r => resolve({ name: upper + '.pdb', text: r.data })) + getText('https://bfvd.steineggerlab.workers.dev/pdb/' + upper + '.pdb') + .then(text => resolve({ name: upper + '.pdb', text })) .catch(fail); } else if (source === 'AlphaFill') { - axios.get('https://alphafill.eu/v1/aff/' + upper) - .then(r => resolve({ name: upper + '.cif', text: r.data })) + getText('https://alphafill.eu/v1/aff/' + upper) + .then(text => resolve({ name: upper + '.cif', text })) .catch(fail); } else if (source === 'AlphaFoldDB') { - axios.get('https://alphafold.ebi.ac.uk/api/search?q=(text:*' + accession + getJson('https://alphafold.ebi.ac.uk/api/search?q=(text:*' + accession + ' OR text:' + accession + '*)&type=main&start=0&rows=1') - .then(resp => { - const docs = resp.data.docs; + .then(data => { + const docs = data.docs; if (!docs || docs.length === 0) { fail(); return; } const cif = docs[0].entryId + '-model_v' + docs[0].latestVersion + '.pdb'; - axios.get('https://alphafold.ebi.ac.uk/files/' + cif) - .then(r => resolve({ name: cif, text: r.data })) + getText('https://alphafold.ebi.ac.uk/files/' + cif) + .then(text => resolve({ name: cif, text })) .catch(fail); }) .catch(fail); @@ -73,11 +68,12 @@ export function fetchAccession(accession, source) { }); } -// --------------------------------------------------------------------------------------------- -// Q-BioLiP — binding sites for a PDB id. Two-step: search, then load a chosen site. -// --------------------------------------------------------------------------------------------- - -/** map label_asym_id to auth_asym_id */ +/** + * Index a receptor mmCIF so a Q-BioLiP binding-site residue can be resolved to this file's own + * chain and residue number. + * + * @returns {Map} keyed `chain|seq` under both schemes + */ export function parseCifResidueChainMap(cifText) { const map = new Map(); const lines = cifText.split('\n'); @@ -95,10 +91,11 @@ export function parseCifResidueChainMap(cifText) { if (headers.length === 0 || !headers[0].startsWith('_atom_site.')) continue; const labelIdx = headers.indexOf('_atom_site.label_asym_id'); + const labelSeqIdx = headers.indexOf('_atom_site.label_seq_id'); const authIdx = headers.indexOf('_atom_site.auth_asym_id'); const seqIdx = headers.indexOf('_atom_site.auth_seq_id'); if (labelIdx < 0 || authIdx < 0 || seqIdx < 0) return map; - const maxIdx = Math.max(labelIdx, authIdx, seqIdx); + const maxIdx = Math.max(labelIdx, authIdx, seqIdx, labelSeqIdx); for (; i < lines.length; i++) { const t = lines[i].trim(); @@ -110,34 +107,80 @@ export function parseCifResidueChainMap(cifText) { if (cols.length <= maxIdx) continue; const auth = cols[authIdx]; const seq = cols[seqIdx]; - const keyLabel = `${cols[labelIdx]}|${seq}`; + const here = { chain: auth, seq }; + + // Auth keys are written first and never overwritten, so an auth match always wins over a + // label match on the same key. const keyAuth = `${auth}|${seq}`; - if (!map.has(keyLabel)) map.set(keyLabel, auth); - if (!map.has(keyAuth)) map.set(keyAuth, auth); + if (!map.has(keyAuth)) map.set(keyAuth, here); + const keyAuthChainLabelSeq = `${cols[labelIdx]}|${seq}`; + if (!map.has(keyAuthChainLabelSeq)) map.set(keyAuthChainLabelSeq, here); } break; } + + // Label-scheme keys in a second pass, so they can never displace an auth-scheme key. + labelSeqPass(cifText, map); return map; } +/** The label_asym_id|label_seq_id keys, added only where nothing auth-keyed already claims them. */ +function labelSeqPass(cifText, map) { + const lines = cifText.split('\n'); + let i = 0; + while (i < lines.length) { + if (lines[i].trim() !== 'loop_') { i++; continue; } + i++; + const headers = []; + while (i < lines.length && lines[i].trim().startsWith('_')) { + headers.push(lines[i].trim().split(/\s+/)[0]); + i++; + } + if (headers.length === 0 || !headers[0].startsWith('_atom_site.')) continue; + + const labelIdx = headers.indexOf('_atom_site.label_asym_id'); + const labelSeqIdx = headers.indexOf('_atom_site.label_seq_id'); + const authIdx = headers.indexOf('_atom_site.auth_asym_id'); + const seqIdx = headers.indexOf('_atom_site.auth_seq_id'); + if (labelSeqIdx < 0 || labelIdx < 0 || authIdx < 0 || seqIdx < 0) return; + const maxIdx = Math.max(labelIdx, labelSeqIdx, authIdx, seqIdx); + + for (; i < lines.length; i++) { + const t = lines[i].trim(); + if (t === '' || t === 'loop_' || t.startsWith('_') || t.startsWith('#') + || t.startsWith('data_')) { + break; + } + const cols = t.split(/\s+/); + if (cols.length <= maxIdx) continue; + if (cols[labelSeqIdx] === '.' || cols[labelSeqIdx] === '?') continue; + for (const key of [`${cols[labelIdx]}|${cols[labelSeqIdx]}`, + `${cols[authIdx]}|${cols[labelSeqIdx]}`]) { + if (!map.has(key)) map.set(key, { chain: cols[authIdx], seq: cols[seqIdx] }); + } + } + break; + } +} + /** - * Convert Q-BioLiP binding-site notation to the internal motif format. - * With a residueMap each residue's chain is resolved to the structure's auth chain and residues - * absent from the structure are dropped. Without a map the reported chain letters are kept as-is. + * Resolve Q-BioLiP binding-site notation against a receptor, residue by residue. + * + * @returns {{residues: string[], dropped: {chain: string, resno: string}[], translated: number}} */ -export function qbiolipBsToMotif(bs, residueMap) { - if (!bs) return ''; - const parts = bs.trim().split(/\s+/); - let currentChain = ''; +export function qbiolipBsResidues(bs, residueMap) { const residues = []; - for (const part of parts) { - let resToken; + const dropped = []; + let translated = 0; + if (!bs) return { residues, dropped, translated }; + + let currentChain = ''; + for (const part of bs.trim().split(/\s+/)) { + let resToken = part; if (part.includes(':')) { const colonIdx = part.indexOf(':'); currentChain = part.slice(0, colonIdx); resToken = part.slice(colonIdx + 1); - } else { - resToken = part; } if (!resToken) continue; // strip leading one-letter amino acid code, keep residue number @@ -146,32 +189,46 @@ export function qbiolipBsToMotif(bs, residueMap) { if (!residueMap) { residues.push(`${currentChain}${resno}`); continue; } const seqMatch = resno.match(/-?\d+/); - const authChain = seqMatch ? residueMap.get(`${currentChain}|${seqMatch[0]}`) : undefined; - if (authChain === undefined) continue; // not present in the loaded structure - residues.push(`${authChain}${resno}`); + const found = seqMatch ? residueMap.get(`${currentChain}|${seqMatch[0]}`) : undefined; + if (found === undefined) { dropped.push({ chain: currentChain, resno }); continue; } + if (found.seq !== seqMatch[0]) translated++; + residues.push(`${found.chain}${found.seq}`); } - return residues.join(','); + return { residues, dropped, translated }; +} + +/** + * Convert Q-BioLiP binding-site notation to the internal motif format. + */ +export function qbiolipBsToMotif(bs, residueMap) { + return qbiolipBsResidues(bs, residueMap).residues.join(','); } export async function searchBindingSites(pdbId) { - const axios = create(); - const response = await axios.post( - 'https://yanglab.qd.sdu.edu.cn/cgi-bin/Q-BioLiP/qbio1.cgi', - new URLSearchParams({ PDB_ID: String(pdbId).trim().toUpperCase() }), - { headers: { Accept: 'application/json' } }, - ); - return Array.isArray(response.data) ? response.data : []; + // URLSearchParams supplies the form-encoded request body. + const data = await getJson('https://yanglab.qd.sdu.edu.cn/cgi-bin/Q-BioLiP/qbio1.cgi', { + method: 'POST', + body: new URLSearchParams({ PDB_ID: String(pdbId).trim().toUpperCase() }), + headers: { Accept: 'application/json' }, + }); + return Array.isArray(data) ? data : []; } -/** Load a chosen binding site's structure and derive its auth-chain-mapped motif. */ +/** + * Load a chosen binding site's structure and express its residues in that file's own numbering. + */ export async function fetchBindingSite(item) { - const axios = create(); const assembly = item.Receptor.assembly; - const response = await axios.get( + const cifText = await getText( `https://yanglab.qd.sdu.edu.cn/Q-BioLiP/DATA/rec_cif/${assembly}.cif`); - const cifText = response.data; - // Q-BioLiP reports label_asym_id chains; the app uses auth_asym_id. + // Q-BioLiP reports label_asym_id chains, and — depending on the entry — either numbering scheme. const residueMap = parseCifResidueChainMap(cifText); - return { name: `${assembly}.cif`, text: cifText, - motif: qbiolipBsToMotif(item.Complex && item.Complex.bs, residueMap) }; + const resolved = qbiolipBsResidues(item.Complex && item.Complex.bs, residueMap); + return { + name: `${assembly}.cif`, + text: cifText, + motif: resolved.residues.join(','), + dropped: resolved.dropped, + translated: resolved.translated, + }; } diff --git a/frontend/lib/alignmentColumns.js b/frontend/lib/alignmentColumns.js new file mode 100644 index 00000000..d2b2ed63 --- /dev/null +++ b/frontend/lib/alignmentColumns.js @@ -0,0 +1,115 @@ +// Mapping alignment columns to residues, and residues to (chain, resno). + +export function getResidueIndices( + seq, + alnPoses /* array of highlighted columns */, +) { + const result = []; + + if (alnPoses.length == 0) { + return result; + } + + const selectedColumns = new Set(alnPoses); + let resno = 0; + for (let i = 0; i < seq.length; i++) { + if (seq[i] == "-") { + continue; + } + if (selectedColumns.has(i)) { + result.push(resno); + } + resno++; + } + return result; +} + +export function getResnoWithChain( + seq, + alnPoses /* array of highlighted columns */, + chains, + offsets, +) { + const result = []; + + if (alnPoses.length == 0) { + return result; + } + + let resi = 0; + let startPos = 0; + const sorted = [...alnPoses].sort((a, b) => a - b); + const isMultimer = Object.keys(offsets).length > 1; + + for (let p of sorted) { + for (let i = startPos; i <= p && i < seq.length; i++) { + if (seq[i] != "-") { + if (i == p) { + const resno = resi + 1; + const chain = isMultimer ? chains[resno] : "A"; + const offset = isMultimer ? offsets[chain] : 0; + result.push(chain + String(resno - offset)); + resi++; + startPos = i + 1; + break; + } + resi++; + } + } + } + return result; +} + +/** + * A FoldMason entry's suffix, as `[{chain, end, offset}]`. + */ +export function parseSuffix(suffix) { + if (!suffix) return []; + + return suffix.split("-").map((s) => { + const out = {}; + const info = s.split("_"); + + if (info.length != 3) return out; + + out.chain = info[0]; + out.end = Number(info[1]); + out.offset = Number(info[2]); + return out; + }); +} + +/** + * Chain bookkeeping for one aligned entry. + * @param {string} aa the gapped alignment string + * @param {string} suffix encodeMultimer's suffix, delimiter stripped; falsy for a monomer + * @returns {{chainInfo: object[], offsets: Record, chains: string[], resns: number[]}} + */ +export function entryChainMap(aa, suffix) { + const chainInfo = parseSuffix(suffix); + + const offsets = {}; + if (chainInfo.length === 0) { + offsets.A = 0; + } else { + for (const obj of chainInfo) offsets[obj.chain] = obj.offset; + } + + const length = aa.replaceAll("-", "").length; + const chains = Array(length + 1).fill("A"); + const resns = Array.from({ length: length + 1 }, (_, i) => i); + + if (chainInfo.length > 0) { + let index = 0; + for (let i = 1; i < length + 1; i++) { + chains[i] = chainInfo[index].chain; + resns[i] = i - chainInfo[index].offset; + + if (i == chainInfo[index].end) { + index++; + } + } + } + + return { chainInfo, offsets, chains, resns }; +} diff --git a/frontend/lib/hitId.js b/frontend/lib/hitId.js new file mode 100644 index 00000000..eace4068 --- /dev/null +++ b/frontend/lib/hitId.js @@ -0,0 +1,53 @@ +// A hit's identity: "dbIdx#entryIdx". Shared by the result pages' selection handling. + +/** + * Accepts "dbIdx#entryIdx", {db, idx}, or [dbIdx, entryIdx] and returns a canonical + * "dbIdx#entryIdx" string, resolving database names through dbToIdx. + * Returns null when the id cannot be resolved, so callers can report it as rejected rather + * than throwing on one bad entry in a batch. + */ +export function normalizeId(id, dbToIdx) { + let db, idx; + if (typeof id === 'string') { + const hash = id.indexOf('#'); + if (hash === -1) return null; + db = id.slice(0, hash); + idx = id.slice(hash + 1); + } else if (Array.isArray(id) && id.length === 2) { + [db, idx] = id; + } else if (id && typeof id === 'object') { + db = id.db; + idx = id.idx; + } else { + return null; + } + + if (idx === undefined || idx === null || idx === '') return null; + + // db may be a numeric index already, or a database name needing lookup. + let dbIdx = null; + if (typeof db === 'number') { + dbIdx = db; + } else if (typeof db === 'string') { + if (/^\d+$/.test(db)) { + dbIdx = Number(db); + } else if (dbToIdx && Object.prototype.hasOwnProperty.call(dbToIdx, db)) { + dbIdx = dbToIdx[db]; + } + } + if (dbIdx === null || dbIdx === undefined || Number.isNaN(Number(dbIdx))) return null; + + const entryIdx = Number(idx); + if (!Number.isFinite(entryIdx)) return null; + + return `${Number(dbIdx)}#${entryIdx}`; +} + +export function splitId(id) { + const hash = String(id).indexOf('#'); + if (hash === -1) return null; + return { + dbIdx: Number(String(id).slice(0, hash)), + entryIdx: Number(String(id).slice(hash + 1)), + }; +} diff --git a/frontend/lib/msaTracks.js b/frontend/lib/msaTracks.js index b4d43c82..6d469050 100644 --- a/frontend/lib/msaTracks.js +++ b/frontend/lib/msaTracks.js @@ -1,28 +1,11 @@ // Read per-column track values out of the msa-webgpu viewer. -// -// The library's public API exposes getTracks(), but that returns the track *catalog* — ids, -// labels and which variants are enabled — not the computed values. The values are produced by a -// WGSL compute shader and cached on the representation: -// -// viewer.representationStore.get(id).columnMetrics -// { quality, occupancy, entropy, modalFractionNonGap, informationContentRaw, -// consensusIndex, consensusTie, conservationScore, conservationMask, counts } -// viewer.representationStore.get(id).trackState -// { alphabet, metrics: {...}, consensus: { columns: [...] } } -// -// `representationStore` is an ordinary property, so it is readable; the methods that *populate* -// it are ECMAScript-private, which is the real constraint — see readRepresentation(). -// -// Everything here is read-only and feature-detected. If the internal shape ever changes (this is -// msa-webgpu 0.0.10 and the bundle is minified), the CPU fallback keeps the same output shape. const CONSERVATION_GROUPS = [ 'hydrophobic', 'polar', 'small', 'proline', 'tiny', 'aliphatic', 'aromatic', 'positive', 'negative', 'charged', ]; -// Residue order the shader buckets into (residue_to_index): the 20 standard amino acids, -// alphabetical. Used only by the CPU fallback. +// A plain alphabetical fallback for callers with no alphabet to hand const AA_CORE = ['A','C','D','E','F','G','H','I','K','L','M','N','P','Q','R','S','T','V','W','Y']; let warnedOnce = false; @@ -66,21 +49,6 @@ function readStore(viewer, repId) { /** * Read a representation's cached metrics, if it has any. - * - * Only the *active* representation is guaranteed to be populated: the library computes - * trackState during activation, and its internal ensure-pass covers other representations only - * when a track variant names them concretely. FoldMason's tracks are all bound to the - * pseudo-representation "active", so nothing ever names "structure". - * - * Two documented ways to force it were tried and neither works: - * setTrackEnabled({trackId:'occupancy', representation:'structure'}, true) - * -> the catalog matches the existing "active" variant instead of creating a concrete one - * setConfig({trackDisplay:{variants:[..., {trackId:'occupancy', representation:'structure'}]}}) - * -> same outcome; the store stays empty - * Verified in a browser against a live FoldMason result. - * - * So: no trick. A caller that wants GPU-quality numbers for the non-active representation should - * switch to it (the API exposes setRepresentation); otherwise the CPU fallback runs and says so. */ export function readRepresentation(viewer, repId) { return readStore(viewer, repId); @@ -102,16 +70,161 @@ function resolveRepId(viewer, repId) { return viewer?.getActiveRepresentation?.()?.id ?? null; } -// --------------------------------------------------------------------------------------------- -// CPU fallback -// --------------------------------------------------------------------------------------------- +// Quality and conservation +// +// Ports of the WGSL `calculate_quality` and `calculate_conservation` in msa-webgpu's compute +// shader, so the same two numbers can be produced without a GPU. Both are plain arithmetic over a +// column's residue counts; nothing about them needed the GPU in the first place. + +/** Livingstone & Barton (1993) physicochemical property groups, bits 0-9. */ +export const AMAS_PROP = { + HYDROPHOBIC: 1 << 0, POLAR: 1 << 1, SMALL: 1 << 2, PROLINE: 1 << 3, TINY: 1 << 4, + ALIPHATIC: 1 << 5, AROMATIC: 1 << 6, POSITIVE: 1 << 7, NEGATIVE: 1 << 8, CHARGED: 1 << 9, +}; +export const AMAS_PROPERTY_MASK_ALL = Object.values(AMAS_PROP).reduce((a, b) => a | b, 0); +export const AMAS_NEGATIVE_SHIFT = 10; +export const AMAS_IDENTITY_BIT = 1 << 20; +export const AMAS_ALL_PROPERTIES_BIT = 1 << 21; + +const P = AMAS_PROP; +/** Indexed by the amino-acid alphabet's bucket order, ARNDCQEGHILKMFPSTWYV. */ +export const AMAS_PROPERTY_BITS = [ + /* A */ P.HYDROPHOBIC | P.SMALL | P.TINY, + /* R */ P.POLAR | P.POSITIVE | P.CHARGED, + /* N */ P.POLAR | P.SMALL, + /* D */ P.POLAR | P.SMALL | P.NEGATIVE | P.CHARGED, + /* C */ P.HYDROPHOBIC | P.SMALL, + /* Q */ P.POLAR, + /* E */ P.POLAR | P.NEGATIVE | P.CHARGED, + /* G */ P.HYDROPHOBIC | P.SMALL | P.TINY, + /* H */ P.HYDROPHOBIC | P.POLAR | P.AROMATIC | P.POSITIVE | P.CHARGED, + /* I */ P.HYDROPHOBIC | P.ALIPHATIC, + /* L */ P.HYDROPHOBIC | P.ALIPHATIC, + /* K */ P.POLAR | P.POSITIVE | P.CHARGED, + /* M */ P.HYDROPHOBIC, + /* F */ P.HYDROPHOBIC | P.AROMATIC, + /* P */ P.SMALL | P.PROLINE, + /* S */ P.POLAR | P.SMALL | P.TINY, + /* T */ P.POLAR | P.SMALL, + /* W */ P.HYDROPHOBIC | P.POLAR | P.AROMATIC, + /* Y */ P.HYDROPHOBIC | P.POLAR | P.AROMATIC, + /* V */ P.HYDROPHOBIC | P.SMALL | P.ALIPHATIC, +]; + +function popcount(n) { + let x = n - ((n >> 1) & 0x55555555); + x = (x & 0x33333333) + ((x >> 2) & 0x33333333); + x = (x + (x >> 4)) & 0x0f0f0f0f; + return (x * 0x01010101) >> 24; +} + +/** + * One column's quality: every ordered pair of residue types present, weighted by how often each + * occurs, scored by the substitution matrix normalised against the better of the two self-scores, + * then scaled by occupancy. + * + * @param {number[]} counts per-core-bucket counts for the column + * @param {{qualityMatrix: ArrayLike, qualityMatrixSize: number, rows: number}} opts + */ +export function columnQuality(counts, { qualityMatrix, qualityMatrixSize, rows }) { + const core = counts.length; + let nonGap = 0; + for (let i = 0; i < core; i++) nonGap += counts[i]; + // A single residue has nothing to be compared against, so the shader reports 0 rather than a + // perfect score. + if (nonGap < 2 || rows === 0) return 0; + + const occupancy = nonGap / rows; + let quality = 0; + let totalPairs = 0; + + for (let i = 0; i < core; i++) { + const countI = counts[i]; + if (countI === 0) continue; + const selfI = qualityMatrix[i * qualityMatrixSize + i]; + for (let j = 0; j < core; j++) { + const countJ = counts[j]; + if (countJ === 0) continue; + const pairCount = countI * countJ; + const selfJ = qualityMatrix[j * qualityMatrixSize + j]; + const denom = Math.max(selfI, selfJ); + const ratio = denom > 0 ? qualityMatrix[i * qualityMatrixSize + j] / denom : 0; + quality += pairCount * ratio; + totalPairs += pairCount; + } + } + if (totalPairs === 0) return 0; + return Math.max(0, (quality / totalPairs) * occupancy); +} /** - * Recompute the column metrics from the alignment strings. Mirrors the shader's definitions: - * lowercase is treated as an insertion and skipped; entropy is normalised by log2(coreSize) and - * is 0 when fewer than two non-gap residues; informationContentRaw is max(0, 1 - entropy). + * One column's conservation: the AMAS score, i.e. how many physicochemical properties every + * sufficiently common residue in the column shares (or all lack). + * + * @param {number[]} counts per-core-bucket counts for the column + * @param {{rows: number, gapCount: number}} opts + * @returns {{score: number, mask: number}} */ -export function computeMetricsCpu(sequences, { symbols = AA_CORE } = {}) { +export function columnConservation(counts, { rows, gapCount }) { + const core = counts.length; + let nonGap = 0; + for (let i = 0; i < core; i++) nonGap += counts[i]; + if (nonGap === 0 || rows === 0) return { score: 0, mask: 0 }; + + // A column that is a quarter gaps is not called conserved at all. + if (gapCount * 100 >= 25 * rows) return { score: 0, mask: 0 }; + + // Residues appearing in 3% of rows or fewer are treated as noise and ignored — integer + // division, so for small alignments the threshold is 0 and every residue counts. + const residueThreshold = Math.floor((rows * 3) / 100); + + let observedKinds = 0; + let observedNonGapKindsAll = 0; + let conservedPositive = AMAS_PROPERTY_MASK_ALL; + let conservedNegative = AMAS_PROPERTY_MASK_ALL; + + for (let aa = 0; aa < core; aa++) { + const count = counts[aa]; + if (count === 0) continue; + observedNonGapKindsAll++; + if (count <= residueThreshold) continue; + const props = AMAS_PROPERTY_BITS[aa] ?? 0; + conservedPositive &= props; + conservedNegative &= AMAS_PROPERTY_MASK_ALL & ~props; + observedKinds++; + } + + if (observedKinds === 0) return { score: 0, mask: 0 }; + + let score = popcount(conservedPositive) + popcount(conservedNegative); + let mask = conservedPositive | (conservedNegative << AMAS_NEGATIVE_SHIFT); + + // One residue type throughout is identity, scored above the 10-property maximum. + if (observedNonGapKindsAll === 1) { + score = 11; + mask |= AMAS_IDENTITY_BIT; + } else if (score === 10) { + mask |= AMAS_ALL_PROPERTIES_BIT; + } + return { score, mask }; +} + +// CPU fallback +/** + * Recompute the column metrics from the alignment strings. Mirrors the shader's definitions + */ +export function computeMetricsCpu(sequences, { symbols = null, alphabet = null } = {}) { + const core = alphabet?.metricConfig?.coreSize ?? null; + const fromAlphabet = alphabet && Array.isArray(alphabet.symbols) + ? alphabet.symbols.slice(0, core ?? alphabet.symbols.length) + : null; + if (fromAlphabet && symbols && symbols.join('') !== fromAlphabet.join('')) { + warnFallback(`symbols do not match alphabet "${alphabet.id}" — using the alphabet's own order`); + } + return computeMetrics(sequences, fromAlphabet ?? symbols ?? AA_CORE, alphabet); +} + +function computeMetrics(sequences, symbols, alphabet) { const rows = sequences.length; const cols = rows ? sequences[0].length : 0; const core = symbols.length; @@ -126,17 +239,36 @@ export function computeMetricsCpu(sequences, { symbols = AA_CORE } = {}) { const consensusTie = new Array(cols).fill(0); const countsPerCol = []; + // The alphabet decides whether the matrix-dependent metrics can be computed at all, and + // conservation is defined for amino acids only — the shader stubs the other alphabets to zero. + const qualityMatrix = alphabet?.qualityMatrix ?? null; + const qualityMatrixSize = alphabet?.metricConfig?.qualityMatrixSize ?? null; + const wantQuality = !!(qualityMatrix && qualityMatrixSize); + const wantConservation = wantQuality && alphabet?.id === 'aa'; + const quality = wantQuality ? new Array(cols).fill(0) : null; + const conservationScore = wantConservation ? new Array(cols).fill(0) : null; + const conservationMask = wantConservation ? new Array(cols).fill(0) : null; + for (let c = 0; c < cols; c++) { const counts = new Array(core).fill(0); let nonGap = 0; + let gapCount = 0; for (let r = 0; r < rows; r++) { const ch = sequences[r][c]; if (ch >= 'a' && ch <= 'z') continue; // insertion column for this row const i = index.get(ch); - if (i === undefined) continue; // gap / unknown + if (i === undefined) { gapCount++; continue; } // gap / unknown counts[i]++; nonGap++; } countsPerCol.push(counts); + if (wantQuality) { + quality[c] = columnQuality(counts, { qualityMatrix, qualityMatrixSize, rows }); + } + if (wantConservation) { + const { score, mask } = columnConservation(counts, { rows, gapCount }); + conservationScore[c] = score; + conservationMask[c] = mask; + } occupancy[c] = rows > 0 ? nonGap / rows : 0; let max = 0, argmax = core, ties = 0; @@ -163,9 +295,7 @@ export function computeMetricsCpu(sequences, { symbols = AA_CORE } = {}) { return { occupancy, entropy, modalFractionNonGap, informationContentRaw, consensusIndex, consensusTie, - // quality and conservation need the substitution matrices the shader carries; the - // fallback reports them as absent rather than inventing numbers. - quality: null, conservationScore: null, conservationMask: null, + quality, conservationScore, conservationMask, counts: countsPerCol, symbols, }; } @@ -190,18 +320,11 @@ function consensusFromCpu(m, symbols) { }); } -// --------------------------------------------------------------------------------------------- // Public surface -// --------------------------------------------------------------------------------------------- - export function getTrackCatalog(viewer) { return viewer?.getTracks?.() ?? []; } -/** - * Column metrics as plain arrays. `source` is 'viewer' or 'cpu-fallback' so callers can tell - * which numbers they are looking at — the fallback cannot produce quality/conservation. - */ export async function getColumnMetrics(viewer, { representationId = null, sequences = null, symbols = null } = {}) { const repId = resolveRepId(viewer, representationId); const rep = repId ? readRepresentation(viewer, repId) : null; @@ -266,12 +389,9 @@ export function getColumnVisibility(viewer, opts = {}) { }; } -/** Values for one track id. Dispatches on the track definition's source type. */ export async function getTrackValues(viewer, trackId, { representationId = null, sequences = null, symbols = null } = {}) { const cfg = viewer?.getConfig?.(); const userTrack = (cfg?.tracks || []).find(t => t.id === trackId); - // `values` tracks carry their data inline — this is how the app's own LDDT track works, and - // it generalises to any future per-column track the app registers. if (userTrack?.source?.type === 'values') { return { source: 'definition', trackId, values: toPlainArray(userTrack.source.values) }; } @@ -288,11 +408,6 @@ export async function getTrackValues(viewer, trackId, { representationId = null, return { source: 'unavailable', trackId, values: null }; } -/** - * One row per alignment column, joining every track. This is the method worth calling: the - * individual getters return heterogeneous TypedArrays that a caller would otherwise have to zip - * by index, and which do not survive JSON.stringify as arrays. - */ export async function getColumnTable(viewer, { representationId = null, sequences = null, diff --git a/frontend/lib/package.json b/frontend/lib/package.json new file mode 100644 index 00000000..330f44b1 --- /dev/null +++ b/frontend/lib/package.json @@ -0,0 +1,4 @@ +{ + "type": "module", + "sideEffects": false +} diff --git a/frontend/lib/parseResults.js b/frontend/lib/parseResults.js new file mode 100644 index 00000000..ee9ca3ca --- /dev/null +++ b/frontend/lib/parseResults.js @@ -0,0 +1,450 @@ +// Result parsing shared by the mounted frontend and the mcp agent layer under mcp/. +// Extracted from Utilities.js + +// Needed for support on both frontend and mcp layer +const APP = typeof __APP__ !== 'undefined' ? __APP__ : (globalThis.__MMSEQS_APP__ ?? 'foldseek'); + +function tryLinkTargetToDB(target, db) { + try { + var res = db.toLowerCase(); + if (res.startsWith("pfam")) { + return "https://www.ebi.ac.uk/interpro/entry/pfam/" + target; + } else if (res.startsWith("pdb")) { + return ( + "https://www.rcsb.org/structure/" + + target + .replaceAll(/-assembly[0-9]+/g, "") + .replaceAll(/\.(cif|pdb|ent)(\.gz)?/g, "") + .replaceAll(/[0-9]+DI_/g, "") // For interface cluster, should we link to cluster web? + .split("_")[0] + ); + } else if (res.startsWith("humanppi")) { + return ( + "http://prodata.swmed.edu/humanPPI/results/" + + target + .split("__") + .map((half) => half.split("_")[0]) + .join("_") + ); + } else if ( + res.startsWith("uniclust") || + res.startsWith("uniprot") || + res.startsWith("sprot") || + res.startsWith("swissprot") + ) { + return "https://www.uniprot.org/uniprot/" + target; + } else if (res.startsWith("eggnog_")) { + return "http://eggnogdb.embl.de/#/app/results?target_nogs=" + target; + } else if (res.startsWith("cdd")) { + return ( + "https://www.ncbi.nlm.nih.gov/Structure/cdd/cddsrv.cgi?uid=" + target + ); + } + + if (APP == "foldseek") { + if (target.startsWith("AF-")) { + // Old: AF--F1-model_v4, entry keyed on the bare accession. + // New: AF--model_v1, entry keyed on the whole AF-, no UniProt. + // Interface entries append ___. + const stem = target.replaceAll( + /-(F[0-9]+-)?model_v[0-9]+(\.(cif|pdb))?(\.gz)?(_[A-Za-z0-9]+){0,3}$/g, + "", + ); + const isUniProt = /-F[0-9]+-model_v/.test(target); + const accession = isUniProt ? stem.substring(3) : stem; + const links = [ + { + label: "AFDB", + accession: accession, + href: "https://www.alphafold.ebi.ac.uk/entry/" + accession, + }, + ]; + if (isUniProt) { + links.push({ + label: "UniProt", + accession: accession, + href: "https://www.uniprot.org/uniprot/" + accession, + }); + } + return links; + } else if (target.startsWith("GMGC")) { + return ( + "https://gmgc.embl.de/search.cgi?search_id=" + + target.replaceAll(/\.(cif|pdb)(\.gz)?/g, "") + ); + } else if (target.startsWith("MGYP")) { + return ( + "https://esmatlas.com/explore/detail/" + + target.replaceAll(/\.(cif|pdb)(\.gz)?/g, "") + ); + } else if (target.startsWith("LevyLab_")) { + let accession = target.split("_")[1]; + return [ + { + label: "AFDB", + accession: accession, + href: "https://www.alphafold.ebi.ac.uk/entry/" + accession, + }, + { + label: "UniProt", + accession: accession, + href: "https://www.uniprot.org/uniprot/" + accession, + }, + ]; + } else if (target.startsWith("ProtVar_")) { + let accession1 = target.split("_")[1]; + let accession2 = target.split("_")[2]; + let result = [ + { + label: "AFDB", + accession: accession1, + href: "https://www.alphafold.ebi.ac.uk/entry/" + accession1, + }, + { + label: "UniProt", + accession: accession1, + href: "https://www.uniprot.org/uniprot/" + accession1, + }, + ]; + if (accession1 != accession2) { + result.push({ + label: "AFDB", + accession: accession2, + href: "https://www.alphafold.ebi.ac.uk/entry/" + accession2, + }); + result.push({ + label: "UniProt", + accession: accession2, + href: "https://www.uniprot.org/uniprot/" + accession2, + }); + } + return result; + } else if (target.startsWith("ModelArchive_")) { + return "https://modelarchive.org/doi/10.5452/" + target.split("_")[1]; + } else if (target.startsWith("Predictome_")) { + return "https://predictomes.org/summary/" + target.split("_")[1]; + } + if (res.startsWith("cath")) { + if (target.startsWith("af_")) { + const cath = target.substring(target.lastIndexOf("_") + 1); + return "https://www.cathdb.info/version/latest/superfamily/" + cath; + } else { + return "https://www.cathdb.info/version/latest/domain/" + target; + } + } else if (res.startsWith("bfvd")) { + const bfvd = target.replaceAll(/_.*/g, ""); + return [ + { + label: "BFVD", + accession: bfvd, + href: "https://bfvd.foldseek.com/cluster/" + bfvd, + }, + { + label: "UniRef", + accession: bfvd, + href: "https://www.uniprot.org/uniref/UniRef100_" + bfvd, + }, + ]; + } + } + return null; + } catch (e) { + return null; + } +} +function tryFixTargetName(target, db) { + var res = db.toLowerCase(); + if (APP == "foldseek") { + if (target.startsWith("AF-")) { + return target.replaceAll(/\.(cif|pdb)(\.gz)?(_[A-Z0-9]+)?$/g, ""); + } else if ( + res.startsWith("pdb") || + res.startsWith("gmgc") || + res.startsWith("mgyp") || + res.startsWith("mgnify") + ) { + return target.replaceAll(/\.(cif|pdb|ent)(\.gz)?/g, ""); + } else if (res.startsWith("bfvd")) { + return target.replaceAll(/_unrelaxed.*/g, ""); + } + if (res.startsWith("cath")) { + if (target.startsWith("af_")) { + const match = target.match( + /^af_([A-Z0-9]+)_(\d+)_(\d+)_(\d+\.\d+\.\d+\.\d+)$/, + ); + if (match && match.length == 5) { + return match[4] + " " + match[1] + " " + match[2] + "-" + match[3]; + } + } + } + } + return target; +} + +// Process e.g. AF-{uniprot ID}-F1_model_v4.cif.pdb.gz to just uniprot ID +export function tryFixName(name) { + if (/-_-_-_/.test(name)) { + name = name.split("-_-_-_")[0]; + } + + if (name.startsWith("AF-")) { + name = name.replaceAll(/(AF[-_]|[-_]F[0-9]+[-_]model[-_]v[0-9]+)/g, ""); + } + return name.replaceAll(/\.(cif|pdb|gz)/g, ""); +} + +export function parseResults(data) { + let empty = 0; + let total = 0; + for (let i in data.results) { + let result = data.results[i]; + let db = result.db; + result.hasDescription = false; + result.hasTaxonomy = false; + if (result.alignments == null) { + empty++; + } + total++; + const isGrouped = + result.alignments != null && !Array.isArray(result.alignments); + const grouped = isGrouped ? result.alignments : {}; + for (let j in result.alignments) { + for (let k in result.alignments[j]) { + let item = result.alignments[j][k]; + if (item.description === undefined) { + let split = item.target.split(" "); + item.description = split.slice(1).join(" "); + item.href = tryLinkTargetToDB(split[0], db); + item.target = tryFixTargetName(split[0], db); + } + if (item.description.length > 1) { + result.hasDescription = true; + } + item.id = "result-" + i + "-" + j; + item.active = false; + if (APP != "foldseek" || data.mode != "tmalign") { + item.eval = + typeof item.eval === "string" + ? item.eval + : item.eval.toExponential(2); + } + if (APP == "foldseek") { + item.prob = + typeof item.prob === "string" ? item.prob : item.prob.toFixed(2); + if (data.mode == "tmalign") { + item.eval = + typeof item.eval === "string" ? item.eval : item.eval.toFixed(3); + } else if (data.mode == "lolalign") { + item.eval = item.eval * 100; + item.eval = parseFloat(item.eval.toFixed(2)).toString(); + } + } + if ("taxId" in item) { + result.hasTaxonomy = true; + } + if (!isGrouped) { + let groupId = item.complexid ?? k; + if (!grouped[groupId]) { + grouped[groupId] = []; + } + grouped[groupId].push(item); + } + } + } + result.alignments = grouped; + } + return total != 0 && empty / total == 1 + ? { results: [], mode: data.mode } + : data; +} + +export function splitAlphaNum(str) { + const len = str.length; + let i = 0; + + while (i < len) { + const cc = str.charCodeAt(i); + if (cc >= 48 && cc <= 57) { + break; + } + i++; + } + const alpha = str.slice(0, i); + + let j = i; + while (j < len) { + const cc = str.charCodeAt(j); + if (cc < 48 || cc > 57) { + break; + } + j++; + } + const numeric = str.slice(i, j); + + let substitution = ""; + if (j < len && str.charCodeAt(j) === 58 /* ':' */) { + substitution = str.slice(j); + } + + return [alpha, numeric, substitution]; +} + +function computeInterresidueDist(splitTarget) { + const dist = []; + let lastChain = null; + let lastPos = null; + + for (let i = 1; i < splitTarget.length; ++i) { + const curr = splitTarget[i]; + + if (curr === "_") { + dist.push(null); + continue; + } + + let [currChain, currPos] = splitAlphaNum(curr); + currPos = currPos | 0; + + // Find last valid residue before current + let j = i - 1; + while (j >= 0 && splitTarget[j] === "_") { + j--; + } + if (j < 0) { + dist.push(null); + lastChain = currChain; + lastPos = currPos; + continue; + } + + let [prevChain, prevPos] = splitAlphaNum(splitTarget[j]); + prevPos = prevPos | 0; + + const delta = currChain === prevChain ? currPos - prevPos : currPos; + dist.push(delta); + + // Update last valid residue + lastChain = currChain; + lastPos = currPos; + } + + return dist; +} + +export function parseResultsRiboseek(data) { + let empty = 0; + let total = 0; + for (let i in data.results) { + let result = data.results[i]; + let db = result.db; + result.hasDescription = false; + result.hasTaxonomy = false; + if (result.alignments == null) { + empty++; + } + total++; + const raw = result.alignments || []; + const groups = raw.length > 0 && Array.isArray(raw[0]) ? raw : [raw]; + const hits = []; + for (const group of groups) { + for (const item of group) { + if (item.description === undefined) { + const split = item.target.split(" "); + item.target = tryFixTargetName(split[0], db); + item.description = split.slice(1).join(" "); + item.href = tryLinkTargetToDB(split[0], db); + } + if (item.description.length > 1) { + result.hasDescription = true; + } + item.id = "result-" + i + "-" + hits.length; + item.evalStr = typeof item.eval === "string" ? item.eval : item.eval.toExponential(2); + item.strand = item.qStartPos > item.qEndPos ? "-" : "+"; + if ("taxId" in item) { + result.hasTaxonomy = true; + } + hits.push(item); + } + } + result.alignments = hits; + } + return total != 0 && empty / total == 1 + ? { results: [], mode: data.mode } + : data; +} + +export function parseResultsFoldDisco(data) { + let empty = 0; + let total = 0; + for (let i in data.results) { + let result = data.results[i]; + let db = result.db; + result.hasDescription = false; + result.hasTaxonomy = false; + if (result.alignments == null) { + empty++; + } + total++; + result.queryresidues = {}; + const grouped = {}; + let meta = null; + if ("meta" in result) { + meta = {}; + for (let j in result.meta) { + let entry = result.meta[j]; + meta[entry.key] = entry; + } + } + result.meta = null; + for (let j in result.alignments) { + let item = result.alignments[j]; + let split = item.target.split("/"); + item.target = split[split.length - 1]; + + const splitTarget = item.targetresidues.split(","); + item.gaps = splitTarget.reduce((acc, s) => { + return acc + (s == "_" ? "0" : "1"); + }, ""); + item.interresiduedist = computeInterresidueDist(splitTarget); + item.idfscore = item.idfscore.toFixed(3); + item.rmsd = item.rmsd.toFixed(3); + + item.queryresidues.split(",").map((r) => { + let [chain, pos, _] = splitAlphaNum(r); + if (!(chain in result.queryresidues)) { + result.queryresidues[chain] = new Set(); + } + result.queryresidues[chain].add(pos - 0); + }); + + item.href = tryLinkTargetToDB(item.target, db); + item.targetname = tryFixTargetName(item.target, db).toUpperCase(); + item.id = "result-" + i + "-" + j; + if (meta != null) { + let header = meta[item.dbkey].header; + let split = header.split(" "); + item.description = split.slice(1).join(" "); + if (item.description.length > 1) { + result.hasDescription = true; + } + if ("taxId" in meta[item.dbkey]) { + item.taxId = meta[item.dbkey].taxId; + item.taxName = meta[item.dbkey].taxName; + result.hasTaxonomy = true; + } + } + let groupId = j; + if (!grouped[groupId]) { + grouped[groupId] = []; + } + grouped[j].push(item); + } + result.alignments = grouped; + Object.keys(result.queryresidues).forEach(function (key, _) { + result.queryresidues[key] = Array.from(result.queryresidues[key]); + }); + } + return total != 0 && empty / total == 1 + ? { results: [], mode: data.mode } + : data; +} diff --git a/frontend/lib/pdbAssembly.js b/frontend/lib/pdbAssembly.js new file mode 100644 index 00000000..746341a9 --- /dev/null +++ b/frontend/lib/pdbAssembly.js @@ -0,0 +1,304 @@ +// Building and re-combining CA-only PDB text. Extracted from Utilities.js + +export const oneToThree = { + A: "ALA", + R: "ARG", + N: "ASN", + D: "ASP", + C: "CYS", + E: "GLU", + Q: "GLN", + G: "GLY", + H: "HIS", + I: "ILE", + L: "LEU", + K: "LYS", + M: "MET", + F: "PHE", + P: "PRO", + S: "SER", + T: "THR", + W: "TRP", + Y: "TYR", + V: "VAL", + U: "SEC", + O: "PHL", + X: "XAA", +}; + +export const threeToOne = { + ALA: "A", + ARG: "R", + ASN: "N", + ASP: "D", + CYS: "C", + GLU: "E", + GLN: "Q", + GLY: "G", + HIS: "H", + ILE: "I", + LEU: "L", + LYS: "K", + MET: "M", + PHE: "F", + PRO: "P", + SER: "S", + THR: "T", + TRP: "W", + TYR: "Y", + VAL: "V", + SEC: "U", + PHL: "O", + XAA: "X", +}; + +/** + * Create a mock PDB from Ca data + * Follows the spacing spec from https://www.wwpdb.org/documentation/file-format-content/format33/sect9.html#ATOM + * Will have to change if/when swapping to fuller data + */ +export function mockPDB(ca, seq, chain) { + const atoms = ca.split(","); + const pdb = new Array(); + let j = 1; + for (let i = 0; i < atoms.length; i += 3, j++) { + let [x, y, z] = atoms.slice(i, i + 3).map((element) => parseFloat(element)); + // if (x == 0 && y == 0 && z == 0) continue; + pdb.push( + "ATOM " + + j.toString().padStart(5) + + " CA " + + oneToThree[ + seq != "" && atoms.length / 3 == seq.length ? seq[i / 3] : "A" + ] + + chain.toString().padStart(2) + + j.toString().padStart(4) + + " " + + x.toFixed(3).padStart(8) + + y.toFixed(3).padStart(8) + + z.toFixed(3).padStart(8) + + " 1.00 0.00 C ", + ); + } + return pdb.join("\n"); +} + +export function mergePdbs(chainPdbs /* [{pdb, chain}] */) { + let serial = 1; + const out = []; + + for (const { pdb, chain } of chainPdbs) { + const lines = pdb.split(/\r?\n/); + for (const line of lines) { + if (/^(ATOM |HETATM)/.test(line)) { + // reassign atom serial no. + let s = serial.toString().padStart(5, " "); + let l = line.padEnd(80, " "); + l = l.slice(0, 6) + s + l.slice(11); + + // change chain_id + l = l.substring(0, 21) + (chain[0] || "A") + l.substring(22); + + out.push(l); + serial++; + } + } + out.push("TER"); + } + + out.push("END"); + return out.join("\n"); +} + +/** + * + * @param {*} chainPdbs : Ca only pdb files with chain information in [{pdb, chain}] format + * @returns concatenated pdb string + * @abstract Concatenate multiple chains into one pdb files with single chain A + */ +export function concatenatePdbs(chainPdbs /* [{pdb, chain}] */) { + let serial = 1; + const out = []; + + for (const { pdb, chain } of chainPdbs) { + const lines = pdb.split(/\r?\n/); + for (const line of lines) { + if (/^(ATOM |HETATM)/.test(line)) { + // reassign atom serial no. and residue sequence no. + let s = serial.toString().padStart(5, " "); + let rs = serial.toString().padStart(4, " "); + let l = line.padEnd(80, " "); + l = l.slice(0, 6) + s + l.slice(11, 21) + "A" + rs + l.slice(26); + out.push(l); + serial++; + } + } + } + out.push("TER"); + out.push("END"); + return out.join("\n"); +} + +/** + * + * @param {*} chainPdbs : Ca only pdb files with chain information in [{pdb, chain}] format + * @returns concatenated pdb strings with suffix containing the chain informations + * @abstract Concatenate multiple chains into one pdb files with single chain A, preserving the chain information in the suffix as well + */ +export function encodeMultimer(chainPdbs /* [{pdb, chain}] */) { + const out = []; + const chainInfoArr = []; + const delimiter = "-_-_-_"; + let atomSerial = 1; + + for (const { pdb, chain } of chainPdbs) { + const lines = pdb.split(/\r?\n/); + const arr = []; + + for (const line of lines) { + if (/^(ATOM |HETATM)/.test(line)) { + // reassign atom serial no. and residue sequence no. + let l = line.padEnd(80, " "); + let s = atomSerial.toString().padStart(5, " "); + let rs = atomSerial.toString().padStart(4, " "); + l = l.slice(0, 6) + s + l.slice(11, 21) + "A" + rs + l.slice(26); + arr.push(l); + atomSerial++; + } + } + + let firstResn = Number(arr.at(0).slice(22, 26)); + let lastResn = Number(arr.at(-1).slice(22, 26)); + + const info = {}; + info.chain = chain; + info.end = lastResn; + info.offset = firstResn - 1; + chainInfoArr.push(info); + + out.push(arr.join("\n")); + } + out.push("TER"); + out.push("END"); + + let suffix = + chainInfoArr.length < 2 + ? "" + : delimiter + + chainInfoArr + .map((e) => { + return e.chain + "_" + e.end + "_" + e.offset; + }) + .join("-"); + + return { + pdb: out.join("\n"), + suffix: suffix, + }; +} + +/** + * + * @param {*} pdb : Ca only pdb file merged by encodeMultimer() + * @param {*} suffix : String containing chain information encoded by encodedMultimer() + * @returns Recovered pdb with original multimer information encoded beforehand + * @abstract Revert back the merged pdb into multimeric pdbs using multiple chain information encoded in suffix. + * Make sure to call this function with Ca only pdb, before running pulchra + */ +export function decodeMultimer(pdb, suffix) { + if (!suffix || suffix.length == 0) return pdb; + + const chainInfos = suffix.split("-").map((s) => { + const out = {}; + const info = s.split("_"); + + if (info.length != 3) return out; + + out.chain = info[0]; + out.end = Number(info[1]); + out.offset = Number(info[2]); + return out; + }); + + let index = 0; + const out = []; + + for (const line of pdb.split("\n")) { + if (line.startsWith("ATOM")) { + const rs = Number(line.slice(22, 26)); + const result = + line.slice(0, 21) + + chainInfos[index].chain + + (rs - chainInfos[index].offset).toString().padStart(4, " ") + + line.slice(26); + out.push(result); + if (rs == chainInfos[index].end) { + index++; + out.push("TER"); + } + } + } + out.push("END"); + + return out.join("\n"); +} + +export function splitMultimer(pdb) { + const arr = pdb.split("\nTER\n"); + const processed = arr.slice(0, -1).map((s) => s + "\nTER\nEND"); + return processed; +} + +export function mergeMultimer(arr) { + let serial = 1; + const merged = arr.map((s) => s.split("\nEND")[0]).join("\n") + "\nEND"; + const out = []; + for (const line of merged.split("\n")) { + if (line.startsWith("ATOM")) { + const result = + line.slice(0, 6) + serial.toString().padStart(5, " ") + line.slice(11); + out.push(result); + serial++; + } else if (line.startsWith("TER") || line.startsWith("END")) { + out.push(line); + } + } + return out.join("\n"); +} + +export function storeChains(pdb) { + const arr = []; + let c = ""; + for (let line of pdb.split("\n")) { + if (line.startsWith("ATOM")) { + c = line.charAt(21); + } else if (line.startsWith("TER")) { + arr.push(c); + } + } + if (arr.length == 0) { + arr.push(c); + } + return arr; +} + +export function revertChainInfo(pdb, chains) { + if (chains.length == 0 || chains[0] == "") { + return pdb; + } + + const arr = []; + let i = 0; + + for (let line of pdb.split("\n")) { + if (line.startsWith("ATOM")) { + line = line.slice(0, 21) + chains[i] + line.slice(22); + } else if (line.startsWith("TER")) { + i++; + } + + arr.push(line); + } + + return arr.join("\n"); +} diff --git a/frontend/lib/resultSort.js b/frontend/lib/resultSort.js index d2d5d432..ee50da3f 100644 --- a/frontend/lib/resultSort.js +++ b/frontend/lib/resultSort.js @@ -1,13 +1,4 @@ // Sorting for the Foldseek / FoldDisco result tables. -// -// This lives outside the table components because only the active tab's child is mounted -// (ResultView.vue renders ResultFoldseekDB with `v-if="(entryidx + 1) == selectedDatabases"`), -// so `getTable({db})` for any other database has no child to ask. Both the mounted table and -// the API import from here, which is the point: they cannot drift into sorting differently. -// -// `alignments` is always the post-parseResults grouped object — keys are group ids -// (complexid for complex searches, the hit index otherwise) and values are arrays of -// chain-level hits. Group ids are NOT guaranteed to be dense; never treat a key as a position. export const FOLDSEEK_SORT_KEYS = [ 'qtm', 'ttm', 'target', 'desc', 'tax', 'prob', 'seqId', 'eval', 'score', @@ -21,11 +12,6 @@ const FOLDDISCO_NUMERIC = { idf: 'idfscore', rmsd: 'rmsd', node: 'nodecount' }; // Keys sorted by a string field on the group's first chain. const STRING_FIELD = { target: 'target', desc: 'description', tax: 'taxName' }; -/** - * The row field a sort key reads, which is NOT always the key itself: `idf` lives in `idfscore`, - * `qtm` in `complexqtm`, `desc` in `description`. Exported because getTableSummary() needs to report - * a value for the active sort key, and hardcoding the key name there produced silent nulls. - */ export function rowFieldForSortKey(sortKey, tool = 'foldseek') { if (STRING_FIELD[sortKey]) return STRING_FIELD[sortKey]; if (tool === 'folddisco') return FOLDDISCO_NUMERIC[sortKey] ?? sortKey; @@ -34,10 +20,6 @@ export function rowFieldForSortKey(sortKey, tool = 'foldseek') { return FOLDSEEK_NUMERIC.includes(sortKey) ? sortKey : sortKey; } -/** - * Per-group numeric reductions, mirroring ResultFoldseekDB.sortKeyCache. - * `eval` reduces with max under tmalign/lolalign (higher is better) and min otherwise. - */ export function buildSortCache(alignments, { mode = '', isComplex = false, tool = 'foldseek' } = {}) { const cache = {}; if (!alignments) return cache; @@ -107,11 +89,6 @@ export function isValidSortKey(sortKey, tool = 'foldseek') { return keys.includes(sortKey); } -/** - * Memoised sortIndices, keyed by (cacheKey, sortKey, sortOrder). The mounted table gets this - * free from Vue's computed caching; the API path calls sortIndices directly and would otherwise - * re-sort tens of thousands of rows on every getTable(). - */ export function createSortMemo() { let store = new Map(); return { diff --git a/frontend/lib/resultsApi.js b/frontend/lib/resultsApi.js deleted file mode 100644 index 790b349b..00000000 --- a/frontend/lib/resultsApi.js +++ /dev/null @@ -1,360 +0,0 @@ -// Programmatic access to whatever page is currently mounted. -// -// The result and search pages hold sort order, filtering, clustering, selection, and the whole -// search form inside Vue component instances that are otherwise unreachable. This registry -// exposes the mounted page on a global so that derived state can be read and driven without -// scraping the DOM. -// -// One page of a given kind is mounted at a time, but the router swaps them without a reload and -// teardown order is not guaranteed (an outgoing page's beforeDestroy can run after the incoming -// page's mounted). Keying by kind + page and republishing on every change keeps the globals honest -// instead of letting a dying page clobber a live one. - -import { routeForTicket } from './ticketRoute.js'; -import { getJobType } from './HistoryMixin'; - -const GLOBAL_FOR = { - result: 'resultsApi', - search: 'searchApi', - queue: 'queueApi', -}; - -/** - * Search routes goToPage() will navigate to, mapped to the registry key each one publishes. - * - * The key matters because "has a search page mounted?" is the wrong question when the caller is - * already on one — window.searchApi would still be the outgoing page's handle and the arrival check - * would pass instantly. Waiting for the specific key the destination registers is correct from a - * result page and from another search page alike. - * - * /interface is deliberately absent: InterfaceSearch.vue predates the page API and registers - * nothing, so arriving there leaves every global undefined — including the goToPage that got you - * there, making it unrecoverable without a reload. The route still exists for the sidebar link. - */ -const SEARCH_PAGES = { - search: 'foldseek', - multimer: 'multimer', - foldmason: 'foldmason', - folddisco: 'folddisco', -}; - -/** Spellings agents are likely to try, matching SearchApiMixin.goTo()'s vocabulary. */ -const PAGE_ALIASES = { - foldseek: 'search', - monomer: 'search', - complex: 'multimer', -}; - -/** kind -> Map */ -const registry = new Map(); -/** kind -> [resolve, ...] for awaitPageApi */ -const waiters = new Map(); - -function kindRegistry(kind) { - if (!registry.has(kind)) registry.set(kind, new Map()); - return registry.get(kind); -} - -function describe() { - const pages = []; - for (const [kind, entries] of registry) { - for (const [key, api] of entries) { - pages.push({ - kind, - page: key, - global: GLOBAL_FOR[kind] ?? null, - methods: Object.keys(api).filter(k => typeof api[k] === 'function').sort(), - ...(typeof api.describePage === 'function' ? api.describePage() : {}), - }); - } - } - return { - pages, - live: [...registry.entries()] - .filter(([, m]) => m.size > 0) - .map(([kind]) => GLOBAL_FOR[kind] ?? kind), - usage: pages.length === 0 - ? 'No page is mounted. Navigate to a search or result route first.' - : 'Call methods directly on the global for a kind, or via .pages[].', - navigation: 'goToTicket(ticket, {type, entry}) is on every global; omit type and any ' - + 'already-resolved type is taken from the shared type store, falling back to the ' - + 'queue, which resolves and redirects. `entry` picks the query in a ' - + 'multi-query ticket. goToPage(name) is also on every global and switches to a search ' - + `page (${Object.keys(SEARCH_PAGES).join(', ')}) without carrying a query — unlike ` - + 'sendTo() on a result page or goTo() on a search page, both of which forward the ' - + 'structure and need one to forward.', - }; -} - -function publish(kind) { - if (typeof window === 'undefined') return; - const name = GLOBAL_FOR[kind]; - if (!name) return; - const entries = kindRegistry(kind); - - if (entries.size === 0) { - delete window[name]; - return; - } - const only = entries.size === 1 ? [...entries.values()][0] : null; - // Copy own enumerable methods onto the handle rather than inheriting them via - // Object.create(only). Prototype-inherited methods are invisible to Object.keys() and to - // console autocomplete, which defeats the point for anything introspecting the object - // before calling it. - const handle = {}; - if (only) { - for (const [k, v] of Object.entries(only)) { - handle[k] = typeof v === 'function' ? v.bind(only) : v; - } - } - handle.pages = Object.fromEntries(entries); - handle.describe = describe; - handle.awaitPageApi = awaitPageApi; - handle.goToTicket = goToTicket; - handle.goToPage = goToPage; - window[name] = handle; -} - -function notifyWaiters(kind) { - const list = waiters.get(kind); - if (!list?.length) return; - const handle = typeof window !== 'undefined' ? window[GLOBAL_FOR[kind]] : null; - if (!handle) return; - waiters.set(kind, []); - for (const resolve of list) resolve(handle); -} - -/** - * Resolve once a page of `kind` is registered and its global is published. - * - * Deliberately a module-level export rather than a method on a page object: the usual caller is - * `sendTo()`, which navigates away and is therefore being unmounted while it waits. A promise - * held by the module survives that; one held by the component does not. - */ -export function awaitPageApi(kind, { timeoutMs = 15000 } = {}) { - const name = GLOBAL_FOR[kind]; - if (!name) return Promise.reject(new Error(`unknown page kind: ${kind}`)); - if (typeof window !== 'undefined' && window[name]) return Promise.resolve(window[name]); - - return new Promise((resolve, reject) => { - let settled = false; - const timer = setTimeout(() => { - if (settled) return; - settled = true; - const list = waiters.get(kind) || []; - waiters.set(kind, list.filter(r => r !== wrapped)); - reject(new Error(`timed out after ${timeoutMs}ms waiting for ${name}`)); - }, timeoutMs); - const wrapped = (handle) => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolve(handle); - }; - if (!waiters.has(kind)) waiters.set(kind, []); - waiters.get(kind).push(wrapped); - }); -} - -/** Any mounted page's component instance, for its $router. Null when nothing is registered. */ -function findVm() { - for (const entries of registry.values()) { - for (const api of entries.values()) { - if (api?._vm?.$router) return api._vm; - } - } - return null; -} - -/** Vue Router rejects a push to the current location; that is not a failure. */ -function isRedundantNavigation(e) { - return /redundant|avoided/i.test(String(e?.message ?? e)); -} - -/** - * Navigate to a ticket's results. - * - * Navigation is not page-specific, so it lives here rather than being duplicated on every page - * API. The router comes from whichever page is currently registered (each exposes `_vm`). - * - * The type is optional. When it is not given, the shared type store is consulted — any ticket - * whose type History or Queue has already resolved routes straight to its result page. Only a - * genuinely unknown type falls back to /queue, which resolves it and redirects on its own. - */ -export async function goToTicket(ticket, { type = null, entry = 0, wait = true, timeoutMs = 15000 } = {}) { - if (!ticket) return { ok: false, reason: 'ticket is required' }; - const known = type ?? getJobType(ticket); - - const vm = findVm(); - if (!vm) return { ok: false, reason: 'no page is mounted, so there is no router to navigate' }; - - // `entry` selects the query within a multi-query ticket; it only applies to the plain - // result route, and routeForTicket ignores it elsewhere. - const route = routeForTicket(ticket, known, { entry }); - const current = vm.$route?.fullPath ?? null; - try { - await vm.$router.push({ name: route.name, params: route.params }); - } catch (e) { - if (!isRedundantNavigation(e)) { - return { ok: false, reason: `navigation failed: ${e?.message ?? e}` }; - } - } - - const out = { ok: true, ticket, route: route.name, viaQueue: route.viaQueue, - type: route.type, navigated: (vm.$route?.fullPath ?? null) !== current, - ...(route.params.entry !== undefined ? { entry: route.params.entry } : {}) }; - if (wait) { - // Direct routes land on a result page. Via the queue we only await the queue itself — - // the job may be PENDING for minutes, so use queueApi.waitForResult() for the rest. - const kind = route.viaQueue ? 'queue' : 'result'; - try { await awaitPageApi(kind, { timeoutMs }); out.arrived = kind; } - catch { out.arrived = false; } - } - return out; -} - -/** Resolve true once `key` is registered under kind 'search', or false on timeout. */ -async function awaitSearchPageKey(key, timeoutMs) { - const entries = kindRegistry('search'); - const deadline = Date.now() + timeoutMs; - for (;;) { - if (entries.has(key)) return true; - if (Date.now() >= deadline) return false; - await new Promise(r => setTimeout(r, 25)); - } -} - -/** - * Switch to a search page, carrying nothing. - * - * The rest of the cross-page navigation in this app is a transfer: sendTo() on a result page and - * goTo() on a search page both stash a structure in IndexedDB before pushing, so both need a - * structure to stash — sendTo('folddisco') fails outright without exactly one selected row. An - * agent that just wants to leave a result page and start a fresh FoldDisco search had no way to - * say so, and reaching for `_vm.$router` instead builds on an escape hatch this module documents - * as unstable. Hence a plain, named navigation. - * - * "Carrying nothing" is about the transfer, not about what the destination ends up holding: each - * search page restores its own persisted query in mounted() (FoldDiscoSearch reads - * `folddisco.query` from IndexedDB), so arriving on one may well show the query it had last time. - * That is the same thing clicking the sidebar link does. Note it resolves asynchronously — a - * getQuery() fired the instant this returns can still read length 0 and then fill in. - * - * Ticket routes are deliberately not reachable here: they need the type resolution and queue - * fallback that goToTicket() already implements. - */ -export async function goToPage(name, { wait = true, timeoutMs = 15000 } = {}) { - const valid = Object.keys(SEARCH_PAGES); - if (!name) return { ok: false, reason: 'name is required', valid }; - - const requested = String(name).trim().toLowerCase(); - const target = PAGE_ALIASES[requested] ?? requested; - if (!Object.prototype.hasOwnProperty.call(SEARCH_PAGES, target)) { - return { ok: false, reason: `unknown page: ${name}`, valid, - hint: 'ticket results are goToTicket(ticket) — this only reaches search pages' }; - } - - const vm = findVm(); - if (!vm) return { ok: false, reason: 'no page is mounted, so there is no router to navigate' }; - - const from = vm.$route?.fullPath ?? null; - try { - await vm.$router.push({ name: target }); - } catch (e) { - if (!isRedundantNavigation(e)) { - return { ok: false, reason: `navigation failed: ${e?.message ?? e}` }; - } - } - - const out = { ok: true, page: target, navigated: (vm.$route?.fullPath ?? null) !== from, - ...(target !== requested ? { requested } : {}) }; - if (wait) { - // Waiting on the destination's own registry key, not on window.searchApi: coming from - // another search page that global is already populated by the outgoing page. - out.arrived = await awaitSearchPageKey(SEARCH_PAGES[target], timeoutMs); - if (!out.arrived) { - out.note = `timed out after ${timeoutMs}ms waiting for ${target} to register`; - } - } - return out; -} - -/** - * @param {'result'|'search'|'queue'} kind - * @param {string} key page identifier, e.g. 'foldseek' - * @param {object} api methods to expose - * @returns {() => void} disposer — call from beforeDestroy() - */ -export function registerPageApi(kind, key, api) { - kindRegistry(kind).set(key, api); - publish(kind); - notifyWaiters(kind); - let disposed = false; - return () => { - if (disposed) return; - disposed = true; - // Only unregister if we still own the slot; a newer page may have replaced us. - const entries = kindRegistry(kind); - if (entries.get(key) === api) { - entries.delete(key); - publish(kind); - } - }; -} - -/** Back-compat alias — the result pages were written against this name. */ -export function registerResultApi(key, api) { - return registerPageApi('result', key, api); -} - -/** - * Accepts "dbIdx#entryIdx", {db, idx}, or [dbIdx, entryIdx] and returns a canonical - * "dbIdx#entryIdx" string, resolving database names through dbToIdx. - * Returns null when the id cannot be resolved, so callers can report it as rejected rather - * than throwing on one bad entry in a batch. - */ -export function normalizeId(id, dbToIdx) { - let db, idx; - if (typeof id === 'string') { - const hash = id.indexOf('#'); - if (hash === -1) return null; - db = id.slice(0, hash); - idx = id.slice(hash + 1); - } else if (Array.isArray(id) && id.length === 2) { - [db, idx] = id; - } else if (id && typeof id === 'object') { - db = id.db; - idx = id.idx; - } else { - return null; - } - - if (idx === undefined || idx === null || idx === '') return null; - - // db may be a numeric index already, or a database name needing lookup. - let dbIdx = null; - if (typeof db === 'number') { - dbIdx = db; - } else if (typeof db === 'string') { - if (/^\d+$/.test(db)) { - dbIdx = Number(db); - } else if (dbToIdx && Object.prototype.hasOwnProperty.call(dbToIdx, db)) { - dbIdx = dbToIdx[db]; - } - } - if (dbIdx === null || dbIdx === undefined || Number.isNaN(Number(dbIdx))) return null; - - const entryIdx = Number(idx); - if (!Number.isFinite(entryIdx)) return null; - - return `${Number(dbIdx)}#${entryIdx}`; -} - -export function splitId(id) { - const hash = String(id).indexOf('#'); - if (hash === -1) return null; - return { - dbIdx: Number(String(id).slice(0, hash)), - entryIdx: Number(String(id).slice(hash + 1)), - }; -} diff --git a/frontend/lib/structureRemark.js b/frontend/lib/structureRemark.js new file mode 100644 index 00000000..5b6202f1 --- /dev/null +++ b/frontend/lib/structureRemark.js @@ -0,0 +1,12 @@ +export function structureRemarkPrefix(text, number) { + const cif = text[0] === '#' || text.startsWith('data_'); + return cif ? '# ' : `REMARK ${number} `; +} + +/** Format one PDB-width remark line, or an unbounded mmCIF comment. */ +export function structureRemarkLine(text, content, number) { + const prefix = structureRemarkPrefix(text, number); + let line = `${prefix}${content}`; + if (prefix !== '# ' && line.length > 79) line = `${line.slice(0, 76)}... `; + return line.padEnd(80, ' '); +} diff --git a/frontend/lib/structureText.js b/frontend/lib/structureText.js new file mode 100644 index 00000000..018c35a1 --- /dev/null +++ b/frontend/lib/structureText.js @@ -0,0 +1,447 @@ +// Dependency-free residue reader for PDB and mmCIF text. + +/** Fixed-column PDB fields, 0-indexed half-open ranges (the spec numbers them from 1). */ +const PDB_RES_NAME = [17, 20]; +const PDB_CHAIN_ID = [21, 22]; +const PDB_RES_SEQ = [22, 26]; +const PDB_I_CODE = [26, 27]; + +function isCif(text) { + const head = text.trimStart(); + return head.startsWith('data_') || head.startsWith('#') || head.includes('_atom_site.'); +} + +/** Normalize known one-column shifts in fixed-width ATOM records. */ +function repairAtomLine(line) { + if (!line.startsWith('ATOM')) return line; + let out = line; + if (out.length > 60 && out[60] !== ' ') out = out.slice(0, 30) + out.slice(31); + if (out.length > 20 && out[20] !== ' ') out = out.slice(0, 17) + out.slice(18); + return out; +} + +/** + * mmCIF values may be quoted ('A 1' or "A 1"). Splitting on whitespace alone would shift every + * later column on such a row, so quoted runs are kept whole. + */ +function splitCifRowSpans(line) { + const out = []; + let i = 0; + while (i < line.length) { + while (i < line.length && /\s/.test(line[i])) i++; + if (i >= line.length) break; + const quote = line[i] === "'" || line[i] === '"' ? line[i] : null; + if (quote) { + const end = line.indexOf(quote, i + 1); + if (end === -1) { out.push({ value: line.slice(i + 1), start: i, end: line.length }); break; } + out.push({ value: line.slice(i + 1, end), start: i, end: end + 1 }); + i = end + 1; + } else { + let j = i; + while (j < line.length && !/\s/.test(line[j])) j++; + out.push({ value: line.slice(i, j), start: i, end: j }); + i = j; + } + } + return out; +} + +function splitCifRow(line) { + return splitCifRowSpans(line).map(s => s.value); +} + +function listResiduesCif(text) { + const residues = []; + const seen = new Set(); + const lines = text.split('\n'); + + let i = 0; + while (i < lines.length) { + if (lines[i].trim() !== 'loop_') { i++; continue; } + i++; + + const headers = []; + while (i < lines.length && lines[i].trim().startsWith('_')) { + headers.push(lines[i].trim().split(/\s+/)[0]); + i++; + } + // Skips _chem_comp and friends; only the atom_site loop describes residues. + if (headers.length === 0 || !headers[0].startsWith('_atom_site.')) continue; + + const nameIdx = headers.indexOf('_atom_site.label_comp_id'); + const groupIdx = headers.indexOf('_atom_site.group_PDB'); + // auth_* is what NGL surfaces as chainname/resno. Some minimal writers emit only the label_ + // scheme, so fall back to it rather than return nothing. + const authChain = headers.indexOf('_atom_site.auth_asym_id'); + const authSeq = headers.indexOf('_atom_site.auth_seq_id'); + const chain = authChain >= 0 ? authChain : headers.indexOf('_atom_site.label_asym_id'); + const seq = authSeq >= 0 ? authSeq : headers.indexOf('_atom_site.label_seq_id'); + if (chain < 0 || seq < 0) return residues; + const maxIdx = Math.max(chain, seq, nameIdx, groupIdx); + + for (; i < lines.length; i++) { + const t = lines[i].trim(); + if (t === '' || t === 'loop_' || t.startsWith('_') || t.startsWith('#') + || t.startsWith('data_')) { + break; + } + const cols = splitCifRow(t); + if (cols.length <= maxIdx) continue; + // '.' and '?' are mmCIF's null markers; a residue cannot be addressed by them. + if (cols[seq] === '.' || cols[seq] === '?') continue; + const key = `${cols[chain]}|${cols[seq]}`; + if (seen.has(key)) continue; + seen.add(key); + residues.push({ + chain: cols[chain], + resno: cols[seq], + resName: nameIdx >= 0 ? cols[nameIdx] : '', + hetero: groupIdx >= 0 ? cols[groupIdx] === 'HETATM' : false, + }); + } + break; + } + return residues; +} + +function listResiduesPdb(text) { + const residues = []; + const seen = new Set(); + + for (const raw of text.split('\n')) { + const record = raw.slice(0, 6); + if (record !== 'ATOM ' && record !== 'HETATM') continue; + const line = repairAtomLine(raw); + + const chain = line.slice(...PDB_CHAIN_ID).trim(); + const resno = line.slice(...PDB_RES_SEQ).trim(); + if (!/^-?\d+$/.test(resno)) continue; // drifted or truncated beyond repair + const iCode = line.slice(...PDB_I_CODE).trim(); + + // Insertion codes distinguish residues that share a number (antibody numbering does this). + const key = `${chain}|${resno}|${iCode}`; + if (seen.has(key)) continue; + seen.add(key); + residues.push({ + chain, + resno, + resName: line.slice(...PDB_RES_NAME).trim(), + insCode: iCode || undefined, + hetero: record === 'HETATM', + }); + } + return residues; +} + +/** + * Every residue in a PDB or mmCIF string, in file order, deduplicated by (chain, residue number). + * + * @param {string} text + * @returns {{chain: string, resno: string, resName: string, insCode?: string, hetero: boolean}[]} + */ +export function listResidues(text) { + if (typeof text !== 'string' || text.trim() === '') return []; + return isCif(text) ? listResiduesCif(text) : listResiduesPdb(text); +} + +export function residueTokenSet(text) { + const tokens = new Set(); + for (const r of listResidues(text)) { + tokens.add(`${r.chain}${r.resno}`); + tokens.add(String(r.resno)); + } + return tokens; +} + +// Chain-grouped CA traces. + +const PDB_ATOM_NAME = [12, 16]; +const PDB_X = [30, 38]; +const PDB_Y = [38, 46]; +const PDB_Z = [46, 54]; + +/** Three-letter to one-letter, with X for anything unrecognised — mockPDB's own table, inverted. */ +const THREE_TO_ONE = { + ALA: 'A', ARG: 'R', ASN: 'N', ASP: 'D', CYS: 'C', GLU: 'E', GLN: 'Q', GLY: 'G', HIS: 'H', + ILE: 'I', LEU: 'L', LYS: 'K', MET: 'M', PHE: 'F', PRO: 'P', SER: 'S', THR: 'T', TRP: 'W', + TYR: 'Y', VAL: 'V', SEC: 'U', PHL: 'O', XAA: 'X', +}; + +function fixed(value) { + const n = Number(value); + return Number.isFinite(n) ? n.toFixed(3) : null; +} + +function caRowsPdb(text) { + const rows = []; + for (const raw of text.split('\n')) { + if (raw.slice(0, 6) !== 'ATOM ') continue; // HETATM is not part of the chain trace + const line = repairAtomLine(raw); + if (line.slice(...PDB_ATOM_NAME).trim() !== 'CA') continue; + const xyz = [ + fixed(line.slice(...PDB_X)), fixed(line.slice(...PDB_Y)), fixed(line.slice(...PDB_Z)), + ]; + if (xyz.some(v => v === null)) continue; + rows.push({ + chain: line.slice(...PDB_CHAIN_ID).trim() || 'A', + resName: line.slice(...PDB_RES_NAME).trim(), + resno: line.slice(...PDB_RES_SEQ).trim(), + xyz, + }); + } + return rows; +} + +function caRowsCif(text) { + const rows = []; + const lines = text.split('\n'); + let i = 0; + while (i < lines.length) { + if (lines[i].trim() !== 'loop_') { i++; continue; } + i++; + const headers = []; + while (i < lines.length && lines[i].trim().startsWith('_')) { + headers.push(lines[i].trim().split(/\s+/)[0]); + i++; + } + if (headers.length === 0 || !headers[0].startsWith('_atom_site.')) continue; + + const idx = { + group: headers.indexOf('_atom_site.group_PDB'), + atom: headers.indexOf('_atom_site.label_atom_id'), + comp: headers.indexOf('_atom_site.label_comp_id'), + alt: headers.indexOf('_atom_site.label_alt_id'), + x: headers.indexOf('_atom_site.Cartn_x'), + y: headers.indexOf('_atom_site.Cartn_y'), + z: headers.indexOf('_atom_site.Cartn_z'), + model: headers.indexOf('_atom_site.pdbx_PDB_model_num'), + }; + const authChain = headers.indexOf('_atom_site.auth_asym_id'); + const authSeq = headers.indexOf('_atom_site.auth_seq_id'); + idx.chain = authChain >= 0 ? authChain : headers.indexOf('_atom_site.label_asym_id'); + idx.seq = authSeq >= 0 ? authSeq : headers.indexOf('_atom_site.label_seq_id'); + if (idx.atom < 0 || idx.x < 0 || idx.chain < 0) return rows; + const maxIdx = Math.max(...Object.values(idx)); + + let firstModel = null; + for (; i < lines.length; i++) { + const t = lines[i].trim(); + if (t === '' || t === 'loop_' || t.startsWith('_') || t.startsWith('#') + || t.startsWith('data_')) { + break; + } + const cols = splitCifRow(t); + if (cols.length <= maxIdx) continue; + if (idx.group >= 0 && cols[idx.group] !== 'ATOM') continue; + if (cols[idx.atom] !== 'CA') continue; + // An NMR ensemble repeats every atom per model, and an alternate location repeats it per + // conformer; either would double the trace. + if (idx.model >= 0) { + if (firstModel === null) firstModel = cols[idx.model]; + if (cols[idx.model] !== firstModel) continue; + } + if (idx.alt >= 0 && cols[idx.alt] !== '.' && cols[idx.alt] !== '?' + && cols[idx.alt] !== 'A') continue; + + const xyz = [fixed(cols[idx.x]), fixed(cols[idx.y]), fixed(cols[idx.z])]; + if (xyz.some(v => v === null)) continue; + rows.push({ + chain: cols[idx.chain], + resName: idx.comp >= 0 ? cols[idx.comp] : '', + resno: idx.seq >= 0 ? cols[idx.seq] : '', + xyz, + }); + } + break; + } + return rows; +} + +/** + * The CA trace of each chain, in file order. + * + * @param {string} text PDB or mmCIF + * @returns {{chain: string, residueCount: number, ca: string, seq: string}[]} + * `ca` is comma-separated x,y,z triplets and `seq` one-letter codes — exactly the pair a search hit + * arrives as, so mockPDB(ca, seq, chain) works on either without a second code path. + */ +export function listChains(text) { + if (typeof text !== 'string' || text.trim() === '') return []; + const rows = isCif(text) ? caRowsCif(text) : caRowsPdb(text); + + const byChain = new Map(); + for (const row of rows) { + const chain = row.chain || 'A'; + if (!byChain.has(chain)) byChain.set(chain, { chain, xyz: [], seq: [] }); + const entry = byChain.get(chain); + entry.xyz.push(...row.xyz); + entry.seq.push(THREE_TO_ONE[row.resName?.toUpperCase()] ?? 'X'); + } + + return [...byChain.values()].map(e => ({ + chain: e.chain, + residueCount: e.seq.length, + ca: e.xyz.join(','), + seq: e.seq.join(''), + })); +} + +// Chain names a motif token can address. + +const CHAIN_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + +/** + * Can a motif token name a residue of this chain unambiguously? + */ +export function isNameableChain(chain) { + return /^[A-Za-z]+$/.test(chain); +} + +/** Alphabetic names, shortest first: A…Z, a…z, then AA, AB, … — 2756 of them before this runs dry. */ +function* alphabeticNames() { + for (const c of CHAIN_ALPHABET) yield c; + for (const a of CHAIN_ALPHABET) for (const b of CHAIN_ALPHABET) yield a + b; +} + +// Which items hold a chain id, per naming scheme. +const CHAIN_ITEMS = { + auth: /(auth_asym_id|pdb_strand_id)$/, + label: /(label_asym_id|asym_id_list|^_struct_asym\.id$|\.asym_id$)/, +}; + +/** Items whose value is a comma-separated list of chain ids rather than one. */ +const CHAIN_LIST_ITEM = /asym_id_list$/; + +/** `_atom_site.auth_asym_id` is what listResidues and listChains read; without it they read label. */ +function effectiveScheme(text) { + return /_atom_site\.auth_asym_id/.test(text) ? 'auth' : 'label'; +} + +/** + * Give an alphabetic name to each chain a motif cannot address, and leave every other chain alone. + * @param {string[]} chains + * @returns {Map} original -> alias, for the chains that were renamed + */ +export function planChainRenames(chains) { + // Every chain that keeps its name is off limits as an alias, whatever its length. + const taken = new Set(chains.filter(isNameableChain)); + const renames = new Map(); + const pending = []; + + // First pass: keep the initial letter where it is free, so A1 stays recognisably A. + for (const chain of chains) { + if (isNameableChain(chain)) continue; + const first = chain[0]; + if (CHAIN_ALPHABET.includes(first) && !taken.has(first)) { + taken.add(first); + renames.set(chain, first); + } else { + pending.push(chain); + } + } + + // Second pass: the rest in name order, two-letter names once the single letters are used up. + const names = alphabeticNames(); + for (const chain of pending) { + let next = names.next(); + while (!next.done && taken.has(next.value)) next = names.next(); + if (next.done) break; // 2756 chains renamed: give up rather than guess + taken.add(next.value); + renames.set(chain, next.value); + } + return renames; +} + +function renameChainsPdb(text, renames) { + // A PDB chain field is a single column, so a long name cannot have come from one. Kept for + // completeness: renaming single-character chains is still a legitimate request. + return text.split('\n').map((line) => { + const record = line.slice(0, 6); + if (record !== 'ATOM ' && record !== 'HETATM' && !line.startsWith('TER')) return line; + if (line.length <= 21) return line; + const alias = renames.get(line[21]); + return alias ? `${line.slice(0, 21)}${alias}${line.slice(22)}` : line; + }).join('\n'); +} + +/** Replace one span of a line, keeping the field's original width so a column-aligned file stays so. */ +function spliceField(line, span, value) { + return line.slice(0, span.start) + value.padEnd(span.end - span.start, ' ') + line.slice(span.end); +} + +function renameChainsCif(text, renames, scheme) { + const matches = CHAIN_ITEMS[scheme]; + const lines = text.split('\n'); + + /** One value, or a comma-separated list of them. */ + const rewrite = (item, value) => { + if (CHAIN_LIST_ITEM.test(item)) { + const parts = value.split(','); + const out = parts.map(p => renames.get(p.trim()) ?? p); + return out.some((p, i) => p !== parts[i]) ? out.join(',') : null; + } + return renames.get(value) ?? null; + }; + + for (let i = 0; i < lines.length;) { + const trimmed = lines[i].trim(); + + // Key-value form: `_struct_site_gen.auth_asym_id A1` + if (trimmed.startsWith('_') && trimmed !== 'loop_') { + const spans = splitCifRowSpans(lines[i]); + if (spans.length >= 2 && matches.test(spans[0].value)) { + const alias = rewrite(spans[0].value, spans[1].value); + if (alias) lines[i] = spliceField(lines[i], spans[1], alias); + } + i++; + continue; + } + + if (trimmed !== 'loop_') { i++; continue; } + i++; + + const headers = []; + while (i < lines.length && lines[i].trim().startsWith('_')) { + headers.push(lines[i].trim().split(/\s+/)[0]); + i++; + } + const columns = headers + .map((h, idx) => (matches.test(h) ? idx : -1)) + .filter(idx => idx >= 0); + if (columns.length === 0) continue; + + for (; i < lines.length; i++) { + const t = lines[i].trim(); + if (t === '' || t === 'loop_' || t.startsWith('_') || t.startsWith('#') + || t.startsWith('data_')) { + break; + } + const spans = splitCifRowSpans(lines[i]); + // Rightmost first, so an earlier splice cannot move a later span's offsets. + for (const column of [...columns].reverse()) { + if (spans.length <= column) continue; + const alias = rewrite(headers[column], spans[column].value); + if (alias) lines[i] = spliceField(lines[i], spans[column], alias); + } + } + } + return lines.join('\n'); +} + +/** + * Rewrite a structure's chain names. + * + * @param {string} text + * @param {Map|object} renames original -> alias + * @param {{scheme?: 'auth'|'label'|'effective'}} [opts] which mmCIF naming scheme to rewrite. + * Default 'effective': the one a reader would surface, which is `auth` when the file has auth + * columns and `label` when it does not — the same choice listResidues and listChains make, so the + * names a motif was built from are the names that get rewritten. + * @returns {string} the same structure with those chains renamed; unchanged if nothing matched + */ +export function renameChains(text, renames, { scheme = 'effective' } = {}) { + const map = renames instanceof Map ? renames : new Map(Object.entries(renames ?? {})); + if (typeof text !== 'string' || map.size === 0) return text; + if (!isCif(text)) return renameChainsPdb(text, map); + return renameChainsCif(text, map, scheme === 'effective' ? effectiveScheme(text) : scheme); +} diff --git a/frontend/lib/targetName.js b/frontend/lib/targetName.js new file mode 100644 index 00000000..86a4ad59 --- /dev/null +++ b/frontend/lib/targetName.js @@ -0,0 +1,48 @@ +// Reading an accession and a chain out of a target name. Extracted from Utilities.js + +export const getChainName = (name) => { + if (/_v[0-9]+$/.test(name) || /^AF-\W+-/.test(name)) { + return "A"; + } + + if (name.includes(' ')) { + name = name.split(' ')[0] + } + + let pos = name.lastIndexOf("_"); + if (pos != -1) { + let match = name.substring(pos + 1); + return match.length >= 1 && isNaN(Number(match[0])) ? match[0] : "A"; + } + // fallback + return "A"; +}; + +export const getAccession = (name) => { + if (/-_-_-_/.test(name)) { + name = name.split("-_-_-_")[0]; + } + + if (name.includes(' ')) { + name = name.split(' ')[0] + } + + if (/^AF-\w+-/.test(name)) { + name = name.split("-")[1]; + } + + // name = name.replaceAll(/-assembly[0-9]/g, ""); + name = name.replaceAll(/\.(cif|pdb|gz)/g, ""); + + if (/_v[0-9]+$/.test(name)) { + return name; + } + + if (/_unrelaxed_rank_/.test(name)) { + let pos = name.indexOf("_unrelaxed_rank_"); + return pos != -1 ? name.substring(0, pos) : name; + } + + let pos = name.lastIndexOf("_"); + return pos != -1 ? name.substring(0, pos) : name; +}; diff --git a/frontend/lib/taxonomyFilter.js b/frontend/lib/taxonomyFilter.js index 7f50281b..e00645e8 100644 --- a/frontend/lib/taxonomyFilter.js +++ b/frontend/lib/taxonomyFilter.js @@ -1,10 +1,5 @@ // Taxonomy subtree expansion over a kraken-style report. -// -// `entry.taxonomyreports[0]` is a flat array in depth-first order: -// { taxon_id, name, rank, depth, proportion, clade_reads, taxon_reads } -// A node's descendants are therefore the rows that follow it with a greater `depth`, up to the -// first row that is not deeper. This is the logic SankeyDiagram.findChildren implements against -// its parsed nodes; extracted here so the API can expand a subtree whether or not the diagram +// Extracted here so the API can expand a subtree whether or not the diagram // has ever been rendered, and so both use the same definition. /** Row for a taxon id, or null. Ids are compared as strings — the report stores them that way. */ diff --git a/frontend/lib/ticketRoute.js b/frontend/lib/ticketRoute.js index 57074d68..12451e7e 100644 --- a/frontend/lib/ticketRoute.js +++ b/frontend/lib/ticketRoute.js @@ -1,11 +1,4 @@ // Where does a ticket's result live? -// -// This mapping existed twice — History.vue (keyed by normalised UI type) and Queue.vue (keyed by -// raw backend type) — so it lives here once and both import it, along with the ticket-navigation -// API. Same reason resultSort.js and accession.js exist. -// -// The important trick is History's fallback: when the type is not known, route to /queue/ and -// let Queue.vue resolve the type and redirect. That means a caller never has to know the type. /** Sentinel for a COMPLETE job whose type we do not render a dedicated avatar for. */ export const RAW_TYPE = 'raw'; @@ -49,13 +42,7 @@ export function routeNameForType(uiType) { } } -/** - * Accept either spelling of a type and return the normalised UI one. - * - * Both spellings are in circulation: the type store caches normalised types while - * `api/ticket/type/{id}` returns raw backend ones. Anything unmappable comes back as RAW_TYPE. - * Idempotent, so it is safe to apply to a value that has already been through it. - */ +// Accept either spelling of a type and return the normalised UI one. export function asUiType(type) { if (!type) { return RAW_TYPE; @@ -63,13 +50,7 @@ export function asUiType(type) { return routeNameForType(type) ? type : normalizeJobType(type); } -/** - * Route descriptor for a ticket. - * - * `type` may be a normalised UI type OR a raw backend type — both are accepted, since History - * caches the former and `api/ticket/type/{id}` returns the latter. Unknown or absent type falls - * back to the queue, which resolves and redirects on its own. - */ +//Route descriptor for a ticket. export function routeForTicket(ticket, type = null, { entry = 0 } = {}) { const ui = type ? asUiType(type) : null; const name = ui ? routeNameForType(ui) : null; diff --git a/frontend/lib/vue-simple-portal/components/Portal.js b/frontend/lib/vue-simple-portal/components/Portal.js index ebb9e28f..2a41e891 100644 --- a/frontend/lib/vue-simple-portal/components/Portal.js +++ b/frontend/lib/vue-simple-portal/components/Portal.js @@ -1,6 +1,6 @@ import Vue from 'vue' -import config, { isBrowser } from '../config' -import TargetContainer from './TargetContainer' +import config, { isBrowser } from '../config.js' +import TargetContainer from './TargetContainer.js' export default Vue.extend({ name: 'VueSimplePortal', diff --git a/frontend/lib/vue-simple-portal/index.js b/frontend/lib/vue-simple-portal/index.js index ed099fe0..199e156c 100644 --- a/frontend/lib/vue-simple-portal/index.js +++ b/frontend/lib/vue-simple-portal/index.js @@ -1,6 +1,6 @@ import Vue from 'vue' -import Portal from './components/Portal' -import config, { setSelector } from './config' +import Portal from './components/Portal.js' +import config, { setSelector } from './config.js' function install(_Vue, options = {}) { _Vue.component(options.name || 'portal', Portal) diff --git a/frontend/webpack.frontend.config.js b/frontend/webpack.frontend.config.js index bfd63d43..af4db0c1 100644 --- a/frontend/webpack.frontend.config.js +++ b/frontend/webpack.frontend.config.js @@ -26,7 +26,7 @@ if (['mmseqs', 'foldseek', 'foldmason'].includes(frontendApp) == false) { } const fs = require('fs'); -const parsePo = require('./lib/po-reader'); +const parsePo = require('./build-tools/po-reader'); const appStrings = { mmseqs: parsePo(fs.readFileSync('./frontend/assets/mmseqs.en_US.po', { encoding: 'utf8', flag: 'r' })).translations, foldseek: parsePo(fs.readFileSync('./frontend/assets/foldseek.en_US.po', { encoding: 'utf8', flag: 'r' })).translations, @@ -75,6 +75,9 @@ module.exports = (env, argv) => { { test: /\.js$/, loader: 'babel-loader', + options: { + babelrcRoots: [path.resolve(__dirname, '..'), path.resolve(__dirname, 'lib')], + }, include: [ path.resolve(__dirname), path.resolve(__dirname, '../node_modules/vuetify/src') @@ -131,7 +134,7 @@ module.exports = (env, argv) => { { test: /\.po$/, use: [ - { loader: path.resolve('./frontend/lib/po-loader.js') }, + { loader: path.resolve('./frontend/build-tools/po-loader.js') }, ] } ] From f127495beaae4985ccf275dac1c92e49e10d2598 Mon Sep 17 00:00:00 2001 From: younghoon Date: Tue, 8 Sep 2026 00:15:50 +0900 Subject: [PATCH 2/2] Refactor: removed legacy browser-api --- frontend/FoldDiscoSearch.vue | 111 +----- frontend/FoldMasonSearch.vue | 154 +------ frontend/MSA.vue | 524 +----------------------- frontend/MultimerSearch.vue | 13 +- frontend/Queue.vue | 73 +--- frontend/ResultFoldDisco.vue | 586 +-------------------------- frontend/ResultFoldDiscoDB.vue | 21 - frontend/ResultFoldMason.vue | 109 ----- frontend/ResultFoldseekDB.vue | 21 - frontend/ResultView.vue | 705 +-------------------------------- frontend/Search.vue | 13 +- frontend/SearchApiMixin.vue | 425 -------------------- frontend/SendToMixin.vue | 137 ------- 13 files changed, 40 insertions(+), 2852 deletions(-) delete mode 100644 frontend/SearchApiMixin.vue delete mode 100644 frontend/SendToMixin.vue diff --git a/frontend/FoldDiscoSearch.vue b/frontend/FoldDiscoSearch.vue index a1f4f29a..e09fbf98 100644 --- a/frontend/FoldDiscoSearch.vue +++ b/frontend/FoldDiscoSearch.vue @@ -186,8 +186,6 @@ import Databases from './Databases.vue'; import QueryTextarea from "./QueryTextarea.vue"; import MotifSelection from "./MotifSelection.vue"; import LigandMotifSelection from "./LigandMotifSelection.vue"; -import SearchApiMixin from "./SearchApiMixin.vue"; -import { searchBindingSites, fetchBindingSite } from "./lib/accession.js"; const db = BlobDatabase(); const storage = new StorageWrapper("folddisco"); @@ -323,12 +321,11 @@ function setDefaultMotif(structure) { } // FoldDisco's backend caps a motif at 32 residues; single source for the check and the report. -const MOTIF_RESIDUE_LIMIT = 32; export default { name: "FolddiscoSearch", tool: "folddisco", - mixins: [ HistoryMixin, SearchApiMixin ], + mixins: [ HistoryMixin ], components: { Panel, FileButton, @@ -473,112 +470,6 @@ export default { // }, }, methods: { - searchApiConfig() { - return { - tool: 'folddisco', - modeInfix: 'FOLDDISCO_', modeValuePrefix: '', - accessionExtras: ['QBioLip'], - sendsMode: false, // search() has `mode` commented out - needsQueryStructure: true, // isMotifValid needs the parsed structure - supportsTaxonomy: false, // taxfilter is commented out in search() - supportsIterative: false, - }; - }, - // ---- FoldDisco-specific API surface (see claude-plan/ai-friendly-search) ---- - searchApiExtraValidation() { - const out = []; - if (!this.queryStructure) out.push('query structure has not parsed yet'); - else if (!this.isMotifValid) out.push('motif is invalid for the loaded structure'); - if (this.motifLen > 32) out.push(`motif has ${this.motifLen} residues; the limit is 32`); - return out; - }, - // Metadata only, mirroring how `query` reports length-not-text: an auto-populated motif is - // the whole chain (801 residues for 4HHB = 914 tokens) and is by definition unsubmittable - // past 32, so shipping the string in an orientation call is pure cost. getMotif() has it. - searchApiExtraState() { - const { motif, ...meta } = this.getMotif(); - return { motif: { ...meta, residues: 'call getMotif() for the residue list' } }; - }, - searchApiExtraNotes() { - return [ - 'The motif is auto-populated from the structure by setQuery(); setMotif() is an ' - + 'optional override and is order-independent.', - 'searchBindingSites()/loadBindingSite() load a Q-BioLiP site and its motif ' - + 'together — the shortest path to a valid FoldDisco query.', - ]; - }, - searchApiExtraMethods() { - return { - getMotif: this.getMotif, - setMotif: this.setMotif, - searchBindingSites: this.apiSearchBindingSites, - loadBindingSite: this.apiLoadBindingSite, - }; - }, - // `valid` is gone rather than redefined. It meant "the residues resolve against the loaded - // structure", which is not what the word implies: an auto-populated 801-residue motif was - // `valid: true` while `error` said "Motif too long" and validate() refused to submit. A stale - // reader of a redefined field gets no warning, so the name is retired. - getMotif() { - const length = this.motifLen; - const withinLimit = length > 0 && length <= MOTIF_RESIDUE_LIMIT; - return { - motif: this.motif ?? '', - length, - limit: MOTIF_RESIDUE_LIMIT, - residuesResolved: !!this.isMotifValid, - withinLimit, - // The field a caller actually wants, and validate()-consistent by construction. - submittable: !!this.isMotifValid && withinLimit, - error: this.motifError || null, - }; - }, - // Order-independent by design. The query watcher calls setDefaultMotif() whenever the - // query changes, so a motif set *before* setQuery() would be silently discarded. - // pendingMotif is the component's own mechanism for exactly this race (onMotifSelect - // uses it for the accession flow), so reuse it rather than documenting an ordering rule. - setMotif(motif) { - const value = String(motif ?? ''); - this.pendingMotif = { query: this.query, motif: value }; - this.motif = value; - return this.getMotif(); - }, - async apiSearchBindingSites(pdbId) { - if (!pdbId) return { ok: false, reason: 'pdbId is empty' }; - try { - const results = await searchBindingSites(pdbId); - this._bindingSites = results; - return { ok: true, pdbId: String(pdbId).toUpperCase(), - sites: results.map((item, index) => ({ - index, - ligand: item.Ligand?.ligname ?? null, - assembly: item.Receptor?.assembly ?? null, - relevant: item.Complex?.relvant === '1', - residues: (item.Complex?.bs ?? '').trim().split(/\s+/).filter(Boolean), - })) }; - } catch { - return { ok: false, reason: `Q-BioLiP lookup failed for ${pdbId}` }; - } - }, - async apiLoadBindingSite(index) { - const sites = this._bindingSites ?? []; - const item = sites[Number(index)]; - if (!item) { - return { ok: false, reason: 'no such binding site; call searchBindingSites() first', - available: sites.length }; - } - let got; - try { - got = await fetchBindingSite(item); - } catch { - return { ok: false, reason: 'failed to load the binding-site structure' }; - } - // Same order the accession button uses: set the query, then hand over the motif via - // pendingMotif so the async query watcher restores it instead of defaulting. - await this.setQuery(got.text); - this.setMotif(got.motif); - return { ok: true, name: got.name, motif: this.getMotif() }; - }, async search() { var request = { q: this.query, diff --git a/frontend/FoldMasonSearch.vue b/frontend/FoldMasonSearch.vue index 8c04e0bc..8ae92a86 100644 --- a/frontend/FoldMasonSearch.vue +++ b/frontend/FoldMasonSearch.vue @@ -131,6 +131,7 @@ diff --git a/frontend/SendToMixin.vue b/frontend/SendToMixin.vue deleted file mode 100644 index 0eb59462..00000000 --- a/frontend/SendToMixin.vue +++ /dev/null @@ -1,137 +0,0 @@ -