@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@datagrok/sequence-translator",
3
3
  "friendlyName": "Sequence Translator",
4
- "version": "1.10.17",
4
+ "version": "1.10.19",
5
5
  "author": {
6
6
  "name": "Davit Rizhinashvili",
7
7
  "email": "drizhinashvili@datagrok.ai"
@@ -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
- const senseRes = placeStrand(model.sense, false, senseY, senseStartX, seqEndX, layoutBase, 'sense');
163
- const antiRes = hasAnti ?
164
- placeStrand(model.antisense!, antiReversed, antiY, antiStartX, seqEndX, layoutBase, 'antisense') :
165
- {chips: [], links: []};
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 = m.kind === 'conjugate' ?
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 = BASE_COLORS[m.base ?? ''] ?? FALLBACK_COLOR;
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 letter — biased upward to leave room for stripe
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(m.base, x + w / 2, y + (h - stripeH) / 2 + 0.5);
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 Map<string, ParsedDuplex>();
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 Map<string, DuplexLayout>();
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(gridCell), layout);
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.get(this.cellKey(gridCell));
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
- let m = this.modelCache.get(helm);
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(gridCell: DG.GridCell): string {
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}`;
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(sugars: Map<string, number>, phos: Map<string, number>): string {
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>, conjs: 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
- // Collapse by canonical symbol so e.g. mR + m → one row
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;
@@ -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
  */
@@ -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',