@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.
Files changed (31) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/package.json +4 -3
  3. package/src/components/assembler/Assembler.cy.jsx +364 -0
  4. package/src/components/assembler/Assembler.jsx +298 -205
  5. package/src/components/assembler/AssemblerPart.cy.jsx +52 -0
  6. package/src/components/assembler/AssemblerPart.jsx +51 -79
  7. package/src/components/assembler/ExistingSyntaxDialog.cy.jsx +251 -0
  8. package/src/components/assembler/ExistingSyntaxDialog.jsx +104 -0
  9. package/src/components/assembler/PlasmidSyntaxTable.jsx +83 -0
  10. package/src/components/assembler/assembler_utils.js +134 -0
  11. package/src/components/assembler/assembler_utils.test.js +193 -0
  12. package/src/components/assembler/assembly_component.module.css +1 -1
  13. package/src/components/assembler/graph_utils.js +153 -0
  14. package/src/components/assembler/graph_utils.test.js +239 -0
  15. package/src/components/assembler/index.js +9 -0
  16. package/src/components/assembler/useAssembler.js +59 -22
  17. package/src/components/assembler/useCombinatorialAssembly.js +76 -0
  18. package/src/components/assembler/usePlasmidsLogic.js +82 -0
  19. package/src/components/eLabFTW/utils.js +0 -9
  20. package/src/components/index.js +2 -0
  21. package/src/components/navigation/SelectTemplateDialog.jsx +0 -1
  22. package/src/components/primers/DownloadPrimersButton.jsx +0 -1
  23. package/src/components/primers/PrimerList.jsx +4 -3
  24. package/src/components/primers/import_primers/ImportPrimersButton.jsx +0 -1
  25. package/src/version.js +1 -1
  26. package/vitest.config.js +2 -4
  27. package/src/components/DraggableDialogPaper.jsx +0 -16
  28. package/src/components/assembler/AssemblePartWidget.jsx +0 -252
  29. package/src/components/assembler/StopIcon.jsx +0 -34
  30. package/src/components/assembler/assembler_data2.json +0 -50
  31. 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
+ });
@@ -20,8 +20,8 @@
20
20
  }
21
21
 
22
22
  .container {
23
- margin:auto;
24
23
  display: flex;
24
+ justify-content: center;
25
25
  flex-direction: row;
26
26
  align-items: center;
27
27
  height: auto;
@@ -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
+ }