@datagrok/sequence-translator 1.10.14 → 1.10.16
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/detectors.js +30 -2
- package/dist/455.js +1 -1
- package/dist/455.js.map +1 -1
- package/dist/package-test.js +1 -1
- package/dist/package-test.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/files/samples/sirna-demo.csv +38 -0
- package/package.json +2 -2
- package/src/oligo-renderer/canvas-renderer.ts +500 -0
- package/src/oligo-renderer/cell-renderer.ts +105 -0
- package/src/oligo-renderer/converters.ts +77 -0
- package/src/oligo-renderer/helm-parser.ts +154 -0
- package/src/oligo-renderer/legend-panel.ts +154 -0
- package/src/oligo-renderer/structures-panel.ts +96 -0
- package/src/oligo-renderer/tooltip.ts +227 -0
- package/src/oligo-renderer/types.ts +222 -0
- package/src/package-api.ts +35 -0
- package/src/package-test.ts +1 -0
- package/src/package.g.ts +45 -0
- package/src/package.ts +76 -0
- package/src/polytool/pt-dialog.ts +2 -1
- package/src/tests/oligo-renderer-tests.ts +299 -0
- package/test-console-output-1.log +215 -121
- package/test-record-1.mp4 +0 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import {category, test, expect} from '@datagrok-libraries/test/src/test';
|
|
2
|
+
|
|
3
|
+
import {parseHelmDuplex, looksLikeHelm} from '../oligo-renderer/helm-parser';
|
|
4
|
+
import {computeLayout, hitTest, drawDuplex} from '../oligo-renderer/canvas-renderer';
|
|
5
|
+
import {
|
|
6
|
+
resolveSugar, resolvePhosphate, resolveConjugate,
|
|
7
|
+
hashColor, SUGAR_MODS, PHOSPHATE_MODS, CONJUGATE_MODS,
|
|
8
|
+
} from '../oligo-renderer/types';
|
|
9
|
+
|
|
10
|
+
// HELMCore canonical: lowercase backbone (`r`, `m`, `d`, `lna`, `fl2r`, `p`, `sp`).
|
|
11
|
+
// Single-char sugars/phosphates are unbracketed; multi-char must be bracketed.
|
|
12
|
+
const SAMPLE_DUPLEX =
|
|
13
|
+
'RNA1{m(G)[sp].m(A)[sp].[fl2r](C)p.[fl2r](U)p.r(G)p.r(A)p.r(A)p.r(U)p.r(A)p.r(U)p.r(A)p.r(A)p.r(A)p.' +
|
|
14
|
+
'r(C)p.r(U)p.r(U)p.m(G)p.m(U)[sp].m(G)[sp].[L3]}|' +
|
|
15
|
+
'RNA2{[fl2r](C)[sp].m(A)[sp].m(A)p.m(G)p.r(U)p.r(U)p.r(U)p.r(A)p.r(U)p.r(A)p.r(U)p.r(U)p.r(C)p.' +
|
|
16
|
+
'r(A)p.r(U)p.m(C)p.m(A)[sp].m(G)[sp].r(U)}$$$$';
|
|
17
|
+
|
|
18
|
+
const SAMPLE_SS = 'RNA1{[lna](C)[sp].[lna](A)[sp].[lna](G)[sp].d(T)[sp].d(C)[sp].d(A)[sp]}$$$$';
|
|
19
|
+
|
|
20
|
+
// Legacy / Pistoia-style symbols that should still render via the alias map.
|
|
21
|
+
const LEGACY_DUPLEX =
|
|
22
|
+
'RNA1{[mR](G)[sP].[mR](A)[sP].[fR](C)P.R(U)P}|RNA2{[mR](C)[sP].R(A)P.R(U)P.R(G)P}$$$$';
|
|
23
|
+
|
|
24
|
+
category('OligoRenderer: parser', () => {
|
|
25
|
+
test('looksLikeHelm — positive', async () => {
|
|
26
|
+
expect(looksLikeHelm(SAMPLE_DUPLEX), true);
|
|
27
|
+
expect(looksLikeHelm('RNA1{R(A)P}'), true);
|
|
28
|
+
expect(looksLikeHelm('PEPTIDE1{A.C.G}'), true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('looksLikeHelm — negative', async () => {
|
|
32
|
+
expect(looksLikeHelm(''), false);
|
|
33
|
+
expect(looksLikeHelm('AUCGUACGUAGCUAGCAU'), false);
|
|
34
|
+
expect(looksLikeHelm('Af Cf Gf Uf'), false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('parses two-strand duplex', async () => {
|
|
38
|
+
const m = parseHelmDuplex(SAMPLE_DUPLEX);
|
|
39
|
+
expect(m.sense.type, 'RNA');
|
|
40
|
+
expect(m.sense.monomers.length, 20); // 19 nucleotides + L3 conjugate
|
|
41
|
+
expect(m.antisense !== null, true);
|
|
42
|
+
expect(m.antisense!.monomers.length, 19);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('parses single-strand', async () => {
|
|
46
|
+
const m = parseHelmDuplex(SAMPLE_SS);
|
|
47
|
+
expect(m.sense.monomers.length, 6);
|
|
48
|
+
expect(m.antisense, null);
|
|
49
|
+
// First nucleotide has LNA sugar, base C, PS phosphate
|
|
50
|
+
const first = m.sense.monomers[0];
|
|
51
|
+
expect(first.kind, 'nucleotide');
|
|
52
|
+
if (first.kind === 'nucleotide') {
|
|
53
|
+
expect(first.sugar, 'lna');
|
|
54
|
+
expect(first.base, 'C');
|
|
55
|
+
expect(first.phosphate, 'sp');
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('single-char unbracketed deoxyribose `d` parses correctly', async () => {
|
|
60
|
+
// Regression: previously `dR(T)` was emitted (invalid HELM); the parser
|
|
61
|
+
// would split `d` as the sugar and choke on the rest. With canonical `d(T)`,
|
|
62
|
+
// sugar=d, base=T, phosphate empty.
|
|
63
|
+
const m = parseHelmDuplex('RNA1{d(T)p.d(C)p.d(G)}$$$$');
|
|
64
|
+
expect(m.sense.monomers.length, 3);
|
|
65
|
+
const first = m.sense.monomers[0];
|
|
66
|
+
if (first.kind === 'nucleotide') {
|
|
67
|
+
expect(first.sugar, 'd');
|
|
68
|
+
expect(first.base, 'T');
|
|
69
|
+
expect(first.phosphate, 'p');
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('parses bracketed conjugate', async () => {
|
|
74
|
+
const m = parseHelmDuplex(SAMPLE_DUPLEX);
|
|
75
|
+
const last = m.sense.monomers[m.sense.monomers.length - 1];
|
|
76
|
+
expect(last.kind, 'conjugate');
|
|
77
|
+
if (last.kind === 'conjugate') expect(last.symbol, 'L3');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('handles empty / malformed without throwing', async () => {
|
|
81
|
+
const m1 = parseHelmDuplex('');
|
|
82
|
+
expect(m1.sense.monomers.length, 0);
|
|
83
|
+
expect(m1.antisense, null);
|
|
84
|
+
const m2 = parseHelmDuplex('not a helm string');
|
|
85
|
+
expect(m2.sense.monomers.length, 0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('passes through unknown sugar/phosphate symbols', async () => {
|
|
89
|
+
const helm = 'RNA1{[xr](A)[zp].[yr](C)p}$$$$';
|
|
90
|
+
const m = parseHelmDuplex(helm);
|
|
91
|
+
const m0 = m.sense.monomers[0];
|
|
92
|
+
if (m0.kind === 'nucleotide') {
|
|
93
|
+
expect(m0.sugar, 'xr');
|
|
94
|
+
expect(m0.phosphate, 'zp');
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
category('OligoRenderer: modification dictionary', () => {
|
|
100
|
+
test('resolves HELMCore canonical sugars', async () => {
|
|
101
|
+
expect(resolveSugar('m', 'A').meta.short, '2\'-OMe');
|
|
102
|
+
expect(resolveSugar('fl2r', 'C').meta.short, '2\'-F');
|
|
103
|
+
expect(resolveSugar('lna', 'G').meta.short, 'LNA');
|
|
104
|
+
expect(resolveSugar('moe', 'A').meta.short, '2\'-MOE');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('aliases legacy uppercase symbols to canonical', async () => {
|
|
108
|
+
// `mR` (legacy) and `m` (canonical) must resolve identically
|
|
109
|
+
expect(resolveSugar('mR', 'A').meta.name, resolveSugar('m', 'A').meta.name);
|
|
110
|
+
expect(resolveSugar('fR', 'C').meta.name, resolveSugar('fl2r', 'C').meta.name);
|
|
111
|
+
expect(resolveSugar('LR', 'G').meta.name, resolveSugar('lna', 'G').meta.name);
|
|
112
|
+
expect(resolveSugar('dR', 'T').meta.name, resolveSugar('d', 'T').meta.name);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('uses base-canonical color for unmodified ribose', async () => {
|
|
116
|
+
const a = resolveSugar('r', 'A');
|
|
117
|
+
const c = resolveSugar('r', 'C');
|
|
118
|
+
expect(a.color !== c.color, true,
|
|
119
|
+
`Unmodified A and C must use distinct canonical colors, got ${a.color} for both`);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('falls back to hash color for unknown sugar', async () => {
|
|
123
|
+
const x = resolveSugar('zzz', 'A');
|
|
124
|
+
expect(x.color.startsWith('hsl('), true);
|
|
125
|
+
expect(x.meta.name, 'zzz');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('hashColor is deterministic', async () => {
|
|
129
|
+
expect(hashColor('foo'), hashColor('foo'));
|
|
130
|
+
expect(hashColor('foo') !== hashColor('bar'), true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('PS phosphate (canonical and legacy) resolve to same color', async () => {
|
|
134
|
+
expect(resolvePhosphate('sp').color, PHOSPHATE_MODS['sp'].color);
|
|
135
|
+
expect(resolvePhosphate('sP').color, PHOSPHATE_MODS['sp'].color);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('GalNAc / L3 / Chol resolve as conjugates', async () => {
|
|
139
|
+
expect(resolveConjugate('GalNAc').meta.category, 'conjugate');
|
|
140
|
+
expect(resolveConjugate('L3').meta.category, 'conjugate');
|
|
141
|
+
expect(resolveConjugate('Chol').meta.color, CONJUGATE_MODS['Chol'].color);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
category('OligoRenderer: layout', () => {
|
|
146
|
+
test('comfortable cell — chips fit width and height', async () => {
|
|
147
|
+
const m = parseHelmDuplex(SAMPLE_DUPLEX);
|
|
148
|
+
const layout = computeLayout(500, 70, m);
|
|
149
|
+
expect(layout.textOnlyFallback, false);
|
|
150
|
+
expect(layout.chipW > 5, true, 'chip width should be larger than minimum');
|
|
151
|
+
expect(layout.chipW <= 17, true, 'chip width should respect max (17)');
|
|
152
|
+
expect(layout.antiY > layout.senseY, true);
|
|
153
|
+
expect(layout.senseChips.length > 0, true);
|
|
154
|
+
expect(layout.antiChips.length > 0, true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('compact cell — both strands still fit in 28px row', async () => {
|
|
158
|
+
const m = parseHelmDuplex(SAMPLE_DUPLEX);
|
|
159
|
+
const layout = computeLayout(300, 28, m);
|
|
160
|
+
expect(layout.antiY > 0, true);
|
|
161
|
+
expect(layout.chipH * 2 + layout.strandGap <= 28, true,
|
|
162
|
+
'two strands must fit within 28px row');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('very narrow cell triggers text-only fallback', async () => {
|
|
166
|
+
const m = parseHelmDuplex(SAMPLE_DUPLEX);
|
|
167
|
+
const layout = computeLayout(60, 30, m);
|
|
168
|
+
expect(layout.textOnlyFallback, true);
|
|
169
|
+
expect(layout.senseChips.length, 0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('aspect ratio is preserved across sizes', async () => {
|
|
173
|
+
const m = parseHelmDuplex(SAMPLE_DUPLEX);
|
|
174
|
+
for (const [w, h] of [[300, 50], [500, 70], [800, 90]]) {
|
|
175
|
+
const layout = computeLayout(w, h, m);
|
|
176
|
+
const ratio = layout.chipH / layout.chipW;
|
|
177
|
+
// ASPECT_H_OVER_W = 1.25 in canvas-renderer.ts
|
|
178
|
+
expect(Math.abs(ratio - 1.25) < 0.01, true,
|
|
179
|
+
`aspect ratio drifted at ${w}x${h}: got ${ratio.toFixed(3)}`);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('single-strand layout', async () => {
|
|
184
|
+
const m = parseHelmDuplex(SAMPLE_SS);
|
|
185
|
+
const layout = computeLayout(400, 50, m);
|
|
186
|
+
expect(layout.antiY, -1);
|
|
187
|
+
expect(layout.antiChips.length, 0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('antisense reversed by default for pair-alignment', async () => {
|
|
191
|
+
const m = parseHelmDuplex(SAMPLE_DUPLEX);
|
|
192
|
+
const layout = computeLayout(800, 70, m);
|
|
193
|
+
expect(layout.antiReversed, true);
|
|
194
|
+
// First antisense chip in display = last antisense monomer in data
|
|
195
|
+
const antiLast = m.antisense!.monomers[m.antisense!.monomers.length - 1];
|
|
196
|
+
expect(layout.antiChips[0].monomer === antiLast, true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('pairAlign: false leaves antisense in data order', async () => {
|
|
200
|
+
const m = parseHelmDuplex(SAMPLE_DUPLEX);
|
|
201
|
+
const layout = computeLayout(800, 70, m, {pairAlign: false});
|
|
202
|
+
expect(layout.antiReversed, false);
|
|
203
|
+
expect(layout.antiChips[0].monomer === m.antisense!.monomers[0], true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('conjugate occupies a wider position; following chip does not overlap', async () => {
|
|
207
|
+
// KRAS-like duplex with [L3] conjugate at sense 3'
|
|
208
|
+
const helm = 'RNA1{m(G)p.m(A)p.m(C)p.[L3]}|RNA2{m(C)p.m(A)p.m(A)p.r(G)}$$$$';
|
|
209
|
+
const m = parseHelmDuplex(helm);
|
|
210
|
+
const layout = computeLayout(600, 70, m);
|
|
211
|
+
const conj = layout.senseChips[3];
|
|
212
|
+
expect(conj.monomer.kind, 'conjugate');
|
|
213
|
+
expect(conj.w >= layout.chipW, true, 'conjugate must be at least chip-wide');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
category('OligoRenderer: hit testing', () => {
|
|
218
|
+
test('hits each chip via stored positions', async () => {
|
|
219
|
+
const m = parseHelmDuplex(SAMPLE_DUPLEX);
|
|
220
|
+
const layout = computeLayout(800, 70, m);
|
|
221
|
+
|
|
222
|
+
const first = layout.senseChips[0];
|
|
223
|
+
const hit = hitTest(first.x + first.w / 2, layout.senseY + layout.chipH / 2, m, layout);
|
|
224
|
+
expect(hit !== null, true);
|
|
225
|
+
expect(hit!.strand, 'sense');
|
|
226
|
+
expect(hit!.position, 0);
|
|
227
|
+
|
|
228
|
+
// Gap immediately after first chip should miss (unless it's a PS link)
|
|
229
|
+
const prev = m.sense.monomers[0];
|
|
230
|
+
const isPS = prev.kind === 'nucleotide' && (prev as any).phosphate === 'sp';
|
|
231
|
+
const gapX = first.x + first.w + layout.chipGap * 0.1;
|
|
232
|
+
const miss = hitTest(gapX, layout.senseY + layout.chipH / 2, m, layout);
|
|
233
|
+
if (isPS) {
|
|
234
|
+
// Should hit the PS linkage marker
|
|
235
|
+
expect(miss !== null, true);
|
|
236
|
+
expect(miss!.linkage !== undefined, true);
|
|
237
|
+
} else {
|
|
238
|
+
expect(miss, null);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('antisense hit returns original position despite reversed display', async () => {
|
|
243
|
+
const m = parseHelmDuplex(SAMPLE_DUPLEX);
|
|
244
|
+
const layout = computeLayout(800, 70, m);
|
|
245
|
+
// Click on the leftmost antisense chip in display — that's the LAST monomer in data
|
|
246
|
+
const leftmost = layout.antiChips[0];
|
|
247
|
+
const hit = hitTest(leftmost.x + leftmost.w / 2, layout.antiY + layout.chipH / 2, m, layout);
|
|
248
|
+
expect(hit !== null, true);
|
|
249
|
+
expect(hit!.strand, 'antisense');
|
|
250
|
+
expect(hit!.position, m.antisense!.monomers.length - 1,
|
|
251
|
+
'leftmost AS chip in pair-aligned display = last monomer in data');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('hovering between two chips with PS linkage returns the linkage', async () => {
|
|
255
|
+
// Force a PS linkage between position 0 and 1
|
|
256
|
+
const helm = 'RNA1{m(G)[sp].m(A)p.m(C)p}$$$$';
|
|
257
|
+
const m = parseHelmDuplex(helm);
|
|
258
|
+
const layout = computeLayout(600, 70, m);
|
|
259
|
+
const c0 = layout.senseChips[0];
|
|
260
|
+
const c1 = layout.senseChips[1];
|
|
261
|
+
const midX = (c0.x + c0.w + c1.x) / 2;
|
|
262
|
+
const hit = hitTest(midX, layout.senseY + layout.chipH / 2, m, layout);
|
|
263
|
+
expect(hit !== null, true);
|
|
264
|
+
expect(hit!.linkage !== undefined, true);
|
|
265
|
+
expect(hit!.linkage!.phosphateSymbol, 'sp');
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
category('OligoRenderer: drawing smoke', () => {
|
|
270
|
+
test('draws without error on offscreen canvas', async () => {
|
|
271
|
+
const canvas = document.createElement('canvas');
|
|
272
|
+
canvas.width = 500; canvas.height = 70;
|
|
273
|
+
const g = canvas.getContext('2d')!;
|
|
274
|
+
const model = parseHelmDuplex(SAMPLE_DUPLEX);
|
|
275
|
+
const layout = drawDuplex(g, 0, 0, 500, 70, model);
|
|
276
|
+
expect(layout.textOnlyFallback, false);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('handles unknown modifications without throwing', async () => {
|
|
280
|
+
const helm = 'RNA1{[xr](A)[zp].[yr](C)p}$$$$';
|
|
281
|
+
const canvas = document.createElement('canvas');
|
|
282
|
+
canvas.width = 200; canvas.height = 50;
|
|
283
|
+
const g = canvas.getContext('2d')!;
|
|
284
|
+
const model = parseHelmDuplex(helm);
|
|
285
|
+
drawDuplex(g, 0, 0, 200, 50, model);
|
|
286
|
+
// Verifying no throw is the assertion.
|
|
287
|
+
expect(true, true);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('renders legacy uppercase symbols via alias map', async () => {
|
|
291
|
+
const canvas = document.createElement('canvas');
|
|
292
|
+
canvas.width = 400; canvas.height = 50;
|
|
293
|
+
const g = canvas.getContext('2d')!;
|
|
294
|
+
const model = parseHelmDuplex(LEGACY_DUPLEX);
|
|
295
|
+
drawDuplex(g, 0, 0, 400, 50, model);
|
|
296
|
+
expect(model.sense.monomers.length, 4);
|
|
297
|
+
expect(model.antisense !== null, true);
|
|
298
|
+
});
|
|
299
|
+
});
|