diff --git a/demo/Fragmentation.jsx b/demo/Fragmentation.jsx new file mode 100644 index 0000000..ebdd4c4 --- /dev/null +++ b/demo/Fragmentation.jsx @@ -0,0 +1,17 @@ +import { SVGMassFragmentation } from '../src/components/SVGMassFragmentation'; + +export default function Fragmentation() { + return ( + <> +

Fragmentation

+
+ +
+ + ); +} diff --git a/demo/Fragmentation.tsx b/demo/Fragmentation.tsx deleted file mode 100644 index 12f240d..0000000 --- a/demo/Fragmentation.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { SVGMassFragmentation } from '../src/components/SVGMassFragmentation'; - -export default function Fragmentation() { - return ( -
- -
- ); -} diff --git a/demo/main.jsx b/demo/main.jsx new file mode 100644 index 0000000..d9aeebd --- /dev/null +++ b/demo/main.jsx @@ -0,0 +1,7 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; + +import Fragmentation from './Fragmentation'; + +const root = createRoot(document.getElementById('root')); +root.render(); diff --git a/demo/main.tsx b/demo/main.tsx deleted file mode 100644 index 243ee11..0000000 --- a/demo/main.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; - -import Fragmentation from './Fragmentation'; - -ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - -

Fragmentation

- -
, -); diff --git a/index.html b/index.html index 4d3c062..31b9988 100644 --- a/index.html +++ b/index.html @@ -1,4 +1,4 @@ - + @@ -7,6 +7,6 @@
- + diff --git a/src/appendResidues.js b/src/appendResidues.js new file mode 100644 index 0000000..5ba772e --- /dev/null +++ b/src/appendResidues.js @@ -0,0 +1,209 @@ +import { groupsObject } from 'chemical-groups'; +import * as Nucleotide from 'nucleotide'; +import * as Peptide from 'peptide'; + +const ALTERNATIVES = ['', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹']; +const SYMBOLS = ['Θ', 'Δ', 'Λ', 'Φ', 'Ω', 'Γ', 'Χ']; + +let currentSymbol = 0; + +/** + * Code that allows to split a sequence of amino acids or nucleotides natural or non natural + * @param {string} [sequence] + * @param {object} [options={}] + * @param {string} [options.kind] - peptide, rna, ds-dna or dna. Default if contains U: rna, otherwise ds-dna + * @param {string} [options.fivePrime=monophosphate] - alcohol, monophosphate, diphosphate, triphosphate + * @param {string} [options.circular=false] + */ + +export function appendResidues(data, sequence, options = {}) { + const { kind = 'peptide' } = options; + + currentSymbol = 0; + // we normalize the sequence to 3 letter codes + + if (kind === 'peptide') { + sequence = Peptide.sequenceToMF(sequence); + } else { + sequence = Nucleotide.sequenceToMF(sequence, options); + } + + const result = { + begin: '', + end: '', + residues: [], + }; + + const STATE_BEGIN = 0; + const STATE_MIDDLE = 1; + const STATE_END = 2; + + let parenthesisLevel = 0; + let state = STATE_BEGIN; // as long as we don't have an uppercase followed by 2 lowercases + for (let i = 0; i < sequence.length; i++) { + let currentChar = sequence.charAt(i); + let nextChar = i < sequence.length - 1 ? sequence.charAt(i + 1) : ''; + let nextNextChar = i < sequence.length - 2 ? sequence.charAt(i + 2) : ''; + + if ( + state === STATE_BEGIN && + currentChar.match(/[A-Z]/) && + nextChar.match(/[a-z]/) && + nextNextChar.match(/[a-z]/) && + parenthesisLevel === 0 + ) { + state = STATE_MIDDLE; + } + + if ( + state === STATE_MIDDLE && + !sequence.substring(i).match(/[A-Z][a-z][a-z]/) && + !currentChar.match(/[a-z]/) && + parenthesisLevel === 0 + ) { + state = STATE_END; + } else if ( + currentChar.match(/[A-Z]/) && + nextChar.match(/[a-z]/) && + nextNextChar.match(/[a-z]/) && + parenthesisLevel === 0 + ) { + result.residues.push(''); + } + + switch (state) { + case STATE_BEGIN: + result.begin = result.begin + currentChar; + break; + case STATE_MIDDLE: + result.residues[result.residues.length - 1] = + result.residues[result.residues.length - 1] + currentChar; + break; + case STATE_END: + result.end = result.end + currentChar; + break; + default: + } + + if (currentChar === '(') { + parenthesisLevel++; + } else if (currentChar === ')') { + parenthesisLevel--; + } + } + + // we process all the residues + let alternatives = {}; + let replacements = {}; + for (let i = 0; i < result.residues.length; i++) { + let label = result.residues[i]; + let residue = { + value: label, + results: { + begin: [], + end: [], + }, + }; + residue.fromBegin = i + 1; + residue.fromEnd = result.residues.length - i; + residue.kind = 'residue'; + if (label.includes('(')) { + getModifiedReplacement(label, residue, alternatives, replacements); + } else if (groupsObject[label] && groupsObject[label].oneLetter) { + residue.label = groupsObject[label].oneLetter; + } else { + getUnknownReplacement(label, residue, replacements); + } + result.residues[i] = residue; + } + result.begin = removeStartEndParenthesis(result.begin); + result.end = removeStartEndParenthesis(result.end); + if (result.begin.length > 2) { + let label = options.kind === 'peptide' ? 'Nter' : "5'"; + replacements[result.begin] = { + label, + }; + result.begin = label; + } + if (result.end.length > 2) { + let label = options.kind === 'peptide' ? 'Cter' : "3'"; + replacements[result.end] = { + label, + }; + result.end = label; + } + + result.begin = { label: result.begin, kind: 'begin' }; + result.end = { label: result.end, kind: 'end' }; + result.alternatives = alternatives; + result.replacements = replacements; + + result.all = [result.begin].concat(result.residues, [result.end]); + + result.all.forEach((entry) => { + entry.info = { + nbOver: 0, + nbUnder: 0, + }; + }); + + data.residues = result; +} + +function getUnknownReplacement(unknownResidue, residue, replacements) { + if (!replacements[unknownResidue]) { + replacements[unknownResidue] = { + label: SYMBOLS[currentSymbol] || '?', + id: unknownResidue, + }; + } + currentSymbol++; + residue.replaced = true; + residue.label = replacements[unknownResidue].label; +} + +function getModifiedReplacement( + modifiedResidue, + residue, + alternatives, + replacements, +) { + if (!replacements[modifiedResidue]) { + let position = modifiedResidue.indexOf('('); + let residueCode = modifiedResidue.substring(0, position); + let modification = removeStartEndParenthesis( + modifiedResidue.substring(position), + ); + + if ( + groupsObject[residueCode] && + groupsObject[residueCode].alternativeOneLetter + ) { + let alternativeOneLetter = groupsObject[residueCode].alternativeOneLetter; + + if (!alternatives[alternativeOneLetter]) { + alternatives[alternativeOneLetter] = { count: 1 }; + } else { + alternatives[alternativeOneLetter].count++; + } + replacements[modifiedResidue] = { + label: + ALTERNATIVES[alternatives[alternativeOneLetter].count - 1] + + alternativeOneLetter, + residue: residueCode, + modification, + }; + } else { + getUnknownReplacement(modifiedResidue, residue, replacements); + } + } + residue.replaced = true; + residue.label = replacements[modifiedResidue].label; +} + +function removeStartEndParenthesis(mf) { + if (mf[0] === '(' && mf[mf.length - 1] === ')') { + return mf.substring(1, mf.length - 1); + } + return mf; +} diff --git a/src/appendResults.js b/src/appendResults.js new file mode 100644 index 0000000..acdfa02 --- /dev/null +++ b/src/appendResults.js @@ -0,0 +1,120 @@ +export function appendResults(data, analysisResult, options = {}) { + const numberResidues = data.residues.residues.length; + const { merge = {}, filter = {} } = options; + + let results = JSON.parse(JSON.stringify(analysisResult)); + results = results.filter((result) => !result.type.match(/^-B[0-9]$/)); + // we calculate all the lines based on the results + for (let result of results) { + let parts = result.type.split(/:|(?=[a-z])/); // we may have ':' but not mandatory + if (parts.length === 2) { + result.internal = true; + if (parts[1].match(/^[abcd][1-9]/)) { + [parts[0], parts[1]] = [parts[1], parts[0]]; + } + result.to = getNumber(parts[0]) - 1; + result.from = numberResidues - getNumber(parts[1]); + } else { + if (parts[0].match(/^[abcd][1-9]/)) { + result.fromBegin = true; + result.position = getNumber(parts[0]) - 1; + } + if (parts[0].match(/^[wxyz][1-9]/)) { + result.fromEnd = true; + result.position = numberResidues - 1 - getNumber(parts[0]); + } + } + + if (result.fromEnd) result.color = 'red'; + if (result.fromBegin) result.color = 'blue'; + if (result.internal) { + switch (result.type.substring(0, 1)) { + case 'a': + result.color = 'green'; + break; + case 'b': + result.color = 'orange'; + break; + case 'c': + result.color = 'cyan'; + break; + default: + result.color = 'green'; + } + } + } + + if (merge.charge) { + const unique = {}; + for (let result of results) { + if (!unique[result.type]) { + unique[result.type] = []; + } + unique[result.type].push(result); + } + results = []; + for (let key in unique) { + let current = unique[key][0]; + current.similarity = unique[key].reduce( + (previous, item) => previous + item.similarity, + 0, + ); + current.similarity = current.similarity / unique[key].length; + results.push(current); + current.charge = ''; + } + } + + for (let result of results) { + if (result.similarity > 0.95) { + result.textColor = 'black'; + } else if (result.similarity > 0.9) { + result.textColor = '#333'; + } else if (result.similariy > 0.8) { + result.textColor = '#666'; + } else { + result.textColor = '#999'; + } + } + + results = filterResults(results, filter); + + // sort by residue length + results.sort((a, b) => a.length - b.length); + data.results = results; +} + +function getNumber(text) { + return Number(text.replace(/^.([0-9]+).*$/, '$1')); +} + +function filterResults(results, filter) { + if (!filter) return; + let { + minRelativeQuantity = 0, + minSimilarity = 0, + minQuantity = 0, + showInternals = true, + } = filter; + + if (minRelativeQuantity) { + minQuantity = + Math.max(...results.map((entry) => entry.quantity)) * minRelativeQuantity; + } + + if (minSimilarity) { + results = results.filter( + (result) => !result.similarity || result.similarity >= minSimilarity, + ); + } + if (minQuantity) { + results = results.filter( + (result) => !result.quantity || result.quantity >= minQuantity, + ); + } + + if (!showInternals) { + results = results.filter((result) => !result.internal); + } + return results; +} diff --git a/src/components/SVGMassFragmentation.jsx b/src/components/SVGMassFragmentation.jsx new file mode 100644 index 0000000..a108189 --- /dev/null +++ b/src/components/SVGMassFragmentation.jsx @@ -0,0 +1,60 @@ +import { appendResidues } from '../appendResidues.js'; +import { appendResults } from '../appendResults.js'; + +import { SVGSequence } from './SVGSequence.jsx'; +import { SVGSequenceElement } from './SVGSequenceElement.jsx'; + +const options = { + width: 600, + leftRightBorders: 50, + spaceBetweenResidues: 20, + spaceBetweenInternalLines: 10, + parsing: { + kind: 'peptide', + }, +}; + +const sequence = + '(Me)MQIFVKTLTGKT(OEt)IT(OMe)LEVEPSD(NH2)TIENVKAKIQD(NH2)KEGIPPDQQ(OMe)'; + +const info = [ + { type: 'b38y33', similarity: 0.9012, charge: 3 }, + { type: 'b30y30', similarity: 0.8686, charge: 3 }, + { type: 'b40y40', similarity: 0.869, charge: 2 }, + { type: 'c1', similarity: 0.8919, charge: 3 }, + { type: 'c1', similarity: 0.9548, charge: 2 }, + { type: 'c5', similarity: 0.8764, charge: 2 }, + { type: 'x1', similarity: 0.8694, charge: 2 }, + { type: 'c28', similarity: 0.8463, charge: 2 }, + { type: 'c29', similarity: 0.8127, charge: 2 }, + { type: 'z28', similarity: 0.7976, charge: 2 }, + { type: 'c30', similarity: 0.7406, charge: 2 }, + { type: 'c27', similarity: 0.7157, charge: 2 }, + { type: 'c34', similarity: 0.6766, charge: 3 }, + { type: 'c17', similarity: 0.6617, charge: 2 }, + { type: 'z9', similarity: 0.6244, charge: 1 }, + { type: 'z9', similarity: 0.6164, charge: 2 }, + { type: 'z9', similarity: 0.5776, charge: 3 }, + { type: 'c38', similarity: 0.5044, charge: 2 }, +]; + +export function SVGMassFragmentation() { + const svgSize = { + width: 1400, + height: 500, + }; + const data = {}; + appendResidues(data, sequence, options.parsing); + console.log(data); + appendResults(data, info); + console.log(data); + + return ( + + + + ); +} diff --git a/src/components/SVGMassFragmentation.tsx b/src/components/SVGMassFragmentation.tsx deleted file mode 100644 index bd32d63..0000000 --- a/src/components/SVGMassFragmentation.tsx +++ /dev/null @@ -1,21 +0,0 @@ -export function SVGMassFragmentation(props) { - const { tree, ...options } = props; - - const svgSize = { - width: 500, - height: 500, - }; - - return ( - - - - ); -} diff --git a/src/components/SVGSequence.jsx b/src/components/SVGSequence.jsx new file mode 100644 index 0000000..cd412d2 --- /dev/null +++ b/src/components/SVGSequence.jsx @@ -0,0 +1,23 @@ +import { appendResidues } from '../appendResidues'; + +import { SVGSequenceElement } from './SVGSequenceElement'; + +export function SVGSequence({ sequence, parsing }) { + const grey = '#cccccc'; + const black = '#555555'; + const xStart = '10'; + const yStart = '69'; + const data = {}; + appendResidues(data, sequence, parsing); + return ( + <> + {data.residues.all.map((e, index) => ( + + ))} + + ); +} diff --git a/src/components/SVGSequenceElement.jsx b/src/components/SVGSequenceElement.jsx new file mode 100644 index 0000000..fd0f3ca --- /dev/null +++ b/src/components/SVGSequenceElement.jsx @@ -0,0 +1,23 @@ +export function SVGSequenceElement({ element, index }) { + const x = String(10 + index * 30); + const y = '69'; + let color = '#555555'; + if (element.replaced) { + color = 'darkviolet'; + } else if (element.kind === 'begin' || element.kind === 'end') { + color = '#cccccc'; + } + return ( + + {element.label} + + ); +} diff --git a/src/render.tsx b/src/render.tsx index feeafc3..ed62264 100644 --- a/src/render.tsx +++ b/src/render.tsx @@ -3,8 +3,10 @@ import { createRoot } from 'react-dom/client'; import { SVGMassFragmentation } from './components/SVGMassFragmentation'; -export function render(tree, options) { - const element = ; +export function render(sequence, info, options) { + const element = ( + + ); const div = document.createElement('div'); const root = createRoot(div); flushSync(() => {