@opencloning/utils 1.0.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 +17 -0
- package/package.json +22 -0
- package/src/config/urlWhitelist.js +19 -0
- package/src/utils/ambiguous_dna_bases.json +14 -0
- package/src/utils/enzyme_utils.js +20 -0
- package/src/utils/error2String.js +18 -0
- package/src/utils/fileParsers.js +69 -0
- package/src/utils/getHttpClient.js +20 -0
- package/src/utils/getStructuredBases.js +109 -0
- package/src/utils/ncbiRequests.js +102 -0
- package/src/utils/network.js +184 -0
- package/src/utils/network.test.js +149 -0
- package/src/utils/other.js +24 -0
- package/src/utils/readNwrite.js +295 -0
- package/src/utils/selectedRegionUtils.js +25 -0
- package/src/utils/selectedRegionUtils.test.js +14 -0
- package/src/utils/sequenceDisplay.js +38 -0
- package/src/utils/sequenceManipulation.js +220 -0
- package/src/utils/sequenceManipulation.test.js +38 -0
- package/src/utils/sequencingFileExtensions.js +8 -0
- package/src/utils/sourceFunctions.js +48 -0
- package/src/utils/thunks.js +46 -0
- package/src/utils/transformCoords.js +71 -0
- package/src/utils/transformCoords.test.js +58 -0
- package/vitest.config.js +17 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { getReverseComplementSequenceAndAnnotations, getReverseComplementSequenceString, getSequenceDataBetweenRange, insertSequenceDataAtPositionOrRange } from '@teselagen/sequence-utils';
|
|
2
|
+
import { fastaToJson } from '@teselagen/bio-parsers';
|
|
3
|
+
import { tidyUpSequenceData } from '@teselagen/sequence-utils';
|
|
4
|
+
|
|
5
|
+
function getSpacerSequence(spacer, spacerFeatureName = 'spacer') {
|
|
6
|
+
if (!spacer) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
const spacerSequence = fastaToJson(spacer)[0].parsedSequence;
|
|
10
|
+
// Add a feature spanning the length of the spacer
|
|
11
|
+
spacerSequence.features = [{
|
|
12
|
+
start: 0,
|
|
13
|
+
end: spacer.length - 1,
|
|
14
|
+
type: 'misc_feature',
|
|
15
|
+
name: spacerFeatureName,
|
|
16
|
+
strand: 1,
|
|
17
|
+
forward: true,
|
|
18
|
+
}];
|
|
19
|
+
return spacerSequence;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function joinSequencesIntoSingleSequence(sequences, locations, orientations, spacers, circularAssembly, spacerFeatureName = 'spacer') {
|
|
23
|
+
// Turn the spacers into sequences by parsing them as FASTA with fastaToJson
|
|
24
|
+
const spacerSequences = spacers.map((spacer) => getSpacerSequence(spacer, spacerFeatureName));
|
|
25
|
+
// Intercalate the spacers into the sequences
|
|
26
|
+
const sequences2join = [];
|
|
27
|
+
const locations2join = [];
|
|
28
|
+
const orientations2join = [];
|
|
29
|
+
|
|
30
|
+
if (!circularAssembly) {
|
|
31
|
+
const firstSpacer = spacerSequences.shift();
|
|
32
|
+
if (firstSpacer) {
|
|
33
|
+
sequences2join.push(firstSpacer);
|
|
34
|
+
locations2join.push({ start: 0, end: firstSpacer.sequence.length - 1 });
|
|
35
|
+
orientations2join.push('forward');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (let i = 0; i < sequences.length; i++) {
|
|
40
|
+
sequences2join.push(sequences[i]);
|
|
41
|
+
locations2join.push(locations[i]);
|
|
42
|
+
orientations2join.push(orientations[i]);
|
|
43
|
+
if (spacerSequences[i]) {
|
|
44
|
+
sequences2join.push(spacerSequences[i]);
|
|
45
|
+
locations2join.push({ start: 0, end: spacerSequences[i].sequence.length - 1 });
|
|
46
|
+
orientations2join.push('forward');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const fragments = sequences2join.map((sequence, index) => {
|
|
51
|
+
const seq = getSequenceDataBetweenRange(sequence, locations2join[index]);
|
|
52
|
+
if (orientations2join[index] === 'reverse') {
|
|
53
|
+
return getReverseComplementSequenceAndAnnotations(seq);
|
|
54
|
+
}
|
|
55
|
+
return seq;
|
|
56
|
+
});
|
|
57
|
+
// Concatenate all fragments
|
|
58
|
+
let outputSequence = fragments[0];
|
|
59
|
+
for (let i = 1; i < fragments.length; i++) {
|
|
60
|
+
outputSequence = insertSequenceDataAtPositionOrRange(fragments[i], outputSequence, outputSequence.sequence.length);
|
|
61
|
+
}
|
|
62
|
+
return outputSequence;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function simulateHomologousRecombination(templateSequence, targetSequence, rois, invertFragment, spacers) {
|
|
66
|
+
const [amplifyRangeSelection, insertionRangeSelection] = rois;
|
|
67
|
+
|
|
68
|
+
const amplifyRange = amplifyRangeSelection.selectionLayer;
|
|
69
|
+
|
|
70
|
+
const insertionRangeOrPosition = insertionRangeSelection.caretPosition === -1 ? insertionRangeSelection.selectionLayer : insertionRangeSelection.caretPosition;
|
|
71
|
+
|
|
72
|
+
let templateFragment = getSequenceDataBetweenRange(templateSequence, amplifyRange);
|
|
73
|
+
if (invertFragment) {
|
|
74
|
+
templateFragment = getReverseComplementSequenceAndAnnotations(templateFragment);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const spacerSequences = spacers.map(getSpacerSequence);
|
|
78
|
+
|
|
79
|
+
let templateWithSpacers = spacerSequences[0] || templateFragment;
|
|
80
|
+
if (spacerSequences[0]) {
|
|
81
|
+
templateWithSpacers = insertSequenceDataAtPositionOrRange(templateFragment, templateWithSpacers, templateWithSpacers.sequence.length);
|
|
82
|
+
}
|
|
83
|
+
if (spacerSequences[1]) {
|
|
84
|
+
templateWithSpacers = insertSequenceDataAtPositionOrRange(spacerSequences[1], templateWithSpacers, templateWithSpacers.sequence.length);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return insertSequenceDataAtPositionOrRange(templateWithSpacers, targetSequence, insertionRangeOrPosition);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function findRotation(str1, str2) {
|
|
91
|
+
const query1 = str1.toUpperCase();
|
|
92
|
+
const query2 = str2.toUpperCase();
|
|
93
|
+
// Check if strings are identical
|
|
94
|
+
if (query1 === query2) return 0;
|
|
95
|
+
// Check if they're the same length
|
|
96
|
+
if (query1.length !== query2.length) return -1;
|
|
97
|
+
|
|
98
|
+
// Double the first string and search for the second string
|
|
99
|
+
const doubled = query1 + query1;
|
|
100
|
+
const rotation = doubled.indexOf(query2);
|
|
101
|
+
return rotation === -1 ? -1 : rotation;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function rotateChromatogramData(chromatogramData, rotation) {
|
|
105
|
+
if (rotation === 0) {
|
|
106
|
+
return {...chromatogramData};
|
|
107
|
+
}
|
|
108
|
+
const {aTrace, baseCalls, basePos, baseTraces, cTrace, gTrace, qualNums, tTrace} = chromatogramData;
|
|
109
|
+
const rotateTrace = rotation * 4;
|
|
110
|
+
return {
|
|
111
|
+
aTrace: [...aTrace.slice(rotateTrace), ...aTrace.slice(0, rotateTrace)],
|
|
112
|
+
cTrace: [...cTrace.slice(rotateTrace), ...cTrace.slice(0, rotateTrace)],
|
|
113
|
+
gTrace: [...gTrace.slice(rotateTrace), ...gTrace.slice(0, rotateTrace)],
|
|
114
|
+
tTrace: [...tTrace.slice(rotateTrace), ...tTrace.slice(0, rotateTrace)],
|
|
115
|
+
baseCalls: [...baseCalls.slice(rotation), ...baseCalls.slice(0, rotation)],
|
|
116
|
+
basePos: [...basePos.slice(rotation), ...basePos.slice(0, rotation)],
|
|
117
|
+
baseTraces: [...baseTraces.slice(rotation), ...baseTraces.slice(0, rotation)],
|
|
118
|
+
qualNums: [...qualNums.slice(rotation), ...qualNums.slice(0, rotation)],
|
|
119
|
+
}};
|
|
120
|
+
|
|
121
|
+
function reverseComplementArray(arr) {
|
|
122
|
+
return arr.slice().reverse();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function reverseComplementChromatogramData(chromatogramDataIn) {
|
|
126
|
+
const chromatogramData = { ...chromatogramDataIn };
|
|
127
|
+
const complement = { A: 'T', T: 'A', G: 'C', C: 'G', N: 'N' };
|
|
128
|
+
function reverseComplementSequence(seq) {
|
|
129
|
+
return seq
|
|
130
|
+
.map((base) => complement[base] || base).reverse();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
chromatogramData.aTrace = reverseComplementArray(chromatogramData.aTrace);
|
|
134
|
+
chromatogramData.tTrace = reverseComplementArray(chromatogramData.tTrace);
|
|
135
|
+
chromatogramData.gTrace = reverseComplementArray(chromatogramData.gTrace);
|
|
136
|
+
chromatogramData.cTrace = reverseComplementArray(chromatogramData.cTrace);
|
|
137
|
+
chromatogramData.basePos = reverseComplementArray(chromatogramData.basePos);
|
|
138
|
+
chromatogramData.baseCalls = reverseComplementSequence(
|
|
139
|
+
chromatogramData.baseCalls,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
chromatogramData.baseTraces = reverseComplementArray(
|
|
143
|
+
chromatogramData.baseTraces,
|
|
144
|
+
).map((traceObj) => ({
|
|
145
|
+
aTrace: reverseComplementArray(traceObj.aTrace),
|
|
146
|
+
tTrace: reverseComplementArray(traceObj.tTrace),
|
|
147
|
+
gTrace: reverseComplementArray(traceObj.gTrace),
|
|
148
|
+
cTrace: reverseComplementArray(traceObj.cTrace),
|
|
149
|
+
}));
|
|
150
|
+
chromatogramData.qualNums = reverseComplementArray(chromatogramData.qualNums);
|
|
151
|
+
|
|
152
|
+
return chromatogramData;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function syncChromatogramDataWithAlignment(chromatogramData, alignmentString) {
|
|
156
|
+
// If possible, syncs the chromatogram data with the alignment sequence, otherwise
|
|
157
|
+
// returns the original chromatogram data
|
|
158
|
+
|
|
159
|
+
const alignmentSequence = alignmentString.replaceAll('-', '');
|
|
160
|
+
|
|
161
|
+
const originalChromatogramData = structuredClone(chromatogramData);
|
|
162
|
+
let newChromatogramData = originalChromatogramData;
|
|
163
|
+
const originalSequence = chromatogramData.baseCalls.join('');
|
|
164
|
+
// Find the rotation of the alignment sequence relative to the original sequence
|
|
165
|
+
let rotation = findRotation(originalSequence, alignmentSequence);
|
|
166
|
+
// If the rotation is -1, it may be reverse complemented
|
|
167
|
+
const reverseComplemented = rotation === -1;;
|
|
168
|
+
if (reverseComplemented) {
|
|
169
|
+
rotation = findRotation(originalSequence, getReverseComplementSequenceString(alignmentSequence));
|
|
170
|
+
newChromatogramData = reverseComplementChromatogramData(newChromatogramData);
|
|
171
|
+
}
|
|
172
|
+
if (rotation !== -1) {
|
|
173
|
+
rotation = reverseComplemented ? originalSequence.length - rotation : rotation;
|
|
174
|
+
newChromatogramData = rotateChromatogramData(newChromatogramData, rotation);
|
|
175
|
+
|
|
176
|
+
return newChromatogramData;
|
|
177
|
+
}
|
|
178
|
+
return originalChromatogramData;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function ebicTemplateAnnotation(templateSequence, roi, {max_inside, max_outside, padding_left, padding_right}) {
|
|
182
|
+
const leftFeature = {
|
|
183
|
+
start: roi.start - padding_left,
|
|
184
|
+
end: roi.start - 1,
|
|
185
|
+
type: 'misc_feature',
|
|
186
|
+
name: 'left_homology_arm',
|
|
187
|
+
strand: 1,
|
|
188
|
+
forward: true,
|
|
189
|
+
};
|
|
190
|
+
const leftMargin = {
|
|
191
|
+
start: roi.start - max_outside,
|
|
192
|
+
end: roi.start + max_inside - 1,
|
|
193
|
+
type: 'misc_feature',
|
|
194
|
+
name: 'left_margin',
|
|
195
|
+
strand: null,
|
|
196
|
+
forward: true,
|
|
197
|
+
};
|
|
198
|
+
const rightMargin = {
|
|
199
|
+
start: roi.end - max_inside,
|
|
200
|
+
end: roi.end + max_outside - 1,
|
|
201
|
+
type: 'misc_feature',
|
|
202
|
+
name: 'right_margin',
|
|
203
|
+
strand: -1,
|
|
204
|
+
};
|
|
205
|
+
const rightFeature = {
|
|
206
|
+
start: roi.end + 1,
|
|
207
|
+
end: roi.end + padding_right,
|
|
208
|
+
type: 'misc_feature',
|
|
209
|
+
name: 'right_homology_arm',
|
|
210
|
+
strand: -1,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const annotatedSequence = structuredClone(templateSequence);
|
|
214
|
+
annotatedSequence.features.push(leftFeature);
|
|
215
|
+
annotatedSequence.features.push(leftMargin);
|
|
216
|
+
annotatedSequence.features.push(rightMargin);
|
|
217
|
+
annotatedSequence.features.push(rightFeature);
|
|
218
|
+
|
|
219
|
+
return tidyUpSequenceData(annotatedSequence);
|
|
220
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { syncChromatogramDataWithAlignment } from './sequenceManipulation';
|
|
2
|
+
import teselaJson from '../../../../cypress/test_files/example_chromatogram.json';
|
|
3
|
+
import { getReverseComplementSequenceString } from '@teselagen/sequence-utils';
|
|
4
|
+
|
|
5
|
+
const exampleChromatogramData = teselaJson.chromatogramData;
|
|
6
|
+
|
|
7
|
+
describe('syncChromatogramDataWithAlignment', () => {
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
it('returns original data when sequences match', () => {
|
|
11
|
+
const result = syncChromatogramDataWithAlignment(exampleChromatogramData, 'TTGAGATC---CTTTTTTT');
|
|
12
|
+
expect(result).toEqual(exampleChromatogramData);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('handles reverse complemented sequence', () => {
|
|
16
|
+
const reverseComplementedSequence = getReverseComplementSequenceString('TTGAGATC---CTTTTTTT');
|
|
17
|
+
const result = syncChromatogramDataWithAlignment(exampleChromatogramData, reverseComplementedSequence);
|
|
18
|
+
expect(result.baseCalls.join('')).toEqual(getReverseComplementSequenceString('TTGAGATCCTTTTTTT'));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('handles rotated sequence', () => {
|
|
22
|
+
const result = syncChromatogramDataWithAlignment(exampleChromatogramData, 'CCTTTTTTTTTGAGAT');
|
|
23
|
+
expect(result.baseCalls.join('')).toEqual('CCTTTTTTTTTGAGAT');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('handles rotated and reverse complemented sequence', () => {
|
|
27
|
+
const reverseComplementedSequence = getReverseComplementSequenceString('CCTTTTTTTTTGAGAT');
|
|
28
|
+
const result = syncChromatogramDataWithAlignment(exampleChromatogramData, reverseComplementedSequence);
|
|
29
|
+
expect(result.baseCalls.join('')).toEqual(getReverseComplementSequenceString('CCTTTTTTTTTGAGAT'));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
it('returns original data when no match found', () => {
|
|
34
|
+
const result = syncChromatogramDataWithAlignment(exampleChromatogramData, 'GGCC');
|
|
35
|
+
expect(result).toEqual(exampleChromatogramData);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export function enzymesInRestrictionEnzymeDigestionSource(source) {
|
|
2
|
+
/**
|
|
3
|
+
* Extracts the enzymes used in a RestrictionEnzymeDigestionSource as an array of strings.
|
|
4
|
+
*/
|
|
5
|
+
if (source.type !== 'RestrictionEnzymeDigestionSource') {
|
|
6
|
+
throw new Error('This function only works on RestrictionEnzymeDigestionSource');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const output = [];
|
|
10
|
+
if (source.left_edge) { output.push(source.left_edge.restriction_enzyme); }
|
|
11
|
+
// add the second one only if it's different
|
|
12
|
+
if (source.right_edge && (!source.left_edge || source.right_edge.restriction_enzyme !== source.left_edge.restriction_enzyme)) {
|
|
13
|
+
output.push(source.right_edge.restriction_enzyme);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return output;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const classNameToEndPointMap = {
|
|
20
|
+
UploadedFileSource: 'uploaded_file',
|
|
21
|
+
RepositoryIdSource: 'repository_id',
|
|
22
|
+
AddgeneIdSource: 'repository_id',
|
|
23
|
+
BenchlingUrlSource: 'repository_id',
|
|
24
|
+
SnapGenePlasmidSource: 'repository_id',
|
|
25
|
+
EuroscarfSource: 'repository_id',
|
|
26
|
+
IGEMSource: 'repository_id',
|
|
27
|
+
SEVASource: 'repository_id',
|
|
28
|
+
WekWikGeneIdSource: 'repository_id',
|
|
29
|
+
OpenDNACollectionsSource: 'repository_id',
|
|
30
|
+
GenomeCoordinatesSource: 'genome_coordinates',
|
|
31
|
+
ManuallyTypedSource: 'manually_typed',
|
|
32
|
+
OligoHybridizationSource: 'oligonucleotide_hybridization',
|
|
33
|
+
RestrictionEnzymeDigestionSource: 'restriction_enzyme_digestion',
|
|
34
|
+
PCRSource: 'pcr',
|
|
35
|
+
PolymeraseExtensionSource: 'polymerase_extension',
|
|
36
|
+
LigationSource: 'ligation',
|
|
37
|
+
GibsonAssemblySource: 'gibson_assembly',
|
|
38
|
+
OverlapExtensionPCRLigationSource: 'gibson_assembly',
|
|
39
|
+
InFusionSource: 'gibson_assembly',
|
|
40
|
+
InVivoAssemblySource: 'gibson_assembly',
|
|
41
|
+
HomologousRecombinationSource: 'homologous_recombination',
|
|
42
|
+
CRISPRSource: 'crispr',
|
|
43
|
+
RestrictionAndLigationSource: 'restriction_and_ligation',
|
|
44
|
+
GatewaySource: 'gateway',
|
|
45
|
+
AnnotationSource: 'annotate',
|
|
46
|
+
ReverseComplementSource: 'reverse_complement',
|
|
47
|
+
CreLoxRecombinationSource: 'cre_lox_recombination',
|
|
48
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { cloneDeep } from 'lodash-es';
|
|
2
|
+
import { base64ToBlob, downloadStateAsJson, downloadStateAsZip, loadFilesToSessionStorage } from './readNwrite';
|
|
3
|
+
import { cloningActions } from '@opencloning/store/cloning';
|
|
4
|
+
import { collectParentSequencesAndSources, getSubState, mergeStates } from './network';
|
|
5
|
+
import { getVerificationFileName } from './readNwrite';
|
|
6
|
+
|
|
7
|
+
const { setState: setCloningState } = cloningActions;
|
|
8
|
+
|
|
9
|
+
export const exportSubStateThunk = (fileName, sequenceId, format = 'json') => async (dispatch, getState) => {
|
|
10
|
+
// Download the subHistory for a given sequence
|
|
11
|
+
const state = getState();
|
|
12
|
+
const substate = getSubState(state, sequenceId);
|
|
13
|
+
if (format === 'json') {
|
|
14
|
+
downloadStateAsJson({ ...substate, description: '' }, fileName);
|
|
15
|
+
} else if (format === 'zip') {
|
|
16
|
+
downloadStateAsZip({ ...substate, description: '' }, fileName);
|
|
17
|
+
} else {
|
|
18
|
+
throw new Error(`Invalid format: ${format}`);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const CopySequenceThunk = (sequenceId) => async (dispatch, getState) => {
|
|
23
|
+
const state = getState();
|
|
24
|
+
const { sequences, sources } = state.cloning;
|
|
25
|
+
const sequencesToCopy = sequences.filter((e) => e.id === sequenceId);
|
|
26
|
+
const sourcesToCopy = sources.filter((s) => s.id === sequenceId);
|
|
27
|
+
const { parentSequences, parentSources } = collectParentSequencesAndSources(sourcesToCopy[0], sources, sequences);
|
|
28
|
+
sequencesToCopy.push(...parentSequences);
|
|
29
|
+
sourcesToCopy.push(...parentSources);
|
|
30
|
+
const sequenceIds = sequencesToCopy.map((e) => e.id);
|
|
31
|
+
const filesToCopy = state.cloning.files.filter((f) => sequenceIds.includes(f.sequence_id));
|
|
32
|
+
const newState = cloneDeep({
|
|
33
|
+
sequences: sequencesToCopy,
|
|
34
|
+
sources: sourcesToCopy,
|
|
35
|
+
primers: [],
|
|
36
|
+
files: filesToCopy,
|
|
37
|
+
});
|
|
38
|
+
const files = filesToCopy.map((f) => new File(
|
|
39
|
+
[base64ToBlob(sessionStorage.getItem(getVerificationFileName(f)))],
|
|
40
|
+
getVerificationFileName(f),
|
|
41
|
+
{ type: 'application/octet-stream' },
|
|
42
|
+
));
|
|
43
|
+
const { mergedState, idShift } = mergeStates(newState, state.cloning, true);
|
|
44
|
+
dispatch(setCloningState(mergedState));
|
|
45
|
+
await loadFilesToSessionStorage(files, idShift);
|
|
46
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { parseFeatureLocation } from '@teselagen/bio-parsers';
|
|
2
|
+
import { flipContainedRange, getRangeLength, isRangeWithinRange, translateRange } from '@teselagen/range-utils';
|
|
3
|
+
import { isEqual } from 'lodash-es';
|
|
4
|
+
import { isAssemblyComplete } from '@opencloning/store/cloning_utils';
|
|
5
|
+
|
|
6
|
+
export default function getTransformCoords(source, parentSequenceData, productLength) {
|
|
7
|
+
if (!isAssemblyComplete(source)) {
|
|
8
|
+
return () => null;
|
|
9
|
+
}
|
|
10
|
+
const { type: sourceType, input } = source;
|
|
11
|
+
let fragments = sourceType !== 'PCRSource' ? structuredClone(input) : [structuredClone(input[1])];
|
|
12
|
+
|
|
13
|
+
let count = 0;
|
|
14
|
+
if (sourceType === 'PCRSource') {
|
|
15
|
+
// Primer is linear sequence, so no need to pass the sequence length
|
|
16
|
+
const fwdPrimerAnnealingPart = parseFeatureLocation(input[0].right_location, 0, 0, 0, 1, null)[0];
|
|
17
|
+
count = fwdPrimerAnnealingPart.start;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
fragments = fragments.map((f) => {
|
|
21
|
+
// The boolean handles undefined parentSequenceData (for PCR where is [undefined, value, undefined])
|
|
22
|
+
const sequence = parentSequenceData.find((e) => Boolean(e) && e.id === f.sequence);
|
|
23
|
+
// If the sequence is not found, it means it's a CRISPR guide / primer
|
|
24
|
+
if (!sequence) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const { size } = sequence;
|
|
28
|
+
const { left_location: left, right_location: right } = f;
|
|
29
|
+
const leftLocation = left ? parseFeatureLocation(left, 0, 0, 0, 1, size)[0] : null;
|
|
30
|
+
const rightLocation = right ? parseFeatureLocation(right, 0, 0, 0, 1, size)[0] : null;
|
|
31
|
+
const leftStart = leftLocation?.start || 0;
|
|
32
|
+
const rightStart = rightLocation?.start || 0;
|
|
33
|
+
const rightEnd = rightLocation?.end || size;
|
|
34
|
+
|
|
35
|
+
// Handle special case for circular sequences with left_location and right_location being identical (insertion)
|
|
36
|
+
let rangeLength = isEqual(leftLocation, rightLocation) ? size : getRangeLength({ start: leftStart, end: rightEnd }, size);
|
|
37
|
+
// Handle special case for circular sequences are excised
|
|
38
|
+
if (rangeLength > productLength) {
|
|
39
|
+
rangeLength = productLength;
|
|
40
|
+
}
|
|
41
|
+
f.rangeInAssembly = translateRange({ start: 0, end: rangeLength - 1 }, count, productLength);
|
|
42
|
+
f.size = size;
|
|
43
|
+
count += getRangeLength({ start: leftStart, end: rightStart - 1 }, size);
|
|
44
|
+
return f;
|
|
45
|
+
});
|
|
46
|
+
fragments = fragments.filter((f) => f !== null);
|
|
47
|
+
const rangeInParent = (selection, id) => {
|
|
48
|
+
if (selection.start === -1) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// In insertion assemblies, more than one fragment has the same id,
|
|
53
|
+
// so we filter instead of find
|
|
54
|
+
const possibleOut = fragments.filter((f) => f.sequence === id).map((fragment) => {
|
|
55
|
+
const { rangeInAssembly, left_location: left, reverse_complemented, size } = fragment;
|
|
56
|
+
const leftLocation = left ? parseFeatureLocation(left, 0, 0, 0, 1, size)[0] : null;
|
|
57
|
+
const startInParent = leftLocation?.start || 0;
|
|
58
|
+
if (isRangeWithinRange(selection, rangeInAssembly, productLength)) {
|
|
59
|
+
const selectionShifted = selection.start <= selection.end ? selection : { start: selection.start, end: selection.end + productLength };
|
|
60
|
+
const outRange = translateRange(selectionShifted, -rangeInAssembly.start + startInParent, size);
|
|
61
|
+
if (reverse_complemented) {
|
|
62
|
+
return flipContainedRange(outRange, { start: 0, end: size - 1 }, size);
|
|
63
|
+
}
|
|
64
|
+
return outRange;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
});
|
|
68
|
+
return possibleOut.find((out) => out !== null) || null;
|
|
69
|
+
};
|
|
70
|
+
return rangeInParent;
|
|
71
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import getTransformCoords from './transformCoords';
|
|
2
|
+
|
|
3
|
+
const testData = require('../../../../cypress/test_files/unittests/test_data_transformCoords.json');
|
|
4
|
+
|
|
5
|
+
const pcrSource = testData.sources.find((s) => s.type === 'PCRSource');
|
|
6
|
+
|
|
7
|
+
const creLoxData = require('../../../../apps/opencloning/public/examples/cre_lox_recombination.json');
|
|
8
|
+
|
|
9
|
+
const plasmidExcisionSource = creLoxData.sources.find((s) => s.id === 3);
|
|
10
|
+
const plasmidInsertionSource = creLoxData.sources.find((s) => s.id === 5);
|
|
11
|
+
|
|
12
|
+
describe('getTransformCoords', () => {
|
|
13
|
+
it('PCRSource', () => {
|
|
14
|
+
const parentSequenceData = [
|
|
15
|
+
{ id: 2, size: 23 },
|
|
16
|
+
];
|
|
17
|
+
const productLength = 18;
|
|
18
|
+
const transformCoords = getTransformCoords(pcrSource, parentSequenceData, productLength);
|
|
19
|
+
// Normal primer
|
|
20
|
+
expect(transformCoords({ start: 12, end: 17 }, 2)).toEqual({ start: 8, end: 13 });
|
|
21
|
+
// Origin-spanning primer
|
|
22
|
+
expect(transformCoords({ start: 0, end: 6 }, 2)).toEqual({ start: 19, end: 2 });
|
|
23
|
+
});
|
|
24
|
+
it('Insertion / circular Assembly', () => {
|
|
25
|
+
let parentSequenceData = [
|
|
26
|
+
{ id: 1, size: 151 },
|
|
27
|
+
];
|
|
28
|
+
let productLength = 113;
|
|
29
|
+
let transformCoords = getTransformCoords(plasmidExcisionSource, parentSequenceData, productLength);
|
|
30
|
+
expect(transformCoords({ start: 1, end: 5 }, 1)).toEqual({ start: 16, end: 20 });
|
|
31
|
+
expect(transformCoords({ start: 100, end: 0 }, 1)).toEqual({ start: 115, end: 128 });
|
|
32
|
+
|
|
33
|
+
parentSequenceData = [
|
|
34
|
+
{ id: 3, size: 113 },
|
|
35
|
+
{ id: 4, size: 38 },
|
|
36
|
+
];
|
|
37
|
+
productLength = 151;
|
|
38
|
+
transformCoords = getTransformCoords(plasmidInsertionSource, parentSequenceData, productLength);
|
|
39
|
+
expect(transformCoords({ start: 15, end: 37 }, 3)).toEqual({ start: 0, end: 22 });
|
|
40
|
+
expect(transformCoords({ start: 2, end: 35 }, 3)).toEqual(null);
|
|
41
|
+
expect(transformCoords({ start: 0, end: 19 }, 4)).toEqual({ start: 0, end: 19 });
|
|
42
|
+
expect(transformCoords({ start: 128, end: 150 }, 4)).toEqual({ start: 15, end: 37 });
|
|
43
|
+
expect(transformCoords({ start: 38, end: 112 }, 3)).toEqual({ start: 23, end: 97 });
|
|
44
|
+
expect(transformCoords({ start: 38, end: 112 }, 4)).toEqual(null);
|
|
45
|
+
});
|
|
46
|
+
it('Edge case', ({ skip }) => {
|
|
47
|
+
// This is not priority, but it may need fixing. When a circular molecule is excised, the rangeInAssembly
|
|
48
|
+
// spans the entire product, so any value in isRangeWithinRange(selection, rangeInAssembly, productLength)
|
|
49
|
+
// is true. When the selection spans the origin, this should not pass, but not sure what else would be affected.
|
|
50
|
+
skip(true);
|
|
51
|
+
const parentSequenceData = [
|
|
52
|
+
{ id: 2, size: 151 },
|
|
53
|
+
];
|
|
54
|
+
const productLength = 113;
|
|
55
|
+
const transformCoords = getTransformCoords(plasmidExcisionSource, parentSequenceData, productLength);
|
|
56
|
+
expect(transformCoords({ start: 100, end: 20 }, 2)).toEqual(null);
|
|
57
|
+
});
|
|
58
|
+
});
|
package/vitest.config.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
test: {
|
|
6
|
+
globals: true,
|
|
7
|
+
environment: 'jsdom',
|
|
8
|
+
setupFiles: '../../tests/setup.js',
|
|
9
|
+
include: ['src/**/*.{test,spec}.{js,jsx}'],
|
|
10
|
+
},
|
|
11
|
+
resolve: {
|
|
12
|
+
alias: {
|
|
13
|
+
'@opencloning/utils': resolve(__dirname, './src/utils'),
|
|
14
|
+
'@opencloning/store': resolve(__dirname, '../store/src'),
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
});
|