@datagrok/sequence-translator 1.10.16 → 1.10.18
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 +274 -253
- package/CREDITS.md +236 -0
- package/detectors.js +8 -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 +3 -3
- 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 +129 -15
- package/src/oligo-renderer/cell-renderer.ts +8 -2
- 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 +11 -0
- package/src/package-test.ts +1 -0
- package/src/package.g.ts +14 -0
- package/src/package.ts +60 -2
- package/src/polytool/pt-chem-enum-dialog.ts +50 -10
- package/src/polytool/pt-chem-enum.ts +76 -7
- package/src/polytool/pt-enumerate-seq-dialog.ts +18 -4
- 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 +196 -147
- package/test-record-1.mp4 +0 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@datagrok/sequence-translator",
|
|
3
3
|
"friendlyName": "Sequence Translator",
|
|
4
|
-
"version": "1.10.
|
|
4
|
+
"version": "1.10.18",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Davit Rizhinashvili",
|
|
7
7
|
"email": "drizhinashvili@datagrok.ai"
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
}
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@datagrok-libraries/bio": "^5.65.
|
|
26
|
-
"@datagrok-libraries/chem-meta": "^1.2.
|
|
25
|
+
"@datagrok-libraries/bio": "^5.65.1",
|
|
26
|
+
"@datagrok-libraries/chem-meta": "^1.2.12",
|
|
27
27
|
"@datagrok-libraries/tutorials": "^1.6.1",
|
|
28
28
|
"@datagrok-libraries/utils": "^4.6.5",
|
|
29
29
|
"@types/react": "^18.0.15",
|
|
@@ -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
|
+
}
|
|
@@ -25,10 +25,12 @@
|
|
|
25
25
|
|
|
26
26
|
import {
|
|
27
27
|
BASE_COLORS, FALLBACK_COLOR,
|
|
28
|
-
canonicalSugarSymbol,
|
|
28
|
+
canonicalPhosphateSymbol, canonicalSugarSymbol,
|
|
29
|
+
displayBase, isCanonicalBase,
|
|
29
30
|
ParsedDuplex, ParsedMonomer, ParsedNucleotide, ParsedStrand,
|
|
30
31
|
resolveConjugate, resolvePhosphate, resolveSugar,
|
|
31
32
|
} from './types';
|
|
33
|
+
import {getNaturalAnalog} from './analog-cache';
|
|
32
34
|
|
|
33
35
|
export interface RenderOpts {
|
|
34
36
|
/** Show base letter inside chip. False at very small sizes. */
|
|
@@ -159,10 +161,28 @@ export function computeLayout(
|
|
|
159
161
|
const senseStartX = seqX + (alignAt - senseLeadW);
|
|
160
162
|
const antiStartX = seqX + (alignAt - antiLeadW);
|
|
161
163
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
// Per-chip widths: nucleotides with multi-char bases (e.g. `cpm6A`) want a
|
|
165
|
+
// wider chip so the full symbol fits. We try the wide layout first; if the
|
|
166
|
+
// total doesn't fit in the cell, we fall back to uniform chipW + ellipsis.
|
|
167
|
+
// For pair alignment, columns sync across strands (pair-aligned column
|
|
168
|
+
// takes the max width of the two strands' chips at that pair-index).
|
|
169
|
+
const senseDisplay = model.sense.monomers;
|
|
170
|
+
const antiDisplay = hasAnti ?
|
|
171
|
+
(antiReversed ? model.antisense!.monomers.slice().reverse() : model.antisense!.monomers) :
|
|
172
|
+
[];
|
|
173
|
+
let widths = computePairSyncedWidths(senseDisplay, antiDisplay, chipW, fontSize);
|
|
174
|
+
// Falls back to uniform chipW if either strand's chips don't fit.
|
|
175
|
+
const widthBudget = seqEndX - (seqX + alignAt);
|
|
176
|
+
if (!fitsInBudget(widths.sense, senseLeadW, chipGap, widthBudget) ||
|
|
177
|
+
!fitsInBudget(widths.anti, antiLeadW, chipGap, widthBudget)) {
|
|
178
|
+
widths = uniformWidths(senseDisplay, antiDisplay, chipW, fontSize);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const senseRes = placeStrand(
|
|
182
|
+
model.sense, false, senseY, senseStartX, seqEndX, layoutBase, 'sense', widths.sense);
|
|
183
|
+
const antiRes = hasAnti ? placeStrand(
|
|
184
|
+
model.antisense!, antiReversed, antiY, antiStartX, seqEndX, layoutBase, 'antisense', widths.anti,
|
|
185
|
+
) : {chips: [], links: []};
|
|
166
186
|
|
|
167
187
|
return {
|
|
168
188
|
...layoutBase,
|
|
@@ -174,11 +194,14 @@ export function computeLayout(
|
|
|
174
194
|
};
|
|
175
195
|
}
|
|
176
196
|
|
|
177
|
-
/** Place chips for one strand, optionally reversed. Returns chip and linkage positions.
|
|
197
|
+
/** Place chips for one strand, optionally reversed. Returns chip and linkage positions.
|
|
198
|
+
* `chipWidths` is per-display-monomer (i.e. matches the order of `strand.monomers` after
|
|
199
|
+
* reversal if `reverse=true`). */
|
|
178
200
|
function placeStrand(
|
|
179
201
|
strand: ParsedStrand, reverse: boolean, y: number, startX: number, endX: number,
|
|
180
202
|
layout: Omit<DuplexLayout, 'senseChips' | 'antiChips' | 'senseLinks' | 'antiLinks' | 'antiReversed'>,
|
|
181
203
|
side: StrandSide,
|
|
204
|
+
chipWidths: number[],
|
|
182
205
|
): { chips: ChipPos[]; links: LinkagePos[] } {
|
|
183
206
|
const monomers = reverse ? strand.monomers.slice().reverse() : strand.monomers;
|
|
184
207
|
const chips: ChipPos[] = [];
|
|
@@ -187,9 +210,7 @@ function placeStrand(
|
|
|
187
210
|
|
|
188
211
|
for (let i = 0; i < monomers.length; i++) {
|
|
189
212
|
const m = monomers[i];
|
|
190
|
-
const w =
|
|
191
|
-
estimateConjugateWidth(m.symbol, layout.chipW, layout.fontSize) :
|
|
192
|
-
layout.chipW;
|
|
213
|
+
const w = chipWidths[i] ?? layout.chipW;
|
|
193
214
|
|
|
194
215
|
if (x + w > endX) break; // truncate at cell edge
|
|
195
216
|
|
|
@@ -241,6 +262,66 @@ function leadingConjugateWidth(
|
|
|
241
262
|
return w;
|
|
242
263
|
}
|
|
243
264
|
|
|
265
|
+
/** Desired width for one monomer if we render its base label in full (no
|
|
266
|
+
* ellipsis). Conjugates get their pill width. Single/two-char bases just use
|
|
267
|
+
* `chipW`; multi-char bases widen to fit the full label, capped at 3× chipW. */
|
|
268
|
+
function desiredChipWidth(m: ParsedMonomer, chipW: number, fontSize: number): number {
|
|
269
|
+
if (m.kind === 'conjugate') return estimateConjugateWidth(m.symbol, chipW, fontSize);
|
|
270
|
+
const base = (m as ParsedNucleotide).base ?? '';
|
|
271
|
+
if (base.length <= 2) return chipW;
|
|
272
|
+
// Empirical: glyph width ≈ fontSize * 0.55 for system-ui at our weights.
|
|
273
|
+
const charW = fontSize * 0.55;
|
|
274
|
+
const textW = base.length * charW;
|
|
275
|
+
const padding = chipW * 0.4;
|
|
276
|
+
return Math.max(chipW, Math.min(chipW * 3, textW + padding));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
interface SyncedWidths { sense: number[]; anti: number[]; }
|
|
280
|
+
|
|
281
|
+
/** For each strand returns a per-chip width array; when both strands have a
|
|
282
|
+
* chip at the same pair-index (counted past leading conjugates), the column
|
|
283
|
+
* width is `max(senseDesired, antiDesired)` so pair-aligned positions stay
|
|
284
|
+
* column-locked even when one side has a long base name. */
|
|
285
|
+
function computePairSyncedWidths(
|
|
286
|
+
senseDisplay: ParsedMonomer[], antiDisplay: ParsedMonomer[], chipW: number, fontSize: number,
|
|
287
|
+
): SyncedWidths {
|
|
288
|
+
const senseW = senseDisplay.map((m) => desiredChipWidth(m, chipW, fontSize));
|
|
289
|
+
const antiW = antiDisplay.map((m) => desiredChipWidth(m, chipW, fontSize));
|
|
290
|
+
|
|
291
|
+
// Strip leading conjugates: the first non-conjugate in display order.
|
|
292
|
+
const senseStart = senseDisplay.findIndex((m) => m.kind === 'nucleotide');
|
|
293
|
+
const antiStart = antiDisplay.findIndex((m) => m.kind === 'nucleotide');
|
|
294
|
+
if (senseStart >= 0 && antiStart >= 0) {
|
|
295
|
+
const pairLen = Math.min(senseDisplay.length - senseStart, antiDisplay.length - antiStart);
|
|
296
|
+
for (let i = 0; i < pairLen; i++) {
|
|
297
|
+
const si = senseStart + i;
|
|
298
|
+
const ai = antiStart + i;
|
|
299
|
+
const w = Math.max(senseW[si], antiW[ai]);
|
|
300
|
+
senseW[si] = w; antiW[ai] = w;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return {sense: senseW, anti: antiW};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Uniform-width fallback (every chip = chipW; conjugates still pill-wide). */
|
|
307
|
+
function uniformWidths(
|
|
308
|
+
senseDisplay: ParsedMonomer[], antiDisplay: ParsedMonomer[], chipW: number, fontSize: number,
|
|
309
|
+
): SyncedWidths {
|
|
310
|
+
const map = (m: ParsedMonomer) => m.kind === 'conjugate' ?
|
|
311
|
+
estimateConjugateWidth(m.symbol, chipW, fontSize) : chipW;
|
|
312
|
+
return {sense: senseDisplay.map(map), anti: antiDisplay.map(map)};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Whether the per-chip widths array fits in `budget` (after the leading shift). */
|
|
316
|
+
function fitsInBudget(widths: number[], leadW: number, chipGap: number, budget: number): boolean {
|
|
317
|
+
let total = leadW;
|
|
318
|
+
for (let i = 0; i < widths.length; i++) {
|
|
319
|
+
total += widths[i];
|
|
320
|
+
if (i < widths.length - 1) total += chipGap;
|
|
321
|
+
}
|
|
322
|
+
return total <= budget;
|
|
323
|
+
}
|
|
324
|
+
|
|
244
325
|
/* ---------------------------------------------------------------- *
|
|
245
326
|
* Drawing
|
|
246
327
|
* ---------------------------------------------------------------- */
|
|
@@ -324,11 +405,11 @@ function drawChip(
|
|
|
324
405
|
w: number, h: number, fontSize: number, opts: RenderOpts,
|
|
325
406
|
): void {
|
|
326
407
|
const sugarRes = resolveSugar(m.sugar, m.base);
|
|
327
|
-
const baseColor =
|
|
408
|
+
const baseColor = resolveBaseColor(m.base);
|
|
328
409
|
const isModSugar = isModifiedSugar(m.sugar);
|
|
329
410
|
const r = Math.min(2.5, w / 4);
|
|
330
411
|
|
|
331
|
-
// Chip body — base-canonical pale color
|
|
412
|
+
// Chip body — base-canonical pale color (or analog's color for custom bases)
|
|
332
413
|
drawRoundRect(g, x, y, w, h, r);
|
|
333
414
|
g.fillStyle = withAlpha(baseColor, CHIP_FILL_ALPHA);
|
|
334
415
|
g.fill();
|
|
@@ -347,26 +428,59 @@ function drawChip(
|
|
|
347
428
|
g.restore();
|
|
348
429
|
}
|
|
349
430
|
|
|
350
|
-
// Base
|
|
431
|
+
// Base label — biased upward to leave room for stripe.
|
|
432
|
+
// Pick full label or shortened (`X…`) based on whether the chip is wide
|
|
433
|
+
// enough at the current fontSize. This way, when the layout granted us a
|
|
434
|
+
// wider chip, the user sees the full HELM symbol; otherwise it's clipped
|
|
435
|
+
// to first-letter + ellipsis for legibility.
|
|
351
436
|
if (opts.showLetters && m.base && fontSize >= 8) {
|
|
352
437
|
const stripeH = isModSugar ? Math.max(2, h * SUGAR_STRIPE_RATIO) : 0;
|
|
438
|
+
const label = pickBaseLabel(m.base, w, fontSize);
|
|
353
439
|
g.fillStyle = '#1a1a1a';
|
|
354
440
|
g.font = `600 ${fontSize}px system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif`;
|
|
355
441
|
g.textBaseline = 'middle';
|
|
356
442
|
g.textAlign = 'center';
|
|
357
|
-
g.fillText(
|
|
443
|
+
g.fillText(label, x + w / 2, y + (h - stripeH) / 2 + 0.5);
|
|
358
444
|
}
|
|
359
445
|
}
|
|
360
446
|
|
|
447
|
+
/** Resolve a chip background color for any base, including custom (multi-char)
|
|
448
|
+
* symbols whose color follows their natural analog (`A` / `C` / `G` / `U` / `T`).
|
|
449
|
+
* Sync — backed by the central Bio monomer library that's wired up at
|
|
450
|
+
* package init. */
|
|
451
|
+
function resolveBaseColor(base: string | null): string {
|
|
452
|
+
if (!base) return FALLBACK_COLOR;
|
|
453
|
+
if (BASE_COLORS[base]) return BASE_COLORS[base];
|
|
454
|
+
if (isCanonicalBase(base)) return BASE_COLORS[base] ?? FALLBACK_COLOR;
|
|
455
|
+
const analog = getNaturalAnalog(base);
|
|
456
|
+
if (analog && BASE_COLORS[analog]) return BASE_COLORS[analog];
|
|
457
|
+
return FALLBACK_COLOR;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Decide the on-chip label given the chip's actual width: full base symbol
|
|
461
|
+
* if it fits, else `firstLetter + …`. Single/two-char bases always show fully. */
|
|
462
|
+
function pickBaseLabel(base: string, chipW: number, fontSize: number): string {
|
|
463
|
+
if (base.length <= 2) return base;
|
|
464
|
+
const charW = fontSize * 0.55;
|
|
465
|
+
const fullW = base.length * charW + 4; // small horizontal padding
|
|
466
|
+
if (fullW <= chipW) return base;
|
|
467
|
+
return displayBase(base);
|
|
468
|
+
}
|
|
469
|
+
|
|
361
470
|
function isModifiedSugar(sugar: string): boolean {
|
|
362
471
|
const c = canonicalSugarSymbol(sugar);
|
|
363
472
|
return c !== 'r' && c !== 'd';
|
|
364
473
|
}
|
|
365
474
|
|
|
366
475
|
function drawLinkage(g: CanvasRenderingContext2D, link: LinkagePos): void {
|
|
476
|
+
// Only the canonical phosphate (`p` / aliased `P`) is treated as "no marker".
|
|
477
|
+
// Every other linkage — known PS / PS₂ / MeP, or unknown custom symbol that
|
|
478
|
+
// got a hash-derived color — gets a bar in the inter-chip gap so the user
|
|
479
|
+
// can see and hover it. Color comes from resolvePhosphate which is
|
|
480
|
+
// deterministic per symbol, so two distinct unknown symbols get distinct bars.
|
|
481
|
+
const canonical = canonicalPhosphateSymbol(link.phosphateSymbol);
|
|
482
|
+
if (canonical === 'p') return;
|
|
367
483
|
const ps = resolvePhosphate(link.phosphateSymbol);
|
|
368
|
-
if (ps.meta.short !== 'PS' && ps.meta.short !== 'PS₂' && ps.meta.short !== 'MeP')
|
|
369
|
-
return; // only draw markers for non-canonical linkages
|
|
370
484
|
const barW = Math.max(2.5, link.w * PS_BAR_RATIO);
|
|
371
485
|
const barX = link.x + (link.w - barW) / 2;
|
|
372
486
|
g.fillStyle = ps.color;
|
|
@@ -98,8 +98,14 @@ export class OligoNucleotideCellRenderer extends DG.GridCellRenderer {
|
|
|
98
98
|
return m;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/** Cache key for a cell's layout. Includes the column's `version` so any
|
|
102
|
+
* edit to the column (which bumps version) orphans previous cache entries
|
|
103
|
+
* — preventing onMouseMove from hit-testing a stale layout that was cached
|
|
104
|
+
* before the edit and not yet replaced by a fresh render(). */
|
|
101
105
|
private cellKey(gridCell: DG.GridCell): string {
|
|
102
|
-
const
|
|
103
|
-
|
|
106
|
+
const col = gridCell.tableColumn;
|
|
107
|
+
const colName = col?.name ?? gridCell.gridColumn?.name ?? '?';
|
|
108
|
+
const ver = col?.version ?? 0;
|
|
109
|
+
return `${colName}@${ver}::${gridCell.tableRowIndex ?? -1}`;
|
|
104
110
|
}
|
|
105
111
|
}
|
|
@@ -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
|
@@ -133,6 +133,10 @@ export namespace funcs {
|
|
|
133
133
|
return await grok.functions.call('SequenceTranslator:GetPtChemEnumeratorDialog', { cell });
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
export async function getPtOligoEnumeratorDialog(cell: any | null): Promise<void> {
|
|
137
|
+
return await grok.functions.call('SequenceTranslator:GetPtOligoEnumeratorDialog', { cell });
|
|
138
|
+
}
|
|
139
|
+
|
|
136
140
|
/**
|
|
137
141
|
Enumerate provided HELM sequence on provided positions with provided monomers and generates new table
|
|
138
142
|
*/
|
|
@@ -158,6 +162,13 @@ export namespace funcs {
|
|
|
158
162
|
return await grok.functions.call('SequenceTranslator:OligoNucleotideCellRenderer', {});
|
|
159
163
|
}
|
|
160
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
|
+
|
|
161
172
|
/**
|
|
162
173
|
Modifications, lengths, conjugates and color legend for an OligoNucleotide cell
|
|
163
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
|
@@ -197,6 +197,12 @@ export async function getPtChemEnumeratorDialog(cell?: any) : Promise<void> {
|
|
|
197
197
|
await PackageFunctions.getPtChemEnumeratorDialog(cell);
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
//name: Polytool Oligo Enumerator dialog
|
|
201
|
+
//input: object cell { nullable: true }
|
|
202
|
+
export async function getPtOligoEnumeratorDialog(cell?: any) : Promise<void> {
|
|
203
|
+
await PackageFunctions.getPtOligoEnumeratorDialog(cell);
|
|
204
|
+
}
|
|
205
|
+
|
|
200
206
|
//name: Enumerate Single HELM Sequence
|
|
201
207
|
//description: Enumerate provided HELM sequence on provided positions with provided monomers and generates new table
|
|
202
208
|
//input: string helmSequence
|
|
@@ -233,6 +239,14 @@ export function oligoNucleotideCellRenderer() : any {
|
|
|
233
239
|
return PackageFunctions.oligoNucleotideCellRenderer();
|
|
234
240
|
}
|
|
235
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
|
+
|
|
236
250
|
//name: Oligo-Nucleotide
|
|
237
251
|
//description: Modifications, lengths, conjugates and color legend for an OligoNucleotide cell
|
|
238
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 {
|
|
@@ -358,6 +360,34 @@ export class PackageFunctions {
|
|
|
358
360
|
return polyToolEnumerateChemUI(cell);
|
|
359
361
|
}
|
|
360
362
|
|
|
363
|
+
|
|
364
|
+
/** Enumerator entry for OligoNucleotide cells.
|
|
365
|
+
*
|
|
366
|
+
* The cell value is HELM (under the hood). The enumerator dialog is built
|
|
367
|
+
* around `Macromolecule` cells, so we wrap the oligo HELM in a temp
|
|
368
|
+
* Macromolecule column and pass that cell in. The `outputAsOligo` flag
|
|
369
|
+
* makes the dialog tag the enumerated result column as OligoNucleotide so
|
|
370
|
+
* the duplex renderer picks it up automatically. */
|
|
371
|
+
@grok.decorators.func({
|
|
372
|
+
name: 'Polytool Oligo Enumerator dialog'
|
|
373
|
+
})
|
|
374
|
+
static async getPtOligoEnumeratorDialog(
|
|
375
|
+
@grok.decorators.param({type: 'object', options: {nullable: true}}) cell?: DG.Cell) {
|
|
376
|
+
if (!cell || cell.value == null)
|
|
377
|
+
return polyToolEnumerateHelmUI(undefined, true);
|
|
378
|
+
|
|
379
|
+
const helm = String(cell.value);
|
|
380
|
+
const tempCol = DG.Column.fromStrings('helm', [helm]);
|
|
381
|
+
tempCol.semType = DG.SEMTYPE.MACROMOLECULE;
|
|
382
|
+
tempCol.meta.units = 'helm';
|
|
383
|
+
tempCol.setTag('aligned', 'SEQ');
|
|
384
|
+
tempCol.setTag('alphabet', 'RNA');
|
|
385
|
+
tempCol.setTag('cell.renderer', 'helm');
|
|
386
|
+
const tempDf = DG.DataFrame.fromColumns([tempCol]);
|
|
387
|
+
const tempCell = tempDf.cell(0, 'helm');
|
|
388
|
+
return polyToolEnumerateHelmUI(tempCell, true);
|
|
389
|
+
}
|
|
390
|
+
|
|
361
391
|
@grok.decorators.func({
|
|
362
392
|
name: 'Enumerate Single HELM Sequence',
|
|
363
393
|
description: 'Enumerate provided HELM sequence on provided positions with provided monomers and generates new table',
|
|
@@ -420,6 +450,34 @@ export class PackageFunctions {
|
|
|
420
450
|
return new OligoNucleotideCellRenderer();
|
|
421
451
|
}
|
|
422
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
|
+
|
|
423
481
|
@grok.decorators.func({
|
|
424
482
|
name: 'Oligo-Nucleotide',
|
|
425
483
|
description: 'Modifications, lengths, conjugates and color legend for an OligoNucleotide cell',
|