@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@datagrok/sequence-translator",
3
3
  "friendlyName": "Sequence Translator",
4
- "version": "1.10.22",
4
+ "version": "1.10.23",
5
5
  "author": {
6
6
  "name": "Davit Rizhinashvili",
7
7
  "email": "drizhinashvili@datagrok.ai"
@@ -15,11 +15,14 @@
15
15
  * resolve fully.
16
16
  */
17
17
 
18
+ import * as DG from 'datagrok-api/dg';
19
+
18
20
  import {IMonomerLib} from '@datagrok-libraries/bio/src/types/monomer-library';
19
21
  import {_package} from '../package';
20
22
 
21
- /** symbol → natural-analog letter, or null if absent / no analog. */
22
- const _cache = new Map<string, string | null>();
23
+ /** symbol → natural-analog letter, or null if absent / no analog.
24
+ * LRU bound prevents unbounded growth across a long-lived session. */
25
+ const _cache = new DG.LruCache<string, string | null>(256);
23
26
 
24
27
  /** Resolve `symbol` to its single-letter natural analog. Returns `null` for
25
28
  * unknowns, the canonical letter (uppercase) for matches. Pure sync. */
@@ -28,6 +28,8 @@
28
28
  * (and below antisense, when present).
29
29
  */
30
30
 
31
+ import * as DG from 'datagrok-api/dg';
32
+
31
33
  import {
32
34
  BASE_COLORS, FALLBACK_COLOR,
33
35
  contrastTextColor,
@@ -54,8 +56,14 @@ const ASPECT_H_OVER_W = 1.25;
54
56
  const STRAND_GAP_RATIO = 0.5;
55
57
  const CHIP_GAP_RATIO = 0.40; // wide gaps give apex triangles room to breathe
56
58
  const MIN_CHIP_W = 5;
57
- const MAX_CHIP_W = 17;
59
+ // No hard upper cap on chip width — chipW grows until either (a) the duplex
60
+ // no longer fits in the cell's available width with all monomers shown at
61
+ // their natural widened size, or (b) the cell's height budget runs out.
62
+ // Show-everything is prioritised over making chips big.
58
63
  const PAD = 4;
64
+ /** Vertical breathing room above the top apex zone and below the bottom one,
65
+ * so the linkage marks never butt right up against the cell border. */
66
+ const V_PAD = 10;
59
67
  const LABEL_W = 30;
60
68
  /** Chip fill opacity — softens the often-saturated library backgrounds so the
61
69
  * sugar stripe and base label stay readable on top. */
@@ -122,20 +130,37 @@ export function computeLayout(
122
130
  const strandsCount = hasAnti ? 2 : 1;
123
131
  const maxLen = Math.max(1, senseLen, antiLen);
124
132
 
125
- const availW = Math.max(0, cellW - LABEL_W - 2 * PAD);
126
- const wChipW = availW / (maxLen + Math.max(0, maxLen - 1) * CHIP_GAP_RATIO);
127
-
128
- // Vertical budget needs to account for apex zones: above sense (always),
129
- // and below antisense when present. Each apex zone is APEX_RATIO * chipH.
133
+ // Height budget caps chipW from above (chip aspect ratio is fixed). The
134
+ // vertical pad reserves breathing room above/below the apex zones so the
135
+ // linkage marks never touch the cell border.
130
136
  const apexCount = hasAnti ? 2 : 1;
131
137
  const heightFactor =
132
138
  strandsCount + (strandsCount - 1) * STRAND_GAP_RATIO + apexCount * APEX_RATIO;
133
- const hChipH = (cellH - 2 * PAD) / heightFactor;
134
- const hChipW = hChipH / ASPECT_H_OVER_W;
135
-
136
- let chipW = Math.min(MAX_CHIP_W, wChipW, hChipW);
137
- const textOnlyFallback = chipW < MIN_CHIP_W;
138
- if (chipW < MIN_CHIP_W) chipW = MIN_CHIP_W;
139
+ const hChipH = (cellH - 2 * V_PAD) / heightFactor;
140
+ const heightCap = hChipH / ASPECT_H_OVER_W;
141
+
142
+ // Pick chipW as the LARGEST value in [MIN_CHIP_W, heightCap] for which the
143
+ // *uniform* layout fits. We size based on uniform (every nucleotide at
144
+ // chipW, conjugates at their pill widths) so the canonical single-char
145
+ // chips stay as big as the cell allows. The body below then decides per
146
+ // cell whether the widened multi-char labels also fit at this chipW —
147
+ // if not, those few chips are ellipsized rather than shrinking every chip
148
+ // on the strand. Show-everything priority: shortening one long label is
149
+ // preferred over making the whole row smaller. If even uniform doesn't
150
+ // fit at MIN_CHIP_W, we fall back to a text-only summary.
151
+ let chipW: number;
152
+ let textOnlyFallback = false;
153
+ // `maxLen` is read so this remains a no-op binding when callers reference it later.
154
+ void maxLen;
155
+ if (heightCap < MIN_CHIP_W) {
156
+ chipW = MIN_CHIP_W;
157
+ textOnlyFallback = true;
158
+ } else if (fitsAtChipW(MIN_CHIP_W, cellW, model, o, 'uniform')) {
159
+ chipW = findMaxChipW(cellW, model, o, 'uniform', MIN_CHIP_W, heightCap);
160
+ } else {
161
+ chipW = MIN_CHIP_W;
162
+ textOnlyFallback = true;
163
+ }
139
164
 
140
165
  const chipH = chipW * ASPECT_H_OVER_W;
141
166
  const chipGap = Math.max(3, chipW * CHIP_GAP_RATIO);
@@ -144,7 +169,7 @@ export function computeLayout(
144
169
  const apexH = chipH * APEX_RATIO;
145
170
 
146
171
  const blockH = strandsCount * chipH + (strandsCount - 1) * strandGap + apexCount * apexH;
147
- const blockTop = Math.max(PAD, (cellH - blockH) / 2);
172
+ const blockTop = Math.max(V_PAD, (cellH - blockH) / 2);
148
173
  // Sense always sits below a top apex zone; antisense (when present) has
149
174
  // its apex zone below it. Single-strand cases get a top apex zone only.
150
175
  const senseY = blockTop + apexH;
@@ -193,9 +218,9 @@ export function computeLayout(
193
218
  // Falls back to uniform chipW if either strand's chips don't fit.
194
219
  const widthBudget = seqEndX - (seqX + alignAt);
195
220
  if (!fitsInBudget(widths.sense, senseLeadW, chipGap, widthBudget) ||
196
- !fitsInBudget(widths.anti, antiLeadW, chipGap, widthBudget)) {
221
+ !fitsInBudget(widths.anti, antiLeadW, chipGap, widthBudget))
197
222
  widths = uniformWidths(senseDisplay, antiDisplay, chipW, fontSize);
198
- }
223
+
199
224
 
200
225
  const senseRes = placeStrand(
201
226
  model.sense, false, senseY, senseStartX, seqEndX, layoutBase, 'sense', widths.sense);
@@ -337,14 +362,70 @@ function uniformWidths(
337
362
  return {sense: senseDisplay.map(map), anti: antiDisplay.map(map)};
338
363
  }
339
364
 
340
- /** Whether the per-chip widths array fits in `budget` (after the leading shift). */
365
+ /** Whether the per-chip widths array fits in `budget` (which is measured from
366
+ * the alignment point, i.e. `seqEndX − (seqX + alignAt)`).
367
+ *
368
+ * `widths` already includes any leading conjugates' pill widths at the front.
369
+ * Those leading conjugates occupy the SHIFT area to the LEFT of the alignment
370
+ * point (between `seqX` and `seqX + alignAt`) — so their cost against the
371
+ * post-alignment `budget` is zero. Equivalently: this strand's chips start at
372
+ * `seqX + (alignAt − leadW)` and span `chipsTotal`, fitting when
373
+ * `chipsTotal ≤ seqEndX − (seqX + alignAt − leadW) = budget + leadW`. */
341
374
  function fitsInBudget(widths: number[], leadW: number, chipGap: number, budget: number): boolean {
342
- let total = leadW;
375
+ let total = 0;
343
376
  for (let i = 0; i < widths.length; i++) {
344
377
  total += widths[i];
345
378
  if (i < widths.length - 1) total += chipGap;
346
379
  }
347
- return total <= budget;
380
+ return total <= budget + leadW;
381
+ }
382
+
383
+ /** True iff a layout at this `chipW` (using either widened or uniform widths)
384
+ * places every chip inside the cell's horizontal budget. Same math as the
385
+ * main `computeLayout` body, just packaged so the binary search can probe. */
386
+ function fitsAtChipW(
387
+ chipW: number, cellW: number, model: ParsedDuplex, opts: RenderOpts,
388
+ mode: 'widened' | 'uniform',
389
+ ): boolean {
390
+ const fontSize = Math.max(7, Math.min(13, chipW * 0.62));
391
+ const chipGap = Math.max(3, chipW * CHIP_GAP_RATIO);
392
+ const hasAnti = !!model.antisense && model.antisense.monomers.length > 0;
393
+ const antiReversed = hasAnti && opts.pairAlign;
394
+
395
+ const senseLeadW = leadingConjugateWidth(model.sense.monomers, false, chipW, fontSize, chipGap);
396
+ const antiLeadW = hasAnti ?
397
+ leadingConjugateWidth(model.antisense!.monomers, antiReversed, chipW, fontSize, chipGap) : 0;
398
+ const alignAt = Math.max(senseLeadW, antiLeadW);
399
+
400
+ const senseDisplay = model.sense.monomers;
401
+ const antiDisplay = hasAnti ?
402
+ (antiReversed ? model.antisense!.monomers.slice().reverse() : model.antisense!.monomers) :
403
+ [];
404
+ const widths = mode === 'widened' ?
405
+ computePairSyncedWidths(senseDisplay, antiDisplay, chipW, fontSize) :
406
+ uniformWidths(senseDisplay, antiDisplay, chipW, fontSize);
407
+
408
+ const widthBudget = (cellW - PAD) - ((PAD + LABEL_W) + alignAt);
409
+ if (widthBudget < 0) return false;
410
+ return fitsInBudget(widths.sense, senseLeadW, chipGap, widthBudget) &&
411
+ fitsInBudget(widths.anti, antiLeadW, chipGap, widthBudget);
412
+ }
413
+
414
+ /** Binary-search the largest chipW ∈ [lo, hi] where `fitsAtChipW(..., mode)`
415
+ * returns true. Assumes `fitsAtChipW(lo, ..., mode) === true` (caller checks).
416
+ * Layout fit is monotone-decreasing in chipW, so a 0.25-px termination gives
417
+ * sub-pixel resolution in a handful of iterations. */
418
+ function findMaxChipW(
419
+ cellW: number, model: ParsedDuplex, opts: RenderOpts,
420
+ mode: 'widened' | 'uniform', lo: number, hi: number,
421
+ ): number {
422
+ if (fitsAtChipW(hi, cellW, model, opts, mode)) return hi;
423
+ while (hi - lo > 0.25) {
424
+ const mid = (lo + hi) / 2;
425
+ if (fitsAtChipW(mid, cellW, model, opts, mode)) lo = mid;
426
+ else hi = mid;
427
+ }
428
+ return lo;
348
429
  }
349
430
 
350
431
  /* ---------------------------------------------------------------- *
@@ -373,20 +454,25 @@ export function drawDuplex(
373
454
 
374
455
  // Strand label "S 5'" left of sense
375
456
  drawStrandLabel(g, 'S', '5\'', layout.padding, layout.senseY + layout.chipH / 2, layout);
376
-
377
- // Chips first; apex triangles paint last so they appear on top of the
457
+ // first draw links so they are behind
458
+ for (const link of layout.senseLinks) drawLinkageApex(g, link, layout);
378
459
  // chip body's anti-aliased corners.
379
460
  drawChips(g, layout.senseChips, layout, o);
380
461
  drawTruncationMarker(g, layout.senseChips, model.sense.monomers.length, layout);
381
- for (const link of layout.senseLinks) drawLinkageApex(g, link, layout);
382
462
 
383
463
  if (layout.antiY >= 0 && model.antisense) {
384
464
  // When reversed, the leftmost chip in display is the 3' end of antisense.
385
465
  const leftLabel = layout.antiReversed ? '3\'' : '5\'';
386
466
  drawStrandLabel(g, 'AS', leftLabel, layout.padding, layout.antiY + layout.chipH / 2, layout);
467
+ for (const link of layout.antiLinks) drawLinkageApex(g, link, layout);
387
468
  drawChips(g, layout.antiChips, layout, o);
388
469
  drawTruncationMarker(g, layout.antiChips, model.antisense.monomers.length, layout);
389
- for (const link of layout.antiLinks) drawLinkageApex(g, link, layout);
470
+
471
+ // Watson-Crick base-pair indicators in the strand gap. Only meaningful
472
+ // when antisense is rendered anti-parallel (i.e. reversed for display) —
473
+ // that's what makes the vertical column alignment actually represent the
474
+ // duplex partner pairs.
475
+ if (layout.antiReversed) drawBasePairings(g, layout);
390
476
  }
391
477
 
392
478
  g.restore();
@@ -441,7 +527,10 @@ function drawChip(
441
527
  const baseColors = m.base ? getMonomerColors('base', m.base) : null;
442
528
  const bg = baseColors?.backgroundcolor ?? resolveBaseColor(m.base);
443
529
  const textC = baseColors?.textcolor ?? contrastTextColor(bg);
444
- const r = Math.min(2.5, w / 4);
530
+ // Corner radius scales with the chip's smaller dimension so chips stay
531
+ // pleasantly rounded at every size — the previous hardcoded 2.5px cap made
532
+ // larger chips read as squares.
533
+ const r = Math.min(w, h) / 4;
445
534
  const stripeH = Math.max(2, h * SUGAR_STRIPE_RATIO);
446
535
 
447
536
  // Chip body — background from monomer library (HELM_BASE), softened with
@@ -517,8 +606,9 @@ function pickBaseLabel(base: string, chipW: number, fontSize: number): string {
517
606
  function drawLinkageApex(g: CanvasRenderingContext2D, link: LinkagePos, layout: DuplexLayout): void {
518
607
  const linkerColors = getMonomerColors('linker', link.phosphateSymbol);
519
608
  const color = linkerColors.backgroundcolor ?? resolvePhosphate(link.phosphateSymbol).color;
520
-
521
609
  const apexH = layout.apexH;
610
+
611
+ const apexWidthMult = Math.max(apexH / 10, 1);
522
612
  const halfW = apexH; // 45° → height equals half-base
523
613
  const centerX = link.x + link.w / 2;
524
614
  const decoSide = decorationSide(link.strand);
@@ -531,13 +621,124 @@ function drawLinkageApex(g: CanvasRenderingContext2D, link: LinkagePos, layout:
531
621
  g.beginPath();
532
622
  g.moveTo(centerX - halfW, baseY);
533
623
  g.quadraticCurveTo(centerX, ctrlY, centerX + halfW, baseY);
534
- g.lineWidth = APEX_LINE_W;
624
+ g.lineWidth = APEX_LINE_W * apexWidthMult;
535
625
  g.lineCap = 'round';
536
626
  g.strokeStyle = color;
537
627
  g.stroke();
538
628
  g.restore();
539
629
  }
540
630
 
631
+ /* ---------------------------------------------------------------- *
632
+ * Watson-Crick base pairing
633
+ *
634
+ * For each display column where a sense chip sits above an antisense chip
635
+ * (counted past leading conjugates), determine the pair kind and draw the
636
+ * canonical biology shorthand in the inter-strand gap:
637
+ * - G ↔ C → 3 vertical lines (3 hydrogen bonds — stronger color)
638
+ * - A ↔ U / A ↔ T → 2 vertical lines (2 hydrogen bonds — lighter color)
639
+ * - anything else → dashed line (mismatch / bulge)
640
+ *
641
+ * Non-canonical bases (modified analogs like `5meC`, `psiU`, `cpm6A`) are
642
+ * resolved via `getNaturalAnalog` against the central Bio monomer library;
643
+ * pairing is determined on the natural-analog letter, so a `2'-OMe-5meC`
644
+ * still pairs as `C` with a `G` partner.
645
+ * ---------------------------------------------------------------- */
646
+ type PairKind = 'GC' | 'AU' | 'mismatch';
647
+ const PAIR_COLOR_AU = '#4a5da8'; // 3 H-bonds — bolder indigo
648
+ const PAIR_COLOR_GC = '#8c9dc8'; // 2 H-bonds — lighter blue-violet
649
+ const PAIR_COLOR_MISMATCH = '#a5a5a5'; // dashed neutral gray
650
+ const PAIR_LINE_W = 1.1;
651
+ const MISMATCH_LINE_W = 1;
652
+
653
+ function drawBasePairings(g: CanvasRenderingContext2D, layout: DuplexLayout): void {
654
+ const senseStart = layout.senseChips.findIndex((c) => c.monomer.kind === 'nucleotide');
655
+ const antiStart = layout.antiChips.findIndex((c) => c.monomer.kind === 'nucleotide');
656
+ if (senseStart < 0 || antiStart < 0) return;
657
+ const pairLen = Math.min(
658
+ layout.senseChips.length - senseStart,
659
+ layout.antiChips.length - antiStart,
660
+ );
661
+
662
+ const yTop = layout.senseY + layout.chipH;
663
+ const yBot = layout.antiY;
664
+ if (yBot <= yTop) return;
665
+
666
+ for (let i = 0; i < pairLen; i++) {
667
+ const sc = layout.senseChips[senseStart + i];
668
+ const ac = layout.antiChips[antiStart + i];
669
+ if (sc.monomer.kind !== 'nucleotide' || ac.monomer.kind !== 'nucleotide') continue;
670
+ const kind = basePairKind(
671
+ (sc.monomer as ParsedNucleotide).base,
672
+ (ac.monomer as ParsedNucleotide).base,
673
+ );
674
+ // Pair markers are anchored on the SENSE chip's center because both
675
+ // chips share the same X column when pair-aligned, but multi-char bases
676
+ // can give them slightly different widths.
677
+ const x = sc.x + sc.w / 2;
678
+ drawPairingMark(g, x, yTop, yBot, kind, sc.w);
679
+ }
680
+ }
681
+
682
+ function drawPairingMark(
683
+ g: CanvasRenderingContext2D, x: number, yTop: number, yBot: number,
684
+ kind: PairKind, chipW: number,
685
+ ): void {
686
+ g.save();
687
+ g.lineCap = 'round';
688
+ const lineWidthMult = Math.max(chipW / 20, 1);
689
+ if (kind === 'mismatch') {
690
+ g.strokeStyle = PAIR_COLOR_MISMATCH;
691
+ g.lineWidth = MISMATCH_LINE_W * lineWidthMult;
692
+ g.setLineDash([1, 1.6]);
693
+ g.beginPath();
694
+ g.moveTo(x, yTop);
695
+ g.lineTo(x, yBot);
696
+ g.stroke();
697
+ } else if (kind === 'GC') {
698
+ // Three lines — spacing scales with chip width to stay readable on
699
+ // both narrow and wide chips, capped so they don't bleed past the chip.
700
+ const off = chipW * 0.22;
701
+ g.strokeStyle = PAIR_COLOR_GC;
702
+ g.lineWidth = PAIR_LINE_W * lineWidthMult;
703
+ for (const dx of [-off, 0, off]) {
704
+ g.beginPath();
705
+ g.moveTo(x + dx, yTop + 1);
706
+ g.lineTo(x + dx, yBot - 1);
707
+ g.stroke();
708
+ }
709
+ } else { // AU / AT
710
+ const off = chipW * 0.14;
711
+ g.strokeStyle = PAIR_COLOR_AU;
712
+ g.lineWidth = PAIR_LINE_W * lineWidthMult;
713
+ for (const dx of [-off, off]) {
714
+ g.beginPath();
715
+ g.moveTo(x + dx, yTop + 1);
716
+ g.lineTo(x + dx, yBot - 1);
717
+ g.stroke();
718
+ }
719
+ }
720
+ g.restore();
721
+ }
722
+
723
+ /** Reduce any base to its canonical A/C/G/U/T letter for pair determination.
724
+ * Returns null when the base is missing or has no resolvable natural analog. */
725
+ function canonicalizeBaseForPair(b: string | null): string | null {
726
+ if (!b) return null;
727
+ if (isCanonicalBase(b)) return b;
728
+ const analog = getNaturalAnalog(b);
729
+ return analog && isCanonicalBase(analog) ? analog : null;
730
+ }
731
+
732
+ function basePairKind(senseBase: string | null, antiBase: string | null): PairKind {
733
+ const s = canonicalizeBaseForPair(senseBase);
734
+ const a = canonicalizeBaseForPair(antiBase);
735
+ if (!s || !a) return 'mismatch';
736
+ if ((s === 'G' && a === 'C') || (s === 'C' && a === 'G')) return 'GC';
737
+ if ((s === 'A' && (a === 'U' || a === 'T')) ||
738
+ ((s === 'U' || s === 'T') && a === 'A')) return 'AU';
739
+ return 'mismatch';
740
+ }
741
+
541
742
  function drawConjugate(
542
743
  g: CanvasRenderingContext2D, symbol: string, x: number, y: number,
543
744
  w: number, chipH: number, fontSize: number,
@@ -593,7 +794,7 @@ function drawFallbackText(
593
794
  }
594
795
 
595
796
  /** Apply alpha to any CSS color string, returning rgba(...). Memoized. */
596
- const _alphaCache = new Map<string, string>();
797
+ const _alphaCache = new DG.LruCache<string, string>(256);
597
798
  function withAlpha(color: string, alpha: number): string {
598
799
  const key = `${color}|${alpha}`;
599
800
  const cached = _alphaCache.get(key);
@@ -605,7 +806,6 @@ function withAlpha(color: string, alpha: number): string {
605
806
  document.body.removeChild(probe);
606
807
  const m = rgb.match(/\d+/g);
607
808
  const out = m ? `rgba(${m[0]},${m[1]},${m[2]},${alpha})` : color;
608
- if (_alphaCache.size > 256) _alphaCache.clear();
609
809
  _alphaCache.set(key, out);
610
810
  return out;
611
811
  }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Cell-level user actions for OligoNucleotide cells.
3
+ *
4
+ * The corresponding decorated entry points live in `package.ts` so the
5
+ * platform can discover them — those methods are kept thin and forward to
6
+ * the implementations below. Keeping the logic here means `package.ts` stays
7
+ * structural and the oligo subsystem stays self-contained.
8
+ *
9
+ * - `openOligoCanvasDialog` — double-click handler: full-screen modal
10
+ * with a hi-res canvas rendering, hover interactions reused from the
11
+ * grid cell renderer.
12
+ * - `openOligoHelmEditorDialog` — "Edit HELM" action: HELM Web Editor;
13
+ * OK writes the edited HELM back to the cell.
14
+ * - `copyHelmToClipboard` — "Copy as HELM" action.
15
+ * - `copyDuplexImageToClipboard` — "Copy as Image" action: hi-res PNG with
16
+ * a transparent background, trimmed to the duplex's natural extent.
17
+ */
18
+
19
+ import * as grok from 'datagrok-api/grok';
20
+ import * as ui from 'datagrok-api/ui';
21
+ import * as DG from 'datagrok-api/dg';
22
+
23
+ import {getHelmHelper} from '@datagrok-libraries/bio/src/helm/helm-helper';
24
+
25
+ import {parseHelmDuplex} from './helm-parser';
26
+ import {drawDuplex, hitTest, DuplexLayout} from './canvas-renderer';
27
+ import {showMonomerTooltip} from './tooltip';
28
+
29
+ /** Reference layout dimensions used as the seed for sizing both the dialog
30
+ * canvas and the clipboard image. Wide enough that any real-world duplex
31
+ * fits without wrapping; the right edge is trimmed back to the real extent. */
32
+ const REF_W = 1500;
33
+ const REF_H = 120;
34
+ const RIGHT_PAD = 20;
35
+
36
+ /* -------------------------------------------------------------------------- *
37
+ * Canvas viewer dialog (double-click on a cell)
38
+ * -------------------------------------------------------------------------- */
39
+
40
+ export function openOligoCanvasDialog(cell: DG.GridCell): void {
41
+ const helm = String(cell.cell?.value ?? '');
42
+ if (!helm) return;
43
+ const model = parseHelmDuplex(helm);
44
+
45
+ // Probe layout at the reference dimensions so we discover the natural
46
+ // horizontal extent of this particular duplex (varies with conjugates,
47
+ // multi-char bases, strand length).
48
+ const refLayout = drawDuplex(
49
+ null as unknown as CanvasRenderingContext2D, 0, 0, REF_W, REF_H, model, undefined, true);
50
+ const trimmedW = Math.max(REF_H, computeMaxChipEndX(refLayout) + RIGHT_PAD);
51
+
52
+ // Scale the trimmed layout up to fill the modal — screen width minus 200px.
53
+ const targetW = Math.max(400, window.innerWidth - 200);
54
+ const scale = targetW / trimmedW;
55
+ const finalW = trimmedW * scale; // === targetW by construction
56
+ const finalH = REF_H * scale;
57
+
58
+ // Backing canvas: dimensions in CSS px, pixel-density bumped by devicePixelRatio.
59
+ const dpr = window.devicePixelRatio || 1;
60
+ const canvas = document.createElement('canvas');
61
+ canvas.width = Math.max(1, Math.round(finalW * dpr));
62
+ canvas.height = Math.max(1, Math.round(finalH * dpr));
63
+ canvas.style.width = `${finalW}px`;
64
+ canvas.style.height = `${finalH}px`;
65
+ canvas.style.display = 'block';
66
+ const g = canvas.getContext('2d');
67
+ if (!g) return;
68
+ g.scale(dpr, dpr);
69
+ const finalLayout = drawDuplex(g, 0, 0, finalW, finalH, model);
70
+
71
+ // Hover support — same hit-test → tooltip pipeline the grid renderer uses,
72
+ // just with canvas-local coords instead of GridCell.bounds-relative ones.
73
+ canvas.addEventListener('mousemove', (e) => {
74
+ const rect = canvas.getBoundingClientRect();
75
+ const localX = e.clientX - rect.left;
76
+ const localY = e.clientY - rect.top;
77
+ const hit = hitTest(localX, localY, model, finalLayout);
78
+ if (!hit) {
79
+ ui.tooltip.hide();
80
+ return;
81
+ }
82
+ showMonomerTooltip(hit, e.clientX, e.clientY);
83
+ });
84
+ canvas.addEventListener('mouseleave', () => ui.tooltip.hide());
85
+
86
+ const container = ui.div([canvas], {style: {
87
+ display: 'flex',
88
+ alignItems: 'center',
89
+ justifyContent: 'center',
90
+ width: '100%',
91
+ height: '100%',
92
+ }});
93
+
94
+ ui.dialog({title: 'Oligonucleotide', showHeader: true, showFooter: false})
95
+ .add(container)
96
+ .show({modal: true, fullScreen: true});
97
+ }
98
+
99
+ /* -------------------------------------------------------------------------- *
100
+ * HELM Web Editor action ("Edit HELM")
101
+ * -------------------------------------------------------------------------- */
102
+
103
+ export async function openOligoHelmEditorDialog(value: DG.SemanticValue): Promise<void> {
104
+ if (!value?.cell) return;
105
+ const cell = value.cell;
106
+ // Helm:editMoleculeCell can't be reused: it calls seqHelper.getSeqHandler(col),
107
+ // which throws unless semType === Macromolecule. OligoNucleotide columns have
108
+ // semType=OligoNucleotide, so we drive the HELM Web Editor directly.
109
+ const helmHelper = await getHelmHelper();
110
+ const view = ui.div();
111
+ const app = helmHelper.createWebEditorApp(view, String(cell.value ?? ''));
112
+ ui.dialog({showHeader: false, showFooter: true})
113
+ .add(view)
114
+ .onOK(() => {
115
+ const helmValue = app.canvas!.getHelm(true)
116
+ .replace(/<\/span>/g, '')
117
+ .replace(/<span style='background:#bbf;'>/g, '');
118
+ cell.value = helmValue;
119
+ })
120
+ .show({modal: true, fullScreen: true});
121
+ }
122
+
123
+ /* -------------------------------------------------------------------------- *
124
+ * Copy as HELM
125
+ * -------------------------------------------------------------------------- */
126
+
127
+ export function copyHelmToClipboard(value: DG.SemanticValue): void {
128
+ const helm = String(value?.value ?? '');
129
+ if (!helm) return;
130
+ navigator.clipboard.writeText(helm).then(() => grok.shell.info('HELM copied to clipboard'));
131
+ }
132
+
133
+ /* -------------------------------------------------------------------------- *
134
+ * Copy as Image
135
+ * -------------------------------------------------------------------------- */
136
+
137
+ const IMAGE_SCALE = 8;
138
+
139
+ export function copyDuplexImageToClipboard(value: DG.SemanticValue): void {
140
+ if (!value?.value) return;
141
+ const model = parseHelmDuplex(String(value.value));
142
+
143
+ // Probe layout at the reference size, then crop the canvas to the natural
144
+ // duplex extent so the resulting image isn't padded with blank pixels.
145
+ const refLayout = drawDuplex(
146
+ null as unknown as CanvasRenderingContext2D, 0, 0, REF_W, REF_H, model, undefined, true);
147
+ const width = Math.max(REF_H, computeMaxChipEndX(refLayout) + RIGHT_PAD);
148
+
149
+ const canvas = document.createElement('canvas');
150
+ canvas.width = Math.max(1, Math.round((width + RIGHT_PAD) * IMAGE_SCALE));
151
+ canvas.height = Math.max(1, Math.round(REF_H * IMAGE_SCALE));
152
+ const g = canvas.getContext('2d');
153
+ if (!g) return;
154
+ g.scale(IMAGE_SCALE, IMAGE_SCALE);
155
+ drawDuplex(g, 0, 0, width, REF_H, model);
156
+
157
+ canvas.toBlob((blob) => {
158
+ if (!blob) return;
159
+ navigator.clipboard.write([new ClipboardItem({'image/png': blob})])
160
+ .then(() => grok.shell.info('Image copied to clipboard'))
161
+ .catch((er) => grok.shell.error(`Failed to copy image: ${er?.message ?? er}`));
162
+ }, 'image/png');
163
+ }
164
+
165
+ /* -------------------------------------------------------------------------- *
166
+ * Internals
167
+ * -------------------------------------------------------------------------- */
168
+
169
+ function computeMaxChipEndX(layout: DuplexLayout): number {
170
+ const lastSense = layout.senseChips.length ?
171
+ layout.senseChips[layout.senseChips.length - 1] : null;
172
+ const lastAnti = layout.antiChips.length ?
173
+ layout.antiChips[layout.antiChips.length - 1] : null;
174
+ return Math.max(
175
+ (lastSense?.x ?? 0) + (lastSense?.w ?? 0),
176
+ (lastAnti?.x ?? 0) + (lastAnti?.w ?? 0),
177
+ );
178
+ }
@@ -10,6 +10,8 @@
10
10
  * Lookups are memoized in a small map keyed by `${kind}:${symbol}`.
11
11
  */
12
12
 
13
+ import * as DG from 'datagrok-api/dg';
14
+
13
15
  import {HelmTypes} from '@datagrok-libraries/js-draw-lite/src/types/org';
14
16
  import {HelmType} from '@datagrok-libraries/bio/src/helm/types';
15
17
  import {_package} from '../package';
@@ -31,7 +33,7 @@ export interface MonomerColorTriple {
31
33
  }
32
34
 
33
35
  const EMPTY: MonomerColorTriple = {backgroundcolor: null, textcolor: null, linecolor: null};
34
- const _cache = new Map<string, MonomerColorTriple>();
36
+ const _cache = new DG.LruCache<string, MonomerColorTriple>(256);
35
37
 
36
38
  /** Resolve background/text/line colors for a HELM monomer. Returns `null`s
37
39
  * when the library has no entry. Pure sync — backed by `_package.bioMonomerLib`. */
@@ -54,7 +56,6 @@ export function getMonomerColors(kind: MonomerKind, symbol: string): MonomerColo
54
56
  }
55
57
  } catch { /* lib not yet initialized — return all-null */ }
56
58
 
57
- if (_cache.size > 512) _cache.clear();
58
59
  _cache.set(key, result);
59
60
  return result;
60
61
  }
@@ -14,6 +14,7 @@
14
14
 
15
15
  import * as grok from 'datagrok-api/grok';
16
16
  import * as ui from 'datagrok-api/ui';
17
+ import * as DG from 'datagrok-api/dg';
17
18
 
18
19
  import {getMonomerLibHelper, IMonomerLib} from '@datagrok-libraries/bio/src/types/monomer-library';
19
20
 
@@ -208,14 +209,13 @@ function makeStructureCell(label: string): { root: HTMLElement; body: HTMLDivEle
208
209
  * Since only one tooltip is visible at a time, moving the cached node
209
210
  * between tooltip hosts via `appendChild` is safe: the previous host is
210
211
  * being torn down. */
211
- const _structCache = new Map<string, HTMLElement>();
212
+ const _structCache = new DG.LruCache<string, HTMLElement>(256);
212
213
 
213
214
  function drawMolfileCached(host: HTMLDivElement, molfile: string, cacheKey: string): void {
214
215
  try {
215
216
  let cached = _structCache.get(cacheKey);
216
217
  if (!cached) {
217
218
  cached = grok.chem.drawMolecule(molfile, STRUCT_W, STRUCT_H, false);
218
- if (_structCache.size > 256) _structCache.clear();
219
219
  _structCache.set(cacheKey, cached);
220
220
  }
221
221
  host.innerHTML = '';
@@ -162,6 +162,13 @@ export namespace funcs {
162
162
  return await grok.functions.call('SequenceTranslator:EditOligoNucleotideCell', { cell });
163
163
  }
164
164
 
165
+ /**
166
+ Edit the oligonucleotide HELM in the HELM Web Editor
167
+ */
168
+ export async function openOligoHelmEditor(value: any ): Promise<void> {
169
+ return await grok.functions.call('SequenceTranslator:OpenOligoHelmEditor', { value });
170
+ }
171
+
165
172
  /**
166
173
  Modifications, lengths, conjugates and color legend for an OligoNucleotide cell
167
174
  */
@@ -176,6 +183,20 @@ export namespace funcs {
176
183
  return await grok.functions.call('SequenceTranslator:OligoNucleotideStructuresPanel', { value });
177
184
  }
178
185
 
186
+ /**
187
+ Copy the HELM string of an oligo cell to the clipboard
188
+ */
189
+ export async function copyOligoAsHelm(value: any ): Promise<void> {
190
+ return await grok.functions.call('SequenceTranslator:CopyOligoAsHelm', { value });
191
+ }
192
+
193
+ /**
194
+ Copy a high-resolution image of the oligo duplex
195
+ */
196
+ export async function copyOligoAsImage(value: any ): Promise<void> {
197
+ return await grok.functions.call('SequenceTranslator:CopyOligoAsImage', { value });
198
+ }
199
+
179
200
  /**
180
201
  Create a new column tagged as OligoNucleotide so HELM duplex cells render with the oligo view
181
202
  */