@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.
- package/CHANGELOG.md +12 -0
- package/dist/package-test.js +1 -1
- package/dist/package-test.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/package.json +1 -1
- package/src/oligo-renderer/analog-cache.ts +5 -2
- package/src/oligo-renderer/canvas-renderer.ts +401 -123
- package/src/oligo-renderer/cell-actions.ts +178 -0
- package/src/oligo-renderer/legend-panel.ts +20 -12
- package/src/oligo-renderer/monomer-colors.ts +61 -0
- package/src/oligo-renderer/tooltip.ts +2 -2
- package/src/package-api.ts +21 -0
- package/src/package.g.ts +26 -2
- package/src/package.ts +57 -16
- package/src/polytool/pt-chem-enum-dialog.ts +6 -2
- package/src/tests/oligo-renderer-tests.ts +224 -14
- package/test-console-output-1.log +179 -179
- package/test-record-1.mp4 +0 -0
|
@@ -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
|
|
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
|
|
|
31
|
+
import * as DG from 'datagrok-api/dg';
|
|
32
|
+
|
|
27
33
|
import {
|
|
28
34
|
BASE_COLORS, FALLBACK_COLOR,
|
|
29
|
-
|
|
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.
|
|
57
|
+
const CHIP_GAP_RATIO = 0.40; // wide gaps give apex triangles room to breathe
|
|
51
58
|
const MIN_CHIP_W = 5;
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
58
|
-
const
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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(
|
|
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
|
-
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
|
|
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
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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` (
|
|
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 =
|
|
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
|
-
|
|
354
|
-
|
|
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)
|
|
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
|
|
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
|
|
409
|
-
const
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
//
|
|
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(
|
|
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
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
430
|
-
}
|
|
560
|
+
g.restore();
|
|
431
561
|
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
/**
|
|
449
|
-
*
|
|
450
|
-
*
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
if (
|
|
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 +
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
|