@datagrok/sequence-translator 1.10.17 → 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 +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 +121 -12
- 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 +185 -137
- 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
|
+
}
|
|
@@ -26,9 +26,11 @@
|
|
|
26
26
|
import {
|
|
27
27
|
BASE_COLORS, FALLBACK_COLOR,
|
|
28
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,17 +428,45 @@ 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';
|
|
@@ -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',
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {after, category, test, expect, awaitCheck, delay} from '@datagrok-libraries/test/src/test';
|
|
2
|
+
import * as grok from 'datagrok-api/grok';
|
|
3
|
+
import * as DG from 'datagrok-api/dg';
|
|
4
|
+
|
|
5
|
+
import $ from 'cash-dom';
|
|
6
|
+
|
|
7
|
+
import {tagAsOligoNucleotide} from '../oligo-renderer/converters';
|
|
8
|
+
|
|
9
|
+
const SAMPLE_HELM =
|
|
10
|
+
'RNA1{r(A)p.r(C)p.r(G)p.r(U)p}|RNA2{r(U)p.r(C)p.r(G)p.r(A)p}$$$$';
|
|
11
|
+
|
|
12
|
+
function dialogCount(): number {
|
|
13
|
+
return $('.d4-dialog').length;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function closeAllDialogs(): void {
|
|
17
|
+
$('.d4-dialog .ui-btn-cancel, .d4-dialog .d4-dialog-header .grok-icon.fa-times').trigger('click');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
category('OligoCellEditor', () => {
|
|
21
|
+
after(async () => {
|
|
22
|
+
closeAllDialogs();
|
|
23
|
+
grok.shell.closeAll();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('cellEditor opens HELM editor for OligoNucleotide cell and saves on OK', async () => {
|
|
27
|
+
const col = DG.Column.fromStrings('seq', [SAMPLE_HELM]);
|
|
28
|
+
tagAsOligoNucleotide(col);
|
|
29
|
+
const df = DG.DataFrame.fromColumns([col]);
|
|
30
|
+
df.name = 'oligo-edit-test';
|
|
31
|
+
const tv = grok.shell.addTableView(df);
|
|
32
|
+
|
|
33
|
+
await awaitCheck(() => $(tv.root).find('.d4-grid canvas').length > 0,
|
|
34
|
+
'Grid canvas did not appear', 5000);
|
|
35
|
+
|
|
36
|
+
// Find the cellEditor that the platform would dispatch on double-click for
|
|
37
|
+
// a column tagged quality=OligoNucleotide. Pre-fix: zero matches (the
|
|
38
|
+
// registration didn't exist). Post-fix: exactly one — editOligoNucleotideCell.
|
|
39
|
+
const matches = DG.Func.find({tags: ['cellEditor'], package: 'SequenceTranslator'})
|
|
40
|
+
.filter((f) => f.description === 'OligoNucleotide');
|
|
41
|
+
expect(matches.length, 1);
|
|
42
|
+
expect(matches[0].name, 'editOligoNucleotideCell');
|
|
43
|
+
|
|
44
|
+
const gridCell = tv.grid.cell('seq', 0);
|
|
45
|
+
expect(gridCell != null, true);
|
|
46
|
+
expect(gridCell.cell.value, SAMPLE_HELM);
|
|
47
|
+
|
|
48
|
+
const dialogsBefore = dialogCount();
|
|
49
|
+
|
|
50
|
+
// Invoke the cellEditor as the platform would on double-click.
|
|
51
|
+
// Pre-fix (delegating to Helm:editMoleculeCell): throws synchronously
|
|
52
|
+
// "The column of notation 'helm' must be 'Macromolecule'" — dialog never opens.
|
|
53
|
+
// Post-fix (using helmHelper.createWebEditorApp directly): dialog opens.
|
|
54
|
+
await matches[0].apply({cell: gridCell});
|
|
55
|
+
|
|
56
|
+
await awaitCheck(() => dialogCount() > dialogsBefore,
|
|
57
|
+
'HELM editor dialog did not open within 15s', 15000);
|
|
58
|
+
|
|
59
|
+
// Wait for HWE async init (Dojo + JSDraw2 + monomer lib) to mount the editor.
|
|
60
|
+
// JSDraw2 renders to SVG, so wait for the OK button to be wired up — that's
|
|
61
|
+
// a reliable signal that the dialog footer is fully constructed.
|
|
62
|
+
await awaitCheck(() => $('.d4-dialog .ui-btn-ok, .d4-dialog button.ui-btn.ui-btn-ok').length > 0,
|
|
63
|
+
'OK button did not appear in HELM editor dialog within 15s', 15000);
|
|
64
|
+
|
|
65
|
+
// Allow the editor a moment to load the HELM string into the canvas before we read it back.
|
|
66
|
+
await delay(1000);
|
|
67
|
+
|
|
68
|
+
const okBtn = $('.d4-dialog .ui-btn-ok, .d4-dialog button.ui-btn.ui-btn-ok').first();
|
|
69
|
+
expect(okBtn.length > 0, true, 'OK button not found in dialog');
|
|
70
|
+
|
|
71
|
+
okBtn.trigger('click');
|
|
72
|
+
|
|
73
|
+
await awaitCheck(() => dialogCount() <= dialogsBefore,
|
|
74
|
+
'Dialog did not close after OK', 5000);
|
|
75
|
+
|
|
76
|
+
// After OK: cell.setValue(helmValue) must have run with the editor's HELM.
|
|
77
|
+
// The editor may canonicalize formatting, so we don't require byte-equality —
|
|
78
|
+
// we require it remains a valid two-strand HELM string for our input.
|
|
79
|
+
const after = gridCell.cell.value as string;
|
|
80
|
+
expect(typeof after === 'string' && after.includes('RNA1{') && after.includes('RNA2{') && after.includes('$$$$'), true,
|
|
81
|
+
`Expected cell value to remain valid HELM after OK; got: ${after}`);
|
|
82
|
+
});
|
|
83
|
+
});
|