@datagrok/sequence-translator 1.10.21 → 1.10.22
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/canvas-renderer.ts +180 -102
- package/src/oligo-renderer/legend-panel.ts +20 -12
- package/src/oligo-renderer/monomer-colors.ts +60 -0
- package/src/polytool/pt-chem-enum-dialog.ts +6 -2
- package/src/tests/oligo-renderer-tests.ts +75 -13
- package/test-console-output-1.log +155 -150
- package/test-record-1.mp4 +0 -0
package/package.json
CHANGED
|
@@ -6,32 +6,37 @@
|
|
|
6
6
|
* unit-test and to drive from the HTML prototype as well.
|
|
7
7
|
*
|
|
8
8
|
* Visual model:
|
|
9
|
-
* - Chip body
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
9
|
+
* - Chip body — colored from the central Bio monomer library
|
|
10
|
+
* (`getMonomerColors(HELM_BASE, base)` → background + text). Falls back to
|
|
11
|
+
* local BASE_COLORS / analog colors if the lib has no entry.
|
|
12
|
+
* - Sugar modifications — narrow colored stripe glued to the *outside* edge
|
|
13
|
+
* of the chip row (top for sense / single-strand, bottom for antisense).
|
|
14
|
+
* Color from `getMonomerColors(HELM_SUGAR, sugar).backgroundcolor`.
|
|
15
|
+
* - Phosphate linkage modifications (PS / s2p / mp / …) — drawn as 45°
|
|
16
|
+
* apex triangles in the inter-chip gap. Apex points *outward* (up for
|
|
17
|
+
* sense / single-strand, down for antisense). Color from
|
|
18
|
+
* `getMonomerColors(HELM_LINKER, phos).backgroundcolor`.
|
|
19
|
+
* - Conjugates — rounded pills at chain ends; their actual width is
|
|
20
|
+
* propagated through layout so adjacent chips don't overlap. Color from
|
|
21
|
+
* `getMonomerColors(HELM_CHEM, symbol)`.
|
|
17
22
|
* - Antisense is rendered 3'→5' (reversed) so position N of sense visually
|
|
18
23
|
* pairs with position N of antisense (anti-parallel base-pair register).
|
|
19
|
-
* - Conjugates are rendered as wider pills at the chain ends; their actual
|
|
20
|
-
* width is propagated through layout so adjacent chips don't overlap.
|
|
21
24
|
*
|
|
22
25
|
* Sizing is fully adaptive: chip dimensions scale to fit the cell, preserving
|
|
23
26
|
* aspect ratio, down to a minimum below which the cell falls back to a text
|
|
24
|
-
* summary.
|
|
27
|
+
* summary. The layout reserves vertical room for the apex zones above sense
|
|
28
|
+
* (and below antisense, when present).
|
|
25
29
|
*/
|
|
26
30
|
|
|
27
31
|
import {
|
|
28
32
|
BASE_COLORS, FALLBACK_COLOR,
|
|
29
|
-
|
|
33
|
+
contrastTextColor,
|
|
30
34
|
displayBase, isCanonicalBase,
|
|
31
35
|
ParsedDuplex, ParsedMonomer, ParsedNucleotide, ParsedStrand,
|
|
32
36
|
resolveConjugate, resolvePhosphate, resolveSugar,
|
|
33
37
|
} from './types';
|
|
34
38
|
import {getNaturalAnalog} from './analog-cache';
|
|
39
|
+
import {getMonomerColors} from './monomer-colors';
|
|
35
40
|
|
|
36
41
|
export interface RenderOpts {
|
|
37
42
|
/** Show base letter inside chip. False at very small sizes. */
|
|
@@ -47,15 +52,19 @@ export const DEFAULT_OPTS: RenderOpts = {showLetters: true, pairAlign: true};
|
|
|
47
52
|
/* Visual tuning. */
|
|
48
53
|
const ASPECT_H_OVER_W = 1.25;
|
|
49
54
|
const STRAND_GAP_RATIO = 0.5;
|
|
50
|
-
const CHIP_GAP_RATIO = 0.
|
|
55
|
+
const CHIP_GAP_RATIO = 0.40; // wide gaps give apex triangles room to breathe
|
|
51
56
|
const MIN_CHIP_W = 5;
|
|
52
57
|
const MAX_CHIP_W = 17;
|
|
53
58
|
const PAD = 4;
|
|
54
59
|
const LABEL_W = 30;
|
|
55
|
-
|
|
60
|
+
/** Chip fill opacity — softens the often-saturated library backgrounds so the
|
|
61
|
+
* sugar stripe and base label stay readable on top. */
|
|
62
|
+
const CHIP_FILL_ALPHA = 0.72;
|
|
63
|
+
const SUGAR_STRIPE_ALPHA = 1;
|
|
56
64
|
const CHIP_BORDER_W = 0.5;
|
|
57
|
-
const SUGAR_STRIPE_RATIO = 0.22; // height
|
|
58
|
-
const
|
|
65
|
+
const SUGAR_STRIPE_RATIO = 0.22; // sugar-mod stripe height as fraction of chipH
|
|
66
|
+
const APEX_RATIO = 0.45; // apex height as fraction of chipH (45° → also half-width)
|
|
67
|
+
const APEX_LINE_W = 2.5;
|
|
59
68
|
|
|
60
69
|
/** Cached layout for one rendered cell. */
|
|
61
70
|
export interface DuplexLayout {
|
|
@@ -69,6 +78,8 @@ export interface DuplexLayout {
|
|
|
69
78
|
senseY: number;
|
|
70
79
|
antiY: number; // -1 if no antisense
|
|
71
80
|
seqX: number;
|
|
81
|
+
/** Height of the apex zone above sense (and below antisense, if present). */
|
|
82
|
+
apexH: number;
|
|
72
83
|
textOnlyFallback: boolean;
|
|
73
84
|
senseChips: ChipPos[];
|
|
74
85
|
antiChips: ChipPos[];
|
|
@@ -114,7 +125,11 @@ export function computeLayout(
|
|
|
114
125
|
const availW = Math.max(0, cellW - LABEL_W - 2 * PAD);
|
|
115
126
|
const wChipW = availW / (maxLen + Math.max(0, maxLen - 1) * CHIP_GAP_RATIO);
|
|
116
127
|
|
|
117
|
-
|
|
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.
|
|
130
|
+
const apexCount = hasAnti ? 2 : 1;
|
|
131
|
+
const heightFactor =
|
|
132
|
+
strandsCount + (strandsCount - 1) * STRAND_GAP_RATIO + apexCount * APEX_RATIO;
|
|
118
133
|
const hChipH = (cellH - 2 * PAD) / heightFactor;
|
|
119
134
|
const hChipW = hChipH / ASPECT_H_OVER_W;
|
|
120
135
|
|
|
@@ -123,21 +138,24 @@ export function computeLayout(
|
|
|
123
138
|
if (chipW < MIN_CHIP_W) chipW = MIN_CHIP_W;
|
|
124
139
|
|
|
125
140
|
const chipH = chipW * ASPECT_H_OVER_W;
|
|
126
|
-
const chipGap = Math.max(
|
|
141
|
+
const chipGap = Math.max(3, chipW * CHIP_GAP_RATIO);
|
|
127
142
|
const strandGap = chipH * STRAND_GAP_RATIO;
|
|
128
143
|
const fontSize = Math.max(7, Math.min(13, chipW * 0.62));
|
|
144
|
+
const apexH = chipH * APEX_RATIO;
|
|
129
145
|
|
|
130
|
-
const blockH = strandsCount * chipH + (strandsCount - 1) * strandGap;
|
|
146
|
+
const blockH = strandsCount * chipH + (strandsCount - 1) * strandGap + apexCount * apexH;
|
|
131
147
|
const blockTop = Math.max(PAD, (cellH - blockH) / 2);
|
|
132
|
-
|
|
133
|
-
|
|
148
|
+
// Sense always sits below a top apex zone; antisense (when present) has
|
|
149
|
+
// its apex zone below it. Single-strand cases get a top apex zone only.
|
|
150
|
+
const senseY = blockTop + apexH;
|
|
151
|
+
const antiY = hasAnti ? senseY + chipH + strandGap : -1;
|
|
134
152
|
const seqX = PAD + LABEL_W;
|
|
135
153
|
const seqEndX = cellW - PAD;
|
|
136
154
|
|
|
137
155
|
const layoutBase: Omit<DuplexLayout, 'senseChips' | 'antiChips' | 'senseLinks' | 'antiLinks' | 'antiReversed'> = {
|
|
138
156
|
chipW, chipH, chipGap, strandGap, fontSize,
|
|
139
157
|
labelW: LABEL_W, padding: PAD,
|
|
140
|
-
senseY, antiY, seqX, textOnlyFallback,
|
|
158
|
+
senseY, antiY, seqX, apexH, textOnlyFallback,
|
|
141
159
|
};
|
|
142
160
|
|
|
143
161
|
if (textOnlyFallback) {
|
|
@@ -217,20 +235,26 @@ function placeStrand(
|
|
|
217
235
|
|
|
218
236
|
chips.push({x, w, monomer: m, origIdx: m.position, strand: side});
|
|
219
237
|
|
|
220
|
-
// Linkage
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
238
|
+
// Linkage in the gap to the right of display index i (between chips i
|
|
239
|
+
// and i+1). The `phosphate` field on a nucleotide always means "what
|
|
240
|
+
// comes AFTER this monomer in 5'→3' data order" — i.e. it lives on the
|
|
241
|
+
// lower-indexed end of the bond. So:
|
|
242
|
+
// - not reversed: gap-to-right of display i pairs data (p, p+1), and
|
|
243
|
+
// the phosphate lives on `m` (data p);
|
|
244
|
+
// - reversed: gap-to-right of display i pairs data (p, p-1), and the
|
|
245
|
+
// phosphate lives on the lower-indexed end, which is monomers[i+1].
|
|
246
|
+
if (i < monomers.length - 1) {
|
|
247
|
+
const linkOwner = reverse ? monomers[i + 1] : m;
|
|
248
|
+
if (linkOwner.kind === 'nucleotide') {
|
|
249
|
+
const nt = linkOwner as ParsedNucleotide;
|
|
250
|
+
if (nt.phosphate) {
|
|
251
|
+
links.push({
|
|
252
|
+
x: x + w, w: layout.chipGap, y, h: layout.chipH,
|
|
253
|
+
phosphateSymbol: nt.phosphate,
|
|
254
|
+
ownerOrigIdx: nt.position,
|
|
255
|
+
strand: side,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
234
258
|
}
|
|
235
259
|
}
|
|
236
260
|
x += w + layout.chipGap;
|
|
@@ -350,18 +374,19 @@ export function drawDuplex(
|
|
|
350
374
|
// Strand label "S 5'" left of sense
|
|
351
375
|
drawStrandLabel(g, 'S', '5\'', layout.padding, layout.senseY + layout.chipH / 2, layout);
|
|
352
376
|
|
|
353
|
-
//
|
|
354
|
-
|
|
377
|
+
// Chips first; apex triangles paint last so they appear on top of the
|
|
378
|
+
// chip body's anti-aliased corners.
|
|
355
379
|
drawChips(g, layout.senseChips, layout, o);
|
|
356
380
|
drawTruncationMarker(g, layout.senseChips, model.sense.monomers.length, layout);
|
|
381
|
+
for (const link of layout.senseLinks) drawLinkageApex(g, link, layout);
|
|
357
382
|
|
|
358
383
|
if (layout.antiY >= 0 && model.antisense) {
|
|
359
384
|
// When reversed, the leftmost chip in display is the 3' end of antisense.
|
|
360
385
|
const leftLabel = layout.antiReversed ? '3\'' : '5\'';
|
|
361
386
|
drawStrandLabel(g, 'AS', leftLabel, layout.padding, layout.antiY + layout.chipH / 2, layout);
|
|
362
|
-
for (const link of layout.antiLinks) drawLinkage(g, link);
|
|
363
387
|
drawChips(g, layout.antiChips, layout, o);
|
|
364
388
|
drawTruncationMarker(g, layout.antiChips, model.antisense.monomers.length, layout);
|
|
389
|
+
for (const link of layout.antiLinks) drawLinkageApex(g, link, layout);
|
|
365
390
|
}
|
|
366
391
|
|
|
367
392
|
g.restore();
|
|
@@ -392,63 +417,77 @@ function drawTruncationMarker(
|
|
|
392
417
|
}
|
|
393
418
|
|
|
394
419
|
function drawChips(g: CanvasRenderingContext2D, chips: ChipPos[], layout: DuplexLayout, opts: RenderOpts): void {
|
|
395
|
-
const
|
|
420
|
+
const side = chips[0]?.strand ?? 'sense';
|
|
421
|
+
const y = side === 'sense' ? layout.senseY : layout.antiY;
|
|
422
|
+
const decoSide = decorationSide(side);
|
|
396
423
|
for (const cp of chips) {
|
|
397
424
|
if (cp.monomer.kind === 'conjugate')
|
|
398
425
|
drawConjugate(g, cp.monomer.symbol, cp.x, y, cp.w, layout.chipH, layout.fontSize);
|
|
399
426
|
else
|
|
400
|
-
drawChip(g, cp.monomer as ParsedNucleotide, cp.x, y, cp.w, layout.chipH, layout.fontSize, opts);
|
|
427
|
+
drawChip(g, cp.monomer as ParsedNucleotide, cp.x, y, cp.w, layout.chipH, layout.fontSize, opts, decoSide);
|
|
401
428
|
}
|
|
402
429
|
}
|
|
403
430
|
|
|
431
|
+
/** Which outside edge of a strand's chip row gets the sugar stripe and apex.
|
|
432
|
+
* Sense (or single-strand) → 'top'; antisense → 'bottom'. */
|
|
433
|
+
function decorationSide(strand: StrandSide): 'top' | 'bottom' {
|
|
434
|
+
return strand === 'antisense' ? 'bottom' : 'top';
|
|
435
|
+
}
|
|
436
|
+
|
|
404
437
|
function drawChip(
|
|
405
438
|
g: CanvasRenderingContext2D, m: ParsedNucleotide, x: number, y: number,
|
|
406
|
-
w: number, h: number, fontSize: number, opts: RenderOpts,
|
|
439
|
+
w: number, h: number, fontSize: number, opts: RenderOpts, decoSide: 'top' | 'bottom',
|
|
407
440
|
): void {
|
|
408
|
-
const
|
|
409
|
-
const
|
|
410
|
-
const
|
|
441
|
+
const baseColors = m.base ? getMonomerColors('base', m.base) : null;
|
|
442
|
+
const bg = baseColors?.backgroundcolor ?? resolveBaseColor(m.base);
|
|
443
|
+
const textC = baseColors?.textcolor ?? contrastTextColor(bg);
|
|
411
444
|
const r = Math.min(2.5, w / 4);
|
|
445
|
+
const stripeH = Math.max(2, h * SUGAR_STRIPE_RATIO);
|
|
412
446
|
|
|
413
|
-
// Chip body —
|
|
447
|
+
// Chip body — background from monomer library (HELM_BASE), softened with
|
|
448
|
+
// a light alpha so the sugar stripe and base label stay readable on top.
|
|
414
449
|
drawRoundRect(g, x, y, w, h, r);
|
|
415
|
-
g.fillStyle = withAlpha(
|
|
450
|
+
g.fillStyle = withAlpha(bg, CHIP_FILL_ALPHA);
|
|
416
451
|
g.fill();
|
|
417
452
|
g.lineWidth = CHIP_BORDER_W;
|
|
418
453
|
g.strokeStyle = 'rgba(0,0,0,0.22)';
|
|
419
454
|
g.stroke();
|
|
420
455
|
|
|
421
|
-
// Sugar
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
456
|
+
// Sugar stripe — drawn for every sugar (including canonical `r` / `d`) so
|
|
457
|
+
// the sugar identity is always visually readable. Flips side per strand
|
|
458
|
+
// (top for sense, bottom for antisense). Clipped to the rounded shape so
|
|
459
|
+
// the stripe follows the chip's corners.
|
|
460
|
+
const sugarColors = getMonomerColors('sugar', m.sugar);
|
|
461
|
+
const stripeColor =
|
|
462
|
+
sugarColors.backgroundcolor ?? resolveSugar(m.sugar, m.base).color;
|
|
463
|
+
g.save();
|
|
464
|
+
drawRoundRect(g, x, y, w, h, r);
|
|
465
|
+
g.clip();
|
|
466
|
+
g.fillStyle = withAlpha(stripeColor, SUGAR_STRIPE_ALPHA);
|
|
467
|
+
if (decoSide === 'top')
|
|
468
|
+
g.fillRect(x, y, w, stripeH);
|
|
469
|
+
else
|
|
428
470
|
g.fillRect(x, y + h - stripeH, w, stripeH);
|
|
429
|
-
|
|
430
|
-
}
|
|
471
|
+
g.restore();
|
|
431
472
|
|
|
432
|
-
// Base label — biased
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
// wider chip, the user sees the full HELM symbol; otherwise it's clipped
|
|
436
|
-
// to first-letter + ellipsis for legibility.
|
|
473
|
+
// Base label — biased AWAY from the stripe edge so it stays centered in
|
|
474
|
+
// the visible body. Full HELM symbol when the chip is wide enough; first-
|
|
475
|
+
// letter + ellipsis otherwise.
|
|
437
476
|
if (opts.showLetters && m.base && fontSize >= 8) {
|
|
438
|
-
const stripeH = isModSugar ? Math.max(2, h * SUGAR_STRIPE_RATIO) : 0;
|
|
439
477
|
const label = pickBaseLabel(m.base, w, fontSize);
|
|
440
|
-
g.fillStyle =
|
|
478
|
+
g.fillStyle = textC;
|
|
441
479
|
g.font = `600 ${fontSize}px system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif`;
|
|
442
480
|
g.textBaseline = 'middle';
|
|
443
481
|
g.textAlign = 'center';
|
|
444
|
-
|
|
482
|
+
// Shift the label towards the unstriped half of the chip
|
|
483
|
+
const yShift = decoSide === 'top' ? stripeH / 2 : -stripeH / 2;
|
|
484
|
+
g.fillText(label, x + w / 2, y + h / 2 + yShift + 0.5);
|
|
445
485
|
}
|
|
446
486
|
}
|
|
447
487
|
|
|
448
|
-
/**
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
* package init. */
|
|
488
|
+
/** Fallback base-color resolution when the central monomer library has no
|
|
489
|
+
* `backgroundcolor` for the base. Tries the canonical palette, then the
|
|
490
|
+
* natural analog's palette, then a neutral fallback. */
|
|
452
491
|
function resolveBaseColor(base: string | null): string {
|
|
453
492
|
if (!base) return FALLBACK_COLOR;
|
|
454
493
|
if (BASE_COLORS[base]) return BASE_COLORS[base];
|
|
@@ -468,41 +507,55 @@ function pickBaseLabel(base: string, chipW: number, fontSize: number): string {
|
|
|
468
507
|
return displayBase(base);
|
|
469
508
|
}
|
|
470
509
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
510
|
+
/** Draw the linkage marker for `link` — a soft arch anchored to the strand's
|
|
511
|
+
* outside edge (top for sense, bottom for antisense). A single quadratic
|
|
512
|
+
* curve sweeps from base-left to base-right with a rounded summit, matching
|
|
513
|
+
* the rounded chip style. Color comes from the central Bio monomer library's
|
|
514
|
+
* `backgroundcolor` (linecolor tends to be flat black across the lib, which
|
|
515
|
+
* would wash differentiation out). Canonical `p` is drawn too — every
|
|
516
|
+
* linkage is visually accounted for. */
|
|
517
|
+
function drawLinkageApex(g: CanvasRenderingContext2D, link: LinkagePos, layout: DuplexLayout): void {
|
|
518
|
+
const linkerColors = getMonomerColors('linker', link.phosphateSymbol);
|
|
519
|
+
const color = linkerColors.backgroundcolor ?? resolvePhosphate(link.phosphateSymbol).color;
|
|
520
|
+
|
|
521
|
+
const apexH = layout.apexH;
|
|
522
|
+
const halfW = apexH; // 45° → height equals half-base
|
|
523
|
+
const centerX = link.x + link.w / 2;
|
|
524
|
+
const decoSide = decorationSide(link.strand);
|
|
525
|
+
const baseY = decoSide === 'top' ? link.y : link.y + link.h;
|
|
526
|
+
// Quadratic-curve midpoint Y sits halfway between baseY and the control Y,
|
|
527
|
+
// so overshooting the control by 2× lands the visual peak at apexH from baseY.
|
|
528
|
+
const ctrlY = decoSide === 'top' ? baseY - apexH * 2 : baseY + apexH * 2;
|
|
475
529
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
const barW = Math.max(2.5, link.w * PS_BAR_RATIO);
|
|
486
|
-
const barX = link.x + (link.w - barW) / 2;
|
|
487
|
-
g.fillStyle = ps.color;
|
|
488
|
-
g.fillRect(barX, link.y + 1, barW, link.h - 2);
|
|
530
|
+
g.save();
|
|
531
|
+
g.beginPath();
|
|
532
|
+
g.moveTo(centerX - halfW, baseY);
|
|
533
|
+
g.quadraticCurveTo(centerX, ctrlY, centerX + halfW, baseY);
|
|
534
|
+
g.lineWidth = APEX_LINE_W;
|
|
535
|
+
g.lineCap = 'round';
|
|
536
|
+
g.strokeStyle = color;
|
|
537
|
+
g.stroke();
|
|
538
|
+
g.restore();
|
|
489
539
|
}
|
|
490
540
|
|
|
491
541
|
function drawConjugate(
|
|
492
542
|
g: CanvasRenderingContext2D, symbol: string, x: number, y: number,
|
|
493
543
|
w: number, chipH: number, fontSize: number,
|
|
494
544
|
): void {
|
|
545
|
+
const conjColors = getMonomerColors('chem', symbol);
|
|
495
546
|
const conj = resolveConjugate(symbol);
|
|
547
|
+
const fill = conjColors.backgroundcolor ?? conj.color;
|
|
548
|
+
const textC = conjColors.textcolor ?? '#ffffff';
|
|
496
549
|
const r = chipH / 2;
|
|
497
550
|
drawRoundRect(g, x, y, w, chipH, r);
|
|
498
|
-
g.fillStyle =
|
|
551
|
+
g.fillStyle = fill;
|
|
499
552
|
g.fill();
|
|
500
553
|
g.lineWidth = 0.5;
|
|
501
554
|
g.strokeStyle = 'rgba(0,0,0,0.2)';
|
|
502
555
|
g.stroke();
|
|
503
556
|
|
|
504
557
|
if (fontSize >= 8) {
|
|
505
|
-
g.fillStyle =
|
|
558
|
+
g.fillStyle = textC;
|
|
506
559
|
g.font = `600 ${Math.max(8, fontSize - 1)}px system-ui, sans-serif`;
|
|
507
560
|
g.textBaseline = 'middle';
|
|
508
561
|
g.textAlign = 'center';
|
|
@@ -576,19 +629,31 @@ export function hitTest(
|
|
|
576
629
|
): HitResult | null {
|
|
577
630
|
if (layout.textOnlyFallback) return null;
|
|
578
631
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
if (
|
|
632
|
+
const apexH = layout.apexH;
|
|
633
|
+
const chipH = layout.chipH;
|
|
634
|
+
|
|
635
|
+
// Sense band: chip row + top apex zone (apex sits above the chip row).
|
|
636
|
+
if (localY >= layout.senseY - apexH && localY <= layout.senseY + chipH) {
|
|
637
|
+
if (localY >= layout.senseY) {
|
|
638
|
+
const cp = findChip(localX, layout.senseChips);
|
|
639
|
+
if (cp) return {strand: 'sense', position: cp.origIdx, monomer: cp.monomer};
|
|
640
|
+
}
|
|
641
|
+
// Apex zone above sense
|
|
642
|
+
if (localY < layout.senseY) {
|
|
643
|
+
const link = findApex(localX, localY, layout.senseLinks, 'top', apexH);
|
|
644
|
+
if (link) return resolveLinkHit(link, model.sense, 'sense');
|
|
645
|
+
}
|
|
585
646
|
}
|
|
586
|
-
// Antisense chip
|
|
587
|
-
if (layout.antiY >= 0 && localY >= layout.antiY && localY <= layout.antiY +
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
647
|
+
// Antisense band: chip row + bottom apex zone (apex sits below the chip row).
|
|
648
|
+
if (layout.antiY >= 0 && localY >= layout.antiY && localY <= layout.antiY + chipH + apexH) {
|
|
649
|
+
if (localY <= layout.antiY + chipH) {
|
|
650
|
+
const cp = findChip(localX, layout.antiChips);
|
|
651
|
+
if (cp) return {strand: 'antisense', position: cp.origIdx, monomer: cp.monomer};
|
|
652
|
+
}
|
|
653
|
+
if (localY > layout.antiY + chipH && model.antisense) {
|
|
654
|
+
const link = findApex(localX, localY, layout.antiLinks, 'bottom', apexH);
|
|
655
|
+
if (link) return resolveLinkHit(link, model.antisense, 'antisense');
|
|
656
|
+
}
|
|
592
657
|
}
|
|
593
658
|
return null;
|
|
594
659
|
}
|
|
@@ -598,9 +663,22 @@ function findChip(x: number, chips: ChipPos[]): ChipPos | null {
|
|
|
598
663
|
return null;
|
|
599
664
|
}
|
|
600
665
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
666
|
+
/** Find a linkage whose apex triangle covers (x, y). For each link, the apex
|
|
667
|
+
* is centered on `link.x + link.w/2`, with 45° slopes — total base width is
|
|
668
|
+
* `2 * apexH`. The bounding box is used (slight over-inclusion at corners is
|
|
669
|
+
* fine for hover targets and matches what users expect). */
|
|
670
|
+
function findApex(
|
|
671
|
+
x: number, y: number, links: LinkagePos[], side: 'top' | 'bottom', apexH: number,
|
|
672
|
+
): LinkagePos | null {
|
|
673
|
+
for (const l of links) {
|
|
674
|
+
const centerX = l.x + l.w / 2;
|
|
675
|
+
if (x < centerX - apexH || x > centerX + apexH) continue;
|
|
676
|
+
if (side === 'top') {
|
|
677
|
+
if (y >= l.y - apexH && y <= l.y) return l;
|
|
678
|
+
} else {
|
|
679
|
+
if (y >= l.y + l.h && y <= l.y + l.h + apexH) return l;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
604
682
|
return null;
|
|
605
683
|
}
|
|
606
684
|
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
ParsedNucleotide, resolveConjugate, resolvePhosphate, resolveSugar,
|
|
21
21
|
} from './types';
|
|
22
22
|
import {getNaturalAnalog} from './analog-cache';
|
|
23
|
+
import {getMonomerColors} from './monomer-colors';
|
|
23
24
|
|
|
24
25
|
export function buildOligoPanel(value: DG.SemanticValue): DG.Widget {
|
|
25
26
|
const helm: string = value.value ?? '';
|
|
@@ -110,9 +111,10 @@ function section(title: string, body: HTMLElement): HTMLElement {
|
|
|
110
111
|
|
|
111
112
|
/** Build a legend filtered to modifications actually present in this cell.
|
|
112
113
|
* One row per unique mod, with the count to the right. Items collapse by
|
|
113
|
-
* canonical symbol so legacy/canonical aliases share a row.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
114
|
+
* canonical symbol so legacy/canonical aliases share a row. Colors come from
|
|
115
|
+
* the same `getMonomerColors()` path the canvas renderer uses — so what's
|
|
116
|
+
* drawn on the chip and what's in the legend swatch are always in sync. The
|
|
117
|
+
* local mod meta only supplies the fallback color and human-readable name. */
|
|
116
118
|
function buildCellLegend(
|
|
117
119
|
sugars: Map<string, number>, phos: Map<string, number>,
|
|
118
120
|
conjs: Map<string, number>, bases: Map<string, number>,
|
|
@@ -120,34 +122,39 @@ function buildCellLegend(
|
|
|
120
122
|
type Item = { label: string; color: string; count: number };
|
|
121
123
|
const items: Item[] = [];
|
|
122
124
|
|
|
123
|
-
//
|
|
125
|
+
// Sugars — collapse by canonical symbol so e.g. mR + m → one row. Canonical
|
|
126
|
+
// ribose/deoxyribose are included too since the canvas now draws a stripe
|
|
127
|
+
// for every sugar, not just the modified ones.
|
|
124
128
|
const sugarByCanon = new Map<string, number>();
|
|
125
129
|
for (const [sym, n] of sugars.entries()) {
|
|
126
130
|
const c = canonicalSugarSymbol(sym);
|
|
127
|
-
if (c === 'r' || c === 'd') continue; // unmodified
|
|
128
131
|
sugarByCanon.set(c, (sugarByCanon.get(c) ?? 0) + n);
|
|
129
132
|
}
|
|
130
133
|
for (const [c, n] of sugarByCanon.entries()) {
|
|
131
134
|
const meta = resolveSugar(c, null).meta;
|
|
132
|
-
|
|
135
|
+
const libColor = getMonomerColors('sugar', c).backgroundcolor;
|
|
136
|
+
items.push({label: meta.name, color: libColor ?? meta.color, count: n});
|
|
133
137
|
}
|
|
134
138
|
|
|
135
|
-
// Phosphate / linkage mods
|
|
139
|
+
// Phosphate / linkage mods — show ALL linkages used in the cell (including
|
|
140
|
+
// canonical `p`) since the canvas now draws an apex for every linkage.
|
|
136
141
|
const phosByCanon = new Map<string, number>();
|
|
137
142
|
for (const [sym, n] of phos.entries()) {
|
|
138
143
|
const c = canonicalPhosphateSymbol(sym);
|
|
139
|
-
if (c === 'p') continue;
|
|
140
144
|
phosByCanon.set(c, (phosByCanon.get(c) ?? 0) + n);
|
|
141
145
|
}
|
|
142
146
|
for (const [c, n] of phosByCanon.entries()) {
|
|
143
147
|
const meta = resolvePhosphate(c).meta;
|
|
144
|
-
|
|
148
|
+
const libColor = getMonomerColors('linker', c).backgroundcolor;
|
|
149
|
+
items.push({label: `${meta.name} (linkage)`, color: libColor ?? meta.color, count: n});
|
|
145
150
|
}
|
|
146
151
|
|
|
147
|
-
// Custom bases —
|
|
152
|
+
// Custom bases — prefer the library's base color; fall back to natural-
|
|
153
|
+
// analog palette and finally to FALLBACK_COLOR.
|
|
148
154
|
for (const [sym, n] of bases.entries()) {
|
|
155
|
+
const libColor = getMonomerColors('base', sym).backgroundcolor;
|
|
149
156
|
const analog = getNaturalAnalog(sym);
|
|
150
|
-
const color = (analog && BASE_COLORS[analog])
|
|
157
|
+
const color = libColor ?? (analog && BASE_COLORS[analog]) ?? FALLBACK_COLOR;
|
|
151
158
|
const label = analog ? `${sym} (base, analog ${analog})` : `${sym} (base)`;
|
|
152
159
|
items.push({label, color, count: n});
|
|
153
160
|
}
|
|
@@ -155,7 +162,8 @@ function buildCellLegend(
|
|
|
155
162
|
// Conjugates
|
|
156
163
|
for (const [sym, n] of conjs.entries()) {
|
|
157
164
|
const meta = resolveConjugate(sym).meta;
|
|
158
|
-
|
|
165
|
+
const libColor = getMonomerColors('chem', sym).backgroundcolor;
|
|
166
|
+
items.push({label: meta.name, color: libColor ?? meta.color, count: n});
|
|
159
167
|
}
|
|
160
168
|
|
|
161
169
|
if (items.length === 0) {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronous color resolution against the central Bio monomer library.
|
|
3
|
+
*
|
|
4
|
+
* `_package.bioMonomerLib` is wired up at package init (see
|
|
5
|
+
* `initSequenceTranslatorInt`), so by the time any cell renders, the library
|
|
6
|
+
* is always present. The lib's `getMonomerColors(biotype, symbol)` returns
|
|
7
|
+
* `{ textcolor?, backgroundcolor?, linecolor? }` (all optional) — the
|
|
8
|
+
* library itself handles natural-analog fallback for custom symbols.
|
|
9
|
+
*
|
|
10
|
+
* Lookups are memoized in a small map keyed by `${kind}:${symbol}`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {HelmTypes} from '@datagrok-libraries/js-draw-lite/src/types/org';
|
|
14
|
+
import {HelmType} from '@datagrok-libraries/bio/src/helm/types';
|
|
15
|
+
import {_package} from '../package';
|
|
16
|
+
|
|
17
|
+
/** The four "kinds" we render — maps to the appropriate `HelmType`. */
|
|
18
|
+
export type MonomerKind = 'sugar' | 'base' | 'linker' | 'chem';
|
|
19
|
+
|
|
20
|
+
const KIND_TO_HELM_TYPE: Record<MonomerKind, HelmType> = {
|
|
21
|
+
sugar: HelmTypes.SUGAR as HelmType,
|
|
22
|
+
base: HelmTypes.BASE as HelmType,
|
|
23
|
+
linker: HelmTypes.LINKER as HelmType,
|
|
24
|
+
chem: HelmTypes.CHEM as HelmType,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface MonomerColorTriple {
|
|
28
|
+
backgroundcolor: string | null;
|
|
29
|
+
textcolor: string | null;
|
|
30
|
+
linecolor: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const EMPTY: MonomerColorTriple = {backgroundcolor: null, textcolor: null, linecolor: null};
|
|
34
|
+
const _cache = new Map<string, MonomerColorTriple>();
|
|
35
|
+
|
|
36
|
+
/** Resolve background/text/line colors for a HELM monomer. Returns `null`s
|
|
37
|
+
* when the library has no entry. Pure sync — backed by `_package.bioMonomerLib`. */
|
|
38
|
+
export function getMonomerColors(kind: MonomerKind, symbol: string): MonomerColorTriple {
|
|
39
|
+
if (!symbol) return EMPTY;
|
|
40
|
+
const key = `${kind}:${symbol}`;
|
|
41
|
+
const cached = _cache.get(key);
|
|
42
|
+
if (cached) return cached;
|
|
43
|
+
|
|
44
|
+
let result: MonomerColorTriple = EMPTY;
|
|
45
|
+
try {
|
|
46
|
+
const lib = _package.bioMonomerLib;
|
|
47
|
+
const colors = lib.getMonomerColors(KIND_TO_HELM_TYPE[kind], symbol);
|
|
48
|
+
if (colors) {
|
|
49
|
+
result = {
|
|
50
|
+
backgroundcolor: colors.backgroundcolor ?? null,
|
|
51
|
+
textcolor: colors.textcolor ?? null,
|
|
52
|
+
linecolor: colors.linecolor ?? null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
} catch { /* lib not yet initialized — return all-null */ }
|
|
56
|
+
|
|
57
|
+
if (_cache.size > 512) _cache.clear();
|
|
58
|
+
_cache.set(key, result);
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
@@ -1269,14 +1269,18 @@ async function executeEnumeration(state: ChemEnumDialogState, _rdkit: RDModule):
|
|
|
1269
1269
|
// Stage 2 — canonicalize the whole Enumerated column in parallel via Chem workers.
|
|
1270
1270
|
pi.update(40, `Canonicalizing ${results.length.toLocaleString()} molecule(s)...`);
|
|
1271
1271
|
try {
|
|
1272
|
-
await grok.functions.call('Chem:convertNotation', {
|
|
1272
|
+
const res: DG.Column = await grok.functions.call('Chem:convertNotation', {
|
|
1273
1273
|
data: df,
|
|
1274
1274
|
molecules: smilesCol,
|
|
1275
1275
|
targetNotation: DG.chem.Notation.Smiles,
|
|
1276
|
-
overwrite:
|
|
1276
|
+
overwrite: false,
|
|
1277
1277
|
join: false,
|
|
1278
1278
|
kekulize: false,
|
|
1279
1279
|
});
|
|
1280
|
+
// in older version of the chem, overwrite is super slow, it has been updated but we can do it like this here
|
|
1281
|
+
const resArr = res.toList();
|
|
1282
|
+
smilesCol.init((i) => resArr[i]);
|
|
1283
|
+
smilesCol.meta.units = DG.chem.Notation.Smiles;
|
|
1280
1284
|
} catch (err: any) {
|
|
1281
1285
|
// Canonicalization is a nice-to-have; the uncanonical SMILES are still valid output.
|
|
1282
1286
|
_package.logger.warning(`Canonicalization skipped: ${err?.message ?? err}`);
|