@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@datagrok/sequence-translator",
3
3
  "friendlyName": "Sequence Translator",
4
- "version": "1.10.21",
4
+ "version": "1.10.22",
5
5
  "author": {
6
6
  "name": "Davit Rizhinashvili",
7
7
  "email": "drizhinashvili@datagrok.ai"
@@ -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 uses base-canonical color (A green / C blue / G tan / U pink).
10
- * - Sugar modifications are shown as a colored stripe at the bottom of the
11
- * chip (so the chip itself stays readable for the base sequence, and the
12
- * modification track scans easily horizontally).
13
- * - Phosphate linkage modifications (PS) are shown as a saturated bar in
14
- * the gap *between* chips — this is the actual chemistry: the linkage
15
- * belongs to the bond, not to either nucleotide.
16
- * - Wide gaps between chips give the modification markers room to breathe.
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
- canonicalPhosphateSymbol, canonicalSugarSymbol,
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.32; // wider gives PS bar room to breathe
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
- const CHIP_FILL_ALPHA = 0.85; // chip body, base-canonical pale color
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 of bottom sugar-mod stripe
58
- const PS_BAR_RATIO = 0.55; // PS bar width as fraction of chip gap
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
- const heightFactor = strandsCount + Math.max(0, strandsCount - 1) * STRAND_GAP_RATIO;
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(2, chipW * CHIP_GAP_RATIO);
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
- const senseY = blockTop;
133
- const antiY = hasAnti ? blockTop + chipH + strandGap : -1;
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 from this monomer's 3'-phosphate goes in the gap to the right.
221
- // When the strand is reversed (anti-parallel), the linkage that was
222
- // "owned by monomer N's 3' end" connects monomer N to monomer N+1 in the
223
- // data; in display these are still adjacent, so the gap-to-right is still
224
- // the right place to draw it. We just need to look up the correct owner.
225
- if (m.kind === 'nucleotide' && i < monomers.length - 1) {
226
- const nt = m as ParsedNucleotide;
227
- if (nt.phosphate) {
228
- links.push({
229
- x: x + w, w: layout.chipGap, y, h: layout.chipH,
230
- phosphateSymbol: nt.phosphate,
231
- ownerOrigIdx: nt.position,
232
- strand: side,
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
- // Linkages first (so chips paint over their rounded edges cleanly)
354
- for (const link of layout.senseLinks) drawLinkage(g, link);
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 y = chips[0]?.strand === 'sense' ? layout.senseY : layout.antiY;
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 sugarRes = resolveSugar(m.sugar, m.base);
409
- const baseColor = resolveBaseColor(m.base);
410
- const isModSugar = isModifiedSugar(m.sugar);
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 — base-canonical pale color (or analog's color for custom bases)
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(baseColor, CHIP_FILL_ALPHA);
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 modification stripe at chip bottom clipped to rounded shape
422
- if (isModSugar) {
423
- const stripeH = Math.max(2, h * SUGAR_STRIPE_RATIO);
424
- g.save();
425
- drawRoundRect(g, x, y, w, h, r);
426
- g.clip();
427
- g.fillStyle = sugarRes.color;
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
- g.restore();
430
- }
471
+ g.restore();
431
472
 
432
- // Base label — biased upward to leave room for stripe.
433
- // Pick full label or shortened (`X…`) based on whether the chip is wide
434
- // enough at the current fontSize. This way, when the layout granted us a
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 = '#1a1a1a';
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
- g.fillText(label, x + w / 2, y + (h - stripeH) / 2 + 0.5);
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
- /** Resolve a chip background color for any base, including custom (multi-char)
449
- * symbols whose color follows their natural analog (`A` / `C` / `G` / `U` / `T`).
450
- * Sync backed by the central Bio monomer library that's wired up at
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
- function isModifiedSugar(sugar: string): boolean {
472
- const c = canonicalSugarSymbol(sugar);
473
- return c !== 'r' && c !== 'd';
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
- function drawLinkage(g: CanvasRenderingContext2D, link: LinkagePos): void {
477
- // Only the canonical phosphate (`p` / aliased `P`) is treated as "no marker".
478
- // Every other linkage — known PS / PS₂ / MeP, or unknown custom symbol that
479
- // got a hash-derived color — gets a bar in the inter-chip gap so the user
480
- // can see and hover it. Color comes from resolvePhosphate which is
481
- // deterministic per symbol, so two distinct unknown symbols get distinct bars.
482
- const canonical = canonicalPhosphateSymbol(link.phosphateSymbol);
483
- if (canonical === 'p') return;
484
- const ps = resolvePhosphate(link.phosphateSymbol);
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 = conj.color;
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 = '#ffffff';
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
- // Sense chip?
580
- if (localY >= layout.senseY && localY <= layout.senseY + layout.chipH) {
581
- const cp = findChip(localX, layout.senseChips);
582
- if (cp) return {strand: 'sense', position: cp.origIdx, monomer: cp.monomer};
583
- const link = findLink(localX, localY, layout.senseLinks);
584
- if (link) return resolveLinkHit(link, model.sense, 'sense');
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 + layout.chipH) {
588
- const cp = findChip(localX, layout.antiChips);
589
- if (cp) return {strand: 'antisense', position: cp.origIdx, monomer: cp.monomer};
590
- const link = findLink(localX, localY, layout.antiLinks);
591
- if (link && model.antisense) return resolveLinkHit(link, model.antisense, 'antisense');
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
- function findLink(x: number, y: number, links: LinkagePos[]): LinkagePos | null {
602
- for (const l of links)
603
- if (x >= l.x && x < l.x + l.w && y >= l.y && y < l.y + l.h) return l;
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. Custom bases
114
- * (non-A/C/G/U/T HELM symbols) are listed with their natural-analog color
115
- * resolved async via the central Bio monomer library. */
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
- // Sugar mods — collapse by canonical symbol so e.g. mR + m → one row
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
- items.push({label: meta.name, color: meta.color, count: n});
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
- items.push({label: `${meta.name} (linkage)`, color: meta.color, count: n});
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 — color via natural analog from the central Bio lib (sync).
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]) ? BASE_COLORS[analog] : FALLBACK_COLOR;
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
- items.push({label: meta.name, color: meta.color, count: n});
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: true,
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}`);