@datagrok/sequence-translator 1.10.17 → 1.10.19
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 +5 -0
- package/CLAUDE.md +15 -12
- 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/apps/common/model/oligo-toolkit-package.ts +13 -1
- package/src/oligo-renderer/analog-cache.ts +48 -0
- package/src/oligo-renderer/canvas-renderer.ts +125 -15
- package/src/oligo-renderer/cell-renderer.ts +19 -20
- package/src/oligo-renderer/helm-parser.ts +3 -2
- package/src/oligo-renderer/legend-panel.ts +31 -6
- package/src/oligo-renderer/types.ts +21 -0
- package/src/package-api.ts +7 -0
- package/src/package-test.ts +1 -0
- package/src/package.g.ts +8 -0
- package/src/package.ts +32 -2
- package/src/tests/oligo-cell-editor-tests.ts +83 -0
- package/src/tests/oligo-renderer-tests.ts +64 -1
- package/test-console-output-1.log +194 -146
- package/test-record-1.mp4 +0 -0
package/package.json
CHANGED
|
@@ -31,6 +31,17 @@ export class OligoToolkitPackage extends DG.Package implements ITranslationHelpe
|
|
|
31
31
|
return this._helmHelper.seqHelper;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Central Bio monomer library (HELMCore + any extra libraries the user has
|
|
35
|
+
* loaded, including our oligo-conjugates set). Populated synchronously from
|
|
36
|
+
* `getMonomerLibHelper()` during package init — guaranteed available the
|
|
37
|
+
* moment any other package function (cell renderer, panels, …) can run. */
|
|
38
|
+
private _bioMonomerLib?: IMonomerLib;
|
|
39
|
+
get bioMonomerLib(): IMonomerLib {
|
|
40
|
+
if (!this._bioMonomerLib)
|
|
41
|
+
throw new Error('Package SequenceTranslator .bioMonomerLib is not initialized');
|
|
42
|
+
return this._bioMonomerLib;
|
|
43
|
+
}
|
|
44
|
+
|
|
34
45
|
private _monomerLib?: IMonomerLib;
|
|
35
46
|
get monomerLib(): IMonomerLib {
|
|
36
47
|
if (!this._monomerLib)
|
|
@@ -65,8 +76,9 @@ export class OligoToolkitPackage extends DG.Package implements ITranslationHelpe
|
|
|
65
76
|
this._initPromise = initPromise;
|
|
66
77
|
}
|
|
67
78
|
|
|
68
|
-
completeInit(helmHelper: IHelmHelper): void {
|
|
79
|
+
completeInit(helmHelper: IHelmHelper, bioMonomerLib: IMonomerLib): void {
|
|
69
80
|
this._helmHelper = helmHelper;
|
|
81
|
+
this._bioMonomerLib = bioMonomerLib;
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
private initLibDataPromise?: Promise<void>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronous lookup of HELM monomer symbol → natural-analog letter.
|
|
3
|
+
*
|
|
4
|
+
* The central Bio monomer library is fetched once during package init
|
|
5
|
+
* (see `initSequenceTranslatorInt` → `_package.completeInit`) and parked on
|
|
6
|
+
* `_package.bioMonomerLib`. Datagrok's `@init` decorator guarantees this
|
|
7
|
+
* runs before any other package function — including cell-renderer factory
|
|
8
|
+
* calls and the renderer's `render()` invocations — so by the time we look
|
|
9
|
+
* up an analog, the lib is always present.
|
|
10
|
+
*
|
|
11
|
+
* Lookups are memoized in a small Map. No async, no subscriptions, no
|
|
12
|
+
* grid invalidation. If the cache is queried before init has finished
|
|
13
|
+
* (defensive — shouldn't happen in normal flow), we return `null` and the
|
|
14
|
+
* caller falls back to a neutral color; the next render after init will
|
|
15
|
+
* resolve fully.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {IMonomerLib} from '@datagrok-libraries/bio/src/types/monomer-library';
|
|
19
|
+
import {_package} from '../package';
|
|
20
|
+
|
|
21
|
+
/** symbol → natural-analog letter, or null if absent / no analog. */
|
|
22
|
+
const _cache = new Map<string, string | null>();
|
|
23
|
+
|
|
24
|
+
/** Resolve `symbol` to its single-letter natural analog. Returns `null` for
|
|
25
|
+
* unknowns, the canonical letter (uppercase) for matches. Pure sync. */
|
|
26
|
+
export function getNaturalAnalog(symbol: string): string | null {
|
|
27
|
+
if (!symbol) return null;
|
|
28
|
+
const cached = _cache.get(symbol);
|
|
29
|
+
if (cached !== undefined) return cached;
|
|
30
|
+
|
|
31
|
+
let lib: IMonomerLib | null = null;
|
|
32
|
+
try { lib = _package.bioMonomerLib; } catch { /* not yet initialized */ }
|
|
33
|
+
if (!lib) return null;
|
|
34
|
+
|
|
35
|
+
const analog = lookup(lib, symbol);
|
|
36
|
+
_cache.set(symbol, analog);
|
|
37
|
+
return analog;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function lookup(lib: IMonomerLib, symbol: string): string | null {
|
|
41
|
+
for (const pt of lib.getPolymerTypes()) {
|
|
42
|
+
const m = lib.getMonomer(pt, symbol);
|
|
43
|
+
const na = m?.naturalAnalog;
|
|
44
|
+
if (na && typeof na === 'string' && na.length === 1)
|
|
45
|
+
return na.toUpperCase();
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable max-params */
|
|
1
2
|
/**
|
|
2
3
|
* Canvas drawing for the OligoNucleotide cell renderer.
|
|
3
4
|
*
|
|
@@ -26,9 +27,11 @@
|
|
|
26
27
|
import {
|
|
27
28
|
BASE_COLORS, FALLBACK_COLOR,
|
|
28
29
|
canonicalPhosphateSymbol, canonicalSugarSymbol,
|
|
30
|
+
displayBase, isCanonicalBase,
|
|
29
31
|
ParsedDuplex, ParsedMonomer, ParsedNucleotide, ParsedStrand,
|
|
30
32
|
resolveConjugate, resolvePhosphate, resolveSugar,
|
|
31
33
|
} from './types';
|
|
34
|
+
import {getNaturalAnalog} from './analog-cache';
|
|
32
35
|
|
|
33
36
|
export interface RenderOpts {
|
|
34
37
|
/** Show base letter inside chip. False at very small sizes. */
|
|
@@ -39,7 +42,7 @@ export interface RenderOpts {
|
|
|
39
42
|
scheme?: string;
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
const DEFAULT_OPTS: RenderOpts = {showLetters: true, pairAlign: true};
|
|
45
|
+
export const DEFAULT_OPTS: RenderOpts = {showLetters: true, pairAlign: true};
|
|
43
46
|
|
|
44
47
|
/* Visual tuning. */
|
|
45
48
|
const ASPECT_H_OVER_W = 1.25;
|
|
@@ -159,10 +162,28 @@ export function computeLayout(
|
|
|
159
162
|
const senseStartX = seqX + (alignAt - senseLeadW);
|
|
160
163
|
const antiStartX = seqX + (alignAt - antiLeadW);
|
|
161
164
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
165
|
+
// Per-chip widths: nucleotides with multi-char bases (e.g. `cpm6A`) want a
|
|
166
|
+
// wider chip so the full symbol fits. We try the wide layout first; if the
|
|
167
|
+
// total doesn't fit in the cell, we fall back to uniform chipW + ellipsis.
|
|
168
|
+
// For pair alignment, columns sync across strands (pair-aligned column
|
|
169
|
+
// takes the max width of the two strands' chips at that pair-index).
|
|
170
|
+
const senseDisplay = model.sense.monomers;
|
|
171
|
+
const antiDisplay = hasAnti ?
|
|
172
|
+
(antiReversed ? model.antisense!.monomers.slice().reverse() : model.antisense!.monomers) :
|
|
173
|
+
[];
|
|
174
|
+
let widths = computePairSyncedWidths(senseDisplay, antiDisplay, chipW, fontSize);
|
|
175
|
+
// Falls back to uniform chipW if either strand's chips don't fit.
|
|
176
|
+
const widthBudget = seqEndX - (seqX + alignAt);
|
|
177
|
+
if (!fitsInBudget(widths.sense, senseLeadW, chipGap, widthBudget) ||
|
|
178
|
+
!fitsInBudget(widths.anti, antiLeadW, chipGap, widthBudget)) {
|
|
179
|
+
widths = uniformWidths(senseDisplay, antiDisplay, chipW, fontSize);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const senseRes = placeStrand(
|
|
183
|
+
model.sense, false, senseY, senseStartX, seqEndX, layoutBase, 'sense', widths.sense);
|
|
184
|
+
const antiRes = hasAnti ? placeStrand(
|
|
185
|
+
model.antisense!, antiReversed, antiY, antiStartX, seqEndX, layoutBase, 'antisense', widths.anti,
|
|
186
|
+
) : {chips: [], links: []};
|
|
166
187
|
|
|
167
188
|
return {
|
|
168
189
|
...layoutBase,
|
|
@@ -174,11 +195,14 @@ export function computeLayout(
|
|
|
174
195
|
};
|
|
175
196
|
}
|
|
176
197
|
|
|
177
|
-
/** Place chips for one strand, optionally reversed. Returns chip and linkage positions.
|
|
198
|
+
/** Place chips for one strand, optionally reversed. Returns chip and linkage positions.
|
|
199
|
+
* `chipWidths` is per-display-monomer (i.e. matches the order of `strand.monomers` after
|
|
200
|
+
* reversal if `reverse=true`). */
|
|
178
201
|
function placeStrand(
|
|
179
202
|
strand: ParsedStrand, reverse: boolean, y: number, startX: number, endX: number,
|
|
180
203
|
layout: Omit<DuplexLayout, 'senseChips' | 'antiChips' | 'senseLinks' | 'antiLinks' | 'antiReversed'>,
|
|
181
204
|
side: StrandSide,
|
|
205
|
+
chipWidths: number[],
|
|
182
206
|
): { chips: ChipPos[]; links: LinkagePos[] } {
|
|
183
207
|
const monomers = reverse ? strand.monomers.slice().reverse() : strand.monomers;
|
|
184
208
|
const chips: ChipPos[] = [];
|
|
@@ -187,9 +211,7 @@ function placeStrand(
|
|
|
187
211
|
|
|
188
212
|
for (let i = 0; i < monomers.length; i++) {
|
|
189
213
|
const m = monomers[i];
|
|
190
|
-
const w =
|
|
191
|
-
estimateConjugateWidth(m.symbol, layout.chipW, layout.fontSize) :
|
|
192
|
-
layout.chipW;
|
|
214
|
+
const w = chipWidths[i] ?? layout.chipW;
|
|
193
215
|
|
|
194
216
|
if (x + w > endX) break; // truncate at cell edge
|
|
195
217
|
|
|
@@ -241,6 +263,66 @@ function leadingConjugateWidth(
|
|
|
241
263
|
return w;
|
|
242
264
|
}
|
|
243
265
|
|
|
266
|
+
/** Desired width for one monomer if we render its base label in full (no
|
|
267
|
+
* ellipsis). Conjugates get their pill width. Single/two-char bases just use
|
|
268
|
+
* `chipW`; multi-char bases widen to fit the full label, capped at 3× chipW. */
|
|
269
|
+
function desiredChipWidth(m: ParsedMonomer, chipW: number, fontSize: number): number {
|
|
270
|
+
if (m.kind === 'conjugate') return estimateConjugateWidth(m.symbol, chipW, fontSize);
|
|
271
|
+
const base = (m as ParsedNucleotide).base ?? '';
|
|
272
|
+
if (base.length <= 2) return chipW;
|
|
273
|
+
// Empirical: glyph width ≈ fontSize * 0.55 for system-ui at our weights.
|
|
274
|
+
const charW = fontSize * 0.55;
|
|
275
|
+
const textW = base.length * charW;
|
|
276
|
+
const padding = chipW * 0.4;
|
|
277
|
+
return Math.max(chipW, Math.min(chipW * 3, textW + padding));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
interface SyncedWidths { sense: number[]; anti: number[]; }
|
|
281
|
+
|
|
282
|
+
/** For each strand returns a per-chip width array; when both strands have a
|
|
283
|
+
* chip at the same pair-index (counted past leading conjugates), the column
|
|
284
|
+
* width is `max(senseDesired, antiDesired)` so pair-aligned positions stay
|
|
285
|
+
* column-locked even when one side has a long base name. */
|
|
286
|
+
function computePairSyncedWidths(
|
|
287
|
+
senseDisplay: ParsedMonomer[], antiDisplay: ParsedMonomer[], chipW: number, fontSize: number,
|
|
288
|
+
): SyncedWidths {
|
|
289
|
+
const senseW = senseDisplay.map((m) => desiredChipWidth(m, chipW, fontSize));
|
|
290
|
+
const antiW = antiDisplay.map((m) => desiredChipWidth(m, chipW, fontSize));
|
|
291
|
+
|
|
292
|
+
// Strip leading conjugates: the first non-conjugate in display order.
|
|
293
|
+
const senseStart = senseDisplay.findIndex((m) => m.kind === 'nucleotide');
|
|
294
|
+
const antiStart = antiDisplay.findIndex((m) => m.kind === 'nucleotide');
|
|
295
|
+
if (senseStart >= 0 && antiStart >= 0) {
|
|
296
|
+
const pairLen = Math.min(senseDisplay.length - senseStart, antiDisplay.length - antiStart);
|
|
297
|
+
for (let i = 0; i < pairLen; i++) {
|
|
298
|
+
const si = senseStart + i;
|
|
299
|
+
const ai = antiStart + i;
|
|
300
|
+
const w = Math.max(senseW[si], antiW[ai]);
|
|
301
|
+
senseW[si] = w; antiW[ai] = w;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return {sense: senseW, anti: antiW};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Uniform-width fallback (every chip = chipW; conjugates still pill-wide). */
|
|
308
|
+
function uniformWidths(
|
|
309
|
+
senseDisplay: ParsedMonomer[], antiDisplay: ParsedMonomer[], chipW: number, fontSize: number,
|
|
310
|
+
): SyncedWidths {
|
|
311
|
+
const map = (m: ParsedMonomer) => m.kind === 'conjugate' ?
|
|
312
|
+
estimateConjugateWidth(m.symbol, chipW, fontSize) : chipW;
|
|
313
|
+
return {sense: senseDisplay.map(map), anti: antiDisplay.map(map)};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Whether the per-chip widths array fits in `budget` (after the leading shift). */
|
|
317
|
+
function fitsInBudget(widths: number[], leadW: number, chipGap: number, budget: number): boolean {
|
|
318
|
+
let total = leadW;
|
|
319
|
+
for (let i = 0; i < widths.length; i++) {
|
|
320
|
+
total += widths[i];
|
|
321
|
+
if (i < widths.length - 1) total += chipGap;
|
|
322
|
+
}
|
|
323
|
+
return total <= budget;
|
|
324
|
+
}
|
|
325
|
+
|
|
244
326
|
/* ---------------------------------------------------------------- *
|
|
245
327
|
* Drawing
|
|
246
328
|
* ---------------------------------------------------------------- */
|
|
@@ -248,11 +330,11 @@ function leadingConjugateWidth(
|
|
|
248
330
|
export function drawDuplex(
|
|
249
331
|
g: CanvasRenderingContext2D, cellX: number, cellY: number,
|
|
250
332
|
cellW: number, cellH: number, model: ParsedDuplex,
|
|
251
|
-
opts: Partial<RenderOpts> = {},
|
|
333
|
+
opts: Partial<RenderOpts> = {}, skipDrawing = false,
|
|
252
334
|
): DuplexLayout {
|
|
253
335
|
const o: RenderOpts = {...DEFAULT_OPTS, ...opts};
|
|
254
336
|
const layout = computeLayout(cellW, cellH, model, o);
|
|
255
|
-
|
|
337
|
+
if (skipDrawing) return layout;
|
|
256
338
|
g.save();
|
|
257
339
|
g.beginPath();
|
|
258
340
|
g.rect(cellX, cellY, cellW, cellH);
|
|
@@ -324,11 +406,11 @@ function drawChip(
|
|
|
324
406
|
w: number, h: number, fontSize: number, opts: RenderOpts,
|
|
325
407
|
): void {
|
|
326
408
|
const sugarRes = resolveSugar(m.sugar, m.base);
|
|
327
|
-
const baseColor =
|
|
409
|
+
const baseColor = resolveBaseColor(m.base);
|
|
328
410
|
const isModSugar = isModifiedSugar(m.sugar);
|
|
329
411
|
const r = Math.min(2.5, w / 4);
|
|
330
412
|
|
|
331
|
-
// Chip body — base-canonical pale color
|
|
413
|
+
// Chip body — base-canonical pale color (or analog's color for custom bases)
|
|
332
414
|
drawRoundRect(g, x, y, w, h, r);
|
|
333
415
|
g.fillStyle = withAlpha(baseColor, CHIP_FILL_ALPHA);
|
|
334
416
|
g.fill();
|
|
@@ -347,17 +429,45 @@ function drawChip(
|
|
|
347
429
|
g.restore();
|
|
348
430
|
}
|
|
349
431
|
|
|
350
|
-
// Base
|
|
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.
|
|
351
437
|
if (opts.showLetters && m.base && fontSize >= 8) {
|
|
352
438
|
const stripeH = isModSugar ? Math.max(2, h * SUGAR_STRIPE_RATIO) : 0;
|
|
439
|
+
const label = pickBaseLabel(m.base, w, fontSize);
|
|
353
440
|
g.fillStyle = '#1a1a1a';
|
|
354
441
|
g.font = `600 ${fontSize}px system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif`;
|
|
355
442
|
g.textBaseline = 'middle';
|
|
356
443
|
g.textAlign = 'center';
|
|
357
|
-
g.fillText(
|
|
444
|
+
g.fillText(label, x + w / 2, y + (h - stripeH) / 2 + 0.5);
|
|
358
445
|
}
|
|
359
446
|
}
|
|
360
447
|
|
|
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. */
|
|
452
|
+
function resolveBaseColor(base: string | null): string {
|
|
453
|
+
if (!base) return FALLBACK_COLOR;
|
|
454
|
+
if (BASE_COLORS[base]) return BASE_COLORS[base];
|
|
455
|
+
if (isCanonicalBase(base)) return BASE_COLORS[base] ?? FALLBACK_COLOR;
|
|
456
|
+
const analog = getNaturalAnalog(base);
|
|
457
|
+
if (analog && BASE_COLORS[analog]) return BASE_COLORS[analog];
|
|
458
|
+
return FALLBACK_COLOR;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Decide the on-chip label given the chip's actual width: full base symbol
|
|
462
|
+
* if it fits, else `firstLetter + …`. Single/two-char bases always show fully. */
|
|
463
|
+
function pickBaseLabel(base: string, chipW: number, fontSize: number): string {
|
|
464
|
+
if (base.length <= 2) return base;
|
|
465
|
+
const charW = fontSize * 0.55;
|
|
466
|
+
const fullW = base.length * charW + 4; // small horizontal padding
|
|
467
|
+
if (fullW <= chipW) return base;
|
|
468
|
+
return displayBase(base);
|
|
469
|
+
}
|
|
470
|
+
|
|
361
471
|
function isModifiedSugar(sugar: string): boolean {
|
|
362
472
|
const c = canonicalSugarSymbol(sugar);
|
|
363
473
|
return c !== 'r' && c !== 'd';
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import * as DG from 'datagrok-api/dg';
|
|
15
15
|
import * as ui from 'datagrok-api/ui';
|
|
16
16
|
|
|
17
|
-
import {drawDuplex, hitTest, DuplexLayout} from './canvas-renderer';
|
|
17
|
+
import {drawDuplex, hitTest, DuplexLayout, RenderOpts, DEFAULT_OPTS} from './canvas-renderer';
|
|
18
18
|
import {looksLikeHelm, parseHelmDuplex} from './helm-parser';
|
|
19
19
|
import {ParsedDuplex} from './types';
|
|
20
20
|
import {showMonomerTooltip} from './tooltip';
|
|
@@ -23,9 +23,9 @@ const CELL_TYPE = 'OligoNucleotide';
|
|
|
23
23
|
|
|
24
24
|
export class OligoNucleotideCellRenderer extends DG.GridCellRenderer {
|
|
25
25
|
/** WeakMap-by-value cache of parsed HELM. Avoids reparsing on redraw. */
|
|
26
|
-
private modelCache = new
|
|
26
|
+
private modelCache = new DG.LruCache<string, ParsedDuplex>();
|
|
27
27
|
/** Last-rendered layout per cell key, for hit-testing on subsequent moves. */
|
|
28
|
-
private layoutCache = new
|
|
28
|
+
private layoutCache = new DG.LruCache<string, DuplexLayout>();
|
|
29
29
|
|
|
30
30
|
get name(): string { return CELL_TYPE; }
|
|
31
31
|
get cellType(): string { return CELL_TYPE; }
|
|
@@ -54,11 +54,16 @@ export class OligoNucleotideCellRenderer extends DG.GridCellRenderer {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
const model = this.getOrParse(value);
|
|
57
|
+
if (!gridCell.cell?.dart || !gridCell.cell.column) {
|
|
58
|
+
w = g.canvas.width;
|
|
59
|
+
h = g.canvas.height;
|
|
60
|
+
x = 0;
|
|
61
|
+
y = 0;
|
|
62
|
+
// this happens in forms....
|
|
63
|
+
}
|
|
57
64
|
const layout = drawDuplex(g, x, y, w, h, model);
|
|
58
65
|
// Cache for hit-test on subsequent mouse moves over the same cell.
|
|
59
|
-
this.layoutCache.set(this.cellKey(
|
|
60
|
-
// Avoid unbounded growth on big tables.
|
|
61
|
-
if (this.layoutCache.size > 5000) this.layoutCache.clear();
|
|
66
|
+
this.layoutCache.set(this.cellKey(value, w, h, DEFAULT_OPTS), layout);
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
override onMouseMove(gridCell: DG.GridCell, e: MouseEvent): void {
|
|
@@ -68,7 +73,11 @@ export class OligoNucleotideCellRenderer extends DG.GridCellRenderer {
|
|
|
68
73
|
return;
|
|
69
74
|
}
|
|
70
75
|
const model = this.getOrParse(value);
|
|
71
|
-
const layout = this.layoutCache.
|
|
76
|
+
const layout = this.layoutCache.getOrCreate(
|
|
77
|
+
this.cellKey(value, gridCell.bounds.width, gridCell.bounds.height, DEFAULT_OPTS), (v) => drawDuplex(
|
|
78
|
+
null as unknown as CanvasRenderingContext2D, 0, 0, gridCell.bounds.width, gridCell.bounds.height,
|
|
79
|
+
model, DEFAULT_OPTS, true,
|
|
80
|
+
));
|
|
72
81
|
if (!layout) return;
|
|
73
82
|
|
|
74
83
|
const bounds = gridCell.bounds;
|
|
@@ -88,24 +97,14 @@ export class OligoNucleotideCellRenderer extends DG.GridCellRenderer {
|
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
private getOrParse(helm: string): ParsedDuplex {
|
|
91
|
-
|
|
92
|
-
if (!m) {
|
|
93
|
-
m = parseHelmDuplex(helm);
|
|
94
|
-
// Cap cache to avoid unbounded growth on huge tables.
|
|
95
|
-
if (this.modelCache.size > 5000) this.modelCache.clear();
|
|
96
|
-
this.modelCache.set(helm, m);
|
|
97
|
-
}
|
|
98
|
-
return m;
|
|
100
|
+
return this.modelCache.getOrCreate(helm, (h) => parseHelmDuplex(h));
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
/** Cache key for a cell's layout. Includes the column's `version` so any
|
|
102
104
|
* edit to the column (which bumps version) orphans previous cache entries
|
|
103
105
|
* — preventing onMouseMove from hit-testing a stale layout that was cached
|
|
104
106
|
* before the edit and not yet replaced by a fresh render(). */
|
|
105
|
-
private cellKey(
|
|
106
|
-
|
|
107
|
-
const colName = col?.name ?? gridCell.gridColumn?.name ?? '?';
|
|
108
|
-
const ver = col?.version ?? 0;
|
|
109
|
-
return `${colName}@${ver}::${gridCell.tableRowIndex ?? -1}`;
|
|
107
|
+
private cellKey(value: string, width: number, height: number, opts: RenderOpts): string {
|
|
108
|
+
return `${value}::${Math.floor(width)}x${Math.floor(height)}::${JSON.stringify(opts)}`;
|
|
110
109
|
}
|
|
111
110
|
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* (see Apr 2026 commits around RNA triplet splitting)
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import {cleanupHelmSymbol} from '@datagrok-libraries/bio/src/helm/utils';
|
|
16
17
|
import {
|
|
17
18
|
canonicalPhosphateSymbol, canonicalSugarSymbol,
|
|
18
19
|
ParsedConjugate, ParsedDuplex, ParsedMonomer, ParsedNucleotide, ParsedStrand,
|
|
@@ -84,7 +85,7 @@ function parseMonomer(s: string, position: number): ParsedMonomer {
|
|
|
84
85
|
// base in parens (optional)
|
|
85
86
|
if (s[i] === '(') {
|
|
86
87
|
const end = s.indexOf(')', i);
|
|
87
|
-
base = s.substring(i + 1, end);
|
|
88
|
+
base = cleanupHelmSymbol(s.substring(i + 1, end));
|
|
88
89
|
i = end + 1;
|
|
89
90
|
}
|
|
90
91
|
|
|
@@ -131,7 +132,7 @@ function serializeCanonicalMonomer(m: ParsedMonomer): string {
|
|
|
131
132
|
const phos = canonicalPhosphateSymbol(nt.phosphate);
|
|
132
133
|
const sugarPart = sugar.length === 1 ? sugar : `[${sugar}]`;
|
|
133
134
|
const phosPart = !phos ? '' : (phos.length === 1 ? phos : `[${phos}]`);
|
|
134
|
-
const baseStr = nt.base ? `(${nt.base})` :
|
|
135
|
+
const baseStr = !nt.base ? '' : (nt.base.length === 1 ? `(${nt.base})` : `([${nt.base}])`);
|
|
135
136
|
return `${sugarPart}${baseStr}${phosPart}`;
|
|
136
137
|
}
|
|
137
138
|
|
|
@@ -14,9 +14,12 @@ import * as ui from 'datagrok-api/ui';
|
|
|
14
14
|
|
|
15
15
|
import {parseHelmDuplex} from './helm-parser';
|
|
16
16
|
import {
|
|
17
|
+
BASE_COLORS, FALLBACK_COLOR,
|
|
17
18
|
canonicalPhosphateSymbol, canonicalSugarSymbol,
|
|
19
|
+
isCanonicalBase,
|
|
18
20
|
ParsedNucleotide, resolveConjugate, resolvePhosphate, resolveSugar,
|
|
19
21
|
} from './types';
|
|
22
|
+
import {getNaturalAnalog} from './analog-cache';
|
|
20
23
|
|
|
21
24
|
export function buildOligoPanel(value: DG.SemanticValue): DG.Widget {
|
|
22
25
|
const helm: string = value.value ?? '';
|
|
@@ -29,12 +32,16 @@ export function buildOligoPanel(value: DG.SemanticValue): DG.Widget {
|
|
|
29
32
|
const sugarCounts = new Map<string, number>();
|
|
30
33
|
const phosCounts = new Map<string, number>();
|
|
31
34
|
const conjCounts = new Map<string, number>();
|
|
35
|
+
/** Custom (non-canonical) base symbols, e.g. `cpm6A`, `5BrU`, `psiU`. */
|
|
36
|
+
const baseCounts = new Map<string, number>();
|
|
32
37
|
const allMonomers = [...model.sense.monomers, ...(model.antisense?.monomers ?? [])];
|
|
33
38
|
for (const m of allMonomers) {
|
|
34
39
|
if (m.kind === 'nucleotide') {
|
|
35
40
|
const nt = m as ParsedNucleotide;
|
|
36
41
|
bump(sugarCounts, nt.sugar);
|
|
37
42
|
if (nt.phosphate) bump(phosCounts, nt.phosphate);
|
|
43
|
+
if (nt.base && !isCanonicalBase(nt.base))
|
|
44
|
+
bump(baseCounts, nt.base);
|
|
38
45
|
} else {
|
|
39
46
|
bump(conjCounts, m.symbol);
|
|
40
47
|
}
|
|
@@ -46,7 +53,7 @@ export function buildOligoPanel(value: DG.SemanticValue): DG.Widget {
|
|
|
46
53
|
root.appendChild(section('Summary', ui.tableFromMap({
|
|
47
54
|
'Sense length': `${sLen} nt`,
|
|
48
55
|
'Antisense length': aLen ? `${aLen} nt` : 'single-strand',
|
|
49
|
-
'Modifications used': humanizeModSet(sugarCounts, phosCounts),
|
|
56
|
+
'Modifications used': humanizeModSet(sugarCounts, phosCounts, baseCounts),
|
|
50
57
|
'Conjugates': conjCounts.size ?
|
|
51
58
|
Array.from(conjCounts.entries()).map(([s, n]) => `${resolveConjugate(s).meta.name} ×${n}`).join(', ') :
|
|
52
59
|
'—',
|
|
@@ -55,7 +62,7 @@ export function buildOligoPanel(value: DG.SemanticValue): DG.Widget {
|
|
|
55
62
|
// Legend — only modifications actually present in this cell.
|
|
56
63
|
// Note: there's no "Copy" section here — the platform already adds a
|
|
57
64
|
// default "Actions | Copy value" entry for any cell.
|
|
58
|
-
root.appendChild(section('Legend', buildCellLegend(sugarCounts, phosCounts, conjCounts)));
|
|
65
|
+
root.appendChild(section('Legend', buildCellLegend(sugarCounts, phosCounts, conjCounts, baseCounts)));
|
|
59
66
|
|
|
60
67
|
return DG.Widget.fromRoot(root);
|
|
61
68
|
}
|
|
@@ -64,7 +71,9 @@ function bump(map: Map<string, number>, key: string): void {
|
|
|
64
71
|
map.set(key, (map.get(key) ?? 0) + 1);
|
|
65
72
|
}
|
|
66
73
|
|
|
67
|
-
function humanizeModSet(
|
|
74
|
+
function humanizeModSet(
|
|
75
|
+
sugars: Map<string, number>, phos: Map<string, number>, bases: Map<string, number>,
|
|
76
|
+
): string {
|
|
68
77
|
const parts: string[] = [];
|
|
69
78
|
// Aggregate by canonical name so legacy + canonical symbols collapse correctly
|
|
70
79
|
const sugarByName = new Map<string, number>();
|
|
@@ -83,6 +92,9 @@ function humanizeModSet(sugars: Map<string, number>, phos: Map<string, number>):
|
|
|
83
92
|
}
|
|
84
93
|
for (const [name, n] of phosByName.entries()) parts.push(`${name} ×${n}`);
|
|
85
94
|
|
|
95
|
+
// Custom bases — list each by its raw HELM symbol
|
|
96
|
+
for (const [sym, n] of bases.entries()) parts.push(`${sym} ×${n}`);
|
|
97
|
+
|
|
86
98
|
return parts.length ? parts.join(', ') : 'unmodified';
|
|
87
99
|
}
|
|
88
100
|
|
|
@@ -98,14 +110,17 @@ function section(title: string, body: HTMLElement): HTMLElement {
|
|
|
98
110
|
|
|
99
111
|
/** Build a legend filtered to modifications actually present in this cell.
|
|
100
112
|
* One row per unique mod, with the count to the right. Items collapse by
|
|
101
|
-
* canonical symbol so legacy/canonical aliases share a row.
|
|
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. */
|
|
102
116
|
function buildCellLegend(
|
|
103
|
-
sugars: Map<string, number>, phos: Map<string, number>,
|
|
117
|
+
sugars: Map<string, number>, phos: Map<string, number>,
|
|
118
|
+
conjs: Map<string, number>, bases: Map<string, number>,
|
|
104
119
|
): HTMLElement {
|
|
105
120
|
type Item = { label: string; color: string; count: number };
|
|
106
121
|
const items: Item[] = [];
|
|
107
122
|
|
|
108
|
-
//
|
|
123
|
+
// Sugar mods — collapse by canonical symbol so e.g. mR + m → one row
|
|
109
124
|
const sugarByCanon = new Map<string, number>();
|
|
110
125
|
for (const [sym, n] of sugars.entries()) {
|
|
111
126
|
const c = canonicalSugarSymbol(sym);
|
|
@@ -117,6 +132,7 @@ function buildCellLegend(
|
|
|
117
132
|
items.push({label: meta.name, color: meta.color, count: n});
|
|
118
133
|
}
|
|
119
134
|
|
|
135
|
+
// Phosphate / linkage mods
|
|
120
136
|
const phosByCanon = new Map<string, number>();
|
|
121
137
|
for (const [sym, n] of phos.entries()) {
|
|
122
138
|
const c = canonicalPhosphateSymbol(sym);
|
|
@@ -128,6 +144,15 @@ function buildCellLegend(
|
|
|
128
144
|
items.push({label: `${meta.name} (linkage)`, color: meta.color, count: n});
|
|
129
145
|
}
|
|
130
146
|
|
|
147
|
+
// Custom bases — color via natural analog from the central Bio lib (sync).
|
|
148
|
+
for (const [sym, n] of bases.entries()) {
|
|
149
|
+
const analog = getNaturalAnalog(sym);
|
|
150
|
+
const color = (analog && BASE_COLORS[analog]) ? BASE_COLORS[analog] : FALLBACK_COLOR;
|
|
151
|
+
const label = analog ? `${sym} (base, analog ${analog})` : `${sym} (base)`;
|
|
152
|
+
items.push({label, color, count: n});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Conjugates
|
|
131
156
|
for (const [sym, n] of conjs.entries()) {
|
|
132
157
|
const meta = resolveConjugate(sym).meta;
|
|
133
158
|
items.push({label: meta.name, color: meta.color, count: n});
|
|
@@ -148,8 +148,29 @@ export const BASE_COLORS: Readonly<Record<string, string>> = Object.freeze({
|
|
|
148
148
|
T: '#F0C5C5',
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
+
/** The canonical single-letter base symbols. Anything else is a custom /
|
|
152
|
+
* modified base (e.g. `cpm6A`, `5BrU`, `psiU`) and gets its color via natural
|
|
153
|
+
* analog lookup against the central Bio monomer library. */
|
|
154
|
+
export const CANONICAL_BASES: Readonly<Set<string>> = new Set(['A', 'C', 'G', 'U', 'T']);
|
|
155
|
+
|
|
151
156
|
export const FALLBACK_COLOR = '#BCBCBC';
|
|
152
157
|
|
|
158
|
+
/** True if `base` is one of the canonical single-letter symbols. */
|
|
159
|
+
export function isCanonicalBase(base: string | null | undefined): boolean {
|
|
160
|
+
return !!base && CANONICAL_BASES.has(base);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Shorten a multi-character base symbol to a chip-friendly label.
|
|
164
|
+
* - 1-2 char symbols: returned as-is
|
|
165
|
+
* - 3+ char symbols: first letter + ellipsis (e.g. `cpm6A` → `c…`)
|
|
166
|
+
* The renderer also has a width-aware path that shows the full symbol when
|
|
167
|
+
* the chip can fit it; this helper is the safe fallback. */
|
|
168
|
+
export function displayBase(base: string | null): string {
|
|
169
|
+
if (!base) return '';
|
|
170
|
+
if (base.length <= 2) return base;
|
|
171
|
+
return base[0] + '…';
|
|
172
|
+
}
|
|
173
|
+
|
|
153
174
|
/** Deterministic color for unknown HELM monomer symbols. Stable across cells. */
|
|
154
175
|
export function hashColor(symbol: string): string {
|
|
155
176
|
let h = 0;
|
package/src/package-api.ts
CHANGED
|
@@ -162,6 +162,13 @@ export namespace funcs {
|
|
|
162
162
|
return await grok.functions.call('SequenceTranslator:OligoNucleotideCellRenderer', {});
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
OligoNucleotide
|
|
167
|
+
*/
|
|
168
|
+
export async function editOligoNucleotideCell(cell: any ): Promise<void> {
|
|
169
|
+
return await grok.functions.call('SequenceTranslator:EditOligoNucleotideCell', { cell });
|
|
170
|
+
}
|
|
171
|
+
|
|
165
172
|
/**
|
|
166
173
|
Modifications, lengths, conjugates and color legend for an OligoNucleotide cell
|
|
167
174
|
*/
|
package/src/package-test.ts
CHANGED
|
@@ -18,6 +18,7 @@ import './tests/polytool-chain-parse-notation-tests';
|
|
|
18
18
|
import './tests/polytool-chain-from-notation-tests';
|
|
19
19
|
import './tests/toAtomicLevel-tests';
|
|
20
20
|
import './tests/oligo-renderer-tests';
|
|
21
|
+
import './tests/oligo-cell-editor-tests';
|
|
21
22
|
|
|
22
23
|
import {OligoToolkitTestPackage} from './tests/utils';
|
|
23
24
|
|
package/src/package.g.ts
CHANGED
|
@@ -239,6 +239,14 @@ export function oligoNucleotideCellRenderer() : any {
|
|
|
239
239
|
return PackageFunctions.oligoNucleotideCellRenderer();
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
//description: OligoNucleotide
|
|
243
|
+
//tags: cellEditor
|
|
244
|
+
//input: grid_cell cell
|
|
245
|
+
//meta.role: cellEditor
|
|
246
|
+
export async function editOligoNucleotideCell(cell: any) : Promise<void> {
|
|
247
|
+
await PackageFunctions.editOligoNucleotideCell(cell);
|
|
248
|
+
}
|
|
249
|
+
|
|
242
250
|
//name: Oligo-Nucleotide
|
|
243
251
|
//description: Modifications, lengths, conjugates and color legend for an OligoNucleotide cell
|
|
244
252
|
//tags: panel, widgets
|
package/src/package.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {CyclizedNotationProvider} from './utils/cyclized';
|
|
|
32
32
|
import {getSeqHelper} from '@datagrok-libraries/bio/src/utils/seq-helper';
|
|
33
33
|
import {PolyToolDataRole, PolyToolTags} from './consts';
|
|
34
34
|
import {getHelmHelper} from '@datagrok-libraries/bio/src/helm/helm-helper';
|
|
35
|
+
import {getMonomerLibHelper} from '@datagrok-libraries/bio/src/types/monomer-library';
|
|
35
36
|
import {getPTCombineDialog} from './polytool/pt-combine-dialog';
|
|
36
37
|
import {PolyToolEnumeratorTypes} from './polytool/types';
|
|
37
38
|
import {splitterAsHelm} from '@datagrok-libraries/bio/src/utils/macromolecule';
|
|
@@ -74,10 +75,11 @@ export const _package: OligoToolkitPackage = new OligoToolkitPackage({debug: tru
|
|
|
74
75
|
let initSequenceTranslatorPromise: Promise<void> | null = null;
|
|
75
76
|
|
|
76
77
|
async function initSequenceTranslatorInt(): Promise<void> {
|
|
77
|
-
const [helmHelper] = await Promise.all([
|
|
78
|
+
const [helmHelper, bioLibHelper] = await Promise.all([
|
|
78
79
|
getHelmHelper(),
|
|
80
|
+
getMonomerLibHelper(),
|
|
79
81
|
]);
|
|
80
|
-
_package.completeInit(helmHelper);
|
|
82
|
+
_package.completeInit(helmHelper, bioLibHelper.getMonomerLib());
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
export class PackageFunctions {
|
|
@@ -448,6 +450,34 @@ export class PackageFunctions {
|
|
|
448
450
|
return new OligoNucleotideCellRenderer();
|
|
449
451
|
}
|
|
450
452
|
|
|
453
|
+
@grok.decorators.func({
|
|
454
|
+
name: 'editOligoNucleotideCell',
|
|
455
|
+
description: 'OligoNucleotide',
|
|
456
|
+
tags: ['cellEditor'],
|
|
457
|
+
meta: {
|
|
458
|
+
role: 'cellEditor',
|
|
459
|
+
},
|
|
460
|
+
})
|
|
461
|
+
static async editOligoNucleotideCell(
|
|
462
|
+
@grok.decorators.param({type: 'grid_cell'}) cell: DG.GridCell,
|
|
463
|
+
): Promise<void> {
|
|
464
|
+
// Helm:editMoleculeCell can't be reused: it calls seqHelper.getSeqHandler(col),
|
|
465
|
+
// which throws unless semType === Macromolecule. OligoNucleotide columns have
|
|
466
|
+
// semType=OligoNucleotide, so we open the HELM Web Editor directly.
|
|
467
|
+
const helmHelper = await getHelmHelper();
|
|
468
|
+
const view = ui.div();
|
|
469
|
+
const app = helmHelper.createWebEditorApp(view, (cell.cell.value as string | null) ?? '');
|
|
470
|
+
ui.dialog({showHeader: false, showFooter: true})
|
|
471
|
+
.add(view)
|
|
472
|
+
.onOK(() => {
|
|
473
|
+
const helmValue = app.canvas!.getHelm(true)
|
|
474
|
+
.replace(/<\/span>/g, '')
|
|
475
|
+
.replace(/<span style='background:#bbf;'>/g, '');
|
|
476
|
+
cell.setValue(helmValue);
|
|
477
|
+
})
|
|
478
|
+
.show({modal: true, fullScreen: true});
|
|
479
|
+
}
|
|
480
|
+
|
|
451
481
|
@grok.decorators.func({
|
|
452
482
|
name: 'Oligo-Nucleotide',
|
|
453
483
|
description: 'Modifications, lengths, conjugates and color legend for an OligoNucleotide cell',
|