From 632dcab38d9c516f6cee447a70c06765e29526c0 Mon Sep 17 00:00:00 2001 From: Brian Cully Date: Fri, 19 Jan 2024 20:35:36 -0500 Subject: add javascript version --- .gitignore | 2 + Makefile | 11 ++ js-ver.org | 513 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ manifest.scm | 4 + style.css | 51 ++++++ 5 files changed, 581 insertions(+) create mode 100644 Makefile create mode 100644 js-ver.org create mode 100644 manifest.scm create mode 100644 style.css diff --git a/.gitignore b/.gitignore index 46a569b..2737664 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /done/ +*.mjs +*.html diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5eed711 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +HTTP_PORT = 8000 +HTTP_IF = -b :: + +PYTHON3 = python3 +ORGC = emacs -Q --batch --eval "(progn (require 'ob-tangle) (dolist (file command-line-args-left) (with-current-buffer (find-file-noselect file) (org-babel-tangle))))" + +index.html: js-ver.org + $(ORGC) $< + +run: index.html + $(PYTHON3) -m http.server $(HTTP_IF) $(HTTP_PORT) diff --git a/js-ver.org b/js-ver.org new file mode 100644 index 0000000..fea26bd --- /dev/null +++ b/js-ver.org @@ -0,0 +1,513 @@ +#+startup: showall + +* http server +** guix +#+begin_src shell + guix shell -m manifest.scm -- make run +#+end_src + +#+RESULTS: + +** by hand +start a python http server with: + +1. to compile this org document, call ~org-babel-tangle~ (=C-c C-v t=), which will produce =index.html=. alternately, you can just call =make= (a =manifest.scm= is provided for guix) + +2. start an http server quickly in python on port 8000 to work around cors issues with file uris: +#+begin_src shell + python3 -m http.server -b :: 8000 +#+end_src + +3. then navigate to http://localhost:8000/ + +* html skeleton +#+name: skeleton +#+begin_src html :noweb yes :tangle index.html + + + + + + + + + + + + functional annotation for varscan + + +

functional annotation for varscan

+ +
+ + + + + + + + + + +
+ +
+
+ + + + + +#+end_src + +* javascript +** entry point +set up the javascript code by loading direct module dependencies and running the analysis when the form is submitted. + +#+begin_src javascript :noweb yes :tangle main.mjs + import codon2AA from './codon2AminoAcid.mjs'; + import aa2Code from './aminoAcid2Code.mjs'; + import process from './varscan.mjs'; + import Log from './logging.mjs'; + + function init() { + console.info('initializing'); + + const varscanForm = document.querySelector('#varscan'); + varscanForm.onsubmit = (event) => { + submitForm(varscanForm); + event.preventDefault(); + }; + const variantsInput = varscanForm.querySelector('input[type="file"]'); + variantsInput.onchange = (event) => { + Log.info("file uploaded"); + submitButton.disabled = variantsInput.value === ""; + }; + + const submitButton = varscanForm.querySelector('button'); + submitButton.disabled = variantsInput.value === ""; + } + + init(); +#+end_src + +*** form submission +when the form is submitted, load the reference genome,its protein coding regions, the uploaded variant data, and spit out the changed proteins. +#+name: submit-form +#+begin_src javascript :noweb yes :tangle main.mjs + function submitForm(form) { + Log.clear(); + + <> + <> + <> + + const filename = + form + .querySelector('input[name="variants-data"]') + .files[0] + .name; + const extIndex = filename.lastIndexOf('.'); + const resultsFilename = + filename.substring(0, extIndex) + "-varscan" + filename.substring(extIndex); + + Promise.all([variantsPromise, genomePromise, protein2PosPromise]) + .then(([variants, genome, protein2Pos]) => { + const results = process(codon2AA, aa2Code, genome, protein2Pos, variants); + updateDownloadLink(resultsFilename, results); + fillTable(results); + }); + } +#+end_src + +*** TODO variants upload +we don't need to upload the file, but we're going to pretend we do for ux reasons. this just returns a promise for the “uploaded” file's csv data. +- [ ] verify file format is csv without (or with) headers +#+name: upload-variant +#+begin_src javascript + const variantsPromise = new Promise((resolve, reject) => { + const file = form.querySelector('input[name="variants-data"]').files[0]; + const reader = new FileReader(); + reader.onload = (event) => { + resolve(event.target.result); + }; + reader.readAsText(file); + }); +#+end_src + +*** reference genome promise +load the reference genome from the server based on the filename in the form. + +#+name: reference-genome +#+begin_src javascript + const referenceGenomeFile = + form.querySelector('select[name="reference-genome"]').value; + + const genomePromise = fetch(referenceGenomeFile) + .then((response) => response.text()) + .then((text) => text.replace('\r', '').replace('\n', '')) + .catch((err) => Log.error("couldn't load reference genome:", err)); +#+end_src + +*** TODO protein coding regions promise +load the protein coding regions from the server based on the filename in the form. + +- [ ] this should probably be tied to the reference genome + +#+name: protein-coding-region +#+begin_src javascript + const proteinCodingRegionsFile = + form.querySelector('select[name="protein-coding-regions"]').value; + + const protein2PosPromise = fetch(proteinCodingRegionsFile) + .then((response) => response.text()) + .then((text) => { + return text + .replace('\r', '') + .split('\n') + .reduce( + (acc, line) => { + const [name, start, stop] = line.split(','); + acc[name] = [Number(start), Number(stop)]; + return acc; + }, + {}); + }) + .catch((err) => Log.error("couldn't load protein coding regions:", err)); +#+end_src + +*** display results +take the results and stuff them in the output table +#+begin_src javascript :noweb yes :tangle main.mjs + function fillTable(results) { + const outputDiv = document.querySelector('#results') + outputDiv.hidden = false; + + const tbody = outputDiv.querySelector('table tbody'); + // clear the table + while (tbody.firstChild) { + tbody.removeChild(tbody.firstChild); + } + + results.forEach((row) => { + const tr = document.createElement('tr'); + row.forEach((col) => { + const td = document.createElement('td'); + td.innerHTML = col; + tr.appendChild(td); + }) + tbody.appendChild(tr); + }); + } +#+end_src + +create a download link +#+begin_src javascript :noweb yes :tangle main.mjs + function updateDownloadLink(filename, results) { + const anchor = document.querySelector('#download'); + if (anchor.url) { + window.URL.revokeObjectURL(anchor.url); + anchor.url = undefined; + } + + const data = results.map((row) => row.join(",") + "\r\n"); + const blob = new Blob( + data, + { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + anchor.href = url; + anchor.download = filename; + + return anchor; + } +#+end_src + +** variant to codon whatever i need to name this +#+begin_src javascript :tangle varscan.mjs + import Log from './logging.mjs'; + + function process(codon2AA, aa2Code, genome, protein2Pos, variants) { + let lineno = 0; + let name = 'unnamed-change'; + return variants + .split('\n') + .reduce( + (acc, line) => { + lineno++; + line.replace('\r', ''); + if (line === "") { + return acc; + } + + const [nameMaybe, ref, posStr, origNucleotide, newNucleotide] = + line.split(','); + name = nameMaybe || name; + const pos = Number(posStr); + + const [protein, proteinStart, aaIndex] = + findProtein(pos, protein2Pos); + if (!protein) { + return acc.concat([[ + name, + origNucleotide.toLowerCase()+posStr+newNucleotide.toLowerCase(), + '', + "non-coding" + ]]); + } + + // start of codon relative to start of protein, 0-based + const changedStart = pos - proteinStart; + + // convert position to start of the codon and the + // offset of the change from the start of the codon. + const offset = changedStart % 3; + const codonStart = changedStart - offset; + + // pos points to the change within the genome (1-index) + // proteinStart points to the start of the changed protein within the genome (1-index) + // changedStart points to the changed nucleotide within the protein (0-index) + // codonStart points to the start of the changed codon within the protein (0-index) + // offset points to the changed nucleotide within the codon (0-index) + + // position of codon within entire genome (0-index) + const absCodonStart = proteinStart + codonStart - 1; + + const origCodon = genome.substring(absCodonStart, absCodonStart + 3); + const origAA = aa2Code[codon2AA[origCodon]]; + const newCodon = origCodon.substring(0, offset) + newNucleotide + origCodon.substring(offset+1); + const newAA = aa2Code[codon2AA[newCodon]]; + + // check the change against the reference genome. + const checkNucleotide = origCodon[offset]; + if (checkNucleotide !== origNucleotide) { + const checkCodon = + origCodon.substring(0, offset) + + origNucleotide + + origCodon.substring(offset+1); + const checkAA = aa2Code[codon2AA[checkCodon]]; + const aaChange = `${checkAA}${aaIndex}${newAA}` + Log.warn(`${name} (line ${lineno}): nucleotide at position ${pos} is “${checkNucleotide.toLowerCase()}” in the reference, but “${origNucleotide.toLowerCase()}” was supplied. If the supplied nucleotide is correct, then the amino acid change is ${aaChange}.`) + } + + const nucleotideChange = `${checkNucleotide.toLowerCase()}${pos}${newNucleotide.toLowerCase()}` + const aaChange = `${origAA}${aaIndex}${newAA}` + return acc.concat([[ + name, + nucleotideChange, + protein, + aaChange + ]]); + }, + []); + } + + // pos is 1-based index + // + // returns protein name, 1-index of start of protein in genome, and + // 1-index of of offset of `pos` in its codon. + function findProtein(pos, protein2Pos) { + for (const name in protein2Pos) { + const [start, end] = protein2Pos[name]; + if (start <= pos && pos <= end) { + // normal people count from 1, not 0 + const index = Math.floor((pos - start) / 3) + 1; + return [name, start, index]; + } + } + return []; + } + export default process +#+end_src + +** codon to amino acid table +:PROPERTIES: +:VISIBILITY: folded +:END: + +#+begin_src javascript :noweb yes :tangle codon2AminoAcid.mjs + const codon2AA = { + 'GCT': 'Ala', + 'GCC': 'Ala', + 'GCA': 'Ala', + 'GCG': 'Ala', + 'CGT': 'Arg', + 'CGC': 'Arg', + 'CGA': 'Arg', + 'CGG': 'Arg', + 'AGA': 'Arg', + 'AGG': 'Arg', + 'AAT': 'Asn', + 'AAC': 'Asn', + 'GAT': 'Asp', + 'GAC': 'Asp', + 'TGT': 'Cys', + 'TGC': 'Cys', + 'CAA': 'Gln', + 'CAG': 'Gln', + 'GAA': 'Glu', + 'GAG': 'Glu', + 'GGT': 'Gly', + 'GGC': 'Gly', + 'GGA': 'Gly', + 'GGG': 'Gly', + 'CAT': 'His', + 'CAC': 'His', + 'ATT': 'Ile', + 'ATC': 'Ile', + 'ATA': 'Ile', + 'CTT': 'Leu', + 'CTC': 'Leu', + 'CTA': 'Leu', + 'CTG': 'Leu', + 'TTA': 'Leu', + 'TTG': 'Leu', + 'AAA': 'Lys', + 'AAG': 'Lys', + 'ATG': 'Met', + 'TTT': 'Phe', + 'TTC': 'Phe', + 'CCT': 'Pro', + 'CCC': 'Pro', + 'CCA': 'Pro', + 'CCG': 'Pro', + 'TCT': 'Ser', + 'TCC': 'Ser', + 'TCA': 'Ser', + 'TCG': 'Ser', + 'AGT': 'Ser', + 'AGC': 'Ser', + 'ACT': 'Thr', + 'ACC': 'Thr', + 'ACA': 'Thr', + 'ACG': 'Thr', + 'TGG': 'Trp', + 'TAT': 'Tyr', + 'TAC': 'Tyr', + 'GTT': 'Val', + 'GTC': 'Val', + 'GTA': 'Val', + 'GTG': 'Val', + 'TAA': 'STOP', + 'TGA': 'STOP', + 'TAG': 'STOP' + } + + export default codon2AA +#+end_src + +** amino acid to letter table +:PROPERTIES: +:VISIBILITY: folded +:END: + +#+begin_src javascript :noweb yes :tangle aminoAcid2Code.mjs + const aa2Code = { + 'Ala': 'A', + 'Arg': 'R', + 'Asn': 'N', + 'Asp': 'D', + 'Cys': 'C', + 'Gln': 'Q', + 'Glu': 'E', + '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', + 'STOP': '*' + } + + export default aa2Code +#+end_src + +** logging +#+begin_src javascript :noweb yes :tangle logging.mjs + class Log { + get elt() { + if (!document.querySelector('#log')) { + const elt = document.createElement('div'); + elt.id = 'log'; + document.body.appendChild(elt); + } + return document.querySelector('#log'); + } + + clear() { + while (this.elt.firstChild) { + this.elt.removeChild(this.elt.firstChild); + } + } + + logAt(level, items) { + const list = document.createElement('ul'); + items.forEach((item) => { + const msg = document.createElement('li'); + msg.setAttribute('class', level); + msg.innerText = item; + list.appendChild(msg); + }); + this.elt.appendChild(list); + } + + error(...items) { + this.logAt('error', items); + console.error.apply(null, items) + } + + warn(...items) { + this.logAt('warn', items); + console.warn.apply(null, items) + } + + info(...items) { + console.info.apply(null, items) + } + + debug(...items) { + console.debug.apply(null, items) + } + }; + Log.logger = new Log(); + Log.clear = Log.logger.clear.bind(Log.logger); + Log.error = Log.logger.error.bind(Log.logger); + Log.warn = Log.logger.warn.bind(Log.logger); + Log.info = Log.logger.info.bind(Log.logger); + Log.debug = Log.logger.debug.bind(Log.logger); + + export default Log +#+end_src diff --git a/manifest.scm b/manifest.scm new file mode 100644 index 0000000..18687f9 --- /dev/null +++ b/manifest.scm @@ -0,0 +1,4 @@ +(specifications->manifest + '("perl" + "make" + "python")) diff --git a/style.css b/style.css new file mode 100644 index 0000000..84fe203 --- /dev/null +++ b/style.css @@ -0,0 +1,51 @@ +form { + display: grid; + grid-template-columns: [label] max-content [input] max-content; +} + +form label { + margin: 1ex 0.5em; + grid-column-start: label; + justify-self: end; +} + +form input, +form select { + margin: 1ex 0.5em; + grid-column-start: input; +} + +form button { + padding: 1ex 2em; + width: 7em; + justify-self: end; + grid-column-start: input; +} + +#output { + display: grid; + grid-template-columns: max-content; + margin-top: 1ex; +} + +#download { + justify-self: end; +} + +#output table { + margin-top: 1ex; + border-collapse: collapse; +} + +#output table thead { + font-weight: bold; +} + +#output table tr { +// border-bottom: 1px solid black; +} + +#output table td { + padding: 0.5ex 0.5em; + border: 1px solid black; +} -- cgit v1.2.3