@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@datagrok/sequence-translator",
3
3
  "friendlyName": "Sequence Translator",
4
- "version": "1.10.16",
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.0",
26
- "@datagrok-libraries/chem-meta": "^1.2.8",
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
- 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: []};
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 = m.kind === 'conjugate' ?
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 = BASE_COLORS[m.base ?? ''] ?? FALLBACK_COLOR;
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 letter — biased upward to leave room for stripe
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(m.base, x + w / 2, y + (h - stripeH) / 2 + 0.5);
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 colName = gridCell.tableColumn?.name ?? gridCell.gridColumn?.name ?? '?';
103
- return `${colName}::${gridCell.tableRowIndex ?? -1}`;
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(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;
@@ -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
  */
@@ -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',