@datagrok/sequence-translator 1.10.13 → 1.10.15
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 +6 -0
- 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/files/tests/chem_enum_cores.csv +5 -0
- package/files/tests/chem_enum_rgroups.csv +5 -0
- package/package.json +2 -2
- package/src/apps/structure/view/ui.ts +1 -1
- package/src/apps/translator/view/ui.ts +1 -1
- 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 +223 -0
- package/src/oligo-renderer/types.ts +221 -0
- package/src/package-api.ts +43 -1
- package/src/package-test.ts +2 -0
- package/src/package.g.ts +56 -3
- package/src/package.ts +92 -5
- package/src/polytool/const.ts +1 -1
- package/src/polytool/pt-chem-enum-dialog.ts +940 -0
- package/src/polytool/pt-chem-enum.ts +553 -0
- package/src/polytool/pt-dialog.ts +4 -125
- package/src/polytool/pt-enumerate-seq-dialog.ts +3 -3
- package/src/tests/oligo-renderer-tests.ts +299 -0
- package/src/tests/polytool-enumerate-chem-tests.ts +408 -0
- package/test-console-output-1.log +303 -97
- package/test-record-1.mp4 +0 -0
- package/src/polytool/pt-enumeration-chem.ts +0 -100
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
/* eslint-disable max-lines-per-function */
|
|
2
|
+
/* eslint-disable max-len */
|
|
3
|
+
import * as grok from 'datagrok-api/grok';
|
|
4
|
+
import * as ui from 'datagrok-api/ui';
|
|
5
|
+
import * as DG from 'datagrok-api/dg';
|
|
6
|
+
|
|
7
|
+
import {RDModule} from '@datagrok-libraries/chem-meta/src/rdkit-api';
|
|
8
|
+
import {getRdKitModule} from '@datagrok-libraries/bio/src/chem/rdkit-module';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
CHEM_ENUM_MAX_RESULTS,
|
|
12
|
+
ChemEnumCore,
|
|
13
|
+
ChemEnumMode,
|
|
14
|
+
ChemEnumModes,
|
|
15
|
+
ChemEnumRGroup,
|
|
16
|
+
enumerateRaw,
|
|
17
|
+
enumerateSampleRaw,
|
|
18
|
+
extractRNumbers,
|
|
19
|
+
makeCore,
|
|
20
|
+
makeRGroup,
|
|
21
|
+
validateParams,
|
|
22
|
+
} from './pt-chem-enum';
|
|
23
|
+
import {_package} from '../package';
|
|
24
|
+
import {defaultErrorHandler} from '../utils/err-info';
|
|
25
|
+
|
|
26
|
+
const DIALOG_TITLE = 'PolyTool Chem Enumeration';
|
|
27
|
+
|
|
28
|
+
const MODE_TOOLTIPS: Record<ChemEnumMode, string> = {
|
|
29
|
+
[ChemEnumModes.Zip]: 'Zip: every R-group list must have the same length N. The i-th result uses the i-th entry from every list. Produces N results per core.',
|
|
30
|
+
[ChemEnumModes.Cartesian]: 'Cartesian: every combination of entries across R-group lists. Produces ∏|Rᵢ| results per core.',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Small thumbnail in cards; big render in hover tooltip.
|
|
34
|
+
const CARD_W = 110;
|
|
35
|
+
const CARD_H = 104;
|
|
36
|
+
const THUMB_W = 100;
|
|
37
|
+
const THUMB_H = 72;
|
|
38
|
+
const TOOLTIP_W = 320;
|
|
39
|
+
const TOOLTIP_H = 260;
|
|
40
|
+
|
|
41
|
+
// ─── Card primitives ────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
interface CardOpts {
|
|
44
|
+
smiles: string;
|
|
45
|
+
subtitle: string;
|
|
46
|
+
error?: string;
|
|
47
|
+
onEdit?: () => void;
|
|
48
|
+
onRemove?: () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Draws a molecule into a fixed-size host, constraining SVG dimensions. */
|
|
52
|
+
function drawMolInto(host: HTMLElement, smi: string, w: number, h: number): void {
|
|
53
|
+
ui.empty(host);
|
|
54
|
+
try {
|
|
55
|
+
const el = grok.chem.drawMolecule(smi, w, h);
|
|
56
|
+
el.style.width = `${w}px`;
|
|
57
|
+
el.style.height = `${h}px`;
|
|
58
|
+
el.style.maxWidth = `${w}px`;
|
|
59
|
+
el.style.maxHeight = `${h}px`;
|
|
60
|
+
el.style.display = 'block';
|
|
61
|
+
host.appendChild(el);
|
|
62
|
+
} catch {
|
|
63
|
+
host.appendChild(ui.divText('—', {style: {color: 'var(--grey-4)'}}));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildCard(opts: CardOpts): HTMLElement {
|
|
68
|
+
const thumbHost = ui.div([], {style: {
|
|
69
|
+
width: `${THUMB_W}px`, height: `${THUMB_H}px`,
|
|
70
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
71
|
+
background: 'transparent', overflow: 'hidden', flex: '0 0 auto',
|
|
72
|
+
}});
|
|
73
|
+
if (opts.smiles && !opts.error) drawMolInto(thumbHost, opts.smiles, THUMB_W, THUMB_H);
|
|
74
|
+
else thumbHost.appendChild(ui.divText('—', {style: {color: 'var(--grey-4)'}}));
|
|
75
|
+
|
|
76
|
+
const subtitleEl = ui.divText(opts.subtitle, {style: {
|
|
77
|
+
fontSize: '10px', color: opts.error ? 'var(--red-3)' : 'var(--grey-5)',
|
|
78
|
+
textAlign: 'center', padding: '2px 4px', whiteSpace: 'nowrap',
|
|
79
|
+
overflow: 'hidden', textOverflow: 'ellipsis',
|
|
80
|
+
}});
|
|
81
|
+
|
|
82
|
+
const card = ui.divV([thumbHost, subtitleEl], {style: {
|
|
83
|
+
width: `${CARD_W}px`, minWidth: `${CARD_W}px`, height: `${CARD_H}px`,
|
|
84
|
+
border: `2px solid ${opts.error ? 'var(--red-3)' : 'var(--grey-2)'}`,
|
|
85
|
+
borderRadius: '4px', position: 'relative', background: 'var(--white)',
|
|
86
|
+
boxSizing: 'border-box', margin: '0 4px 0 0', overflow: 'hidden', flex: '0 0 auto',
|
|
87
|
+
}});
|
|
88
|
+
|
|
89
|
+
// Large preview on hover — only when the card is valid. Wrap in a function for typing.
|
|
90
|
+
if (opts.smiles && !opts.error) {
|
|
91
|
+
let bigHost: HTMLElement | null = null;
|
|
92
|
+
ui.tooltip.bind(card, () => {
|
|
93
|
+
if (!bigHost) {
|
|
94
|
+
bigHost = ui.div([], {style: {width: `${TOOLTIP_W}px`, height: `${TOOLTIP_H}px`}});
|
|
95
|
+
drawMolInto(bigHost, opts.smiles, TOOLTIP_W, TOOLTIP_H);
|
|
96
|
+
}
|
|
97
|
+
return bigHost;
|
|
98
|
+
});
|
|
99
|
+
} else if (opts.error) {
|
|
100
|
+
ui.tooltip.bind(card, opts.error);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Hover-revealed action icons (edit + delete).
|
|
104
|
+
const actions = ui.divH([], {style: {
|
|
105
|
+
position: 'absolute', top: '2px', right: '2px', gap: '2px',
|
|
106
|
+
background: 'rgba(255,255,255,0.85)', borderRadius: '4px',
|
|
107
|
+
padding: '1px 2px', display: 'none',
|
|
108
|
+
}});
|
|
109
|
+
if (opts.onEdit) {
|
|
110
|
+
const editBtn = ui.icons.edit((e: MouseEvent) => { e.stopPropagation(); opts.onEdit!(); }, 'Edit');
|
|
111
|
+
actions.appendChild(editBtn);
|
|
112
|
+
}
|
|
113
|
+
if (opts.onRemove) {
|
|
114
|
+
const delBtn = ui.icons.delete((e: MouseEvent) => { e.stopPropagation(); opts.onRemove!(); }, 'Remove');
|
|
115
|
+
actions.appendChild(delBtn);
|
|
116
|
+
}
|
|
117
|
+
card.addEventListener('mouseenter', () => { actions.style.display = 'flex'; });
|
|
118
|
+
card.addEventListener('mouseleave', () => { actions.style.display = 'none'; });
|
|
119
|
+
if (opts.onEdit || opts.onRemove) card.appendChild(actions);
|
|
120
|
+
|
|
121
|
+
return card;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Draw core / R-group (single dialog with sketcher + R# below) ───────────
|
|
125
|
+
|
|
126
|
+
async function smilesFromSketcher(sk: DG.chem.Sketcher, rdkit: RDModule): Promise<string | null> {
|
|
127
|
+
const mol = sk.getMolFile();
|
|
128
|
+
if (!mol || mol.trim() === '') return null;
|
|
129
|
+
if (!DG.chem.isMolBlock(mol)) return mol.trim();
|
|
130
|
+
try {
|
|
131
|
+
const smi = await grok.functions.call('Chem:convertMolNotation', {
|
|
132
|
+
molecule: mol,
|
|
133
|
+
sourceNotation: DG.chem.Notation.MolBlock,
|
|
134
|
+
targetNotation: DG.chem.Notation.Smiles,
|
|
135
|
+
});
|
|
136
|
+
return (smi ?? '').toString().trim() || null;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Draw-a-core dialog. OK is enabled iff the sketched molecule has ≥1 R-label.
|
|
144
|
+
* `initialMolfile` preloads the sketcher for editing an existing core.
|
|
145
|
+
*/
|
|
146
|
+
async function openCoreSketchDialog(rdkit: RDModule, initialMolfile?: string): Promise<ChemEnumCore | null> {
|
|
147
|
+
return new Promise((resolve) => {
|
|
148
|
+
const sk = new DG.chem.Sketcher(DG.chem.SKETCHER_MODE.INPLACE);
|
|
149
|
+
sk.syncCurrentObject = false;
|
|
150
|
+
if (initialMolfile) sk.setMolFile(initialMolfile);
|
|
151
|
+
|
|
152
|
+
const status = ui.divText('', {style: {fontSize: '11px', padding: '4px 0', minHeight: '18px'}});
|
|
153
|
+
let currentSmiles: string | null = null;
|
|
154
|
+
let okBtn: HTMLButtonElement | null = null;
|
|
155
|
+
|
|
156
|
+
const revalidate = async () => {
|
|
157
|
+
currentSmiles = await smilesFromSketcher(sk, rdkit);
|
|
158
|
+
const rs = currentSmiles ? extractRNumbers(currentSmiles) : [];
|
|
159
|
+
if (!currentSmiles) {
|
|
160
|
+
status.innerText = 'Draw a molecule with at least one R-group label.';
|
|
161
|
+
status.style.color = 'var(--grey-4)';
|
|
162
|
+
} else if (rs.length === 0) {
|
|
163
|
+
status.innerText = 'No R-group detected — add e.g. [*:1] to mark an attachment point.';
|
|
164
|
+
status.style.color = 'var(--red-3)';
|
|
165
|
+
} else {
|
|
166
|
+
status.innerText = `Detected R-groups: ${rs.map((n) => 'R' + n).join(', ')}.`;
|
|
167
|
+
status.style.color = 'var(--green-2)';
|
|
168
|
+
}
|
|
169
|
+
if (okBtn) okBtn.disabled = !(currentSmiles && rs.length > 0);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
sk.onChanged.subscribe(() => { revalidate(); });
|
|
173
|
+
|
|
174
|
+
const body = ui.divV([sk.root, status]);
|
|
175
|
+
const dialog = ui.dialog({title: initialMolfile ? 'Edit Core' : 'Draw Core'})
|
|
176
|
+
.add(body)
|
|
177
|
+
.onOK(() => resolve(currentSmiles ? makeCore(currentSmiles, '', rdkit) : null))
|
|
178
|
+
.onCancel(() => resolve(null));
|
|
179
|
+
dialog.show({resizable: true});
|
|
180
|
+
okBtn = dialog.getButton('OK') as HTMLButtonElement;
|
|
181
|
+
okBtn.disabled = true;
|
|
182
|
+
revalidate();
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Draw-an-R-group dialog. R# is auto-inferred from the sketched SMILES.
|
|
188
|
+
* OK is enabled iff the molecule has exactly one R-label AND R# is set.
|
|
189
|
+
*/
|
|
190
|
+
async function openRGroupSketchDialog(rdkit: RDModule, initialMolfile?: string, initialRNum?: number): Promise<ChemEnumRGroup | null> {
|
|
191
|
+
return new Promise((resolve) => {
|
|
192
|
+
const sk = new DG.chem.Sketcher(DG.chem.SKETCHER_MODE.INPLACE);
|
|
193
|
+
sk.syncCurrentObject = false;
|
|
194
|
+
if (initialMolfile) sk.setMolFile(initialMolfile);
|
|
195
|
+
|
|
196
|
+
const rNumberInput = ui.input.int('Target R#', {value: initialRNum, min: 1});
|
|
197
|
+
const status = ui.divText('', {style: {fontSize: '11px', padding: '4px 0', minHeight: '18px'}});
|
|
198
|
+
let currentSmiles: string | null = null;
|
|
199
|
+
let detectedRs: number[] = [];
|
|
200
|
+
let okBtn: HTMLButtonElement | null = null;
|
|
201
|
+
let userTouchedR = false;
|
|
202
|
+
rNumberInput.onChanged.subscribe(() => { userTouchedR = true; updateOk(); });
|
|
203
|
+
|
|
204
|
+
const updateOk = () => {
|
|
205
|
+
const n = rNumberInput.value;
|
|
206
|
+
const ok = currentSmiles != null && n != null && n >= 1 && detectedRs.length === 1;
|
|
207
|
+
if (okBtn) okBtn.disabled = !ok;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const revalidate = async () => {
|
|
211
|
+
currentSmiles = await smilesFromSketcher(sk, rdkit);
|
|
212
|
+
detectedRs = currentSmiles ? extractRNumbers(currentSmiles) : [];
|
|
213
|
+
if (!currentSmiles) {
|
|
214
|
+
status.innerText = 'Draw an R-group with exactly one attachment point.';
|
|
215
|
+
status.style.color = 'var(--grey-4)';
|
|
216
|
+
} else if (detectedRs.length === 0) {
|
|
217
|
+
status.innerText = 'No R-group detected — add [*:N] to mark the attachment point.';
|
|
218
|
+
status.style.color = 'var(--red-3)';
|
|
219
|
+
} else if (detectedRs.length > 1) {
|
|
220
|
+
status.innerText = `R-group must contain exactly one attachment point. Found ${detectedRs.length}: ${detectedRs.map((n) => 'R' + n).join(', ')}.`;
|
|
221
|
+
status.style.color = 'var(--red-3)';
|
|
222
|
+
} else {
|
|
223
|
+
if (!userTouchedR || rNumberInput.value == null) rNumberInput.value = detectedRs[0];
|
|
224
|
+
status.innerText = `Detected R${detectedRs[0]}. Target R# is used for joining; change it to remap.`;
|
|
225
|
+
status.style.color = 'var(--green-2)';
|
|
226
|
+
}
|
|
227
|
+
updateOk();
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
sk.onChanged.subscribe(() => { revalidate(); });
|
|
231
|
+
|
|
232
|
+
const body = ui.divV([sk.root, rNumberInput.root, status]);
|
|
233
|
+
const dialog = ui.dialog({title: initialMolfile ? 'Edit R-Group' : 'Draw R-Group'})
|
|
234
|
+
.add(body)
|
|
235
|
+
.onOK(() => {
|
|
236
|
+
const n = rNumberInput.value;
|
|
237
|
+
if (currentSmiles && n != null) resolve(makeRGroup(currentSmiles, n, '', rdkit));
|
|
238
|
+
else resolve(null);
|
|
239
|
+
})
|
|
240
|
+
.onCancel(() => resolve(null));
|
|
241
|
+
dialog.show({resizable: true});
|
|
242
|
+
okBtn = dialog.getButton('OK') as HTMLButtonElement;
|
|
243
|
+
okBtn.disabled = true;
|
|
244
|
+
revalidate();
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─── Import wizard ──────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
function inferRNumberFromColumnName(name: string): number | null {
|
|
251
|
+
const m = name.match(/r[\s_\-:]*(\d+)/i);
|
|
252
|
+
return m ? parseInt(m[1], 10) : null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
interface ImportResult {
|
|
256
|
+
cores?: ChemEnumCore[];
|
|
257
|
+
rGroups?: ChemEnumRGroup[];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function openImportWizard(
|
|
261
|
+
kind: 'cores' | 'rgroups', rdkit: RDModule, defaultDedup: boolean,
|
|
262
|
+
): Promise<ImportResult | null> {
|
|
263
|
+
return new Promise((resolve) => {
|
|
264
|
+
const isPickable = (c: DG.Column) => c.semType === DG.SEMTYPE.MOLECULE;
|
|
265
|
+
const tableInput = ui.input.table('Table', {value: grok.shell.t ?? undefined});
|
|
266
|
+
const columnInput = ui.input.choice<string>('Column', {items: [] as string[], nullable: true});
|
|
267
|
+
const rNumberInput = kind === 'rgroups' ?
|
|
268
|
+
ui.input.int('Target R#', {value: 1, min: 1}) : null;
|
|
269
|
+
const dedupInput = ui.input.bool('Remove duplicates', {value: defaultDedup});
|
|
270
|
+
ui.tooltip.bind(dedupInput.input,
|
|
271
|
+
kind === 'cores' ?
|
|
272
|
+
'When checked, identical cores in the selected column are collapsed into one entry.' :
|
|
273
|
+
'When checked, identical R-groups are collapsed. Leave off for Zip mode if your lists are intentionally aligned by position.');
|
|
274
|
+
|
|
275
|
+
const currentColumn = (): DG.Column | null => {
|
|
276
|
+
const t = tableInput.value;
|
|
277
|
+
const n = columnInput.value;
|
|
278
|
+
return t && n ? t.col(n) : null;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const repopulateColumns = (autoPick: boolean) => {
|
|
282
|
+
const t = tableInput.value;
|
|
283
|
+
if (!t) { columnInput.items = []; columnInput.value = null; return; }
|
|
284
|
+
const cols = t.columns.toList().filter(isPickable);
|
|
285
|
+
columnInput.items = cols.map((c) => c.name);
|
|
286
|
+
if (autoPick) {
|
|
287
|
+
const mol = cols.find((c) => c.semType === DG.SEMTYPE.MOLECULE);
|
|
288
|
+
columnInput.value = (mol ?? cols[0])?.name ?? null;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const previewHost = ui.div([], {style: {height: `${CARD_H + 26}px`, overflow: 'hidden', padding: '4px'}});
|
|
293
|
+
const countText = ui.divText('', {style: {fontSize: '11px', color: 'var(--grey-5)', margin: '4px 0'}});
|
|
294
|
+
const badge = makeErrorBadge();
|
|
295
|
+
|
|
296
|
+
let built: ImportResult = {};
|
|
297
|
+
let okButton: HTMLButtonElement | null = null;
|
|
298
|
+
let vv: DG.VirtualView | null = null;
|
|
299
|
+
|
|
300
|
+
const rebuildPreview = () => {
|
|
301
|
+
badge.setErrors([]);
|
|
302
|
+
built = {};
|
|
303
|
+
const col = currentColumn();
|
|
304
|
+
if (!col) {
|
|
305
|
+
countText.innerText = '';
|
|
306
|
+
if (okButton) okButton.disabled = true;
|
|
307
|
+
if (vv) vv.setData(0, () => ui.div());
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const values: string[] = [];
|
|
312
|
+
for (let i = 0; i < col.length; i++) {
|
|
313
|
+
if (col.isNone(i)) continue;
|
|
314
|
+
const v = col.get(i);
|
|
315
|
+
if (v == null || String(v).trim() === '') continue;
|
|
316
|
+
values.push(String(v));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const dedup = dedupInput.value;
|
|
320
|
+
const items: { smi: string, err?: string, subtitle: string }[] = [];
|
|
321
|
+
const errs: string[] = [];
|
|
322
|
+
if (kind === 'cores') {
|
|
323
|
+
const parsed = values.map((v) => makeCore(v, '', rdkit));
|
|
324
|
+
let cores: ChemEnumCore[] = parsed;
|
|
325
|
+
let dupCount = 0;
|
|
326
|
+
if (dedup) {
|
|
327
|
+
const seen = new Set<string>();
|
|
328
|
+
cores = [];
|
|
329
|
+
for (const c of parsed) {
|
|
330
|
+
const k = c.error ? `err:${c.originalSmiles}` : c.smiles;
|
|
331
|
+
if (seen.has(k)) { dupCount++; continue; }
|
|
332
|
+
seen.add(k);
|
|
333
|
+
cores.push(c);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
built.cores = cores;
|
|
337
|
+
cores.forEach((c, i) => {
|
|
338
|
+
if (c.error) errs.push(`Core ${i + 1}: ${c.error}`);
|
|
339
|
+
items.push({
|
|
340
|
+
smi: c.error ? '' : c.smiles, err: c.error,
|
|
341
|
+
subtitle: c.error ? 'invalid' : c.rNumbers.map((n) => 'R' + n).join(', '),
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
countText.innerText = `${cores.length} core${cores.length === 1 ? '' : 's'}` +
|
|
345
|
+
(dedup && dupCount ? ` (${dupCount} duplicate${dupCount === 1 ? '' : 's'} skipped)` : '') + '.';
|
|
346
|
+
} else {
|
|
347
|
+
const n = rNumberInput!.value ?? 1;
|
|
348
|
+
const parsed = values.map((v) => makeRGroup(v, n, '', rdkit));
|
|
349
|
+
let rGroups: ChemEnumRGroup[] = parsed;
|
|
350
|
+
let dupCount = 0;
|
|
351
|
+
if (dedup) {
|
|
352
|
+
const seen = new Set<string>();
|
|
353
|
+
rGroups = [];
|
|
354
|
+
for (const rg of parsed) {
|
|
355
|
+
const k = rg.error ? `err:${rg.originalSmiles}` : rg.smiles;
|
|
356
|
+
if (seen.has(k)) { dupCount++; continue; }
|
|
357
|
+
seen.add(k);
|
|
358
|
+
rGroups.push(rg);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
built.rGroups = rGroups;
|
|
362
|
+
rGroups.forEach((rg, i) => {
|
|
363
|
+
if (rg.error) errs.push(`R-group ${i + 1}: ${rg.error}`);
|
|
364
|
+
items.push({
|
|
365
|
+
smi: rg.error ? '' : rg.smiles, err: rg.error,
|
|
366
|
+
subtitle: rg.error ? 'invalid' : `R${rg.rNumber}${rg.sourceRNumber != null && rg.sourceRNumber !== rg.rNumber ? ` (from R${rg.sourceRNumber})` : ''}`,
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
countText.innerText = `${rGroups.length} R-group${rGroups.length === 1 ? '' : 's'} for R${n}` +
|
|
370
|
+
(dedup && dupCount ? ` (${dupCount} duplicate${dupCount === 1 ? '' : 's'} skipped)` : '') + '.';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
badge.setErrors(errs);
|
|
374
|
+
if (!vv) {
|
|
375
|
+
vv = ui.virtualView(items.length, (i) => buildCard({smiles: items[i].smi, subtitle: items[i].subtitle, error: items[i].err}), false, 1);
|
|
376
|
+
vv.root.style.height = `${CARD_H + 12}px`;
|
|
377
|
+
vv.root.style.width = '100%';
|
|
378
|
+
previewHost.appendChild(vv.root);
|
|
379
|
+
} else {
|
|
380
|
+
vv.setData(items.length, (i) => buildCard({smiles: items[i].smi, subtitle: items[i].subtitle, error: items[i].err}));
|
|
381
|
+
}
|
|
382
|
+
if (okButton) okButton.disabled = values.length === 0;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
tableInput.onChanged.subscribe(async () => {
|
|
386
|
+
const t = tableInput.value;
|
|
387
|
+
if (t) { await t.meta.detectSemanticTypes(); await grok.data.detectSemanticTypes(t); }
|
|
388
|
+
repopulateColumns(true);
|
|
389
|
+
rebuildPreview();
|
|
390
|
+
});
|
|
391
|
+
columnInput.onChanged.subscribe(() => {
|
|
392
|
+
if (kind === 'rgroups' && columnInput.value) {
|
|
393
|
+
const inferred = inferRNumberFromColumnName(columnInput.value);
|
|
394
|
+
if (inferred != null) rNumberInput!.value = inferred;
|
|
395
|
+
}
|
|
396
|
+
rebuildPreview();
|
|
397
|
+
});
|
|
398
|
+
if (rNumberInput) rNumberInput.onChanged.subscribe(() => rebuildPreview());
|
|
399
|
+
dedupInput.onChanged.subscribe(() => rebuildPreview());
|
|
400
|
+
|
|
401
|
+
const body = ui.divV([
|
|
402
|
+
tableInput.root,
|
|
403
|
+
columnInput.root,
|
|
404
|
+
...(rNumberInput ? [rNumberInput.root] : []),
|
|
405
|
+
dedupInput.root,
|
|
406
|
+
ui.divH([countText, badge.root], {style: {alignItems: 'center', gap: '8px'}}),
|
|
407
|
+
previewHost,
|
|
408
|
+
]);
|
|
409
|
+
|
|
410
|
+
const dialog = ui.dialog({title: kind === 'cores' ? 'Import Cores' : 'Import R-Groups'})
|
|
411
|
+
.add(body)
|
|
412
|
+
.onOK(() => resolve(built))
|
|
413
|
+
.onCancel(() => resolve(null));
|
|
414
|
+
dialog.show({resizable: true, width: 640});
|
|
415
|
+
okButton = dialog.getButton('OK') as HTMLButtonElement;
|
|
416
|
+
okButton.disabled = true;
|
|
417
|
+
|
|
418
|
+
if (tableInput.value) {
|
|
419
|
+
tableInput.value.meta.detectSemanticTypes().then(() => {
|
|
420
|
+
repopulateColumns(true);
|
|
421
|
+
rebuildPreview();
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ─── Error badge ────────────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
function makeErrorBadge(): { root: HTMLElement, setErrors: (errs: string[]) => void } {
|
|
430
|
+
const root = ui.div([], {style: {
|
|
431
|
+
display: 'none', alignItems: 'center', gap: '4px',
|
|
432
|
+
padding: '2px 8px', borderRadius: '10px',
|
|
433
|
+
background: 'var(--red-2)', color: 'var(--red-3)',
|
|
434
|
+
fontSize: '11px', fontWeight: '600', cursor: 'help',
|
|
435
|
+
}});
|
|
436
|
+
return {
|
|
437
|
+
root,
|
|
438
|
+
setErrors(errs: string[]) {
|
|
439
|
+
if (errs.length === 0) { root.style.display = 'none'; return; }
|
|
440
|
+
root.style.display = 'inline-flex';
|
|
441
|
+
root.innerText = `⚠ ${errs.length} issue${errs.length === 1 ? '' : 's'}`;
|
|
442
|
+
ui.tooltip.bind(root, errs.join('\n'));
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ─── Main dialog ────────────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
interface ChemEnumDialogState {
|
|
450
|
+
cores: ChemEnumCore[];
|
|
451
|
+
rGroupsByNum: Map<number, ChemEnumRGroup[]>;
|
|
452
|
+
mode: ChemEnumMode;
|
|
453
|
+
appendToTable: DG.DataFrame | null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Extracts a molfile string from a cell whose column is of MOLECULE semtype. */
|
|
457
|
+
async function cellToMolfile(cell: DG.Cell): Promise<string | null> {
|
|
458
|
+
try {
|
|
459
|
+
if (!cell || cell.rowIndex < 0 || cell.column?.semType !== DG.SEMTYPE.MOLECULE) return null;
|
|
460
|
+
const raw = cell.value;
|
|
461
|
+
if (!raw) return null;
|
|
462
|
+
if (DG.chem.isMolBlock(raw)) return raw;
|
|
463
|
+
return (await grok.functions.call('Chem:convertMolNotation', {
|
|
464
|
+
molecule: raw,
|
|
465
|
+
sourceNotation: cell.column.getTag(DG.TAGS.UNITS) ?? DG.chem.Notation.Unknown,
|
|
466
|
+
targetNotation: DG.chem.Notation.MolBlock,
|
|
467
|
+
})) ?? null;
|
|
468
|
+
} catch {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export async function polyToolEnumerateChemUI(cell?: DG.Cell): Promise<void> {
|
|
474
|
+
await _package.initPromise;
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
const rdkit = await getRdKitModule();
|
|
478
|
+
|
|
479
|
+
// If launched from a molecule cell context menu, preload that molecule as the first core.
|
|
480
|
+
let preloadCore: ChemEnumCore | null = null;
|
|
481
|
+
if (cell) {
|
|
482
|
+
const mol = await cellToMolfile(cell);
|
|
483
|
+
if (mol) {
|
|
484
|
+
const smi = (await grok.functions.call('Chem:convertMolNotation', {
|
|
485
|
+
molecule: mol,
|
|
486
|
+
sourceNotation: DG.chem.Notation.MolBlock,
|
|
487
|
+
targetNotation: DG.chem.Notation.Smiles,
|
|
488
|
+
})) as string;
|
|
489
|
+
if (smi) {
|
|
490
|
+
const c = makeCore(smi, '', rdkit);
|
|
491
|
+
if (!c.error) preloadCore = c;
|
|
492
|
+
else grok.shell.info('Selected molecule has no R-group — add at least one R-label to use it as a core.');
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const panel = buildChemEnumPanel(rdkit, preloadCore);
|
|
498
|
+
const dialog = ui.dialog({title: DIALOG_TITLE})
|
|
499
|
+
.add(panel.root)
|
|
500
|
+
.onOK(async () => { await panel.execute(); });
|
|
501
|
+
panel.bindActionButton(dialog.getButton('OK') as HTMLButtonElement);
|
|
502
|
+
dialog.show({resizable: true, width: 960});
|
|
503
|
+
} catch (err: any) {
|
|
504
|
+
defaultErrorHandler(err);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Opens the chem enumeration UI as a top-level view (app entry point). */
|
|
509
|
+
export async function polyToolEnumerateChemApp(): Promise<DG.View | null> {
|
|
510
|
+
await _package.initPromise;
|
|
511
|
+
try {
|
|
512
|
+
const rdkit = await getRdKitModule();
|
|
513
|
+
const panel = buildChemEnumPanel(rdkit, null);
|
|
514
|
+
const runBtn = ui.bigButton('Enumerate', async () => { await panel.execute(); });
|
|
515
|
+
panel.bindActionButton(runBtn as HTMLButtonElement);
|
|
516
|
+
|
|
517
|
+
const view = DG.View.create();
|
|
518
|
+
view.name = DIALOG_TITLE;
|
|
519
|
+
view.box = true;
|
|
520
|
+
view.root.appendChild(ui.divV([panel.root, ui.div([runBtn], {style: {padding: '8px 4px 0'}})],
|
|
521
|
+
{style: {height: '100%', width: '100%', padding: '8px'}}));
|
|
522
|
+
return view;
|
|
523
|
+
} catch (err: any) {
|
|
524
|
+
defaultErrorHandler(err);
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
interface ChemEnumPanel {
|
|
530
|
+
root: HTMLElement;
|
|
531
|
+
state: ChemEnumDialogState;
|
|
532
|
+
execute: () => Promise<void>;
|
|
533
|
+
/** Bind the action (OK / Enumerate) button so it tracks validation state. */
|
|
534
|
+
bindActionButton: (btn: HTMLButtonElement) => void;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null): ChemEnumPanel {
|
|
538
|
+
const state: ChemEnumDialogState = {
|
|
539
|
+
cores: preloadCore ? [preloadCore] : [],
|
|
540
|
+
rGroupsByNum: new Map(),
|
|
541
|
+
mode: ChemEnumModes.Cartesian,
|
|
542
|
+
appendToTable: null,
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
// ── Cores: single-row horizontal virtualView ───────────────────────────────
|
|
546
|
+
const ROW_H = CARD_H + 16; // card height + horizontal scrollbar breathing room
|
|
547
|
+
const coresEmpty = ui.divText('No cores — draw or import at least one.', {style: {color: 'var(--grey-4)', padding: '20px 12px', fontSize: '12px'}});
|
|
548
|
+
const coresVvHost = ui.div([], {style: {width: '100%', height: `${ROW_H}px`, overflow: 'hidden'}});
|
|
549
|
+
let coresVv: DG.VirtualView | null = null;
|
|
550
|
+
|
|
551
|
+
const coresRenderer = (i: number): HTMLElement => {
|
|
552
|
+
const c = state.cores[i];
|
|
553
|
+
return buildCard({
|
|
554
|
+
smiles: c.error ? '' : c.smiles,
|
|
555
|
+
subtitle: c.error ? 'invalid' : `core ${i + 1} · ${c.rNumbers.map((n) => 'R' + n).join(', ')}`,
|
|
556
|
+
error: c.error,
|
|
557
|
+
onEdit: async () => {
|
|
558
|
+
const edited = await openCoreSketchDialog(rdkit, c.smiles);
|
|
559
|
+
if (edited) { state.cores[i] = edited; refresh(); }
|
|
560
|
+
},
|
|
561
|
+
onRemove: () => { state.cores.splice(i, 1); refresh(); },
|
|
562
|
+
});
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const redrawCores = () => {
|
|
566
|
+
if (state.cores.length === 0) {
|
|
567
|
+
if (coresVvHost.firstChild) ui.empty(coresVvHost);
|
|
568
|
+
coresVv = null;
|
|
569
|
+
if (coresEmpty.parentElement !== coresVvHost.parentElement)
|
|
570
|
+
coresVvHost.parentElement?.insertBefore(coresEmpty, coresVvHost.nextSibling);
|
|
571
|
+
|
|
572
|
+
coresVvHost.style.display = 'none';
|
|
573
|
+
coresEmpty.style.display = 'block';
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
coresVvHost.style.display = 'block';
|
|
577
|
+
coresEmpty.style.display = 'none';
|
|
578
|
+
if (!coresVv) {
|
|
579
|
+
coresVv = ui.virtualView(state.cores.length, coresRenderer, false, 1);
|
|
580
|
+
applyHorizontalRowStyle(coresVv.root);
|
|
581
|
+
coresVvHost.appendChild(coresVv.root);
|
|
582
|
+
} else {
|
|
583
|
+
coresVv.setData(state.cores.length, coresRenderer);
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
/** VirtualView defaults to a vertical-scroll viewport; clamp it to horizontal-only for our rows. */
|
|
588
|
+
function applyHorizontalRowStyle(vvRoot: HTMLElement) {
|
|
589
|
+
vvRoot.style.width = '100%';
|
|
590
|
+
vvRoot.style.height = `${ROW_H}px`;
|
|
591
|
+
vvRoot.style.setProperty('overflow-y', 'hidden', 'important');
|
|
592
|
+
vvRoot.style.setProperty('overflow-x', 'auto', 'important');
|
|
593
|
+
const viewport = vvRoot.firstElementChild as HTMLElement | null;
|
|
594
|
+
if (viewport) {
|
|
595
|
+
viewport.style.setProperty('overflow-y', 'hidden', 'important');
|
|
596
|
+
viewport.style.setProperty('overflow-x', 'auto', 'important');
|
|
597
|
+
viewport.style.height = `${ROW_H}px`;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ── R-Groups: capped-height column, one horizontal virtualView per R# ─────
|
|
602
|
+
const rGroupsHost = ui.div([], {style: {
|
|
603
|
+
width: '100%', padding: '2px 0',
|
|
604
|
+
flex: '1 1 auto', minHeight: '0',
|
|
605
|
+
overflowY: 'auto', overflowX: 'hidden',
|
|
606
|
+
}});
|
|
607
|
+
const rGroupsEmpty = ui.divText('No R-groups — draw or import for each R number used by your cores.', {style: {color: 'var(--grey-4)', padding: '12px', fontSize: '12px'}});
|
|
608
|
+
|
|
609
|
+
const rGroupsRenderers = new Map<number, {row: HTMLElement, vv: DG.VirtualView, header: HTMLElement}>();
|
|
610
|
+
|
|
611
|
+
const redrawRGroups = () => {
|
|
612
|
+
ui.empty(rGroupsHost);
|
|
613
|
+
rGroupsRenderers.clear();
|
|
614
|
+
|
|
615
|
+
if (state.rGroupsByNum.size === 0) { rGroupsHost.appendChild(rGroupsEmpty); return; }
|
|
616
|
+
|
|
617
|
+
const sortedNums = [...state.rGroupsByNum.keys()].sort((a, b) => a - b);
|
|
618
|
+
for (const n of sortedNums) {
|
|
619
|
+
const list = state.rGroupsByNum.get(n)!;
|
|
620
|
+
const label = ui.divText(`R${n} (${list.length})`, {style: {fontWeight: '600', fontSize: '12px', color: 'var(--grey-6)', alignSelf: 'center'}});
|
|
621
|
+
const clearBtn = ui.button('Remove all', () => { state.rGroupsByNum.delete(n); refresh(); });
|
|
622
|
+
clearBtn.style.marginLeft = '4px';
|
|
623
|
+
ui.tooltip.bind(clearBtn, `Remove all R${n} groups`);
|
|
624
|
+
const header = ui.divH([label, clearBtn], {style: {alignItems: 'center', justifyContent: 'flex-start', gap: '0', margin: '6px 0 2px'}});
|
|
625
|
+
const renderer = (i: number): HTMLElement => {
|
|
626
|
+
const rg = list[i];
|
|
627
|
+
const remap = rg.sourceRNumber != null && rg.sourceRNumber !== rg.rNumber ? ` (from R${rg.sourceRNumber})` : '';
|
|
628
|
+
return buildCard({
|
|
629
|
+
smiles: rg.error ? '' : rg.smiles,
|
|
630
|
+
subtitle: rg.error ? 'invalid' : `r group ${i + 1} · R${rg.rNumber}${remap}`,
|
|
631
|
+
error: rg.error,
|
|
632
|
+
onEdit: async () => {
|
|
633
|
+
const edited = await openRGroupSketchDialog(rdkit, rg.smiles, rg.rNumber);
|
|
634
|
+
if (!edited) return;
|
|
635
|
+
// If the R# changed, move between lists.
|
|
636
|
+
if (edited.rNumber !== rg.rNumber) {
|
|
637
|
+
list.splice(i, 1);
|
|
638
|
+
if (list.length === 0) state.rGroupsByNum.delete(rg.rNumber);
|
|
639
|
+
const target = state.rGroupsByNum.get(edited.rNumber) ?? [];
|
|
640
|
+
target.push(edited);
|
|
641
|
+
state.rGroupsByNum.set(edited.rNumber, target);
|
|
642
|
+
} else {
|
|
643
|
+
list[i] = edited;
|
|
644
|
+
}
|
|
645
|
+
refresh();
|
|
646
|
+
},
|
|
647
|
+
onRemove: () => {
|
|
648
|
+
list.splice(i, 1);
|
|
649
|
+
if (list.length === 0) state.rGroupsByNum.delete(n);
|
|
650
|
+
refresh();
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
};
|
|
654
|
+
const vv = ui.virtualView(list.length, renderer, false, 1);
|
|
655
|
+
applyHorizontalRowStyle(vv.root);
|
|
656
|
+
const row = ui.divV([header, vv.root]);
|
|
657
|
+
rGroupsHost.appendChild(row);
|
|
658
|
+
rGroupsRenderers.set(n, {row, vv, header});
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
// ── Preview (up to 12 random samples, wrapped flex) ───────────────────────
|
|
663
|
+
const PREVIEW_COUNT = 12;
|
|
664
|
+
const previewHost = ui.div([], {style: {
|
|
665
|
+
width: '100%', display: 'flex', flexWrap: 'wrap', gap: '6px',
|
|
666
|
+
alignContent: 'flex-start', padding: '2px 0', maxHeight: '450px', overflow: 'scroll'
|
|
667
|
+
}});
|
|
668
|
+
|
|
669
|
+
const redrawPreview = () => {
|
|
670
|
+
ui.empty(previewHost);
|
|
671
|
+
// Uncanonicalized but parseable — no sync RDKit work during enumeration;
|
|
672
|
+
// drawMolecule parses each visible SMILES lazily when the card renders.
|
|
673
|
+
const samples = enumerateSampleRaw(
|
|
674
|
+
{cores: state.cores, rGroups: state.rGroupsByNum, mode: state.mode, maxResults: Math.min(CHEM_ENUM_MAX_RESULTS, 1000)},
|
|
675
|
+
PREVIEW_COUNT);
|
|
676
|
+
if (samples.length === 0) {
|
|
677
|
+
previewHost.appendChild(ui.divText('Preview will appear once cores and R-groups are valid.', {style: {color: 'var(--grey-4)', padding: '20px 12px', fontSize: '12px'}}));
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
samples.forEach((s: {smiles: string; rGroupSmilesByNum: Map<number, string>}, i: number) => {
|
|
681
|
+
const rTag = [...s.rGroupSmilesByNum.keys()].sort((a, b) => a - b).map((n) => 'R' + n).join(',');
|
|
682
|
+
previewHost.appendChild(buildCard({smiles: s.smiles, subtitle: `sample ${i + 1} · ${rTag}`}));
|
|
683
|
+
});
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
// ── Status line: count text + error badge ──────────────────────────────────
|
|
687
|
+
const countText = ui.divText('', {style: {fontSize: '12px', color: 'var(--grey-6)'}});
|
|
688
|
+
const errorBadge = makeErrorBadge();
|
|
689
|
+
|
|
690
|
+
const modeInput = ui.input.choice<ChemEnumMode>('Enumerator type', {
|
|
691
|
+
value: state.mode,
|
|
692
|
+
items: Object.values(ChemEnumModes),
|
|
693
|
+
onValueChanged: (v) => { state.mode = v!; updateModeTooltip(); refresh(); },
|
|
694
|
+
}) as DG.ChoiceInput<ChemEnumMode>;
|
|
695
|
+
const updateModeTooltip = () => ui.tooltip.bind(modeInput.input, MODE_TOOLTIPS[state.mode] ?? '');
|
|
696
|
+
updateModeTooltip();
|
|
697
|
+
|
|
698
|
+
const appendToTableInput = ui.input.table('Append to table', {
|
|
699
|
+
items: grok.shell.tables, nullable: true,
|
|
700
|
+
onValueChanged: (v) => { state.appendToTable = v; },
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
let okButton: HTMLButtonElement | null = null;
|
|
704
|
+
|
|
705
|
+
/** Collects positional error messages (no reference to internal ids). */
|
|
706
|
+
const collectPositionalErrors = (): string[] => {
|
|
707
|
+
const errs: string[] = [];
|
|
708
|
+
state.cores.forEach((c, i) => { if (c.error) errs.push(`Core ${i + 1}: ${c.error}`); });
|
|
709
|
+
const sortedNums = [...state.rGroupsByNum.keys()].sort((a, b) => a - b);
|
|
710
|
+
for (const n of sortedNums) {
|
|
711
|
+
const list = state.rGroupsByNum.get(n)!;
|
|
712
|
+
list.forEach((rg, i) => { if (rg.error) errs.push(`R${n} group ${i + 1}: ${rg.error}`); });
|
|
713
|
+
}
|
|
714
|
+
const v = validateParams({cores: state.cores, rGroups: state.rGroupsByNum, mode: state.mode});
|
|
715
|
+
// Strip duplicates — core/rg errors are already listed above; add only global ones.
|
|
716
|
+
for (const m of v.errors) {
|
|
717
|
+
if (/^Core "/.test(m)) continue; // id-based core messages — already represented positionally
|
|
718
|
+
errs.push(m);
|
|
719
|
+
}
|
|
720
|
+
return errs;
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
// eslint-disable-next-line prefer-const
|
|
724
|
+
let coresClearBtn: HTMLButtonElement | undefined;
|
|
725
|
+
// eslint-disable-next-line prefer-const
|
|
726
|
+
let rGroupsClearBtn: HTMLButtonElement | undefined;
|
|
727
|
+
|
|
728
|
+
const refresh = () => {
|
|
729
|
+
redrawCores();
|
|
730
|
+
redrawRGroups();
|
|
731
|
+
const v = validateParams({cores: state.cores, rGroups: state.rGroupsByNum, mode: state.mode});
|
|
732
|
+
countText.innerText = v.predictedCount > 0 ?
|
|
733
|
+
`${v.predictedCount.toLocaleString()} molecule${v.predictedCount === 1 ? '' : 's'} will be generated` : '';
|
|
734
|
+
errorBadge.setErrors(collectPositionalErrors());
|
|
735
|
+
if (okButton) okButton.disabled = !v.ok;
|
|
736
|
+
if (coresClearBtn) coresClearBtn.disabled = state.cores.length === 0;
|
|
737
|
+
if (rGroupsClearBtn) rGroupsClearBtn.disabled = state.rGroupsByNum.size === 0;
|
|
738
|
+
redrawPreview();
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
// ── Add/import handlers (dedup now lives in the import wizard, gated by its checkbox) ──
|
|
742
|
+
const addCoreFromSketcher = async () => {
|
|
743
|
+
const c = await openCoreSketchDialog(rdkit);
|
|
744
|
+
if (!c) return;
|
|
745
|
+
state.cores.push(c);
|
|
746
|
+
refresh();
|
|
747
|
+
};
|
|
748
|
+
const addCoresFromImport = async () => {
|
|
749
|
+
const res = await openImportWizard('cores', rdkit, state.mode === ChemEnumModes.Cartesian);
|
|
750
|
+
if (!res?.cores) return;
|
|
751
|
+
state.cores.push(...res.cores);
|
|
752
|
+
refresh();
|
|
753
|
+
};
|
|
754
|
+
const addRGroupFromSketcher = async () => {
|
|
755
|
+
const rg = await openRGroupSketchDialog(rdkit);
|
|
756
|
+
if (!rg) return;
|
|
757
|
+
const list = state.rGroupsByNum.get(rg.rNumber) ?? [];
|
|
758
|
+
list.push(rg);
|
|
759
|
+
state.rGroupsByNum.set(rg.rNumber, list);
|
|
760
|
+
refresh();
|
|
761
|
+
};
|
|
762
|
+
const addRGroupsFromImport = async () => {
|
|
763
|
+
const res = await openImportWizard('rgroups', rdkit, state.mode === ChemEnumModes.Cartesian);
|
|
764
|
+
if (!res?.rGroups) return;
|
|
765
|
+
for (const rg of res.rGroups) {
|
|
766
|
+
const list = state.rGroupsByNum.get(rg.rNumber) ?? [];
|
|
767
|
+
list.push(rg);
|
|
768
|
+
state.rGroupsByNum.set(rg.rNumber, list);
|
|
769
|
+
}
|
|
770
|
+
refresh();
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
const sectionHeader = (
|
|
774
|
+
label: string,
|
|
775
|
+
onDraw?: () => void, onImport?: () => void, onClear?: () => void,
|
|
776
|
+
): {root: HTMLElement, clearBtn?: HTMLButtonElement} => {
|
|
777
|
+
const parts: HTMLElement[] = [
|
|
778
|
+
ui.divText(label, {style: {fontWeight: '600', fontSize: '12px', color: 'var(--grey-6)', alignSelf: 'center', minWidth: '60px'}}),
|
|
779
|
+
];
|
|
780
|
+
if (onDraw) {
|
|
781
|
+
const drawBtn = ui.button('+ Draw', onDraw);
|
|
782
|
+
drawBtn.style.marginLeft = '4px';
|
|
783
|
+
ui.tooltip.bind(drawBtn, `${label}: open sketcher`);
|
|
784
|
+
parts.push(drawBtn);
|
|
785
|
+
}
|
|
786
|
+
if (onImport) {
|
|
787
|
+
const importBtn = ui.button('↓ Import…', onImport);
|
|
788
|
+
importBtn.style.marginLeft = '4px';
|
|
789
|
+
ui.tooltip.bind(importBtn, `${label}: pick a table + column`);
|
|
790
|
+
parts.push(importBtn);
|
|
791
|
+
}
|
|
792
|
+
let clearBtn: HTMLButtonElement | undefined;
|
|
793
|
+
if (onClear) {
|
|
794
|
+
clearBtn = ui.button('Remove all', onClear) as HTMLButtonElement;
|
|
795
|
+
clearBtn.style.marginLeft = '4px';
|
|
796
|
+
ui.tooltip.bind(clearBtn, `Remove all ${label.toLowerCase()}`);
|
|
797
|
+
parts.push(clearBtn);
|
|
798
|
+
}
|
|
799
|
+
const root = ui.divH(parts, {style: {
|
|
800
|
+
alignItems: 'center', justifyContent: 'flex-start',
|
|
801
|
+
gap: '0', margin: '0 0 2px', flex: '0 0 auto',
|
|
802
|
+
}});
|
|
803
|
+
return {root, clearBtn};
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
const sectionStyle = {
|
|
807
|
+
display: 'flex', flexDirection: 'column',
|
|
808
|
+
minHeight: '150px', padding: '4px 0',
|
|
809
|
+
} as const;
|
|
810
|
+
|
|
811
|
+
// ── Layout: horizontal split — cores + r-groups (left 60%) | preview (right 40%) ──
|
|
812
|
+
const coresHeader = sectionHeader(
|
|
813
|
+
'Cores', addCoreFromSketcher, addCoresFromImport,
|
|
814
|
+
() => { state.cores.splice(0, state.cores.length); refresh(); },
|
|
815
|
+
);
|
|
816
|
+
coresClearBtn = coresHeader.clearBtn;
|
|
817
|
+
const coresSection = ui.divV([
|
|
818
|
+
coresHeader.root,
|
|
819
|
+
ui.div([coresVvHost, coresEmpty], {style: {padding: '0'}}),
|
|
820
|
+
], {style: sectionStyle});
|
|
821
|
+
|
|
822
|
+
const rGroupsHeader = sectionHeader(
|
|
823
|
+
'R-Groups', addRGroupFromSketcher, addRGroupsFromImport,
|
|
824
|
+
() => { state.rGroupsByNum.clear(); refresh(); },
|
|
825
|
+
);
|
|
826
|
+
rGroupsClearBtn = rGroupsHeader.clearBtn;
|
|
827
|
+
const rGroupsSection = ui.divV([
|
|
828
|
+
rGroupsHeader.root,
|
|
829
|
+
rGroupsHost,
|
|
830
|
+
], {style: {
|
|
831
|
+
display: 'flex', flexDirection: 'column',
|
|
832
|
+
maxHeight: '300px', padding: '4px 0',
|
|
833
|
+
}});
|
|
834
|
+
|
|
835
|
+
const previewSection = ui.divV([
|
|
836
|
+
sectionHeader(`Preview`).root,
|
|
837
|
+
previewHost,
|
|
838
|
+
], {style: {
|
|
839
|
+
...sectionStyle,
|
|
840
|
+
width: '100%', height: '100%',
|
|
841
|
+
overflowY: 'auto', overflowX: 'hidden',
|
|
842
|
+
}});
|
|
843
|
+
|
|
844
|
+
const leftColumn = ui.divV([coresSection, rGroupsSection], {style: {
|
|
845
|
+
flex: '0 0 60%', width: '60%',
|
|
846
|
+
display: 'flex', flexDirection: 'column', gap: '4px',
|
|
847
|
+
paddingRight: '8px', borderRight: '1px solid var(--grey-2)',
|
|
848
|
+
}});
|
|
849
|
+
|
|
850
|
+
const rightColumn = ui.divV([previewSection], {style: {
|
|
851
|
+
flex: '0 0 40%', width: '40%',
|
|
852
|
+
display: 'flex', flexDirection: 'column',
|
|
853
|
+
paddingLeft: '8px',
|
|
854
|
+
}});
|
|
855
|
+
|
|
856
|
+
const split = ui.divH([leftColumn, rightColumn], {style: {
|
|
857
|
+
width: '100%', alignItems: 'stretch', gap: '0',
|
|
858
|
+
}});
|
|
859
|
+
|
|
860
|
+
const statusLine = ui.divH([
|
|
861
|
+
modeInput.root,
|
|
862
|
+
countText,
|
|
863
|
+
errorBadge.root,
|
|
864
|
+
], {style: {alignItems: 'center', gap: '16px', padding: '4px 0'}});
|
|
865
|
+
|
|
866
|
+
const footer = ui.div([appendToTableInput.root], {style: {padding: '2px 0'}});
|
|
867
|
+
|
|
868
|
+
const body = ui.divV([
|
|
869
|
+
split, statusLine, footer,
|
|
870
|
+
], {style: {
|
|
871
|
+
width: '100%', padding: '4px',
|
|
872
|
+
display: 'flex', flexDirection: 'column', gap: '4px',
|
|
873
|
+
}});
|
|
874
|
+
|
|
875
|
+
refresh();
|
|
876
|
+
return {
|
|
877
|
+
root: body,
|
|
878
|
+
state,
|
|
879
|
+
execute: () => executeEnumeration(state, rdkit),
|
|
880
|
+
bindActionButton: (btn) => { okButton = btn; refresh(); },
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ─── Execution ──────────────────────────────────────────────────────────────
|
|
885
|
+
|
|
886
|
+
async function executeEnumeration(state: ChemEnumDialogState, _rdkit: RDModule): Promise<void> {
|
|
887
|
+
const pi = DG.TaskBarProgressIndicator.create('Enumerating...');
|
|
888
|
+
try {
|
|
889
|
+
// Stage 1 — string-level join, no RDKit calls. Produces parseable but uncanonical SMILES.
|
|
890
|
+
pi.update(10, 'Building molecules...');
|
|
891
|
+
const results = enumerateRaw({cores: state.cores, rGroups: state.rGroupsByNum, mode: state.mode});
|
|
892
|
+
if (!results) { grok.shell.warning('Enumeration failed — check validation messages in the dialog.'); return; }
|
|
893
|
+
if (results.length === 0) { grok.shell.warning('No molecules produced.'); return; }
|
|
894
|
+
|
|
895
|
+
const rNumbersUsed = new Set<number>();
|
|
896
|
+
for (const r of results) for (const n of r.rGroupSmilesByNum.keys()) rNumbersUsed.add(n);
|
|
897
|
+
const sortedRs = [...rNumbersUsed].sort((a, b) => a - b);
|
|
898
|
+
|
|
899
|
+
const smilesCol = DG.Column.fromStrings('Enumerated', results.map((r) => r.smiles));
|
|
900
|
+
smilesCol.semType = DG.SEMTYPE.MOLECULE;
|
|
901
|
+
const coreCol = DG.Column.fromStrings('Core', results.map((r) => r.coreSmiles));
|
|
902
|
+
coreCol.semType = DG.SEMTYPE.MOLECULE;
|
|
903
|
+
const rCols = sortedRs.map((n) =>
|
|
904
|
+
DG.Column.fromStrings(`R${n}`, results.map((r) => r.rGroupSmilesByNum.get(n) ?? '')));
|
|
905
|
+
for (const c of rCols) c.semType = DG.SEMTYPE.MOLECULE;
|
|
906
|
+
|
|
907
|
+
const df = DG.DataFrame.fromColumns([smilesCol, coreCol, ...rCols]);
|
|
908
|
+
df.name = 'Chem Enumeration';
|
|
909
|
+
|
|
910
|
+
// Stage 2 — canonicalize the whole Enumerated column in parallel via Chem workers.
|
|
911
|
+
pi.update(40, `Canonicalizing ${results.length.toLocaleString()} molecule(s)...`);
|
|
912
|
+
try {
|
|
913
|
+
await grok.functions.call('Chem:convertNotation', {
|
|
914
|
+
data: df,
|
|
915
|
+
molecules: smilesCol,
|
|
916
|
+
targetNotation: DG.chem.Notation.Smiles,
|
|
917
|
+
overwrite: true,
|
|
918
|
+
join: false,
|
|
919
|
+
kekulize: false,
|
|
920
|
+
});
|
|
921
|
+
} catch (err: any) {
|
|
922
|
+
// Canonicalization is a nice-to-have; the uncanonical SMILES are still valid output.
|
|
923
|
+
_package.logger.warning(`Canonicalization skipped: ${err?.message ?? err}`);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
pi.update(90, 'Finalizing...');
|
|
927
|
+
await grok.data.detectSemanticTypes(df);
|
|
928
|
+
|
|
929
|
+
if (state.appendToTable) {
|
|
930
|
+
state.appendToTable.append(df, true);
|
|
931
|
+
await state.appendToTable.meta.detectSemanticTypes();
|
|
932
|
+
} else {
|
|
933
|
+
grok.shell.addTableView(df);
|
|
934
|
+
}
|
|
935
|
+
} catch (err: any) {
|
|
936
|
+
defaultErrorHandler(err);
|
|
937
|
+
} finally {
|
|
938
|
+
pi.close();
|
|
939
|
+
}
|
|
940
|
+
}
|