@opencloning/ui 1.2.0 → 1.3.1
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 +27 -0
- package/package.json +4 -3
- package/src/components/assembler/Assembler.cy.jsx +364 -0
- package/src/components/assembler/Assembler.jsx +298 -205
- package/src/components/assembler/AssemblerPart.cy.jsx +52 -0
- package/src/components/assembler/AssemblerPart.jsx +51 -79
- package/src/components/assembler/ExistingSyntaxDialog.cy.jsx +251 -0
- package/src/components/assembler/ExistingSyntaxDialog.jsx +104 -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/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,134 @@
|
|
|
1
|
+
import { isRangeWithinRange } from '@teselagen/range-utils';
|
|
2
|
+
import { getComplementSequenceString, getAminoAcidFromSequenceTriplet, getDigestFragmentsForRestrictionEnzymes, getReverseComplementSequenceString } from '@teselagen/sequence-utils';
|
|
3
|
+
import { allSimplePaths } from 'graphology-simple-path';
|
|
4
|
+
|
|
5
|
+
export function tripletsToTranslation(triplets) {
|
|
6
|
+
if (!triplets) return ''
|
|
7
|
+
return triplets.map(triplet =>
|
|
8
|
+
/[^ACGT]/i.test(triplet) ? ' - ' :
|
|
9
|
+
getAminoAcidFromSequenceTriplet(triplet).threeLettersName.replace('Stop', '***')
|
|
10
|
+
).join('')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function partDataToDisplayData(data) {
|
|
14
|
+
const {
|
|
15
|
+
left_codon_start: leftCodonStart,
|
|
16
|
+
right_codon_start: rightCodonStart,
|
|
17
|
+
left_overhang: leftOverhang,
|
|
18
|
+
right_overhang: rightOverhang,
|
|
19
|
+
left_inside: leftInside,
|
|
20
|
+
right_inside: rightInside,
|
|
21
|
+
glyph
|
|
22
|
+
} = data
|
|
23
|
+
const leftOverhangRc = getComplementSequenceString(leftOverhang)
|
|
24
|
+
const rightOverhangRc = getComplementSequenceString(rightOverhang)
|
|
25
|
+
const leftInsideRc = getComplementSequenceString(leftInside)
|
|
26
|
+
const rightInsideRc = getComplementSequenceString(rightInside)
|
|
27
|
+
let leftTranslationOverhang = ''
|
|
28
|
+
let leftTranslationInside = ''
|
|
29
|
+
if (leftCodonStart) {
|
|
30
|
+
const triplets = (leftOverhang + leftInside).slice(leftCodonStart - 1).match(/.{3}/g)
|
|
31
|
+
const padding = ' '.repeat(leftCodonStart - 1)
|
|
32
|
+
const translationLeft = padding + tripletsToTranslation(triplets)
|
|
33
|
+
leftTranslationOverhang = translationLeft.slice(0, leftOverhang.length)
|
|
34
|
+
leftTranslationInside = translationLeft.slice(leftOverhang.length)
|
|
35
|
+
}
|
|
36
|
+
let rightTranslationOverhang = ''
|
|
37
|
+
let rightTranslationInside = ''
|
|
38
|
+
if (rightCodonStart) {
|
|
39
|
+
const triplets = (rightInside + rightOverhang).slice(rightCodonStart - 1).match(/.{3}/g)
|
|
40
|
+
const padding = ' '.repeat(rightCodonStart - 1)
|
|
41
|
+
const translationRight = padding + tripletsToTranslation(triplets)
|
|
42
|
+
rightTranslationInside = translationRight.slice(0, rightInside.length)
|
|
43
|
+
rightTranslationOverhang = translationRight.slice(rightInside.length)
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
leftTranslationOverhang,
|
|
47
|
+
leftTranslationInside,
|
|
48
|
+
rightTranslationOverhang,
|
|
49
|
+
rightTranslationInside,
|
|
50
|
+
leftOverhangRc,
|
|
51
|
+
rightOverhangRc,
|
|
52
|
+
leftInsideRc,
|
|
53
|
+
rightInsideRc,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
export function simplifyDigestFragment({cut1, cut2}) {
|
|
59
|
+
return {
|
|
60
|
+
left: {ovhg: cut1.overhangBps.toUpperCase(), forward: cut1.forward},
|
|
61
|
+
right: {ovhg: cut2.overhangBps.toUpperCase(), forward: cut2.forward},
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function reverseComplementSimplifiedDigestFragment({left, right, longestFeature}) {
|
|
66
|
+
return {
|
|
67
|
+
left: {ovhg: getReverseComplementSequenceString(right.ovhg), forward: !right.forward},
|
|
68
|
+
right: {ovhg: getReverseComplementSequenceString(left.ovhg), forward: !left.forward},
|
|
69
|
+
longestFeature
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function longestFeatureInDigestFragment(digestFragment, sequenceData) {
|
|
74
|
+
const {cut1, cut2} = digestFragment;
|
|
75
|
+
const leftEdge = cut1.overhangSize >=0 ? cut1.topSnipPosition : cut1.bottomSnipPosition;
|
|
76
|
+
const rightEdge = cut2.overhangSize >=0 ? cut2.bottomSnipPosition : cut2.topSnipPosition;
|
|
77
|
+
if (!sequenceData.features || sequenceData.features.length === 0) return null;
|
|
78
|
+
const featuresInside = sequenceData.features.filter(feature => isRangeWithinRange(feature, {start: leftEdge, end: rightEdge}, sequenceData.length));
|
|
79
|
+
return featuresInside.reduce((longest, feature) => {
|
|
80
|
+
if (!longest) return feature;
|
|
81
|
+
return feature.end - feature.start > longest.end - longest.start ? feature : longest;
|
|
82
|
+
}, null);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getSimplifiedDigestFragments(sequenceData, enzymes) {
|
|
86
|
+
const { sequence, circular } = sequenceData;
|
|
87
|
+
|
|
88
|
+
const digestFragments = getDigestFragmentsForRestrictionEnzymes(
|
|
89
|
+
sequence,
|
|
90
|
+
circular,
|
|
91
|
+
enzymes,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const longestFeatures = digestFragments.map(fragment => longestFeatureInDigestFragment(fragment, sequenceData));
|
|
95
|
+
const simplifiedDigestFragments = digestFragments.map(simplifyDigestFragment);
|
|
96
|
+
simplifiedDigestFragments.forEach((fragment, index) => {
|
|
97
|
+
fragment.longestFeature = longestFeatures[index];
|
|
98
|
+
});
|
|
99
|
+
const simplifiedDigestFragmentsRc = simplifiedDigestFragments.map(reverseComplementSimplifiedDigestFragment);
|
|
100
|
+
return simplifiedDigestFragments.concat(simplifiedDigestFragmentsRc);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function assignSequenceToSyntaxPart(sequenceData, enzymes, graph) {
|
|
104
|
+
const simplifiedDigestFragments = getSimplifiedDigestFragments(sequenceData, enzymes);
|
|
105
|
+
const foundParts = [];
|
|
106
|
+
simplifiedDigestFragments
|
|
107
|
+
.filter(f => f.left.forward && !f.right.forward && graph.hasNode(f.left.ovhg) && graph.hasNode(f.right.ovhg))
|
|
108
|
+
.forEach(fragment => {
|
|
109
|
+
const paths = allSimplePaths(graph, fragment.left.ovhg, fragment.right.ovhg);
|
|
110
|
+
if (paths.length > 0) {
|
|
111
|
+
foundParts.push({left_overhang: fragment.left.ovhg, right_overhang: fragment.right.ovhg, longestFeature: fragment.longestFeature});
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return foundParts;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function arrayCombinations(sets) {
|
|
118
|
+
if (sets.length === 0) {
|
|
119
|
+
return null;
|
|
120
|
+
} else if (sets.length === 1) {
|
|
121
|
+
return sets[0].map((el) => [el]);
|
|
122
|
+
} else
|
|
123
|
+
return sets[0].flatMap((val) =>
|
|
124
|
+
arrayCombinations(sets.slice(1)).map((c) => [val].concat(c))
|
|
125
|
+
);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export function categoryFilter(category, categories, previousCategoryId) {
|
|
129
|
+
if (previousCategoryId === null) {
|
|
130
|
+
return category.left_overhang === categories[0].left_overhang
|
|
131
|
+
}
|
|
132
|
+
const previousCategory = categories.find((category) => category.id === previousCategoryId)
|
|
133
|
+
return previousCategory?.right_overhang === category.left_overhang
|
|
134
|
+
}
|
|
@@ -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
|
+
}
|