@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/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 async function editOligoNucleotideCell(cell: any) : Promise<void> {
240
- await PackageFunctions.editOligoNucleotideCell(cell);
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 async editOligoNucleotideCell(
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
- // Helm:editMoleculeCell can't be reused: it calls seqHelper.getSeqHandler(col),
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
- expect(layout.chipW <= 17, true, 'chip width should respect max (17)');
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 () => {