@datagrok/sequence-translator 1.10.14 → 1.10.16
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/detectors.js +30 -2
- package/dist/455.js +1 -1
- package/dist/455.js.map +1 -1
- 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/files/samples/sirna-demo.csv +38 -0
- package/package.json +2 -2
- package/src/oligo-renderer/canvas-renderer.ts +500 -0
- package/src/oligo-renderer/cell-renderer.ts +105 -0
- package/src/oligo-renderer/converters.ts +77 -0
- package/src/oligo-renderer/helm-parser.ts +154 -0
- package/src/oligo-renderer/legend-panel.ts +154 -0
- package/src/oligo-renderer/structures-panel.ts +96 -0
- package/src/oligo-renderer/tooltip.ts +227 -0
- package/src/oligo-renderer/types.ts +222 -0
- package/src/package-api.ts +35 -0
- package/src/package-test.ts +1 -0
- package/src/package.g.ts +45 -0
- package/src/package.ts +76 -0
- package/src/polytool/pt-dialog.ts +2 -1
- package/src/tests/oligo-renderer-tests.ts +299 -0
- package/test-console-output-1.log +215 -121
- package/test-record-1.mp4 +0 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OligoNucleotide grid cell renderer.
|
|
3
|
+
*
|
|
4
|
+
* - Cell value is HELM (under the hood). The renderer parses once per
|
|
5
|
+
* distinct value (cached by reference) and draws a duplex view.
|
|
6
|
+
* - No image cache: drawing is cheap; reparses only when the value changes.
|
|
7
|
+
* - Tooltip on hover shows monomer details + an async-loaded RDKit
|
|
8
|
+
* structure for the hovered monomer.
|
|
9
|
+
*
|
|
10
|
+
* Self-contained: does not require `_package.initLibData()` to draw.
|
|
11
|
+
* The library is consulted lazily inside the tooltip for structure rendering.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as DG from 'datagrok-api/dg';
|
|
15
|
+
import * as ui from 'datagrok-api/ui';
|
|
16
|
+
|
|
17
|
+
import {drawDuplex, hitTest, DuplexLayout} from './canvas-renderer';
|
|
18
|
+
import {looksLikeHelm, parseHelmDuplex} from './helm-parser';
|
|
19
|
+
import {ParsedDuplex} from './types';
|
|
20
|
+
import {showMonomerTooltip} from './tooltip';
|
|
21
|
+
|
|
22
|
+
const CELL_TYPE = 'OligoNucleotide';
|
|
23
|
+
|
|
24
|
+
export class OligoNucleotideCellRenderer extends DG.GridCellRenderer {
|
|
25
|
+
/** WeakMap-by-value cache of parsed HELM. Avoids reparsing on redraw. */
|
|
26
|
+
private modelCache = new Map<string, ParsedDuplex>();
|
|
27
|
+
/** Last-rendered layout per cell key, for hit-testing on subsequent moves. */
|
|
28
|
+
private layoutCache = new Map<string, DuplexLayout>();
|
|
29
|
+
|
|
30
|
+
get name(): string { return CELL_TYPE; }
|
|
31
|
+
get cellType(): string { return CELL_TYPE; }
|
|
32
|
+
get defaultWidth(): number | null { return 320; }
|
|
33
|
+
get defaultHeight(): number | null { return 64; }
|
|
34
|
+
|
|
35
|
+
render(
|
|
36
|
+
g: CanvasRenderingContext2D,
|
|
37
|
+
x: number, y: number, w: number, h: number,
|
|
38
|
+
gridCell: DG.GridCell, _cellStyle: DG.GridCellStyle,
|
|
39
|
+
): void {
|
|
40
|
+
const value = gridCell.cell.value as string | null;
|
|
41
|
+
if (!value || !looksLikeHelm(value)) {
|
|
42
|
+
// Render raw value as plain text — same fallback as default DG cell.
|
|
43
|
+
g.save();
|
|
44
|
+
g.beginPath();
|
|
45
|
+
g.rect(x, y, w, h);
|
|
46
|
+
g.clip();
|
|
47
|
+
g.fillStyle = '#8b949e';
|
|
48
|
+
g.font = '11px ui-monospace, Menlo, monospace';
|
|
49
|
+
g.textBaseline = 'middle';
|
|
50
|
+
g.textAlign = 'left';
|
|
51
|
+
g.fillText(value ?? '', x + 4, y + h / 2);
|
|
52
|
+
g.restore();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const model = this.getOrParse(value);
|
|
57
|
+
const layout = drawDuplex(g, x, y, w, h, model);
|
|
58
|
+
// 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();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
override onMouseMove(gridCell: DG.GridCell, e: MouseEvent): void {
|
|
65
|
+
const value = gridCell.cell.value as string | null;
|
|
66
|
+
if (!value || !looksLikeHelm(value)) {
|
|
67
|
+
ui.tooltip.hide();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const model = this.getOrParse(value);
|
|
71
|
+
const layout = this.layoutCache.get(this.cellKey(gridCell));
|
|
72
|
+
if (!layout) return;
|
|
73
|
+
|
|
74
|
+
const bounds = gridCell.bounds;
|
|
75
|
+
const localX = e.offsetX - bounds.x;
|
|
76
|
+
const localY = e.offsetY - bounds.y;
|
|
77
|
+
const hit = hitTest(localX, localY, model, layout);
|
|
78
|
+
if (!hit) {
|
|
79
|
+
ui.tooltip.hide();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
showMonomerTooltip(hit, e.clientX, e.clientY);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
override onMouseLeave(_gridCell: DG.GridCell, _e: MouseEvent): void {
|
|
87
|
+
ui.tooltip.hide();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
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;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private cellKey(gridCell: DG.GridCell): string {
|
|
102
|
+
const colName = gridCell.tableColumn?.name ?? gridCell.gridColumn?.name ?? '?';
|
|
103
|
+
return `${colName}::${gridCell.tableRowIndex ?? -1}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Column converters for the OligoNucleotide semantic type.
|
|
3
|
+
*
|
|
4
|
+
* - `tagAsOligoNucleotide(col)` — flags an existing HELM column so the
|
|
5
|
+
* platform's renderer registry picks up the oligo cell renderer.
|
|
6
|
+
* - `convertHelmColumnToOligo(table, helmCol)` — creates a NEW column
|
|
7
|
+
* with the same HELM values but tagged as OligoNucleotide. Adds it to
|
|
8
|
+
* the table next to the source column. Original column is left intact.
|
|
9
|
+
* - `combineSenseAntisenseToOligo(table, senseCol, antiCol)` — produces
|
|
10
|
+
* an OligoNucleotide column from two existing single-strand HELM columns.
|
|
11
|
+
*
|
|
12
|
+
* No autodetection: the user opts in by calling one of these.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as DG from 'datagrok-api/dg';
|
|
16
|
+
|
|
17
|
+
import {looksLikeHelm} from './helm-parser';
|
|
18
|
+
import {OLIGO_SEM_TYPE, OLIGO_UNITS} from './types';
|
|
19
|
+
|
|
20
|
+
/** Set the OligoNucleotide semType + unit + cell-renderer hint on a column. */
|
|
21
|
+
export function tagAsOligoNucleotide(col: DG.Column<string>): DG.Column<string> {
|
|
22
|
+
col.semType = OLIGO_SEM_TYPE;
|
|
23
|
+
col.meta.units = OLIGO_UNITS;
|
|
24
|
+
// Make the renderer choice explicit even if the platform's autodiscovery
|
|
25
|
+
// hasn't run yet for this session.
|
|
26
|
+
col.setTag('cell.renderer', OLIGO_SEM_TYPE);
|
|
27
|
+
col.setTag('quality', OLIGO_SEM_TYPE);
|
|
28
|
+
return col;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Clone a HELM column and tag the clone as OligoNucleotide. */
|
|
32
|
+
export function convertHelmColumnToOligo(
|
|
33
|
+
table: DG.DataFrame, helmCol: DG.Column<string>, newName?: string,
|
|
34
|
+
): DG.Column<string> {
|
|
35
|
+
const values: string[] = new Array(helmCol.length);
|
|
36
|
+
for (let i = 0; i < helmCol.length; i++)
|
|
37
|
+
values[i] = helmCol.get(i) ?? '';
|
|
38
|
+
const out = DG.Column.fromStrings(table.columns.getUnusedName(newName ?? `${helmCol.name} (oligo)`), values);
|
|
39
|
+
tagAsOligoNucleotide(out);
|
|
40
|
+
table.columns.add(out);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Build an OligoNucleotide column from separate sense + antisense HELM columns.
|
|
45
|
+
* Each cell becomes `RNA1{...}|RNA2{...}` with the original chain bodies.
|
|
46
|
+
* If a row's value isn't HELM, that cell is left empty. */
|
|
47
|
+
export function combineSenseAntisenseToOligo(
|
|
48
|
+
table: DG.DataFrame, senseCol: DG.Column<string>, antiCol: DG.Column<string>, newName?: string,
|
|
49
|
+
): DG.Column<string> {
|
|
50
|
+
if (senseCol.length !== antiCol.length)
|
|
51
|
+
throw new Error('Sense and antisense columns must have the same length');
|
|
52
|
+
|
|
53
|
+
const values: string[] = new Array(senseCol.length);
|
|
54
|
+
for (let i = 0; i < senseCol.length; i++) {
|
|
55
|
+
const s = senseCol.get(i) ?? '';
|
|
56
|
+
const a = antiCol.get(i) ?? '';
|
|
57
|
+
if (!looksLikeHelm(s)) { values[i] = ''; continue; }
|
|
58
|
+
const senseChain = renumberChain(s, 1);
|
|
59
|
+
const antiChain = looksLikeHelm(a) ? renumberChain(a, 2) : '';
|
|
60
|
+
const polymerSection = antiChain ? `${senseChain}|${antiChain}` : senseChain;
|
|
61
|
+
values[i] = `${polymerSection}$$$$`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const out = DG.Column.fromStrings(
|
|
65
|
+
table.columns.getUnusedName(newName ?? `${senseCol.name}+${antiCol.name} (oligo)`),
|
|
66
|
+
values,
|
|
67
|
+
);
|
|
68
|
+
tagAsOligoNucleotide(out);
|
|
69
|
+
table.columns.add(out);
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Strip the polymer-section index off `RNA1{...}` / `RNA2{...}` and rewrite to `RNA<n>{...}`. */
|
|
74
|
+
function renumberChain(helm: string, n: number): string {
|
|
75
|
+
const polymerSection = helm.split('$')[0];
|
|
76
|
+
return polymerSection.replace(/^(RNA|DNA|PEPTIDE|CHEM|BLOB)\d+\{/, `$1${n}{`);
|
|
77
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight HELM parser dedicated to RNA / DNA duplex visualization.
|
|
3
|
+
*
|
|
4
|
+
* Parses strings of the shape `RNA1{...}|RNA2{...}$$$$` (single or two
|
|
5
|
+
* chain), with each monomer in `sugar(base)phosphate` form (with brackets
|
|
6
|
+
* around multi-char codes). Conjugates / linkers without a base are
|
|
7
|
+
* recognized as standalone units (e.g. `[GalNAc]`, `[L3]`, `[Chol]`).
|
|
8
|
+
*
|
|
9
|
+
* Why a custom parser rather than reusing bio's `HelmHelper.parse()`:
|
|
10
|
+
* - we want plain `{sugar, base, phosphate}` triples, not a JSDraw2 mol graph
|
|
11
|
+
* - parsing is on the cell-render hot path; this is ~zero-allocation
|
|
12
|
+
* - bio's HELM splitter has had several normalization changes recently
|
|
13
|
+
* (see Apr 2026 commits around RNA triplet splitting)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
canonicalPhosphateSymbol, canonicalSugarSymbol,
|
|
18
|
+
ParsedConjugate, ParsedDuplex, ParsedMonomer, ParsedNucleotide, ParsedStrand,
|
|
19
|
+
} from './types';
|
|
20
|
+
|
|
21
|
+
/** Parse the whole HELM string. Tolerant of whitespace and missing `$$$$`. */
|
|
22
|
+
export function parseHelmDuplex(helm: string): ParsedDuplex {
|
|
23
|
+
const polymerSection = (helm ?? '').split('$')[0] ?? '';
|
|
24
|
+
const chains = polymerSection.split('|').map((c) => c.trim()).filter((c) => c.length > 0);
|
|
25
|
+
|
|
26
|
+
const parsedChains: ParsedStrand[] = chains.map(parseChain);
|
|
27
|
+
return {
|
|
28
|
+
sense: parsedChains[0] ?? {type: 'RNA', monomers: []},
|
|
29
|
+
antisense: parsedChains[1] ?? null,
|
|
30
|
+
raw: helm,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseChain(chain: string): ParsedStrand {
|
|
35
|
+
// RNA1{...}, DNA2{...}, CHEM3{...}
|
|
36
|
+
const match = chain.match(/^(RNA|DNA|CHEM|PEPTIDE|BLOB)\d+\{(.*)\}$/);
|
|
37
|
+
if (!match)
|
|
38
|
+
return {type: 'CHEM', monomers: []};
|
|
39
|
+
const type = match[1];
|
|
40
|
+
const content = match[2];
|
|
41
|
+
return {type, monomers: parseMonomers(content)};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Split chain contents on top-level dots (depth-aware to preserve `[a.b]`/`(c.d)`). */
|
|
45
|
+
function parseMonomers(content: string): ParsedMonomer[] {
|
|
46
|
+
const out: ParsedMonomer[] = [];
|
|
47
|
+
let depth = 0;
|
|
48
|
+
let cur = '';
|
|
49
|
+
let pos = 0;
|
|
50
|
+
for (const ch of content) {
|
|
51
|
+
if (ch === '[' || ch === '(') {
|
|
52
|
+
depth++;
|
|
53
|
+
} else if (ch === ']' || ch === ')') {
|
|
54
|
+
depth--;
|
|
55
|
+
} else if (ch === '.' && depth === 0) {
|
|
56
|
+
if (cur) out.push(parseMonomer(cur, pos++));
|
|
57
|
+
cur = '';
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
cur += ch;
|
|
61
|
+
}
|
|
62
|
+
if (cur) out.push(parseMonomer(cur, pos));
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Parse one monomer text into a structured triple OR conjugate. */
|
|
67
|
+
function parseMonomer(s: string, position: number): ParsedMonomer {
|
|
68
|
+
let i = 0;
|
|
69
|
+
let sugar = '';
|
|
70
|
+
let base: string | null = null;
|
|
71
|
+
let phosphate = '';
|
|
72
|
+
const raw = s;
|
|
73
|
+
|
|
74
|
+
// sugar (optional brackets)
|
|
75
|
+
if (s[0] === '[') {
|
|
76
|
+
const end = s.indexOf(']');
|
|
77
|
+
sugar = s.substring(1, end);
|
|
78
|
+
i = end + 1;
|
|
79
|
+
} else {
|
|
80
|
+
sugar = s[0] ?? '';
|
|
81
|
+
i = 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// base in parens (optional)
|
|
85
|
+
if (s[i] === '(') {
|
|
86
|
+
const end = s.indexOf(')', i);
|
|
87
|
+
base = s.substring(i + 1, end);
|
|
88
|
+
i = end + 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// phosphate / linkage (optional, possibly bracketed)
|
|
92
|
+
if (i < s.length) {
|
|
93
|
+
if (s[i] === '[') {
|
|
94
|
+
const end = s.indexOf(']', i);
|
|
95
|
+
phosphate = s.substring(i + 1, end);
|
|
96
|
+
i = end + 1;
|
|
97
|
+
} else {
|
|
98
|
+
phosphate = s.substring(i);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// No base → it's a conjugate / linker / cap (e.g. [GalNAc], [Chol], [L3])
|
|
103
|
+
if (base === null) {
|
|
104
|
+
const conj: ParsedConjugate = {kind: 'conjugate', position, symbol: sugar, raw};
|
|
105
|
+
return conj;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const nt: ParsedNucleotide = {kind: 'nucleotide', position, sugar, base, phosphate, raw};
|
|
109
|
+
return nt;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Quick check that a string is HELM-like enough to attempt parsing. */
|
|
113
|
+
export function looksLikeHelm(s: string): boolean {
|
|
114
|
+
return /^\s*(?:RNA|DNA|PEPTIDE|CHEM|BLOB)\d+\s*\{/.test(s ?? '');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ---------------------------------------------------------------- *
|
|
118
|
+
* Canonicalization — rewrite aliased sugar / phosphate symbols
|
|
119
|
+
* (e.g. `mR` → `m`, `fR` → `fl2r`, `LR` → `lna`, `sP` → `sp`) to the
|
|
120
|
+
* HELMCore canonical forms before sending the HELM to Bio's
|
|
121
|
+
* monomer-library-driven pipelines (toAtomicLevelPanel, helmToMolfileV3K, …).
|
|
122
|
+
* Without this, the central Bio library can't find aliased monomers.
|
|
123
|
+
* ---------------------------------------------------------------- */
|
|
124
|
+
|
|
125
|
+
/** Serialize a parsed monomer back to HELM, bracketing multi-char symbols
|
|
126
|
+
* and using canonical (HELMCore) sugar / phosphate codes. */
|
|
127
|
+
function serializeCanonicalMonomer(m: ParsedMonomer): string {
|
|
128
|
+
if (m.kind === 'conjugate') return `[${m.symbol}]`;
|
|
129
|
+
const nt = m as ParsedNucleotide;
|
|
130
|
+
const sugar = canonicalSugarSymbol(nt.sugar);
|
|
131
|
+
const phos = canonicalPhosphateSymbol(nt.phosphate);
|
|
132
|
+
const sugarPart = sugar.length === 1 ? sugar : `[${sugar}]`;
|
|
133
|
+
const phosPart = !phos ? '' : (phos.length === 1 ? phos : `[${phos}]`);
|
|
134
|
+
const baseStr = nt.base ? `(${nt.base})` : '';
|
|
135
|
+
return `${sugarPart}${baseStr}${phosPart}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Canonicalize the body (between `{...}`) of a HELM chain. */
|
|
139
|
+
export function canonicalizeChainBody(body: string): string {
|
|
140
|
+
return parseMonomers(body).map(serializeCanonicalMonomer).join('.');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Canonicalize a full HELM string (any number of `|`-separated chains). */
|
|
144
|
+
export function canonicalizeHelm(helm: string): string {
|
|
145
|
+
const polymerSection = (helm ?? '').split('$')[0];
|
|
146
|
+
const chains = polymerSection.split('|').map((c) => c.trim()).filter(Boolean);
|
|
147
|
+
const out = chains.map((c) => {
|
|
148
|
+
const m = c.match(/^((?:RNA|DNA|PEPTIDE|CHEM|BLOB)\d+)\{(.*)\}$/);
|
|
149
|
+
if (!m) return c;
|
|
150
|
+
const [, prefix, body] = m;
|
|
151
|
+
return `${prefix}{${canonicalizeChainBody(body)}}`;
|
|
152
|
+
});
|
|
153
|
+
return `${out.join('|')}$$$$`;
|
|
154
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context-panel widget for an OligoNucleotide cell.
|
|
3
|
+
*
|
|
4
|
+
* - Per-cell summary (lengths, modification counts, conjugates).
|
|
5
|
+
* - Color legend for the column (one-time visual key, not per-cell repeat).
|
|
6
|
+
* - Quick actions: copy HELM, open in pattern designer (TODO).
|
|
7
|
+
*
|
|
8
|
+
* Registered via `tags: panel` + `input: semantic_value oligo {semType: OligoNucleotide}`
|
|
9
|
+
* so the platform shows it automatically when an oligo cell is selected.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as DG from 'datagrok-api/dg';
|
|
13
|
+
import * as ui from 'datagrok-api/ui';
|
|
14
|
+
|
|
15
|
+
import {parseHelmDuplex} from './helm-parser';
|
|
16
|
+
import {
|
|
17
|
+
canonicalPhosphateSymbol, canonicalSugarSymbol,
|
|
18
|
+
ParsedNucleotide, resolveConjugate, resolvePhosphate, resolveSugar,
|
|
19
|
+
} from './types';
|
|
20
|
+
|
|
21
|
+
export function buildOligoPanel(value: DG.SemanticValue): DG.Widget {
|
|
22
|
+
const helm: string = value.value ?? '';
|
|
23
|
+
const model = parseHelmDuplex(helm);
|
|
24
|
+
|
|
25
|
+
const sLen = model.sense.monomers.filter((m) => m.kind === 'nucleotide').length;
|
|
26
|
+
const aLen = model.antisense ? model.antisense.monomers.filter((m) => m.kind === 'nucleotide').length : 0;
|
|
27
|
+
|
|
28
|
+
// Aggregate modification usage in this oligo
|
|
29
|
+
const sugarCounts = new Map<string, number>();
|
|
30
|
+
const phosCounts = new Map<string, number>();
|
|
31
|
+
const conjCounts = new Map<string, number>();
|
|
32
|
+
const allMonomers = [...model.sense.monomers, ...(model.antisense?.monomers ?? [])];
|
|
33
|
+
for (const m of allMonomers) {
|
|
34
|
+
if (m.kind === 'nucleotide') {
|
|
35
|
+
const nt = m as ParsedNucleotide;
|
|
36
|
+
bump(sugarCounts, nt.sugar);
|
|
37
|
+
if (nt.phosphate) bump(phosCounts, nt.phosphate);
|
|
38
|
+
} else {
|
|
39
|
+
bump(conjCounts, m.symbol);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const root = ui.divV([], {style: {fontSize: '12px'}});
|
|
44
|
+
|
|
45
|
+
// Summary
|
|
46
|
+
root.appendChild(section('Summary', ui.tableFromMap({
|
|
47
|
+
'Sense length': `${sLen} nt`,
|
|
48
|
+
'Antisense length': aLen ? `${aLen} nt` : 'single-strand',
|
|
49
|
+
'Modifications used': humanizeModSet(sugarCounts, phosCounts),
|
|
50
|
+
'Conjugates': conjCounts.size ?
|
|
51
|
+
Array.from(conjCounts.entries()).map(([s, n]) => `${resolveConjugate(s).meta.name} ×${n}`).join(', ') :
|
|
52
|
+
'—',
|
|
53
|
+
})));
|
|
54
|
+
|
|
55
|
+
// Legend — only modifications actually present in this cell.
|
|
56
|
+
// Note: there's no "Copy" section here — the platform already adds a
|
|
57
|
+
// default "Actions | Copy value" entry for any cell.
|
|
58
|
+
root.appendChild(section('Legend', buildCellLegend(sugarCounts, phosCounts, conjCounts)));
|
|
59
|
+
|
|
60
|
+
return DG.Widget.fromRoot(root);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function bump(map: Map<string, number>, key: string): void {
|
|
64
|
+
map.set(key, (map.get(key) ?? 0) + 1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function humanizeModSet(sugars: Map<string, number>, phos: Map<string, number>): string {
|
|
68
|
+
const parts: string[] = [];
|
|
69
|
+
// Aggregate by canonical name so legacy + canonical symbols collapse correctly
|
|
70
|
+
const sugarByName = new Map<string, number>();
|
|
71
|
+
for (const [sym, n] of sugars.entries()) {
|
|
72
|
+
const meta = resolveSugar(sym, null).meta;
|
|
73
|
+
if (meta.short === 'RNA' || meta.short === 'DNA') continue;
|
|
74
|
+
sugarByName.set(meta.short, (sugarByName.get(meta.short) ?? 0) + n);
|
|
75
|
+
}
|
|
76
|
+
for (const [name, n] of sugarByName.entries()) parts.push(`${name} ×${n}`);
|
|
77
|
+
|
|
78
|
+
const phosByName = new Map<string, number>();
|
|
79
|
+
for (const [sym, n] of phos.entries()) {
|
|
80
|
+
const meta = resolvePhosphate(sym).meta;
|
|
81
|
+
if (meta.short === 'PO') continue;
|
|
82
|
+
phosByName.set(meta.short, (phosByName.get(meta.short) ?? 0) + n);
|
|
83
|
+
}
|
|
84
|
+
for (const [name, n] of phosByName.entries()) parts.push(`${name} ×${n}`);
|
|
85
|
+
|
|
86
|
+
return parts.length ? parts.join(', ') : 'unmodified';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function section(title: string, body: HTMLElement): HTMLElement {
|
|
90
|
+
return ui.divV([
|
|
91
|
+
ui.divText(title, {style: {
|
|
92
|
+
fontWeight: '600', marginTop: '8px', marginBottom: '4px',
|
|
93
|
+
color: 'var(--grey-5, #555)', textTransform: 'uppercase', fontSize: '10px', letterSpacing: '0.04em',
|
|
94
|
+
}}),
|
|
95
|
+
body,
|
|
96
|
+
]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Build a legend filtered to modifications actually present in this cell.
|
|
100
|
+
* One row per unique mod, with the count to the right. Items collapse by
|
|
101
|
+
* canonical symbol so legacy/canonical aliases share a row. */
|
|
102
|
+
function buildCellLegend(
|
|
103
|
+
sugars: Map<string, number>, phos: Map<string, number>, conjs: Map<string, number>,
|
|
104
|
+
): HTMLElement {
|
|
105
|
+
type Item = { label: string; color: string; count: number };
|
|
106
|
+
const items: Item[] = [];
|
|
107
|
+
|
|
108
|
+
// Collapse by canonical symbol so e.g. mR + m → one row
|
|
109
|
+
const sugarByCanon = new Map<string, number>();
|
|
110
|
+
for (const [sym, n] of sugars.entries()) {
|
|
111
|
+
const c = canonicalSugarSymbol(sym);
|
|
112
|
+
if (c === 'r' || c === 'd') continue; // unmodified
|
|
113
|
+
sugarByCanon.set(c, (sugarByCanon.get(c) ?? 0) + n);
|
|
114
|
+
}
|
|
115
|
+
for (const [c, n] of sugarByCanon.entries()) {
|
|
116
|
+
const meta = resolveSugar(c, null).meta;
|
|
117
|
+
items.push({label: meta.name, color: meta.color, count: n});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const phosByCanon = new Map<string, number>();
|
|
121
|
+
for (const [sym, n] of phos.entries()) {
|
|
122
|
+
const c = canonicalPhosphateSymbol(sym);
|
|
123
|
+
if (c === 'p') continue;
|
|
124
|
+
phosByCanon.set(c, (phosByCanon.get(c) ?? 0) + n);
|
|
125
|
+
}
|
|
126
|
+
for (const [c, n] of phosByCanon.entries()) {
|
|
127
|
+
const meta = resolvePhosphate(c).meta;
|
|
128
|
+
items.push({label: `${meta.name} (linkage)`, color: meta.color, count: n});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const [sym, n] of conjs.entries()) {
|
|
132
|
+
const meta = resolveConjugate(sym).meta;
|
|
133
|
+
items.push({label: meta.name, color: meta.color, count: n});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (items.length === 0) {
|
|
137
|
+
return ui.divText('No modifications. Chip color reflects the base (A/C/G/U).', {
|
|
138
|
+
style: {fontSize: '12px', color: 'var(--grey-4, #888)', lineHeight: '1.4'},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const rows = items.map(({label, color, count}) => ui.divH([
|
|
143
|
+
ui.div([], {style: {
|
|
144
|
+
width: '14px', height: '14px', borderRadius: '3px',
|
|
145
|
+
background: color, border: '1px solid rgba(0,0,0,0.15)', flexShrink: '0',
|
|
146
|
+
}}),
|
|
147
|
+
ui.divText(label, {style: {fontSize: '12px', flex: '1'}}),
|
|
148
|
+
ui.divText(`×${count}`, {style: {
|
|
149
|
+
fontSize: '11px', color: 'var(--grey-4, #888)', fontVariantNumeric: 'tabular-nums',
|
|
150
|
+
}}),
|
|
151
|
+
], {style: {gap: '6px', alignItems: 'center', marginBottom: '3px'}}));
|
|
152
|
+
|
|
153
|
+
return ui.divV(rows);
|
|
154
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context-panel widget that renders the sense and antisense strands of an
|
|
3
|
+
* OligoNucleotide cell as separate full molecular structures via Bio's
|
|
4
|
+
* `Bio:toAtomicLevelPanel` function.
|
|
5
|
+
*
|
|
6
|
+
* The duplex HELM (e.g. `RNA1{...}|RNA2{...}$$$$`) is split into two
|
|
7
|
+
* standalone HELM strings (`RNA1{...}$$$$` each), placed into a tiny
|
|
8
|
+
* temporary DataFrame with proper Macromolecule/HELM tags, and each row's
|
|
9
|
+
* cell is wrapped in a `DG.SemanticValue` and forwarded to `toAtomicLevelPanel`.
|
|
10
|
+
*
|
|
11
|
+
* Each strand is rendered as a collapsible accordion pane with lazy content:
|
|
12
|
+
* the molfile assembly only runs when the pane is first expanded, so opening
|
|
13
|
+
* a row's context panel never blocks on rendering antisense if the user only
|
|
14
|
+
* cares about sense (and vice versa).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as DG from 'datagrok-api/dg';
|
|
18
|
+
import * as grok from 'datagrok-api/grok';
|
|
19
|
+
import * as ui from 'datagrok-api/ui';
|
|
20
|
+
|
|
21
|
+
import {canonicalizeHelm} from './helm-parser';
|
|
22
|
+
|
|
23
|
+
const HELM_SECTION_RE = /^(RNA|DNA|PEPTIDE|CHEM|BLOB)\d+\{/;
|
|
24
|
+
|
|
25
|
+
export function buildOligoStructuresPanel(value: DG.SemanticValue): DG.Widget {
|
|
26
|
+
const helm: string = value.value ?? '';
|
|
27
|
+
const polymerSection = helm.split('$')[0];
|
|
28
|
+
const chains = polymerSection.split('|').map((c) => c.trim()).filter((c) => c.length > 0);
|
|
29
|
+
|
|
30
|
+
if (chains.length === 0) {
|
|
31
|
+
return DG.Widget.fromRoot(ui.divText('No HELM chains found', {
|
|
32
|
+
style: {fontSize: '12px', color: 'var(--grey-4, #888)'},
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Renumber each chain to "RNA1{...}" and canonicalize aliased symbols
|
|
37
|
+
// (e.g. `mR` → `m`, `fR` → `fl2r`, `LR` → `lna`, `sP` → `sp`) so the
|
|
38
|
+
// central Bio monomer library — which only knows the canonical HELMCore
|
|
39
|
+
// forms — can resolve every monomer when assembling the molfile.
|
|
40
|
+
const standaloneHelms = chains.map((c) =>
|
|
41
|
+
canonicalizeHelm(c.replace(HELM_SECTION_RE, '$11{') + '$$$$'),
|
|
42
|
+
);
|
|
43
|
+
const labels = chains.length === 1 ? ['Strand'] : ['Sense', 'Antisense'];
|
|
44
|
+
|
|
45
|
+
// Build a temp DataFrame whose helm column carries the right tags so
|
|
46
|
+
// Bio:toAtomicLevelPanel sees a real Macromolecule cell.
|
|
47
|
+
const helmCol = DG.Column.fromStrings('helm', standaloneHelms);
|
|
48
|
+
helmCol.semType = DG.SEMTYPE.MACROMOLECULE;
|
|
49
|
+
helmCol.meta.units = 'helm';
|
|
50
|
+
helmCol.setTag('aligned', 'SEQ');
|
|
51
|
+
helmCol.setTag('alphabet', 'RNA');
|
|
52
|
+
helmCol.setTag('cell.renderer', 'helm');
|
|
53
|
+
const df = DG.DataFrame.fromColumns([helmCol]);
|
|
54
|
+
|
|
55
|
+
const acc = ui.accordion('oligo chemical structures');
|
|
56
|
+
for (let i = 0; i < df.rowCount; i++) {
|
|
57
|
+
const label = labels[i];
|
|
58
|
+
const cell = df.cell(i, 'helm');
|
|
59
|
+
// Lazy content: the molfile assembly fires only when the pane expands.
|
|
60
|
+
acc.addPane(label, () => buildStrandPaneContent(cell, label));
|
|
61
|
+
}
|
|
62
|
+
return DG.Widget.fromRoot(acc.root);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Builds the host element for one strand's molecular structure. The actual
|
|
66
|
+
* Bio:toAtomicLevelPanel call is async — the host is returned immediately so
|
|
67
|
+
* the accordion expands without blocking, then content swaps in when ready. */
|
|
68
|
+
function buildStrandPaneContent(cell: DG.Cell, label: string): HTMLElement {
|
|
69
|
+
const host = ui.div([], {style: {minHeight: '60px'}});
|
|
70
|
+
host.appendChild(ui.divText('Building structure…', {
|
|
71
|
+
style: {fontSize: '11px', color: 'var(--grey-4, #888)', padding: '4px'},
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
(async () => {
|
|
75
|
+
try {
|
|
76
|
+
const sv = DG.SemanticValue.fromTableCell(cell);
|
|
77
|
+
const widget = await grok.functions.call('Bio:toAtomicLevelPanel', {sequence: sv}) as DG.Widget;
|
|
78
|
+
host.innerHTML = '';
|
|
79
|
+
host.appendChild(widget?.root ?? notAvailable());
|
|
80
|
+
} catch (e) {
|
|
81
|
+
host.innerHTML = '';
|
|
82
|
+
host.appendChild(ui.divText(
|
|
83
|
+
`${label} render failed: ${(e as Error)?.message ?? String(e)}`,
|
|
84
|
+
{style: {fontSize: '11px', color: 'var(--grey-4, #888)'}},
|
|
85
|
+
));
|
|
86
|
+
}
|
|
87
|
+
})();
|
|
88
|
+
|
|
89
|
+
return host;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function notAvailable(): HTMLElement {
|
|
93
|
+
return ui.divText('Structure not available', {
|
|
94
|
+
style: {fontSize: '11px', color: 'var(--grey-4, #888)'},
|
|
95
|
+
});
|
|
96
|
+
}
|