@datagrok/sequence-translator 1.10.21 → 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.
@@ -6,32 +6,39 @@
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
 
31
+ import * as DG from 'datagrok-api/dg';
32
+
27
33
  import {
28
34
  BASE_COLORS, FALLBACK_COLOR,
29
- canonicalPhosphateSymbol, canonicalSugarSymbol,
35
+ contrastTextColor,
30
36
  displayBase, isCanonicalBase,
31
37
  ParsedDuplex, ParsedMonomer, ParsedNucleotide, ParsedStrand,
32
38
  resolveConjugate, resolvePhosphate, resolveSugar,
33
39
  } from './types';
34
40
  import {getNaturalAnalog} from './analog-cache';
41
+ import {getMonomerColors} from './monomer-colors';
35
42
 
36
43
  export interface RenderOpts {
37
44
  /** Show base letter inside chip. False at very small sizes. */
@@ -47,15 +54,25 @@ export const DEFAULT_OPTS: RenderOpts = {showLetters: true, pairAlign: true};
47
54
  /* Visual tuning. */
48
55
  const ASPECT_H_OVER_W = 1.25;
49
56
  const STRAND_GAP_RATIO = 0.5;
50
- const CHIP_GAP_RATIO = 0.32; // wider gives PS bar room to breathe
57
+ const CHIP_GAP_RATIO = 0.40; // wide gaps give apex triangles room to breathe
51
58
  const MIN_CHIP_W = 5;
52
- const MAX_CHIP_W = 17;
59
+ // No hard upper cap on chip width — chipW grows until either (a) the duplex
60
+ // no longer fits in the cell's available width with all monomers shown at
61
+ // their natural widened size, or (b) the cell's height budget runs out.
62
+ // Show-everything is prioritised over making chips big.
53
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;
54
67
  const LABEL_W = 30;
55
- const CHIP_FILL_ALPHA = 0.85; // chip body, base-canonical pale color
68
+ /** Chip fill opacity softens the often-saturated library backgrounds so the
69
+ * sugar stripe and base label stay readable on top. */
70
+ const CHIP_FILL_ALPHA = 0.72;
71
+ const SUGAR_STRIPE_ALPHA = 1;
56
72
  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
73
+ const SUGAR_STRIPE_RATIO = 0.22; // sugar-mod stripe height as fraction of chipH
74
+ const APEX_RATIO = 0.45; // apex height as fraction of chipH (45° → also half-width)
75
+ const APEX_LINE_W = 2.5;
59
76
 
60
77
  /** Cached layout for one rendered cell. */
61
78
  export interface DuplexLayout {
@@ -69,6 +86,8 @@ export interface DuplexLayout {
69
86
  senseY: number;
70
87
  antiY: number; // -1 if no antisense
71
88
  seqX: number;
89
+ /** Height of the apex zone above sense (and below antisense, if present). */
90
+ apexH: number;
72
91
  textOnlyFallback: boolean;
73
92
  senseChips: ChipPos[];
74
93
  antiChips: ChipPos[];
@@ -111,33 +130,57 @@ export function computeLayout(
111
130
  const strandsCount = hasAnti ? 2 : 1;
112
131
  const maxLen = Math.max(1, senseLen, antiLen);
113
132
 
114
- const availW = Math.max(0, cellW - LABEL_W - 2 * PAD);
115
- const wChipW = availW / (maxLen + Math.max(0, maxLen - 1) * CHIP_GAP_RATIO);
116
-
117
- const heightFactor = strandsCount + Math.max(0, strandsCount - 1) * STRAND_GAP_RATIO;
118
- const hChipH = (cellH - 2 * PAD) / heightFactor;
119
- const hChipW = hChipH / ASPECT_H_OVER_W;
120
-
121
- let chipW = Math.min(MAX_CHIP_W, wChipW, hChipW);
122
- const textOnlyFallback = chipW < MIN_CHIP_W;
123
- if (chipW < MIN_CHIP_W) chipW = MIN_CHIP_W;
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.
136
+ const apexCount = hasAnti ? 2 : 1;
137
+ const heightFactor =
138
+ strandsCount + (strandsCount - 1) * STRAND_GAP_RATIO + apexCount * APEX_RATIO;
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
+ }
124
164
 
125
165
  const chipH = chipW * ASPECT_H_OVER_W;
126
- const chipGap = Math.max(2, chipW * CHIP_GAP_RATIO);
166
+ const chipGap = Math.max(3, chipW * CHIP_GAP_RATIO);
127
167
  const strandGap = chipH * STRAND_GAP_RATIO;
128
168
  const fontSize = Math.max(7, Math.min(13, chipW * 0.62));
129
-
130
- const blockH = strandsCount * chipH + (strandsCount - 1) * strandGap;
131
- const blockTop = Math.max(PAD, (cellH - blockH) / 2);
132
- const senseY = blockTop;
133
- const antiY = hasAnti ? blockTop + chipH + strandGap : -1;
169
+ const apexH = chipH * APEX_RATIO;
170
+
171
+ const blockH = strandsCount * chipH + (strandsCount - 1) * strandGap + apexCount * apexH;
172
+ const blockTop = Math.max(V_PAD, (cellH - blockH) / 2);
173
+ // Sense always sits below a top apex zone; antisense (when present) has
174
+ // its apex zone below it. Single-strand cases get a top apex zone only.
175
+ const senseY = blockTop + apexH;
176
+ const antiY = hasAnti ? senseY + chipH + strandGap : -1;
134
177
  const seqX = PAD + LABEL_W;
135
178
  const seqEndX = cellW - PAD;
136
179
 
137
180
  const layoutBase: Omit<DuplexLayout, 'senseChips' | 'antiChips' | 'senseLinks' | 'antiLinks' | 'antiReversed'> = {
138
181
  chipW, chipH, chipGap, strandGap, fontSize,
139
182
  labelW: LABEL_W, padding: PAD,
140
- senseY, antiY, seqX, textOnlyFallback,
183
+ senseY, antiY, seqX, apexH, textOnlyFallback,
141
184
  };
142
185
 
143
186
  if (textOnlyFallback) {
@@ -175,9 +218,9 @@ export function computeLayout(
175
218
  // Falls back to uniform chipW if either strand's chips don't fit.
176
219
  const widthBudget = seqEndX - (seqX + alignAt);
177
220
  if (!fitsInBudget(widths.sense, senseLeadW, chipGap, widthBudget) ||
178
- !fitsInBudget(widths.anti, antiLeadW, chipGap, widthBudget)) {
221
+ !fitsInBudget(widths.anti, antiLeadW, chipGap, widthBudget))
179
222
  widths = uniformWidths(senseDisplay, antiDisplay, chipW, fontSize);
180
- }
223
+
181
224
 
182
225
  const senseRes = placeStrand(
183
226
  model.sense, false, senseY, senseStartX, seqEndX, layoutBase, 'sense', widths.sense);
@@ -217,20 +260,26 @@ function placeStrand(
217
260
 
218
261
  chips.push({x, w, monomer: m, origIdx: m.position, strand: side});
219
262
 
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
- });
263
+ // Linkage in the gap to the right of display index i (between chips i
264
+ // and i+1). The `phosphate` field on a nucleotide always means "what
265
+ // comes AFTER this monomer in 5'3' data order" i.e. it lives on the
266
+ // lower-indexed end of the bond. So:
267
+ // - not reversed: gap-to-right of display i pairs data (p, p+1), and
268
+ // the phosphate lives on `m` (data p);
269
+ // - reversed: gap-to-right of display i pairs data (p, p-1), and the
270
+ // phosphate lives on the lower-indexed end, which is monomers[i+1].
271
+ if (i < monomers.length - 1) {
272
+ const linkOwner = reverse ? monomers[i + 1] : m;
273
+ if (linkOwner.kind === 'nucleotide') {
274
+ const nt = linkOwner as ParsedNucleotide;
275
+ if (nt.phosphate) {
276
+ links.push({
277
+ x: x + w, w: layout.chipGap, y, h: layout.chipH,
278
+ phosphateSymbol: nt.phosphate,
279
+ ownerOrigIdx: nt.position,
280
+ strand: side,
281
+ });
282
+ }
234
283
  }
235
284
  }
236
285
  x += w + layout.chipGap;
@@ -313,14 +362,70 @@ function uniformWidths(
313
362
  return {sense: senseDisplay.map(map), anti: antiDisplay.map(map)};
314
363
  }
315
364
 
316
- /** Whether the per-chip widths array fits in `budget` (after the leading shift). */
365
+ /** Whether the per-chip widths array fits in `budget` (which is measured from
366
+ * the alignment point, i.e. `seqEndX − (seqX + alignAt)`).
367
+ *
368
+ * `widths` already includes any leading conjugates' pill widths at the front.
369
+ * Those leading conjugates occupy the SHIFT area to the LEFT of the alignment
370
+ * point (between `seqX` and `seqX + alignAt`) — so their cost against the
371
+ * post-alignment `budget` is zero. Equivalently: this strand's chips start at
372
+ * `seqX + (alignAt − leadW)` and span `chipsTotal`, fitting when
373
+ * `chipsTotal ≤ seqEndX − (seqX + alignAt − leadW) = budget + leadW`. */
317
374
  function fitsInBudget(widths: number[], leadW: number, chipGap: number, budget: number): boolean {
318
- let total = leadW;
375
+ let total = 0;
319
376
  for (let i = 0; i < widths.length; i++) {
320
377
  total += widths[i];
321
378
  if (i < widths.length - 1) total += chipGap;
322
379
  }
323
- 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;
324
429
  }
325
430
 
326
431
  /* ---------------------------------------------------------------- *
@@ -349,9 +454,9 @@ export function drawDuplex(
349
454
 
350
455
  // Strand label "S 5'" left of sense
351
456
  drawStrandLabel(g, 'S', '5\'', layout.padding, layout.senseY + layout.chipH / 2, layout);
352
-
353
- // Linkages first (so chips paint over their rounded edges cleanly)
354
- for (const link of layout.senseLinks) drawLinkage(g, link);
457
+ // first draw links so they are behind
458
+ for (const link of layout.senseLinks) drawLinkageApex(g, link, layout);
459
+ // chip body's anti-aliased corners.
355
460
  drawChips(g, layout.senseChips, layout, o);
356
461
  drawTruncationMarker(g, layout.senseChips, model.sense.monomers.length, layout);
357
462
 
@@ -359,9 +464,15 @@ export function drawDuplex(
359
464
  // When reversed, the leftmost chip in display is the 3' end of antisense.
360
465
  const leftLabel = layout.antiReversed ? '3\'' : '5\'';
361
466
  drawStrandLabel(g, 'AS', leftLabel, layout.padding, layout.antiY + layout.chipH / 2, layout);
362
- for (const link of layout.antiLinks) drawLinkage(g, link);
467
+ for (const link of layout.antiLinks) drawLinkageApex(g, link, layout);
363
468
  drawChips(g, layout.antiChips, layout, o);
364
469
  drawTruncationMarker(g, layout.antiChips, model.antisense.monomers.length, layout);
470
+
471
+ // Watson-Crick base-pair indicators in the strand gap. Only meaningful
472
+ // when antisense is rendered anti-parallel (i.e. reversed for display) —
473
+ // that's what makes the vertical column alignment actually represent the
474
+ // duplex partner pairs.
475
+ if (layout.antiReversed) drawBasePairings(g, layout);
365
476
  }
366
477
 
367
478
  g.restore();
@@ -392,63 +503,80 @@ function drawTruncationMarker(
392
503
  }
393
504
 
394
505
  function drawChips(g: CanvasRenderingContext2D, chips: ChipPos[], layout: DuplexLayout, opts: RenderOpts): void {
395
- const y = chips[0]?.strand === 'sense' ? layout.senseY : layout.antiY;
506
+ const side = chips[0]?.strand ?? 'sense';
507
+ const y = side === 'sense' ? layout.senseY : layout.antiY;
508
+ const decoSide = decorationSide(side);
396
509
  for (const cp of chips) {
397
510
  if (cp.monomer.kind === 'conjugate')
398
511
  drawConjugate(g, cp.monomer.symbol, cp.x, y, cp.w, layout.chipH, layout.fontSize);
399
512
  else
400
- drawChip(g, cp.monomer as ParsedNucleotide, cp.x, y, cp.w, layout.chipH, layout.fontSize, opts);
513
+ drawChip(g, cp.monomer as ParsedNucleotide, cp.x, y, cp.w, layout.chipH, layout.fontSize, opts, decoSide);
401
514
  }
402
515
  }
403
516
 
517
+ /** Which outside edge of a strand's chip row gets the sugar stripe and apex.
518
+ * Sense (or single-strand) → 'top'; antisense → 'bottom'. */
519
+ function decorationSide(strand: StrandSide): 'top' | 'bottom' {
520
+ return strand === 'antisense' ? 'bottom' : 'top';
521
+ }
522
+
404
523
  function drawChip(
405
524
  g: CanvasRenderingContext2D, m: ParsedNucleotide, x: number, y: number,
406
- w: number, h: number, fontSize: number, opts: RenderOpts,
525
+ w: number, h: number, fontSize: number, opts: RenderOpts, decoSide: 'top' | 'bottom',
407
526
  ): void {
408
- const sugarRes = resolveSugar(m.sugar, m.base);
409
- const baseColor = resolveBaseColor(m.base);
410
- const isModSugar = isModifiedSugar(m.sugar);
411
- const r = Math.min(2.5, w / 4);
412
-
413
- // Chip body base-canonical pale color (or analog's color for custom bases)
527
+ const baseColors = m.base ? getMonomerColors('base', m.base) : null;
528
+ const bg = baseColors?.backgroundcolor ?? resolveBaseColor(m.base);
529
+ const textC = baseColors?.textcolor ?? contrastTextColor(bg);
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;
534
+ const stripeH = Math.max(2, h * SUGAR_STRIPE_RATIO);
535
+
536
+ // Chip body — background from monomer library (HELM_BASE), softened with
537
+ // a light alpha so the sugar stripe and base label stay readable on top.
414
538
  drawRoundRect(g, x, y, w, h, r);
415
- g.fillStyle = withAlpha(baseColor, CHIP_FILL_ALPHA);
539
+ g.fillStyle = withAlpha(bg, CHIP_FILL_ALPHA);
416
540
  g.fill();
417
541
  g.lineWidth = CHIP_BORDER_W;
418
542
  g.strokeStyle = 'rgba(0,0,0,0.22)';
419
543
  g.stroke();
420
544
 
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;
545
+ // Sugar stripe drawn for every sugar (including canonical `r` / `d`) so
546
+ // the sugar identity is always visually readable. Flips side per strand
547
+ // (top for sense, bottom for antisense). Clipped to the rounded shape so
548
+ // the stripe follows the chip's corners.
549
+ const sugarColors = getMonomerColors('sugar', m.sugar);
550
+ const stripeColor =
551
+ sugarColors.backgroundcolor ?? resolveSugar(m.sugar, m.base).color;
552
+ g.save();
553
+ drawRoundRect(g, x, y, w, h, r);
554
+ g.clip();
555
+ g.fillStyle = withAlpha(stripeColor, SUGAR_STRIPE_ALPHA);
556
+ if (decoSide === 'top')
557
+ g.fillRect(x, y, w, stripeH);
558
+ else
428
559
  g.fillRect(x, y + h - stripeH, w, stripeH);
429
- g.restore();
430
- }
560
+ g.restore();
431
561
 
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.
562
+ // Base label — biased AWAY from the stripe edge so it stays centered in
563
+ // the visible body. Full HELM symbol when the chip is wide enough; first-
564
+ // letter + ellipsis otherwise.
437
565
  if (opts.showLetters && m.base && fontSize >= 8) {
438
- const stripeH = isModSugar ? Math.max(2, h * SUGAR_STRIPE_RATIO) : 0;
439
566
  const label = pickBaseLabel(m.base, w, fontSize);
440
- g.fillStyle = '#1a1a1a';
567
+ g.fillStyle = textC;
441
568
  g.font = `600 ${fontSize}px system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif`;
442
569
  g.textBaseline = 'middle';
443
570
  g.textAlign = 'center';
444
- g.fillText(label, x + w / 2, y + (h - stripeH) / 2 + 0.5);
571
+ // Shift the label towards the unstriped half of the chip
572
+ const yShift = decoSide === 'top' ? stripeH / 2 : -stripeH / 2;
573
+ g.fillText(label, x + w / 2, y + h / 2 + yShift + 0.5);
445
574
  }
446
575
  }
447
576
 
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. */
577
+ /** Fallback base-color resolution when the central monomer library has no
578
+ * `backgroundcolor` for the base. Tries the canonical palette, then the
579
+ * natural analog's palette, then a neutral fallback. */
452
580
  function resolveBaseColor(base: string | null): string {
453
581
  if (!base) return FALLBACK_COLOR;
454
582
  if (BASE_COLORS[base]) return BASE_COLORS[base];
@@ -468,41 +596,167 @@ function pickBaseLabel(base: string, chipW: number, fontSize: number): string {
468
596
  return displayBase(base);
469
597
  }
470
598
 
471
- function isModifiedSugar(sugar: string): boolean {
472
- const c = canonicalSugarSymbol(sugar);
473
- return c !== 'r' && c !== 'd';
599
+ /** Draw the linkage marker for `link` — a soft arch anchored to the strand's
600
+ * outside edge (top for sense, bottom for antisense). A single quadratic
601
+ * curve sweeps from base-left to base-right with a rounded summit, matching
602
+ * the rounded chip style. Color comes from the central Bio monomer library's
603
+ * `backgroundcolor` (linecolor tends to be flat black across the lib, which
604
+ * would wash differentiation out). Canonical `p` is drawn too — every
605
+ * linkage is visually accounted for. */
606
+ function drawLinkageApex(g: CanvasRenderingContext2D, link: LinkagePos, layout: DuplexLayout): void {
607
+ const linkerColors = getMonomerColors('linker', link.phosphateSymbol);
608
+ const color = linkerColors.backgroundcolor ?? resolvePhosphate(link.phosphateSymbol).color;
609
+ const apexH = layout.apexH;
610
+
611
+ const apexWidthMult = Math.max(apexH / 10, 1);
612
+ const halfW = apexH; // 45° → height equals half-base
613
+ const centerX = link.x + link.w / 2;
614
+ const decoSide = decorationSide(link.strand);
615
+ const baseY = decoSide === 'top' ? link.y : link.y + link.h;
616
+ // Quadratic-curve midpoint Y sits halfway between baseY and the control Y,
617
+ // so overshooting the control by 2× lands the visual peak at apexH from baseY.
618
+ const ctrlY = decoSide === 'top' ? baseY - apexH * 2 : baseY + apexH * 2;
619
+
620
+ g.save();
621
+ g.beginPath();
622
+ g.moveTo(centerX - halfW, baseY);
623
+ g.quadraticCurveTo(centerX, ctrlY, centerX + halfW, baseY);
624
+ g.lineWidth = APEX_LINE_W * apexWidthMult;
625
+ g.lineCap = 'round';
626
+ g.strokeStyle = color;
627
+ g.stroke();
628
+ g.restore();
629
+ }
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;
474
730
  }
475
731
 
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);
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';
489
740
  }
490
741
 
491
742
  function drawConjugate(
492
743
  g: CanvasRenderingContext2D, symbol: string, x: number, y: number,
493
744
  w: number, chipH: number, fontSize: number,
494
745
  ): void {
746
+ const conjColors = getMonomerColors('chem', symbol);
495
747
  const conj = resolveConjugate(symbol);
748
+ const fill = conjColors.backgroundcolor ?? conj.color;
749
+ const textC = conjColors.textcolor ?? '#ffffff';
496
750
  const r = chipH / 2;
497
751
  drawRoundRect(g, x, y, w, chipH, r);
498
- g.fillStyle = conj.color;
752
+ g.fillStyle = fill;
499
753
  g.fill();
500
754
  g.lineWidth = 0.5;
501
755
  g.strokeStyle = 'rgba(0,0,0,0.2)';
502
756
  g.stroke();
503
757
 
504
758
  if (fontSize >= 8) {
505
- g.fillStyle = '#ffffff';
759
+ g.fillStyle = textC;
506
760
  g.font = `600 ${Math.max(8, fontSize - 1)}px system-ui, sans-serif`;
507
761
  g.textBaseline = 'middle';
508
762
  g.textAlign = 'center';
@@ -540,7 +794,7 @@ function drawFallbackText(
540
794
  }
541
795
 
542
796
  /** Apply alpha to any CSS color string, returning rgba(...). Memoized. */
543
- const _alphaCache = new Map<string, string>();
797
+ const _alphaCache = new DG.LruCache<string, string>(256);
544
798
  function withAlpha(color: string, alpha: number): string {
545
799
  const key = `${color}|${alpha}`;
546
800
  const cached = _alphaCache.get(key);
@@ -552,7 +806,6 @@ function withAlpha(color: string, alpha: number): string {
552
806
  document.body.removeChild(probe);
553
807
  const m = rgb.match(/\d+/g);
554
808
  const out = m ? `rgba(${m[0]},${m[1]},${m[2]},${alpha})` : color;
555
- if (_alphaCache.size > 256) _alphaCache.clear();
556
809
  _alphaCache.set(key, out);
557
810
  return out;
558
811
  }
@@ -576,19 +829,31 @@ export function hitTest(
576
829
  ): HitResult | null {
577
830
  if (layout.textOnlyFallback) return null;
578
831
 
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');
832
+ const apexH = layout.apexH;
833
+ const chipH = layout.chipH;
834
+
835
+ // Sense band: chip row + top apex zone (apex sits above the chip row).
836
+ if (localY >= layout.senseY - apexH && localY <= layout.senseY + chipH) {
837
+ if (localY >= layout.senseY) {
838
+ const cp = findChip(localX, layout.senseChips);
839
+ if (cp) return {strand: 'sense', position: cp.origIdx, monomer: cp.monomer};
840
+ }
841
+ // Apex zone above sense
842
+ if (localY < layout.senseY) {
843
+ const link = findApex(localX, localY, layout.senseLinks, 'top', apexH);
844
+ if (link) return resolveLinkHit(link, model.sense, 'sense');
845
+ }
585
846
  }
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');
847
+ // Antisense band: chip row + bottom apex zone (apex sits below the chip row).
848
+ if (layout.antiY >= 0 && localY >= layout.antiY && localY <= layout.antiY + chipH + apexH) {
849
+ if (localY <= layout.antiY + chipH) {
850
+ const cp = findChip(localX, layout.antiChips);
851
+ if (cp) return {strand: 'antisense', position: cp.origIdx, monomer: cp.monomer};
852
+ }
853
+ if (localY > layout.antiY + chipH && model.antisense) {
854
+ const link = findApex(localX, localY, layout.antiLinks, 'bottom', apexH);
855
+ if (link) return resolveLinkHit(link, model.antisense, 'antisense');
856
+ }
592
857
  }
593
858
  return null;
594
859
  }
@@ -598,9 +863,22 @@ function findChip(x: number, chips: ChipPos[]): ChipPos | null {
598
863
  return null;
599
864
  }
600
865
 
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;
866
+ /** Find a linkage whose apex triangle covers (x, y). For each link, the apex
867
+ * is centered on `link.x + link.w/2`, with 45° slopes — total base width is
868
+ * `2 * apexH`. The bounding box is used (slight over-inclusion at corners is
869
+ * fine for hover targets and matches what users expect). */
870
+ function findApex(
871
+ x: number, y: number, links: LinkagePos[], side: 'top' | 'bottom', apexH: number,
872
+ ): LinkagePos | null {
873
+ for (const l of links) {
874
+ const centerX = l.x + l.w / 2;
875
+ if (x < centerX - apexH || x > centerX + apexH) continue;
876
+ if (side === 'top') {
877
+ if (y >= l.y - apexH && y <= l.y) return l;
878
+ } else {
879
+ if (y >= l.y + l.h && y <= l.y + l.h + apexH) return l;
880
+ }
881
+ }
604
882
  return null;
605
883
  }
606
884