@datagrok/sequence-translator 1.10.22 → 1.10.23
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 +6 -0
- 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/package.json +1 -1
- package/src/oligo-renderer/analog-cache.ts +5 -2
- package/src/oligo-renderer/canvas-renderer.ts +227 -27
- package/src/oligo-renderer/cell-actions.ts +178 -0
- package/src/oligo-renderer/monomer-colors.ts +3 -2
- package/src/oligo-renderer/tooltip.ts +2 -2
- package/src/package-api.ts +21 -0
- package/src/package.g.ts +26 -2
- package/src/package.ts +57 -16
- package/src/tests/oligo-renderer-tests.ts +149 -1
- package/test-console-output-1.log +173 -178
- package/test-record-1.mp4 +0 -0
package/src/package.g.ts
CHANGED
|
@@ -236,8 +236,16 @@ export function oligoNucleotideCellRenderer() : any {
|
|
|
236
236
|
//tags: cellEditor
|
|
237
237
|
//input: grid_cell cell
|
|
238
238
|
//meta.role: cellEditor
|
|
239
|
-
export
|
|
240
|
-
|
|
239
|
+
export function editOligoNucleotideCell(cell: any) : void {
|
|
240
|
+
PackageFunctions.editOligoNucleotideCell(cell);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
//name: Open HELM Editor
|
|
244
|
+
//description: Edit the oligonucleotide HELM in the HELM Web Editor
|
|
245
|
+
//input: semantic_value value { semType: OligoNucleotide }
|
|
246
|
+
//meta.action: Edit HELM
|
|
247
|
+
export function openOligoHelmEditor(value: DG.SemanticValue) : void {
|
|
248
|
+
PackageFunctions.openOligoHelmEditor(value);
|
|
241
249
|
}
|
|
242
250
|
|
|
243
251
|
//name: Oligo-Nucleotide
|
|
@@ -258,6 +266,22 @@ export function oligoNucleotideStructuresPanel(value: DG.SemanticValue) : any {
|
|
|
258
266
|
return PackageFunctions.oligoNucleotideStructuresPanel(value);
|
|
259
267
|
}
|
|
260
268
|
|
|
269
|
+
//name: Copy as HELM
|
|
270
|
+
//description: Copy the HELM string of an oligo cell to the clipboard
|
|
271
|
+
//input: semantic_value value { semType: OligoNucleotide }
|
|
272
|
+
//meta.action: Copy as HELM
|
|
273
|
+
export function copyOligoAsHelm(value: DG.SemanticValue) : void {
|
|
274
|
+
PackageFunctions.copyOligoAsHelm(value);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
//name: Copy as Image
|
|
278
|
+
//description: Copy a high-resolution image of the oligo duplex
|
|
279
|
+
//input: semantic_value value { semType: OligoNucleotide }
|
|
280
|
+
//meta.action: Copy as Image
|
|
281
|
+
export function copyOligoAsImage(value: DG.SemanticValue) : void {
|
|
282
|
+
PackageFunctions.copyOligoAsImage(value);
|
|
283
|
+
}
|
|
284
|
+
|
|
261
285
|
//description: Create a new column tagged as OligoNucleotide so HELM duplex cells render with the oligo view
|
|
262
286
|
//input: dataframe table
|
|
263
287
|
//input: column helmCol { caption: HELM column; semType: Macromolecule }
|
package/src/package.ts
CHANGED
|
@@ -20,6 +20,10 @@ import {OligoNucleotideCellRenderer} from './oligo-renderer/cell-renderer';
|
|
|
20
20
|
import {buildOligoPanel} from './oligo-renderer/legend-panel';
|
|
21
21
|
import {buildOligoStructuresPanel} from './oligo-renderer/structures-panel';
|
|
22
22
|
import {combineSenseAntisenseToOligo, convertHelmColumnToOligo} from './oligo-renderer/converters';
|
|
23
|
+
import {
|
|
24
|
+
openOligoCanvasDialog, openOligoHelmEditorDialog,
|
|
25
|
+
copyHelmToClipboard, copyDuplexImageToClipboard,
|
|
26
|
+
} from './oligo-renderer/cell-actions';
|
|
23
27
|
|
|
24
28
|
import {polyToolConvert, polyToolConvertUI} from './polytool/pt-dialog';
|
|
25
29
|
import {polyToolEnumerateChemApp, polyToolEnumerateChemUI} from './polytool/pt-chem-enum-dialog';
|
|
@@ -443,6 +447,10 @@ export class PackageFunctions {
|
|
|
443
447
|
return new OligoNucleotideCellRenderer();
|
|
444
448
|
}
|
|
445
449
|
|
|
450
|
+
/** Double-click cell editor: opens a full-screen modal with a nicely-rendered
|
|
451
|
+
* canvas view of the duplex. Hover interactions (monomer/linkage tooltip
|
|
452
|
+
* with cached RDKit structures) mirror what works in the grid cell. Editing
|
|
453
|
+
* the HELM itself happens through the separate `Open HELM Editor` action. */
|
|
446
454
|
@grok.decorators.func({
|
|
447
455
|
name: 'editOligoNucleotideCell',
|
|
448
456
|
description: 'OligoNucleotide',
|
|
@@ -451,24 +459,25 @@ export class PackageFunctions {
|
|
|
451
459
|
role: 'cellEditor',
|
|
452
460
|
},
|
|
453
461
|
})
|
|
454
|
-
static
|
|
462
|
+
static editOligoNucleotideCell(
|
|
455
463
|
@grok.decorators.param({type: 'grid_cell'}) cell: DG.GridCell,
|
|
464
|
+
): void {
|
|
465
|
+
openOligoCanvasDialog(cell);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Cell context-menu action: open the HELM Web Editor for the cell's
|
|
469
|
+
* sequence and write the edited HELM back on OK. Lives in the "Actions"
|
|
470
|
+
* group on the cell's context menu (same surfacing convention as
|
|
471
|
+
* `Copy as HELM`). */
|
|
472
|
+
@grok.decorators.func({
|
|
473
|
+
name: 'Open HELM Editor',
|
|
474
|
+
description: 'Edit the oligonucleotide HELM in the HELM Web Editor',
|
|
475
|
+
meta: {'action': 'Edit HELM'},
|
|
476
|
+
})
|
|
477
|
+
static openOligoHelmEditor(
|
|
478
|
+
@grok.decorators.param({options: {semType: 'OligoNucleotide'}}) value: DG.SemanticValue,
|
|
456
479
|
): Promise<void> {
|
|
457
|
-
|
|
458
|
-
// which throws unless semType === Macromolecule. OligoNucleotide columns have
|
|
459
|
-
// semType=OligoNucleotide, so we open the HELM Web Editor directly.
|
|
460
|
-
const helmHelper = await getHelmHelper();
|
|
461
|
-
const view = ui.div();
|
|
462
|
-
const app = helmHelper.createWebEditorApp(view, (cell.cell.value as string | null) ?? '');
|
|
463
|
-
ui.dialog({showHeader: false, showFooter: true})
|
|
464
|
-
.add(view)
|
|
465
|
-
.onOK(() => {
|
|
466
|
-
const helmValue = app.canvas!.getHelm(true)
|
|
467
|
-
.replace(/<\/span>/g, '')
|
|
468
|
-
.replace(/<span style='background:#bbf;'>/g, '');
|
|
469
|
-
cell.setValue(helmValue);
|
|
470
|
-
})
|
|
471
|
-
.show({modal: true, fullScreen: true});
|
|
480
|
+
return openOligoHelmEditorDialog(value);
|
|
472
481
|
}
|
|
473
482
|
|
|
474
483
|
@grok.decorators.func({
|
|
@@ -495,6 +504,38 @@ export class PackageFunctions {
|
|
|
495
504
|
return buildOligoStructuresPanel(value);
|
|
496
505
|
}
|
|
497
506
|
|
|
507
|
+
/** Cell context-menu action: copy the raw HELM string. Surfaced automatically
|
|
508
|
+
* by the platform under the cell's "Copy" submenu because of `meta.action:
|
|
509
|
+
* 'Copy as HELM'`; we set `exclude-actions-panel` so it doesn't also show up
|
|
510
|
+
* in the right-side actions panel. */
|
|
511
|
+
@grok.decorators.func({
|
|
512
|
+
name: 'Copy as HELM',
|
|
513
|
+
description: 'Copy the HELM string of an oligo cell to the clipboard',
|
|
514
|
+
meta: {'action': 'Copy as HELM'},
|
|
515
|
+
})
|
|
516
|
+
static copyOligoAsHelm(
|
|
517
|
+
@grok.decorators.param({options: {semType: 'OligoNucleotide'}}) value: DG.SemanticValue,
|
|
518
|
+
): void {
|
|
519
|
+
copyHelmToClipboard(value);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Cell context-menu action: render the duplex to a high-resolution PNG with
|
|
523
|
+
* transparent background and copy it to the system clipboard. Canvas pixel
|
|
524
|
+
* dimensions are scaled up but the logical layout sees the original
|
|
525
|
+
* gridCell bounds — so chip sizes match what's on-screen, just at higher
|
|
526
|
+
* pixel density. drawDuplex itself never paints a backdrop, which keeps
|
|
527
|
+
* the alpha channel clean. */
|
|
528
|
+
@grok.decorators.func({
|
|
529
|
+
name: 'Copy as Image',
|
|
530
|
+
description: 'Copy a high-resolution image of the oligo duplex',
|
|
531
|
+
meta: {'action': 'Copy as Image'},
|
|
532
|
+
})
|
|
533
|
+
static copyOligoAsImage(
|
|
534
|
+
@grok.decorators.param({options: {semType: 'OligoNucleotide'}}) value: DG.SemanticValue,
|
|
535
|
+
): void {
|
|
536
|
+
copyDuplexImageToClipboard(value);
|
|
537
|
+
}
|
|
538
|
+
|
|
498
539
|
// Invoked from the column / cell context menu via detectors.js (no top-menu).
|
|
499
540
|
@grok.decorators.func({
|
|
500
541
|
name: 'convertHelmToOligoNucleotide',
|
|
@@ -211,10 +211,158 @@ category('OligoRenderer: layout', () => {
|
|
|
211
211
|
const layout = computeLayout(500, 70, m);
|
|
212
212
|
expect(layout.textOnlyFallback, false);
|
|
213
213
|
expect(layout.chipW > 5, true, 'chip width should be larger than minimum');
|
|
214
|
-
|
|
214
|
+
// chipW is bounded above by the cell's height budget. V_PAD=10 is the
|
|
215
|
+
// top/bottom breathing room; the rest is divided among 2 strands +
|
|
216
|
+
// 1 strand-gap + 2 apex zones with the fixed chip aspect ratio.
|
|
217
|
+
const heightBudget = (70 - 2 * 10) / (2 + 0.5 + 0.9) / 1.25;
|
|
218
|
+
expect(layout.chipW <= heightBudget + 0.01, true,
|
|
219
|
+
`chipW should be bounded by height (got ${layout.chipW}, cap ${heightBudget})`);
|
|
215
220
|
expect(layout.antiY > layout.senseY, true);
|
|
216
221
|
expect(layout.senseChips.length > 0, true);
|
|
217
222
|
expect(layout.antiChips.length > 0, true);
|
|
223
|
+
// Top apex must sit at least V_PAD=10 from the cell's top edge.
|
|
224
|
+
expect(layout.senseY - layout.apexH >= 10 - 0.01, true,
|
|
225
|
+
`top apex zone violates 10px top margin: senseY=${layout.senseY}, apexH=${layout.apexH}`);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('chipW grows with cell height (no hard max cap)', async () => {
|
|
229
|
+
// Same duplex, taller cells. Without a hardcoded MAX_CHIP_W cap, chipW
|
|
230
|
+
// must grow proportionally with cellH (height budget governs the cap).
|
|
231
|
+
// Smallest cell here is 60px to stay above the V_PAD=10 floor where
|
|
232
|
+
// the height budget would otherwise dip under MIN_CHIP_W.
|
|
233
|
+
const m = parseHelmDuplex(SAMPLE_DUPLEX);
|
|
234
|
+
const layoutSmall = computeLayout(1000, 60, m);
|
|
235
|
+
const layoutMid = computeLayout(1000, 100, m);
|
|
236
|
+
const layoutBig = computeLayout(1000, 180, m);
|
|
237
|
+
expect(layoutMid.chipW > layoutSmall.chipW, true,
|
|
238
|
+
`expected chipW to grow with height: ${layoutSmall.chipW} → ${layoutMid.chipW}`);
|
|
239
|
+
expect(layoutBig.chipW > layoutMid.chipW, true,
|
|
240
|
+
`expected chipW to keep growing: ${layoutMid.chipW} → ${layoutBig.chipW}`);
|
|
241
|
+
// Previously this was capped at MAX_CHIP_W=17. With the cap removed and
|
|
242
|
+
// a 180px-tall cell, chipW comfortably exceeds 30 even after V_PAD=10
|
|
243
|
+
// takes 20px out of the vertical budget.
|
|
244
|
+
expect(layoutBig.chipW > 30, true,
|
|
245
|
+
`chipW should exceed previous 17px cap on tall cells: got ${layoutBig.chipW}`);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('chipW grows with cell width when not height-limited', async () => {
|
|
249
|
+
// Wide+tall cell with a small duplex (4 chips per strand). Width budget
|
|
250
|
+
// is generous, so chipW should be governed by the height cap.
|
|
251
|
+
const helm = 'RNA1{m(A)p.m(C)p.m(G)p.m(U)}|RNA2{m(U)p.m(G)p.m(C)p.m(A)}$$$$';
|
|
252
|
+
const m = parseHelmDuplex(helm);
|
|
253
|
+
const tallWide = computeLayout(2000, 140, m);
|
|
254
|
+
// Should fit easily; chipW capped by height budget. (cellH - 2*V_PAD)
|
|
255
|
+
// / heightFactor / aspect.
|
|
256
|
+
const heightCap = (140 - 2 * 10) / (2 + 0.5 + 0.9) / 1.25;
|
|
257
|
+
expect(Math.abs(tallWide.chipW - heightCap) < 0.5, true,
|
|
258
|
+
`tall+wide cell: chipW should ride the height cap (got ${tallWide.chipW}, expected ~${heightCap.toFixed(1)})`);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('top apex and bottom apex sit at least 10px from cell edges', async () => {
|
|
262
|
+
// V_PAD=10 guarantees vertical breathing room so the apex tips never
|
|
263
|
+
// touch the cell border.
|
|
264
|
+
const m = parseHelmDuplex(SAMPLE_DUPLEX);
|
|
265
|
+
for (const [w, h] of [[600, 70], [1000, 120], [1500, 200]]) {
|
|
266
|
+
const layout = computeLayout(w, h, m);
|
|
267
|
+
if (layout.textOnlyFallback) continue;
|
|
268
|
+
const topApexEdge = layout.senseY - layout.apexH;
|
|
269
|
+
const bottomApexEdge = layout.antiY >= 0 ? layout.antiY + layout.chipH + layout.apexH : -1;
|
|
270
|
+
expect(topApexEdge >= 10 - 0.01, true,
|
|
271
|
+
`top apex too close to top at ${w}×${h}: ${topApexEdge}`);
|
|
272
|
+
if (bottomApexEdge > 0)
|
|
273
|
+
expect(h - bottomApexEdge >= 10 - 0.01, true,
|
|
274
|
+
`bottom apex too close to bottom at ${w}×${h}: cellH-edge=${h - bottomApexEdge}`);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('multi-char base ellipsizes — chipW does NOT shrink for everyone else', async () => {
|
|
279
|
+
// Tight cell with one long custom base on sense. Show-everything priority
|
|
280
|
+
// means: keep chipW as big as uniform-fit allows; ellipsize the long
|
|
281
|
+
// multi-char chip rather than shrinking every chip on the row.
|
|
282
|
+
const canonical = parseHelmDuplex(
|
|
283
|
+
'RNA1{m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)}|' +
|
|
284
|
+
'RNA2{m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)}$$$$');
|
|
285
|
+
const multichar = parseHelmDuplex(
|
|
286
|
+
'RNA1{m([cpm6A])p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)}|' +
|
|
287
|
+
'RNA2{m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)}$$$$');
|
|
288
|
+
const cellW = 300; const cellH = 50;
|
|
289
|
+
const lCanon = computeLayout(cellW, cellH, canonical);
|
|
290
|
+
const lMulti = computeLayout(cellW, cellH, multichar);
|
|
291
|
+
// Sizing is governed by uniform-fit, which depends only on chip COUNT
|
|
292
|
+
// and conjugate widths — not on whether a base is single- or multi-char.
|
|
293
|
+
// So a multi-char base must not pull chipW down.
|
|
294
|
+
expect(Math.abs(lMulti.chipW - lCanon.chipW) < 0.5, true,
|
|
295
|
+
`multi-char chipW=${lMulti.chipW.toFixed(2)} drifted from canonical chipW=${lCanon.chipW.toFixed(2)}; ` +
|
|
296
|
+
`sizing should be based on uniform fit, not widened`);
|
|
297
|
+
// Every monomer still draws (just the multi-char one shows an ellipsized label).
|
|
298
|
+
expect(lMulti.senseChips.length, multichar.sense.monomers.length);
|
|
299
|
+
expect(lMulti.antiChips.length, multichar.antisense!.monomers.length);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('leading conjugate does NOT cause chipW to stall on roomy cells', async () => {
|
|
303
|
+
// Regression: `fitsInBudget` used to double-count the leading conjugate
|
|
304
|
+
// width (added `leadW` to the running total AND `widths[0]` already
|
|
305
|
+
// contained the pill width), so the binary search bailed early on cells
|
|
306
|
+
// where the conjugate strand had enough room. Net effect was the layout
|
|
307
|
+
// chose a chipW much smaller than what the cell could actually hold.
|
|
308
|
+
// This is the exact HELM the user reported, on a wide+tall cell.
|
|
309
|
+
const helm =
|
|
310
|
+
'RNA1{[Chol].m(G)[sp].[fl2r](A)[sp].m(C)p.[fl2r](U)p.m(G)p.[fl2r](A)p.m(A)p.[fl2r](U)p.m(A)p.' +
|
|
311
|
+
'[fl2r](U)p.m(A)p.[fl2r](A)p.m(A)p.[fl2r](C)p.m(U)p.[fl2r](U)p.m(G)[sp].[fl2r](U)[sp].m(G)[L3]}|' +
|
|
312
|
+
'RNA2{m(C)[sp].[fl2r]([br8A])[sp].m(C)p.[fl2r](A)p.m(A)p.[fl2r](G)p.m(U)p.[fl2r](U)p.m(U)p.' +
|
|
313
|
+
'[fl2r](A)p.m(U)p.[fl2r](A)p.m(U)p.[fl2r]([cneT])p.m(C)p.[fl2r](A)p.m(G)[sp].' +
|
|
314
|
+
'[fl2r]([c7io7A])[sp].m(C)}$$$$';
|
|
315
|
+
const m = parseHelmDuplex(helm);
|
|
316
|
+
const layoutNoConj = computeLayout(1000, 120, parseHelmDuplex(
|
|
317
|
+
'RNA1{m(G)p.m(A)p.m(C)p.m(U)p.m(G)p.m(A)p.m(A)p.m(U)p.m(A)p.m(U)p.m(A)p.m(A)p.m(A)p.m(C)p.m(U)p.m(U)p.m(G)p.m(U)p.m(G)}|' +
|
|
318
|
+
'RNA2{m(C)p.m(A)p.m(C)p.m(A)p.m(A)p.m(G)p.m(U)p.m(U)p.m(U)p.m(A)p.m(U)p.m(A)p.m(U)p.m(U)p.m(C)p.m(A)p.m(G)p.m(U)p.m(C)}$$$$'));
|
|
319
|
+
const layoutWithConj = computeLayout(1000, 120, m);
|
|
320
|
+
// With a 1000×120 cell, the height cap allows chipW ~28. The version
|
|
321
|
+
// without conjugates rides that cap; the version with [Chol]/[L3] +
|
|
322
|
+
// multi-char bases should pick a chipW that's a meaningful fraction
|
|
323
|
+
// (≥ 60%) of that — not collapse back to single digits.
|
|
324
|
+
expect(layoutWithConj.chipW >= layoutNoConj.chipW * 0.6, true,
|
|
325
|
+
`with-conjugate chipW=${layoutWithConj.chipW.toFixed(2)} dropped too far ` +
|
|
326
|
+
`below no-conjugate chipW=${layoutNoConj.chipW.toFixed(2)} on the same roomy cell`);
|
|
327
|
+
// And every monomer must place.
|
|
328
|
+
expect(layoutWithConj.senseChips.length, m.sense.monomers.length);
|
|
329
|
+
expect(layoutWithConj.antiChips.length, m.antisense!.monomers.length);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('leading conjugate reduces chipW but no monomer is dropped', async () => {
|
|
333
|
+
// With a Cholesterol conjugate at sense 5', the conjugate pill consumes
|
|
334
|
+
// a chunk of horizontal budget. chipW shrinks compared to the same data
|
|
335
|
+
// without the conjugate — but all chips still place.
|
|
336
|
+
const noConj = parseHelmDuplex(
|
|
337
|
+
'RNA1{m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)}|' +
|
|
338
|
+
'RNA2{m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)}$$$$');
|
|
339
|
+
const withConj = parseHelmDuplex(
|
|
340
|
+
'RNA1{[Chol].m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)}|' +
|
|
341
|
+
'RNA2{m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)}$$$$');
|
|
342
|
+
const cellW = 350; const cellH = 70;
|
|
343
|
+
const lNo = computeLayout(cellW, cellH, noConj);
|
|
344
|
+
const lW = computeLayout(cellW, cellH, withConj);
|
|
345
|
+
expect(lW.chipW <= lNo.chipW + 0.01, true,
|
|
346
|
+
`with-conjugate chipW=${lW.chipW} must be ≤ no-conjugate chipW=${lNo.chipW}`);
|
|
347
|
+
// Every monomer (incl. the conjugate pill) is placed.
|
|
348
|
+
expect(lW.senseChips.length, withConj.sense.monomers.length);
|
|
349
|
+
expect(lW.antiChips.length, withConj.antisense!.monomers.length);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('tight cell falls back to uniform widths with ellipsis (no text-only)', async () => {
|
|
353
|
+
// A duplex with a long base name on a moderately tight cell: widened
|
|
354
|
+
// would overflow even at MIN_CHIP_W, but uniform widths (with the
|
|
355
|
+
// multi-char chip ellipsized) still fit.
|
|
356
|
+
const m = parseHelmDuplex(
|
|
357
|
+
'RNA1{m([cpm6A])p.m([5Br-dC])p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)p.m(U)p.m(A)p.m(C)p.m(G)}|' +
|
|
358
|
+
'RNA2{m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)p.m(A)p.m(U)p.m(G)p.m(C)}$$$$');
|
|
359
|
+
const layout = computeLayout(220, 50, m);
|
|
360
|
+
// Not text-only — everything still draws as chips, just with truncated labels.
|
|
361
|
+
expect(layout.textOnlyFallback, false,
|
|
362
|
+
'tight-but-survivable cell should not collapse to text-only');
|
|
363
|
+
// All chips placed (no chip dropped).
|
|
364
|
+
expect(layout.senseChips.length, m.sense.monomers.length);
|
|
365
|
+
expect(layout.antiChips.length, m.antisense!.monomers.length);
|
|
218
366
|
});
|
|
219
367
|
|
|
220
368
|
test('compact cell — both strands still fit in 28px row', async () => {
|