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(() => {