@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/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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 *
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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(
|
|
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` (
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 = '';
|
package/src/package-api.ts
CHANGED
|
@@ -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
|
*/
|