@opencloning/ui 1.1.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/package.json +5 -4
- package/src/components/MainSequenceEditor.jsx +2 -0
- package/src/components/assembler/Assembler.cy.jsx +364 -0
- package/src/components/assembler/Assembler.jsx +298 -206
- package/src/components/assembler/AssemblerPart.cy.jsx +52 -0
- package/src/components/assembler/AssemblerPart.jsx +51 -79
- package/src/components/assembler/ExistingSyntaxDialog.cy.jsx +194 -0
- package/src/components/assembler/ExistingSyntaxDialog.jsx +65 -0
- package/src/components/assembler/PlasmidSyntaxTable.jsx +83 -0
- package/src/components/assembler/assembler_utils.js +134 -0
- package/src/components/assembler/assembler_utils.test.js +193 -0
- package/src/components/assembler/assembly_component.module.css +1 -1
- package/src/components/assembler/graph_utils.js +153 -0
- package/src/components/assembler/graph_utils.test.js +239 -0
- package/src/components/assembler/index.js +9 -0
- package/src/components/assembler/useAssembler.js +59 -22
- package/src/components/assembler/useCombinatorialAssembly.js +76 -0
- package/src/components/assembler/usePlasmidsLogic.js +82 -0
- package/src/components/eLabFTW/utils.js +0 -9
- package/src/components/index.js +2 -0
- package/src/components/navigation/SelectTemplateDialog.jsx +0 -1
- package/src/components/primers/DownloadPrimersButton.jsx +0 -1
- package/src/components/primers/PrimerList.jsx +4 -3
- package/src/components/primers/import_primers/ImportPrimersButton.jsx +0 -1
- package/src/components/primers/primer_design/SequenceTabComponents/PrimerDesignContext.jsx +110 -91
- package/src/components/primers/primer_design/SequenceTabComponents/PrimerDesignGatewayBP.jsx +1 -1
- package/src/components/primers/primer_design/SequenceTabComponents/PrimerDesignGibsonAssembly.jsx +2 -2
- package/src/components/primers/primer_design/SequenceTabComponents/PrimerDesignHomologousRecombination.jsx +1 -1
- package/src/components/primers/primer_design/SequenceTabComponents/PrimerDesignRestriction.jsx +1 -1
- package/src/components/primers/primer_design/SequenceTabComponents/PrimerDesignSimplePair.jsx +1 -1
- package/src/components/primers/primer_design/SequenceTabComponents/PrimerSpacerForm.jsx +5 -22
- package/src/components/primers/primer_design/SequenceTabComponents/TabPannelSettings.jsx +6 -4
- package/src/hooks/useStoreEditor.js +4 -0
- package/src/version.js +1 -1
- package/vitest.config.js +2 -4
- package/src/components/DraggableDialogPaper.jsx +0 -16
- package/src/components/assembler/AssemblePartWidget.jsx +0 -252
- package/src/components/assembler/StopIcon.jsx +0 -34
- package/src/components/assembler/assembler_data2.json +0 -50
- package/src/components/assembler/moclo.json +0 -110
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { aliasedEnzymesByName, getDigestFragmentsForRestrictionEnzymes, getReverseComplementSequenceString, getComplementSequenceString } from "@teselagen/sequence-utils";
|
|
2
|
+
import { assignSequenceToSyntaxPart, simplifyDigestFragment, reverseComplementSimplifiedDigestFragment, tripletsToTranslation, partDataToDisplayData, arrayCombinations } from "./assembler_utils";
|
|
3
|
+
import { partsToEdgesGraph } from "./graph_utils";
|
|
4
|
+
|
|
5
|
+
const sequenceBsaI = 'tgggtctcaTACTagagtcacacaggactactaAATGagagacctac';
|
|
6
|
+
const sequenceBsaI2 = 'tgggtctcaAATGagagtcacacaggactactaAGGTagagacctac'
|
|
7
|
+
const sequenceBsaI3 = 'tgggtctcaTACTagagtcacacaggactactaAGGTagagacctac';
|
|
8
|
+
|
|
9
|
+
describe('reverseComplementSimplifiedDigestFragment', () => {
|
|
10
|
+
it('works', () => {
|
|
11
|
+
|
|
12
|
+
const digestFragments = getDigestFragmentsForRestrictionEnzymes(
|
|
13
|
+
sequenceBsaI,
|
|
14
|
+
true,
|
|
15
|
+
aliasedEnzymesByName["bsai"],
|
|
16
|
+
);
|
|
17
|
+
const sequenceRc = getReverseComplementSequenceString(sequenceBsaI);
|
|
18
|
+
const digestFragments2 = getDigestFragmentsForRestrictionEnzymes(
|
|
19
|
+
sequenceRc,
|
|
20
|
+
true,
|
|
21
|
+
aliasedEnzymesByName["bsai"],
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const simplifiedDigestFragments1 = digestFragments.map(simplifyDigestFragment);
|
|
25
|
+
const simplifiedDigestFragments2 = digestFragments2.map(simplifyDigestFragment);
|
|
26
|
+
const simplifiedDigestFragments3 = simplifiedDigestFragments2.map(reverseComplementSimplifiedDigestFragment);
|
|
27
|
+
expect(simplifiedDigestFragments1).toEqual(simplifiedDigestFragments3);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
describe('assignSequenceToSyntaxPart', () => {
|
|
33
|
+
it('works', () => {
|
|
34
|
+
const enzymes = [aliasedEnzymesByName["bsai"]];
|
|
35
|
+
const parts = [{left_overhang: 'TACT', right_overhang: 'AATG'}, {left_overhang: 'AATG', right_overhang: 'AGGT'}];
|
|
36
|
+
const graph = partsToEdgesGraph(parts);
|
|
37
|
+
|
|
38
|
+
for (const reverseComplement of [false, true]) {
|
|
39
|
+
const seq1 = reverseComplement ? getReverseComplementSequenceString(sequenceBsaI) : sequenceBsaI;
|
|
40
|
+
const seq2 = reverseComplement ? getReverseComplementSequenceString(sequenceBsaI2) : sequenceBsaI2;
|
|
41
|
+
const seq3 = reverseComplement ? getReverseComplementSequenceString(sequenceBsaI + sequenceBsaI2) : sequenceBsaI + sequenceBsaI2;
|
|
42
|
+
|
|
43
|
+
const sequenceData1 = { sequence: seq1, circular: true };
|
|
44
|
+
const sequenceData2 = { sequence: seq2, circular: true };
|
|
45
|
+
const sequenceData3 = { sequence: seq3, circular: true };
|
|
46
|
+
|
|
47
|
+
const result = assignSequenceToSyntaxPart(sequenceData1, enzymes, graph);
|
|
48
|
+
expect(result).toEqual([{left_overhang: 'TACT', right_overhang: 'AATG', longestFeature: null}]);
|
|
49
|
+
|
|
50
|
+
const result2 = assignSequenceToSyntaxPart(sequenceData2, enzymes, graph);
|
|
51
|
+
expect(result2).toEqual([{left_overhang: 'AATG', right_overhang: 'AGGT', longestFeature: null}]);
|
|
52
|
+
|
|
53
|
+
const result3 = assignSequenceToSyntaxPart(sequenceData3, enzymes, graph);
|
|
54
|
+
if (reverseComplement) {
|
|
55
|
+
expect(result3).toEqual([{left_overhang: 'AATG', right_overhang: 'AGGT', longestFeature: null}, {left_overhang: 'TACT', right_overhang: 'AATG', longestFeature: null}]);
|
|
56
|
+
} else {
|
|
57
|
+
expect(result3).toEqual([{left_overhang: 'TACT', right_overhang: 'AATG', longestFeature: null}, {left_overhang: 'AATG', right_overhang: 'AGGT', longestFeature: null}]);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Multi-spanning fragments are also picked up
|
|
62
|
+
const resultMulti = assignSequenceToSyntaxPart({ sequence: sequenceBsaI3, circular: true }, enzymes, graph);
|
|
63
|
+
expect(resultMulti).toEqual([{left_overhang: 'TACT', right_overhang: 'AGGT', longestFeature: null}]);
|
|
64
|
+
|
|
65
|
+
const result4 = assignSequenceToSyntaxPart({ sequence: '', circular: true }, enzymes, graph);
|
|
66
|
+
expect(result4).toEqual([])
|
|
67
|
+
|
|
68
|
+
const result5 = assignSequenceToSyntaxPart({ sequence: 'AACGTAGACAGATTA', circular: true }, enzymes, graph);
|
|
69
|
+
expect(result5).toEqual([])
|
|
70
|
+
|
|
71
|
+
// Features are picked up
|
|
72
|
+
const sequenceDataWithFeatures = {
|
|
73
|
+
sequence: sequenceBsaI3,
|
|
74
|
+
circular: true,
|
|
75
|
+
features: [
|
|
76
|
+
{start: 15, end: 30, type: 'misc_feature', name: 'feature1'},
|
|
77
|
+
{start: 2, end: 45, type: 'misc_feature', name: 'feature2'}
|
|
78
|
+
]};
|
|
79
|
+
|
|
80
|
+
const result6 = assignSequenceToSyntaxPart(sequenceDataWithFeatures, enzymes, graph);
|
|
81
|
+
const {start, end, type, name} = result6[0].longestFeature;
|
|
82
|
+
expect(start).toBe(15);
|
|
83
|
+
expect(end).toBe(30);
|
|
84
|
+
expect(type).toBe('misc_feature');
|
|
85
|
+
expect(name).toBe('feature1');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('tripletsToTranslation', () => {
|
|
90
|
+
it('returns empty string for falsy input', () => {
|
|
91
|
+
expect(tripletsToTranslation(null)).toBe('');
|
|
92
|
+
expect(tripletsToTranslation(undefined)).toBe('');
|
|
93
|
+
expect(tripletsToTranslation(false)).toBe('');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('returns empty string for empty array', () => {
|
|
97
|
+
expect(tripletsToTranslation([])).toBe('');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('translates valid triplets to amino acid codes', () => {
|
|
101
|
+
// ATG = Methionine (Met)
|
|
102
|
+
expect(tripletsToTranslation(['ATG'])).toBe('Met');
|
|
103
|
+
|
|
104
|
+
// TTT = Phenylalanine (Phe)
|
|
105
|
+
expect(tripletsToTranslation(['TTT'])).toBe('Phe');
|
|
106
|
+
|
|
107
|
+
// Multiple triplets
|
|
108
|
+
expect(tripletsToTranslation(['ATG', 'TTT', 'GCA'])).toBe('MetPheAla');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('replaces Stop codons with ***', () => {
|
|
112
|
+
// TAA, TAG, TGA are stop codons
|
|
113
|
+
const stopCodons = ['TAA', 'TAG', 'TGA'];
|
|
114
|
+
const result = tripletsToTranslation(stopCodons);
|
|
115
|
+
expect(result).toBe('*********');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('returns " - " for triplets with non-ACGT characters', () => {
|
|
119
|
+
expect(tripletsToTranslation(['ATN'])).toBe(' - ');
|
|
120
|
+
expect(tripletsToTranslation(['XYZ'])).toBe(' - ');
|
|
121
|
+
expect(tripletsToTranslation(['AT-'])).toBe(' - ');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('handles mixed valid and invalid triplets', () => {
|
|
125
|
+
const result = tripletsToTranslation(['ATG', 'XYZ', 'TTT']);
|
|
126
|
+
expect(result).toBe('Met - Phe');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('partDataToDisplayData', () => {
|
|
131
|
+
it('computes reverse complements for all sequences and returns empty translations when no codon starts provided', () => {
|
|
132
|
+
const data = {
|
|
133
|
+
left_overhang: 'ATGC',
|
|
134
|
+
right_overhang: 'CGTA',
|
|
135
|
+
left_inside: 'TTAA',
|
|
136
|
+
right_inside: 'AATT'
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const result = partDataToDisplayData(data);
|
|
140
|
+
|
|
141
|
+
expect(result.leftOverhangRc).toBe(getComplementSequenceString('ATGC'));
|
|
142
|
+
expect(result.rightOverhangRc).toBe(getComplementSequenceString('CGTA'));
|
|
143
|
+
expect(result.leftInsideRc).toBe(getComplementSequenceString('TTAA'));
|
|
144
|
+
expect(result.rightInsideRc).toBe(getComplementSequenceString('AATT'));
|
|
145
|
+
|
|
146
|
+
expect(result.leftTranslationOverhang).toBe('');
|
|
147
|
+
expect(result.leftTranslationInside).toBe('');
|
|
148
|
+
expect(result.rightTranslationOverhang).toBe('');
|
|
149
|
+
expect(result.rightTranslationInside).toBe('');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('computes left translation when left_codon_start is provided', () => {
|
|
153
|
+
const data = {
|
|
154
|
+
left_overhang: 'AT',
|
|
155
|
+
left_inside: 'GCGCA',
|
|
156
|
+
left_codon_start: 1,
|
|
157
|
+
right_overhang: '',
|
|
158
|
+
right_inside: ''
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const result = partDataToDisplayData(data);
|
|
162
|
+
|
|
163
|
+
// ATG CGC A... should translate to Met Arg...
|
|
164
|
+
expect(result.leftTranslationOverhang).toBe('Me');
|
|
165
|
+
expect(result.leftTranslationInside).toBe('tArg');
|
|
166
|
+
|
|
167
|
+
const data2 = {
|
|
168
|
+
left_overhang: 'ATTT',
|
|
169
|
+
left_inside: 'GCGCA',
|
|
170
|
+
left_codon_start: 3,
|
|
171
|
+
right_overhang: '',
|
|
172
|
+
right_inside: ''
|
|
173
|
+
};
|
|
174
|
+
const result2 = partDataToDisplayData(data2);
|
|
175
|
+
expect(result2.leftTranslationOverhang).toBe(' Le');
|
|
176
|
+
expect(result2.leftTranslationInside).toBe('uArg');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('arrayCombinations', () => {
|
|
182
|
+
it('returns null for empty array', () => {
|
|
183
|
+
expect(arrayCombinations([])).toBe(null);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('returns array of arrays for single array', () => {
|
|
187
|
+
expect(arrayCombinations([[1, 2, 3]])).toEqual([[1], [2], [3]]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('returns array of arrays for multiple arrays', () => {
|
|
191
|
+
expect(arrayCombinations([[1, 2], [3, 4], [5, 6]])).toEqual([[1, 3, 5], [1, 3, 6], [1, 4, 5], [1, 4, 6], [2, 3, 5], [2, 3, 6], [2, 4, 5], [2, 4, 6]]);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { DirectedGraph } from 'graphology';
|
|
2
|
+
import { topologicalGenerations } from 'graphology-dag';
|
|
3
|
+
import { allSimplePaths } from 'graphology-simple-path';
|
|
4
|
+
|
|
5
|
+
export const GRAPH_SPACER = '---------';
|
|
6
|
+
|
|
7
|
+
// Convert parts to a directed graph where nodes are "edges" (e.g., "CCCT-AACG")
|
|
8
|
+
export function partsToGraph(parts) {
|
|
9
|
+
const graph = new DirectedGraph();
|
|
10
|
+
for (const part of parts) {
|
|
11
|
+
const node = `${part.left_overhang}-${part.right_overhang}`;
|
|
12
|
+
if (!graph.hasNode(node)) {
|
|
13
|
+
graph.addNode(node);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// Connect nodes that share an overhang (right of one = left of another)
|
|
17
|
+
graph.forEachNode((node) => {
|
|
18
|
+
const [, right] = node.split('-');
|
|
19
|
+
graph.forEachNode((node2) => {
|
|
20
|
+
if (node !== node2 && node2.startsWith(right + '-')) {
|
|
21
|
+
graph.mergeEdge(node, node2);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
return graph;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function partsToEdgesGraph(parts) {
|
|
29
|
+
const graph = new DirectedGraph();
|
|
30
|
+
for (const part of parts) {
|
|
31
|
+
for (const node of [part.left_overhang, part.right_overhang]) {
|
|
32
|
+
if (!graph.hasNode(node)) {
|
|
33
|
+
graph.addNode(node);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
graph.mergeEdge(part.left_overhang, part.right_overhang);
|
|
37
|
+
}
|
|
38
|
+
return graph;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Break cycles by removing incoming edges to a node
|
|
42
|
+
export function openCycleAtNode(graph, cutNode) {
|
|
43
|
+
for (const edge of graph.inEdges(cutNode)) {
|
|
44
|
+
graph.dropEdge(edge);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Convert DAG to MSA-like matrix (rows = paths, columns = topological generations)
|
|
49
|
+
function dagToMSA(graph) {
|
|
50
|
+
const generations = topologicalGenerations(graph);
|
|
51
|
+
const nodeToCol = new Map();
|
|
52
|
+
generations.forEach((gen, col) => gen.forEach(node => nodeToCol.set(node, col)));
|
|
53
|
+
|
|
54
|
+
const sources = graph.nodes().filter(n => graph.inDegree(n) === 0);
|
|
55
|
+
const sinks = graph.nodes().filter(n => graph.outDegree(n) === 0);
|
|
56
|
+
|
|
57
|
+
const allPaths = sources.flatMap(src => sinks.flatMap(sink => allSimplePaths(graph, src, sink)));
|
|
58
|
+
|
|
59
|
+
return allPaths.map(path => {
|
|
60
|
+
const row = new Array(generations.length).fill(GRAPH_SPACER);
|
|
61
|
+
path.forEach(node => { row[nodeToCol.get(node)] = node; });
|
|
62
|
+
return row;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Find independent segments in the MSA
|
|
67
|
+
// Two adjacent columns are in the same segment if they're correlated (vary together)
|
|
68
|
+
// They're independent if all combinations of values exist
|
|
69
|
+
function getIndependentSegments(msa) {
|
|
70
|
+
if (msa.length === 0) return [];
|
|
71
|
+
|
|
72
|
+
const numCols = msa[0].length;
|
|
73
|
+
const uniqueValues = col => new Set(msa.map(row => row[col]));
|
|
74
|
+
const isStable = col => uniqueValues(col).size === 1;
|
|
75
|
+
|
|
76
|
+
// Check if two columns vary independently
|
|
77
|
+
const areIndependent = (col1, col2) => {
|
|
78
|
+
const pairs = new Set(msa.map(row => `${row[col1]}|${row[col2]}`));
|
|
79
|
+
return pairs.size > Math.max(uniqueValues(col1).size, uniqueValues(col2).size);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Find segment boundaries
|
|
83
|
+
const boundaries = [0];
|
|
84
|
+
for (let col = 0; col < numCols - 1; col++) {
|
|
85
|
+
const stable1 = isStable(col), stable2 = isStable(col + 1);
|
|
86
|
+
if (stable1 !== stable2 || (!stable1 && !stable2 && areIndependent(col, col + 1))) {
|
|
87
|
+
boundaries.push(col + 1);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
boundaries.push(numCols);
|
|
91
|
+
|
|
92
|
+
// Build segments
|
|
93
|
+
return boundaries.slice(0, -1).map((start, i) => {
|
|
94
|
+
const end = boundaries[i + 1] - 1;
|
|
95
|
+
const alts = [...new Set(msa.map(row => row.slice(start, end + 1).join(' | ')))];
|
|
96
|
+
return { start, end, stable: isStable(start), alternatives: alts };
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Find minimum set of rows that covers all unique alternatives (greedy set cover)
|
|
101
|
+
function minimumCoveringRows(msa) {
|
|
102
|
+
if (msa.length === 0) return [];
|
|
103
|
+
|
|
104
|
+
const segments = getIndependentSegments(msa).filter(s => !s.stable);
|
|
105
|
+
if (segments.length === 0) return [msa[0]];
|
|
106
|
+
|
|
107
|
+
// All alternatives that need to be covered
|
|
108
|
+
const allAlts = new Set(segments.flatMap((seg, i) => seg.alternatives.map(alt => `${i}:${alt}`)));
|
|
109
|
+
|
|
110
|
+
// Count non-gap elements in a row (gap is '---------')
|
|
111
|
+
const countElements = row => row.filter(cell => cell !== GRAPH_SPACER).length;
|
|
112
|
+
|
|
113
|
+
// What each row covers + element count
|
|
114
|
+
const rowCoverage = msa.map(row => ({
|
|
115
|
+
row,
|
|
116
|
+
elements: countElements(row),
|
|
117
|
+
covered: new Set(segments.map((seg, i) => `${i}:${row.slice(seg.start, seg.end + 1).join(' | ')}`))
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
// Greedy: pick rows that cover the most uncovered alternatives
|
|
121
|
+
// When tied, prefer rows with more elements (fewer gaps)
|
|
122
|
+
const selected = [];
|
|
123
|
+
const covered = new Set();
|
|
124
|
+
|
|
125
|
+
while (covered.size < allAlts.size) {
|
|
126
|
+
const best = rowCoverage.reduce((best, rc) => {
|
|
127
|
+
const newCount = [...rc.covered].filter(a => !covered.has(a)).length;
|
|
128
|
+
// Prefer higher coverage, then more elements as tiebreaker
|
|
129
|
+
if (newCount > best.count) return { rc, count: newCount, elements: rc.elements };
|
|
130
|
+
if (newCount === best.count && rc.elements > best.elements) return { rc, count: newCount, elements: rc.elements };
|
|
131
|
+
return best;
|
|
132
|
+
}, { rc: null, count: 0, elements: -1 });
|
|
133
|
+
|
|
134
|
+
if (!best.rc) break;
|
|
135
|
+
selected.push(best.rc.row);
|
|
136
|
+
best.rc.covered.forEach(a => covered.add(a));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Sort by number of elements (most elements first)
|
|
140
|
+
return selected.sort((a, b) => countElements(b) - countElements(a));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function graphToMSA(graph) {
|
|
144
|
+
if (graph.nodes().length === 0) return [];
|
|
145
|
+
const newGraph = graph.copy();
|
|
146
|
+
openCycleAtNode(newGraph, newGraph.nodes()[0]);
|
|
147
|
+
return minimumCoveringRows(dagToMSA(newGraph));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function graphHasCycle(graph) {
|
|
151
|
+
if (graph.nodes().length === 0) return false;
|
|
152
|
+
return allSimplePaths(graph, graph.nodes()[0], graph.nodes()[0]).length > 0;
|
|
153
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { DirectedGraph } from 'graphology';
|
|
2
|
+
import {
|
|
3
|
+
GRAPH_SPACER,
|
|
4
|
+
partsToGraph,
|
|
5
|
+
partsToEdgesGraph,
|
|
6
|
+
openCycleAtNode,
|
|
7
|
+
graphToMSA,
|
|
8
|
+
graphHasCycle
|
|
9
|
+
} from './graph_utils';
|
|
10
|
+
|
|
11
|
+
describe('GRAPH_SPACER', () => {
|
|
12
|
+
it('is the expected spacer string', () => {
|
|
13
|
+
expect(GRAPH_SPACER).toBe('---------');
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('partsToGraph', () => {
|
|
18
|
+
it('creates nodes for each part', () => {
|
|
19
|
+
const parts = [
|
|
20
|
+
{ left_overhang: 'A', right_overhang: 'B' },
|
|
21
|
+
{ left_overhang: 'C', right_overhang: 'D' }
|
|
22
|
+
];
|
|
23
|
+
const graph = partsToGraph(parts);
|
|
24
|
+
expect(graph.hasNode('A-B')).toBe(true);
|
|
25
|
+
expect(graph.hasNode('C-D')).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('connects nodes that share an overhang', () => {
|
|
29
|
+
const parts = [
|
|
30
|
+
{ left_overhang: 'A', right_overhang: 'B' },
|
|
31
|
+
{ left_overhang: 'B', right_overhang: 'C' }
|
|
32
|
+
];
|
|
33
|
+
const graph = partsToGraph(parts);
|
|
34
|
+
expect(graph.hasEdge('A-B', 'B-C')).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does not connect nodes that do not share an overhang', () => {
|
|
38
|
+
const parts = [
|
|
39
|
+
{ left_overhang: 'A', right_overhang: 'B' },
|
|
40
|
+
{ left_overhang: 'C', right_overhang: 'D' }
|
|
41
|
+
];
|
|
42
|
+
const graph = partsToGraph(parts);
|
|
43
|
+
expect(graph.hasEdge('A-B', 'C-D')).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('handles duplicate parts', () => {
|
|
47
|
+
const parts = [
|
|
48
|
+
{ left_overhang: 'A', right_overhang: 'B' },
|
|
49
|
+
{ left_overhang: 'A', right_overhang: 'B' }
|
|
50
|
+
];
|
|
51
|
+
const graph = partsToGraph(parts);
|
|
52
|
+
expect(graph.nodes().length).toBe(1);
|
|
53
|
+
expect(graph.hasNode('A-B')).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('handles empty parts array', () => {
|
|
57
|
+
const graph = partsToGraph([]);
|
|
58
|
+
expect(graph.nodes().length).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('creates a chain of connected nodes', () => {
|
|
62
|
+
const parts = [
|
|
63
|
+
{ left_overhang: 'A', right_overhang: 'B' },
|
|
64
|
+
{ left_overhang: 'B', right_overhang: 'C' },
|
|
65
|
+
{ left_overhang: 'C', right_overhang: 'D' }
|
|
66
|
+
];
|
|
67
|
+
const graph = partsToGraph(parts);
|
|
68
|
+
expect(graph.hasEdge('A-B', 'B-C')).toBe(true);
|
|
69
|
+
expect(graph.hasEdge('B-C', 'C-D')).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('partsToEdgesGraph', () => {
|
|
74
|
+
it('creates nodes for each unique overhang and creates edges between them', () => {
|
|
75
|
+
const parts = [
|
|
76
|
+
{ left_overhang: 'A', right_overhang: 'B' },
|
|
77
|
+
{ left_overhang: 'C', right_overhang: 'D' }
|
|
78
|
+
];
|
|
79
|
+
const graph = partsToEdgesGraph(parts);
|
|
80
|
+
expect(graph.hasNode('A')).toBe(true);
|
|
81
|
+
expect(graph.hasNode('B')).toBe(true);
|
|
82
|
+
expect(graph.hasNode('C')).toBe(true);
|
|
83
|
+
expect(graph.hasNode('D')).toBe(true);
|
|
84
|
+
expect(graph.hasEdge('A', 'B')).toBe(true);
|
|
85
|
+
expect(graph.hasEdge('C', 'D')).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('handles duplicate overhangs', () => {
|
|
89
|
+
const parts = [
|
|
90
|
+
{ left_overhang: 'A', right_overhang: 'B' },
|
|
91
|
+
{ left_overhang: 'A', right_overhang: 'B' }
|
|
92
|
+
];
|
|
93
|
+
const graph = partsToEdgesGraph(parts);
|
|
94
|
+
expect(graph.hasNode('A')).toBe(true);
|
|
95
|
+
expect(graph.hasNode('B')).toBe(true);
|
|
96
|
+
expect(graph.hasEdge('A', 'B')).toBe(true);
|
|
97
|
+
expect(graph.nodes().length).toBe(2);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('handles empty parts array', () => {
|
|
101
|
+
const graph = partsToEdgesGraph([]);
|
|
102
|
+
expect(graph.nodes().length).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('openCycleAtNode', () => {
|
|
107
|
+
it('removes incoming edges to the specified node and does not affect outgoing edges', () => {
|
|
108
|
+
const graph = new DirectedGraph();
|
|
109
|
+
graph.addNode('A');
|
|
110
|
+
graph.addNode('B');
|
|
111
|
+
graph.addNode('C');
|
|
112
|
+
graph.addEdge('A', 'B');
|
|
113
|
+
graph.addEdge('B', 'C');
|
|
114
|
+
graph.addEdge('C', 'B'); // Creates cycle
|
|
115
|
+
|
|
116
|
+
expect(graph.inDegree('B')).toBe(2);
|
|
117
|
+
openCycleAtNode(graph, 'B');
|
|
118
|
+
expect(graph.inDegree('B')).toBe(0);
|
|
119
|
+
expect(graph.outDegree('B')).toBe(1);
|
|
120
|
+
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('does not affect outgoing edges', () => {
|
|
124
|
+
const graph = new DirectedGraph();
|
|
125
|
+
graph.addNode('A');
|
|
126
|
+
graph.addNode('B');
|
|
127
|
+
graph.addEdge('A', 'B');
|
|
128
|
+
graph.addEdge('B', 'A'); // Cycle
|
|
129
|
+
|
|
130
|
+
openCycleAtNode(graph, 'A');
|
|
131
|
+
expect(graph.outDegree('A')).toBe(1);
|
|
132
|
+
expect(graph.hasEdge('A', 'B')).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('handles node with no incoming edges', () => {
|
|
136
|
+
const graph = new DirectedGraph();
|
|
137
|
+
graph.addNode('A');
|
|
138
|
+
graph.addNode('B');
|
|
139
|
+
graph.addEdge('A', 'B');
|
|
140
|
+
|
|
141
|
+
openCycleAtNode(graph, 'A');
|
|
142
|
+
expect(graph.inDegree('A')).toBe(0);
|
|
143
|
+
expect(graph.hasEdge('A', 'B')).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('graphToMSA', () => {
|
|
148
|
+
it('converts a simple linear DAG to MSA', () => {
|
|
149
|
+
const graph = new DirectedGraph();
|
|
150
|
+
graph.addNode('A');
|
|
151
|
+
graph.addNode('B');
|
|
152
|
+
graph.addNode('C');
|
|
153
|
+
graph.addEdge('A', 'B');
|
|
154
|
+
graph.addEdge('B', 'C');
|
|
155
|
+
graph.addEdge('C', 'A');
|
|
156
|
+
const msa = graphToMSA(graph);
|
|
157
|
+
expect(msa.length).toBe(1);
|
|
158
|
+
expect(msa[0]).toContain('A');
|
|
159
|
+
expect(msa[0]).toContain('B');
|
|
160
|
+
expect(msa[0]).toContain('C');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('handles branching paths', () => {
|
|
164
|
+
const graph = new DirectedGraph();
|
|
165
|
+
graph.addNode('A');
|
|
166
|
+
graph.addNode('B1');
|
|
167
|
+
graph.addNode('B2');
|
|
168
|
+
graph.addNode('B3');
|
|
169
|
+
graph.addNode('C');
|
|
170
|
+
graph.addEdge('A', 'B1');
|
|
171
|
+
graph.addEdge('A', 'B2');
|
|
172
|
+
graph.addEdge('B1', 'C');
|
|
173
|
+
graph.addEdge('B2', 'B3');
|
|
174
|
+
graph.addEdge('B3', 'C');
|
|
175
|
+
graph.addEdge('C', 'A');
|
|
176
|
+
const msa = graphToMSA(graph);
|
|
177
|
+
expect(msa).toEqual([
|
|
178
|
+
['A', 'B2', 'B3', 'C'],
|
|
179
|
+
['A', 'B1', GRAPH_SPACER, 'C'],
|
|
180
|
+
]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('handles empty graph', () => {
|
|
184
|
+
const graph = new DirectedGraph();
|
|
185
|
+
const msa = graphToMSA(graph);
|
|
186
|
+
expect(msa).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('handles single node', () => {
|
|
190
|
+
const graph = new DirectedGraph();
|
|
191
|
+
graph.addNode('A');
|
|
192
|
+
const msa = graphToMSA(graph);
|
|
193
|
+
expect(msa).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
describe('graphHasCycle', () => {
|
|
199
|
+
it('returns false for acyclic graph', () => {
|
|
200
|
+
const graph = new DirectedGraph();
|
|
201
|
+
graph.addNode('A');
|
|
202
|
+
graph.addNode('B');
|
|
203
|
+
graph.addNode('C');
|
|
204
|
+
graph.addEdge('A', 'B');
|
|
205
|
+
graph.addEdge('B', 'C');
|
|
206
|
+
|
|
207
|
+
expect(graphHasCycle(graph)).toBe(false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('returns true for cyclic graph', () => {
|
|
211
|
+
const graph = new DirectedGraph();
|
|
212
|
+
graph.addNode('A');
|
|
213
|
+
graph.addNode('B');
|
|
214
|
+
graph.addEdge('A', 'B');
|
|
215
|
+
graph.addEdge('B', 'A');
|
|
216
|
+
|
|
217
|
+
expect(graphHasCycle(graph)).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('handles self-loop', () => {
|
|
221
|
+
const graph = new DirectedGraph();
|
|
222
|
+
graph.addNode('A');
|
|
223
|
+
graph.addEdge('A', 'A');
|
|
224
|
+
|
|
225
|
+
expect(graphHasCycle(graph)).toBe(true);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('handles empty graph', () => {
|
|
229
|
+
const graph = new DirectedGraph();
|
|
230
|
+
expect(graphHasCycle(graph)).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('handles single node with no edges', () => {
|
|
234
|
+
const graph = new DirectedGraph();
|
|
235
|
+
graph.addNode('A');
|
|
236
|
+
|
|
237
|
+
expect(graphHasCycle(graph)).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { default as AssemblerPart } from './AssemblerPart';
|
|
2
|
+
export { AssemblerPartCore } from './AssemblerPart';
|
|
3
|
+
export { getSvgByGlyph } from './sbol_visual_glyphs';
|
|
4
|
+
export { DisplayOverhang, DisplayInside, AssemblerPartContainer } from './AssemblerPart';
|
|
5
|
+
export { partDataToDisplayData } from './assembler_utils.js';
|
|
6
|
+
export { partsToGraph, graphToMSA, graphHasCycle, partsToEdgesGraph, GRAPH_SPACER } from './graph_utils.js';
|
|
7
|
+
export { usePlasmidsLogic } from './usePlasmidsLogic.js';
|
|
8
|
+
export { default as PlasmidSyntaxTable } from './PlasmidSyntaxTable.jsx';
|
|
9
|
+
export { default as ExistingSyntaxDialog } from './ExistingSyntaxDialog.jsx';
|