@datagrok/sequence-translator 1.10.19 → 1.10.21
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 +4 -0
- package/detectors.js +4 -4
- 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/enumeration/chem_enum_cores.csv +5 -0
- package/files/enumeration/chem_enum_rgroups.csv +5 -0
- package/package.json +1 -1
- package/src/package-api.ts +2 -9
- package/src/package.g.ts +6 -13
- package/src/package.ts +9 -16
- package/src/polytool/pt-chem-enum-dialog.ts +377 -58
- package/test-console-output-1.log +164 -163
- package/test-record-1.mp4 +0 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable max-lines */
|
|
1
2
|
/* eslint-disable max-lines-per-function */
|
|
2
3
|
/* eslint-disable max-len */
|
|
3
4
|
import * as grok from 'datagrok-api/grok';
|
|
@@ -18,12 +19,13 @@ import {
|
|
|
18
19
|
extractRNumbers,
|
|
19
20
|
makeCore,
|
|
20
21
|
makeRGroup,
|
|
22
|
+
normalizeRLabels,
|
|
21
23
|
validateParams,
|
|
22
24
|
} from './pt-chem-enum';
|
|
23
25
|
import {_package} from '../package';
|
|
24
26
|
import {defaultErrorHandler} from '../utils/err-info';
|
|
25
27
|
|
|
26
|
-
const DIALOG_TITLE = '
|
|
28
|
+
const DIALOG_TITLE = 'Markush Enumerator';
|
|
27
29
|
|
|
28
30
|
const MODE_TOOLTIPS: Record<ChemEnumMode, string> = {
|
|
29
31
|
[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.',
|
|
@@ -49,11 +51,17 @@ interface CardOpts {
|
|
|
49
51
|
onRemove?: () => void;
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
// Popular multi symbol single atoms for quick lookup in card builder
|
|
55
|
+
const SINGLE_ATOM_SYMBOLS_LOOKUP = new Set([
|
|
56
|
+
'Cl', 'Br', 'Al', 'Si', 'Li', 'Na', 'Mg', 'Ca', 'Ti', 'At', 'Fe', 'Co', 'Ni', 'Cu', 'Zn', 'Ga', 'Ge', 'As', 'Se', 'Kr', 'Rb',
|
|
57
|
+
'Au', 'Ag', 'Pt', 'Pb', 'Sn', 'Sb', 'Te', 'Xe', 'Cs', 'Ba', 'La', 'Ce', 'Pr', 'Nd', 'Pm', 'Sm', 'Eu', 'Gd', 'Tb', 'Dy', 'Ho']);
|
|
58
|
+
|
|
52
59
|
/** Draws a molecule into a fixed-size host, constraining SVG dimensions. */
|
|
53
60
|
function drawMolInto(host: HTMLElement, smi: string, w: number, h: number): void {
|
|
54
61
|
ui.empty(host);
|
|
55
62
|
try {
|
|
56
|
-
const
|
|
63
|
+
const correctedSmi = smi.length === 1 || SINGLE_ATOM_SYMBOLS_LOOKUP.has(smi) ? `[${smi}]` : smi;
|
|
64
|
+
const el = grok.chem.drawMolecule(correctedSmi, w, h);
|
|
57
65
|
el.style.width = `${w}px`;
|
|
58
66
|
el.style.height = `${h}px`;
|
|
59
67
|
el.style.maxWidth = `${w}px`;
|
|
@@ -71,7 +79,8 @@ function buildCard(opts: CardOpts): HTMLElement {
|
|
|
71
79
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
72
80
|
background: 'transparent', overflow: 'hidden', flex: '0 0 auto',
|
|
73
81
|
}});
|
|
74
|
-
if (opts.smiles && !opts.error)
|
|
82
|
+
if (opts.smiles && !opts.error)
|
|
83
|
+
drawMolInto(thumbHost, opts.smiles, THUMB_W, THUMB_H);
|
|
75
84
|
else thumbHost.appendChild(ui.divText('—', {style: {color: 'var(--grey-4)'}}));
|
|
76
85
|
|
|
77
86
|
const subtitleEl = ui.divText(opts.subtitle, {style: {
|
|
@@ -478,6 +487,149 @@ interface ChemEnumDialogState {
|
|
|
478
487
|
appendToTable: DG.DataFrame | null;
|
|
479
488
|
}
|
|
480
489
|
|
|
490
|
+
// ─── History (localStorage-backed, shared by dialog + app) ──────────────────
|
|
491
|
+
|
|
492
|
+
const HISTORY_KEY = 'd4-markush-enumeration-history';
|
|
493
|
+
const HISTORY_MAX = 10;
|
|
494
|
+
|
|
495
|
+
interface ChemEnumHistoryEntry {
|
|
496
|
+
/** ISO timestamp of when the enumeration was recorded. */
|
|
497
|
+
date: string;
|
|
498
|
+
/** One-line summary: `"6 cores · R1:6 · R2:4"`. */
|
|
499
|
+
summary: string;
|
|
500
|
+
mode: ChemEnumMode;
|
|
501
|
+
/** Original (unnormalized) SMILES for each core. */
|
|
502
|
+
cores: string[];
|
|
503
|
+
/** R-number → list of original R-group SMILES. JSON object so we can round-trip. */
|
|
504
|
+
rGroups: { [rNumber: string]: string[] };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function readHistory(): ChemEnumHistoryEntry[] {
|
|
508
|
+
try {
|
|
509
|
+
const raw = localStorage.getItem(HISTORY_KEY);
|
|
510
|
+
if (!raw) return [];
|
|
511
|
+
const arr = JSON.parse(raw);
|
|
512
|
+
return Array.isArray(arr) ? arr : [];
|
|
513
|
+
} catch {
|
|
514
|
+
return [];
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function writeHistory(entries: ChemEnumHistoryEntry[]): void {
|
|
519
|
+
try {
|
|
520
|
+
localStorage.setItem(HISTORY_KEY, JSON.stringify(entries));
|
|
521
|
+
} catch {
|
|
522
|
+
// Quota exceeded or storage unavailable — silently skip; history is best-effort.
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function summarizeState(state: ChemEnumDialogState): string {
|
|
527
|
+
const partsR: string[] = [];
|
|
528
|
+
const sortedNums = [...state.rGroupsByNum.keys()].sort((a, b) => a - b);
|
|
529
|
+
for (const n of sortedNums) partsR.push(`R${n}:${state.rGroupsByNum.get(n)!.length}`);
|
|
530
|
+
return `${state.cores.length} core${state.cores.length === 1 ? '' : 's'}` +
|
|
531
|
+
(partsR.length ? ' · ' + partsR.join(' · ') : '');
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function buildHistoryEntry(state: ChemEnumDialogState): ChemEnumHistoryEntry {
|
|
535
|
+
const rGroups: { [n: string]: string[] } = {};
|
|
536
|
+
for (const [n, list] of state.rGroupsByNum)
|
|
537
|
+
rGroups[String(n)] = list.map((rg) => rg.originalSmiles);
|
|
538
|
+
return {
|
|
539
|
+
date: new Date().toISOString(),
|
|
540
|
+
summary: summarizeState(state),
|
|
541
|
+
mode: state.mode,
|
|
542
|
+
cores: state.cores.map((c) => c.originalSmiles),
|
|
543
|
+
rGroups,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Prepends `state` as a new entry, keeps at most {@link HISTORY_MAX}. */
|
|
548
|
+
function recordHistory(state: ChemEnumDialogState): void {
|
|
549
|
+
const entry = buildHistoryEntry(state);
|
|
550
|
+
const prev = readHistory();
|
|
551
|
+
writeHistory([entry, ...prev].slice(0, HISTORY_MAX));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Reparses SMILES through `makeCore` / `makeRGroup` so validation runs with the current rdkit. */
|
|
555
|
+
function applyHistoryEntry(entry: ChemEnumHistoryEntry, state: ChemEnumDialogState, rdkit: RDModule): void {
|
|
556
|
+
state.cores = entry.cores.map((smi) => makeCore(smi, '', rdkit));
|
|
557
|
+
state.rGroupsByNum = new Map();
|
|
558
|
+
for (const [nStr, list] of Object.entries(entry.rGroups ?? {})) {
|
|
559
|
+
const num = parseInt(nStr, 10);
|
|
560
|
+
if (!Number.isFinite(num)) continue;
|
|
561
|
+
state.rGroupsByNum.set(num, list.map((smi) => makeRGroup(smi, num, '', rdkit)));
|
|
562
|
+
}
|
|
563
|
+
if (entry.mode === ChemEnumModes.Zip || entry.mode === ChemEnumModes.Cartesian)
|
|
564
|
+
state.mode = entry.mode;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function formatHistoryDate(iso: string): string {
|
|
568
|
+
try {
|
|
569
|
+
const d = new Date(iso);
|
|
570
|
+
return `${d.toLocaleDateString()} ${d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}`;
|
|
571
|
+
} catch {
|
|
572
|
+
return iso;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** Pops a context menu listing history entries. `onPick` applies + refreshes. */
|
|
577
|
+
function showHistoryMenu(onPick: (entry: ChemEnumHistoryEntry) => void): void {
|
|
578
|
+
const entries = readHistory();
|
|
579
|
+
const menu = DG.Menu.popup();
|
|
580
|
+
if (entries.length === 0) {
|
|
581
|
+
menu.item('No entries', () => {});
|
|
582
|
+
} else {
|
|
583
|
+
for (const entry of entries) {
|
|
584
|
+
const label = `${formatHistoryDate(entry.date)} — ${entry.summary}`;
|
|
585
|
+
menu.item(label, () => onPick(entry));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
menu.show();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ─── CSV preload (app mode only, when history is empty) ─────────────────────
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Seeds `state` from the shipped `files/enumeration/{cores,rgroups}.csv`. The cores CSV has a
|
|
595
|
+
* single "Core" column; the R-groups CSV has one column per R-number (`R1`, `R2`, …).
|
|
596
|
+
* Returns whether anything was loaded.
|
|
597
|
+
*/
|
|
598
|
+
async function preloadFromFiles(state: ChemEnumDialogState, rdkit: RDModule): Promise<boolean> {
|
|
599
|
+
try {
|
|
600
|
+
const coresText = await _package.files.readAsText('enumeration/chem_enum_cores.csv');
|
|
601
|
+
const coresDf = DG.DataFrame.fromCsv(coresText);
|
|
602
|
+
const coreCol = coresDf.col('Core') ?? coresDf.columns.byIndex(0);
|
|
603
|
+
if (coreCol) {
|
|
604
|
+
for (let i = 0; i < coreCol.length; i++) {
|
|
605
|
+
if (coreCol.isNone(i)) continue;
|
|
606
|
+
const v = String(coreCol.get(i) ?? '').trim();
|
|
607
|
+
if (!v) continue;
|
|
608
|
+
state.cores.push(makeCore(v, '', rdkit));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const rgText = await _package.files.readAsText('enumeration/chem_enum_rgroups.csv');
|
|
613
|
+
const rgDf = DG.DataFrame.fromCsv(rgText);
|
|
614
|
+
for (const col of rgDf.columns.toList()) {
|
|
615
|
+
const m = col.name.match(/^r\s*(\d+)$/i);
|
|
616
|
+
if (!m) continue;
|
|
617
|
+
const num = parseInt(m[1], 10);
|
|
618
|
+
const list: ChemEnumRGroup[] = [];
|
|
619
|
+
for (let i = 0; i < col.length; i++) {
|
|
620
|
+
if (col.isNone(i)) continue;
|
|
621
|
+
const v = String(col.get(i) ?? '').trim();
|
|
622
|
+
if (!v) continue;
|
|
623
|
+
list.push(makeRGroup(v, num, '', rdkit));
|
|
624
|
+
}
|
|
625
|
+
if (list.length > 0) state.rGroupsByNum.set(num, list);
|
|
626
|
+
}
|
|
627
|
+
return state.cores.length > 0 || state.rGroupsByNum.size > 0;
|
|
628
|
+
} catch {
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
481
633
|
/** Extracts a molfile string from a cell whose column is of MOLECULE semtype. */
|
|
482
634
|
async function cellToMolfile(cell: DG.Cell): Promise<string | null> {
|
|
483
635
|
try {
|
|
@@ -522,9 +674,22 @@ export async function polyToolEnumerateChemUI(cell?: DG.Cell): Promise<void> {
|
|
|
522
674
|
const panel = buildChemEnumPanel(rdkit, preloadCore);
|
|
523
675
|
const dialog = ui.dialog({title: DIALOG_TITLE})
|
|
524
676
|
.add(panel.root)
|
|
677
|
+
.addButton('History', () => {
|
|
678
|
+
showHistoryMenu((entry) => {
|
|
679
|
+
applyHistoryEntry(entry, panel.state, rdkit);
|
|
680
|
+
panel.refresh();
|
|
681
|
+
});
|
|
682
|
+
})
|
|
525
683
|
.onOK(async () => { await panel.execute(); });
|
|
526
684
|
panel.bindActionButton(dialog.getButton('OK') as HTMLButtonElement);
|
|
527
685
|
dialog.show({resizable: true, width: 960});
|
|
686
|
+
const historyButton = dialog.getButton('History') as HTMLButtonElement;
|
|
687
|
+
if (historyButton) {
|
|
688
|
+
historyButton.style.order = '1';
|
|
689
|
+
historyButton.style.marginRight = 'auto';
|
|
690
|
+
ui.tooltip.bind(historyButton, 'View and apply past enumerations');
|
|
691
|
+
historyButton.innerHTML = ui.iconFA('history', () => {}).outerHTML;
|
|
692
|
+
}
|
|
528
693
|
} catch (err: any) {
|
|
529
694
|
defaultErrorHandler(err);
|
|
530
695
|
}
|
|
@@ -535,15 +700,29 @@ export async function polyToolEnumerateChemApp(): Promise<DG.View | null> {
|
|
|
535
700
|
await _package.initPromise;
|
|
536
701
|
try {
|
|
537
702
|
const rdkit = await getRdKitModule();
|
|
538
|
-
const panel = buildChemEnumPanel(rdkit, null);
|
|
539
|
-
const runBtn = ui.
|
|
703
|
+
const panel = buildChemEnumPanel(rdkit, null, 'app');
|
|
704
|
+
const runBtn = ui.button('Enumerate', async () => { await panel.execute(); });
|
|
540
705
|
panel.bindActionButton(runBtn as HTMLButtonElement);
|
|
706
|
+
panel.appActionHost?.appendChild(runBtn);
|
|
707
|
+
|
|
708
|
+
// Seed the panel: most-recent history wins; if there is none, fall back to the shipped
|
|
709
|
+
// demo CSVs so the app never opens to an empty screen. Both paths mutate panel.state and
|
|
710
|
+
// panel.refresh() picks the changes up.
|
|
711
|
+
const history = readHistory();
|
|
712
|
+
if (history.length > 0) {
|
|
713
|
+
applyHistoryEntry(history[0], panel.state, rdkit);
|
|
714
|
+
panel.refresh();
|
|
715
|
+
} else {
|
|
716
|
+
preloadFromFiles(panel.state, rdkit).then((loaded) => {
|
|
717
|
+
if (loaded) panel.refresh();
|
|
718
|
+
});
|
|
719
|
+
}
|
|
541
720
|
|
|
542
721
|
const view = DG.View.create();
|
|
543
722
|
view.name = DIALOG_TITLE;
|
|
544
723
|
view.box = true;
|
|
545
|
-
view.root.appendChild(ui.
|
|
546
|
-
{style: {height: '100%', width: '100%', padding: '8px'}}));
|
|
724
|
+
view.root.appendChild(ui.div([panel.root],
|
|
725
|
+
{style: {height: '100%', width: '100%', padding: '8px', boxSizing: 'border-box'}}));
|
|
547
726
|
return view;
|
|
548
727
|
} catch (err: any) {
|
|
549
728
|
defaultErrorHandler(err);
|
|
@@ -557,9 +736,20 @@ interface ChemEnumPanel {
|
|
|
557
736
|
execute: () => Promise<void>;
|
|
558
737
|
/** Bind the action (OK / Enumerate) button so it tracks validation state. */
|
|
559
738
|
bindActionButton: (btn: HTMLButtonElement) => void;
|
|
739
|
+
/** Re-render after mutating `state` from outside (e.g. applying a history entry). */
|
|
740
|
+
refresh: () => void;
|
|
741
|
+
/**
|
|
742
|
+
* Bottom-right "controls" cell host for the caller's Enumerate button.
|
|
743
|
+
* Only populated in app layout; undefined in dialog layout.
|
|
744
|
+
*/
|
|
745
|
+
appActionHost?: HTMLElement;
|
|
560
746
|
}
|
|
561
747
|
|
|
562
|
-
|
|
748
|
+
type ChemEnumLayout = 'dialog' | 'app';
|
|
749
|
+
|
|
750
|
+
function buildChemEnumPanel(
|
|
751
|
+
rdkit: RDModule, preloadCore: ChemEnumCore | null, layout: ChemEnumLayout = 'dialog',
|
|
752
|
+
): ChemEnumPanel {
|
|
563
753
|
const state: ChemEnumDialogState = {
|
|
564
754
|
cores: preloadCore ? [preloadCore] : [],
|
|
565
755
|
rGroupsByNum: new Map(),
|
|
@@ -567,10 +757,14 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
|
|
|
567
757
|
appendToTable: null,
|
|
568
758
|
};
|
|
569
759
|
|
|
570
|
-
// ── Cores: single-row horizontal virtualView
|
|
760
|
+
// ── Cores: single-row horizontal virtualView (dialog) or wrapped flow (app) ─
|
|
571
761
|
const ROW_H = CARD_H + 16; // card height + horizontal scrollbar breathing room
|
|
572
762
|
const coresEmpty = ui.divText('No cores — draw or import at least one.', {style: {color: 'var(--grey-4)', padding: '20px 12px', fontSize: '12px'}});
|
|
573
|
-
|
|
763
|
+
// App layout puts cores into the top-left grid cell — fill height, scroll vertically as cards wrap.
|
|
764
|
+
// Dialog layout keeps the original single-row horizontal scroller.
|
|
765
|
+
const coresVvHost = layout === 'app' ?
|
|
766
|
+
ui.div([], {style: {width: '100%', flex: '1 1 auto', minHeight: '0', overflowY: 'auto', overflowX: 'hidden'}}) :
|
|
767
|
+
ui.div([], {style: {width: '100%', height: `${ROW_H}px`, overflow: 'hidden'}});
|
|
574
768
|
let coresVv: DG.VirtualView | null = null;
|
|
575
769
|
|
|
576
770
|
const coresRenderer = (i: number): HTMLElement => {
|
|
@@ -604,6 +798,20 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
|
|
|
604
798
|
}
|
|
605
799
|
coresVvHost.style.display = 'block';
|
|
606
800
|
coresEmpty.style.display = 'none';
|
|
801
|
+
if (layout === 'app') {
|
|
802
|
+
// Cores are realistically dozens at most — render all directly in a wrapped flow so
|
|
803
|
+
// the top-left cell uses its full area instead of constraining cards to one row.
|
|
804
|
+
ui.empty(coresVvHost);
|
|
805
|
+
coresVv = null;
|
|
806
|
+
const wrap = ui.div([], {style: {
|
|
807
|
+
display: 'flex', flexWrap: 'wrap', gap: '4px',
|
|
808
|
+
width: '100%', alignContent: 'flex-start', padding: '2px 0',
|
|
809
|
+
}});
|
|
810
|
+
for (let i = 0; i < state.cores.length; i++)
|
|
811
|
+
wrap.appendChild(coresRenderer(i));
|
|
812
|
+
coresVvHost.appendChild(wrap);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
607
815
|
if (!coresVv) {
|
|
608
816
|
coresVv = ui.virtualView(state.cores.length, coresRenderer, false, 1);
|
|
609
817
|
applyHorizontalRowStyle(coresVv.root);
|
|
@@ -701,9 +909,14 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
|
|
|
701
909
|
|
|
702
910
|
// ── Preview (up to 12 random samples, wrapped flex) ───────────────────────
|
|
703
911
|
const PREVIEW_COUNT = 12;
|
|
704
|
-
|
|
912
|
+
// Dialog caps preview height at 450px (fits next to the cores/r-groups column).
|
|
913
|
+
// App layout drops the cap so preview fills its bottom-left cell.
|
|
914
|
+
const previewHost = ui.div([], {style: layout === 'app' ? {
|
|
915
|
+
width: '100%', height: '100%', display: 'flex', flexWrap: 'wrap', gap: '6px',
|
|
916
|
+
alignContent: 'flex-start', padding: '2px 0', flex: '1 1 auto', minHeight: '0', overflow: 'auto',
|
|
917
|
+
} : {
|
|
705
918
|
width: '100%', display: 'flex', flexWrap: 'wrap', gap: '6px',
|
|
706
|
-
alignContent: 'flex-start', padding: '2px 0', maxHeight: '450px', overflow: 'scroll'
|
|
919
|
+
alignContent: 'flex-start', padding: '2px 0', maxHeight: '450px', overflow: 'scroll',
|
|
707
920
|
}});
|
|
708
921
|
|
|
709
922
|
const redrawPreview = () => {
|
|
@@ -848,69 +1061,169 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
|
|
|
848
1061
|
minHeight: '150px', padding: '4px 0',
|
|
849
1062
|
} as const;
|
|
850
1063
|
|
|
851
|
-
// ──
|
|
1064
|
+
// ── Section headers (shared between layouts) ──────────────────────────────
|
|
852
1065
|
const coresHeader = sectionHeader(
|
|
853
1066
|
'Cores', addCoreFromSketcher, addCoresFromImport,
|
|
854
1067
|
() => { state.cores.splice(0, state.cores.length); refresh(); },
|
|
855
1068
|
);
|
|
856
1069
|
coresClearBtn = coresHeader.clearBtn;
|
|
857
|
-
const coresSection = ui.divV([
|
|
858
|
-
coresHeader.root,
|
|
859
|
-
ui.div([coresVvHost, coresEmpty], {style: {padding: '0'}}),
|
|
860
|
-
], {style: sectionStyle});
|
|
861
1070
|
|
|
862
1071
|
const rGroupsHeader = sectionHeader(
|
|
863
1072
|
'R-Groups', addRGroupFromSketcher, addRGroupsFromImport,
|
|
864
1073
|
() => { state.rGroupsByNum.clear(); refresh(); },
|
|
865
1074
|
);
|
|
866
1075
|
rGroupsClearBtn = rGroupsHeader.clearBtn;
|
|
867
|
-
const rGroupsSection = ui.divV([
|
|
868
|
-
rGroupsHeader.root,
|
|
869
|
-
rGroupsHost,
|
|
870
|
-
], {style: {
|
|
871
|
-
display: 'flex', flexDirection: 'column',
|
|
872
|
-
maxHeight: '300px', padding: '4px 0',
|
|
873
|
-
}});
|
|
874
1076
|
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1077
|
+
let body: HTMLElement;
|
|
1078
|
+
let appActionHost: HTMLElement | undefined;
|
|
1079
|
+
|
|
1080
|
+
if (layout === 'app') {
|
|
1081
|
+
// ── App: two flex columns. Left = cores + preview. Right = r-groups (grows) + controls (compact). ──
|
|
1082
|
+
const cellBaseStyle = {
|
|
1083
|
+
display: 'flex', flexDirection: 'column',
|
|
1084
|
+
minHeight: '0', minWidth: '0',
|
|
1085
|
+
padding: '8px', boxSizing: 'border-box',
|
|
1086
|
+
border: '1px solid var(--grey-2)', borderRadius: '4px',
|
|
1087
|
+
background: 'var(--white)', overflow: 'hidden',
|
|
1088
|
+
} as const;
|
|
1089
|
+
const growCellStyle = {...cellBaseStyle, flex: '1 1 0'} as const;
|
|
1090
|
+
|
|
1091
|
+
countText.style.fontSize = '11px';
|
|
1092
|
+
countText.style.color = 'var(--grey-5)';
|
|
1093
|
+
|
|
1094
|
+
const coresCell = ui.divV([
|
|
1095
|
+
coresHeader.root,
|
|
1096
|
+
coresVvHost,
|
|
1097
|
+
coresEmpty,
|
|
1098
|
+
], {style: growCellStyle});
|
|
1099
|
+
|
|
1100
|
+
const rGroupsCell = ui.divV([
|
|
1101
|
+
rGroupsHeader.root,
|
|
1102
|
+
rGroupsHost,
|
|
1103
|
+
], {style: growCellStyle});
|
|
1104
|
+
|
|
1105
|
+
const previewCell = ui.divV([
|
|
1106
|
+
sectionHeader('Preview').root,
|
|
1107
|
+
previewHost,
|
|
1108
|
+
], {style: growCellStyle});
|
|
883
1109
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1110
|
+
// Run header carries the live status — count + issues — inline on the right.
|
|
1111
|
+
const runLabel = ui.divText('Run', {style: {
|
|
1112
|
+
fontWeight: '600', fontSize: '12px', color: 'var(--grey-6)', alignSelf: 'center', minWidth: '40px',
|
|
1113
|
+
}});
|
|
1114
|
+
const runHeaderStats = ui.divH([countText, errorBadge.root], {style: {
|
|
1115
|
+
alignItems: 'center', gap: '8px', marginLeft: 'auto',
|
|
1116
|
+
}});
|
|
1117
|
+
const runHeader = ui.divH([runLabel, runHeaderStats], {style: {
|
|
1118
|
+
alignItems: 'center', gap: '8px', margin: '0 0 6px',
|
|
1119
|
+
flex: '0 0 auto', width: '100%',
|
|
1120
|
+
}});
|
|
889
1121
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1122
|
+
// History icon — sits next to Enumerate. Reads localStorage on click and pops a menu.
|
|
1123
|
+
const historyBtn = ui.iconFA('history', () => {
|
|
1124
|
+
showHistoryMenu((entry) => {
|
|
1125
|
+
applyHistoryEntry(entry, state, rdkit);
|
|
1126
|
+
refresh();
|
|
1127
|
+
});
|
|
1128
|
+
}, 'Show recent enumerations');
|
|
1129
|
+
historyBtn.style.fontSize = '16px';
|
|
1130
|
+
historyBtn.style.padding = '4px 6px';
|
|
1131
|
+
historyBtn.style.cursor = 'pointer';
|
|
1132
|
+
historyBtn.style.color = 'var(--blue-3)';
|
|
1133
|
+
|
|
1134
|
+
const clearAllBtn = ui.button('Clear all', () => {
|
|
1135
|
+
state.cores.splice(0, state.cores.length);
|
|
1136
|
+
state.rGroupsByNum.clear();
|
|
1137
|
+
refresh();
|
|
1138
|
+
});
|
|
1139
|
+
ui.tooltip.bind(clearAllBtn, 'Remove all cores and R-groups');
|
|
895
1140
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1141
|
+
appActionHost = ui.div([], {style: {flex: '0 0 auto'}});
|
|
1142
|
+
const actionRow = ui.divH([historyBtn, appActionHost], {style: {
|
|
1143
|
+
alignItems: 'center', gap: '8px', marginTop: '8px', flex: '0 0 auto',
|
|
1144
|
+
}});
|
|
899
1145
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1146
|
+
const controlsCell = ui.divV([
|
|
1147
|
+
runHeader,
|
|
1148
|
+
modeInput.root,
|
|
1149
|
+
appendToTableInput.root,
|
|
1150
|
+
clearAllBtn,
|
|
1151
|
+
actionRow,
|
|
1152
|
+
], {style: {
|
|
1153
|
+
...cellBaseStyle, flex: '0 0 auto', overflow: 'visible',
|
|
1154
|
+
}});
|
|
905
1155
|
|
|
906
|
-
|
|
1156
|
+
const leftColumn = ui.divV([coresCell, previewCell], {style: {
|
|
1157
|
+
flex: '1 1 50%', minWidth: '0',
|
|
1158
|
+
display: 'flex', flexDirection: 'column', gap: '8px',
|
|
1159
|
+
}});
|
|
907
1160
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1161
|
+
const rightColumn = ui.divV([rGroupsCell, controlsCell], {style: {
|
|
1162
|
+
flex: '1 1 50%', minWidth: '0',
|
|
1163
|
+
display: 'flex', flexDirection: 'column', gap: '8px',
|
|
1164
|
+
}});
|
|
1165
|
+
|
|
1166
|
+
body = ui.divH([leftColumn, rightColumn], {style: {
|
|
1167
|
+
width: '100%', height: '100%',
|
|
1168
|
+
display: 'flex', flexDirection: 'row',
|
|
1169
|
+
gap: '8px',
|
|
1170
|
+
padding: '4px', boxSizing: 'border-box',
|
|
1171
|
+
}});
|
|
1172
|
+
} else {
|
|
1173
|
+
// ── Dialog: horizontal split — cores + r-groups (left 60%) | preview (right 40%) ──
|
|
1174
|
+
const coresSection = ui.divV([
|
|
1175
|
+
coresHeader.root,
|
|
1176
|
+
ui.div([coresVvHost, coresEmpty], {style: {padding: '0'}}),
|
|
1177
|
+
], {style: sectionStyle});
|
|
1178
|
+
|
|
1179
|
+
const rGroupsSection = ui.divV([
|
|
1180
|
+
rGroupsHeader.root,
|
|
1181
|
+
rGroupsHost,
|
|
1182
|
+
], {style: {
|
|
1183
|
+
display: 'flex', flexDirection: 'column',
|
|
1184
|
+
maxHeight: '300px', padding: '4px 0',
|
|
1185
|
+
}});
|
|
1186
|
+
|
|
1187
|
+
const previewSection = ui.divV([
|
|
1188
|
+
sectionHeader(`Preview`).root,
|
|
1189
|
+
previewHost,
|
|
1190
|
+
], {style: {
|
|
1191
|
+
...sectionStyle,
|
|
1192
|
+
width: '100%', height: '100%',
|
|
1193
|
+
overflowY: 'auto', overflowX: 'hidden',
|
|
1194
|
+
}});
|
|
1195
|
+
|
|
1196
|
+
const leftColumn = ui.divV([coresSection, rGroupsSection], {style: {
|
|
1197
|
+
flex: '0 0 60%', width: '60%',
|
|
1198
|
+
display: 'flex', flexDirection: 'column', gap: '4px',
|
|
1199
|
+
paddingRight: '8px', borderRight: '1px solid var(--grey-2)',
|
|
1200
|
+
}});
|
|
1201
|
+
|
|
1202
|
+
const rightColumn = ui.divV([previewSection], {style: {
|
|
1203
|
+
flex: '0 0 40%', width: '40%',
|
|
1204
|
+
display: 'flex', flexDirection: 'column',
|
|
1205
|
+
paddingLeft: '8px',
|
|
1206
|
+
}});
|
|
1207
|
+
|
|
1208
|
+
const split = ui.divH([leftColumn, rightColumn], {style: {
|
|
1209
|
+
width: '100%', alignItems: 'stretch', gap: '0',
|
|
1210
|
+
}});
|
|
1211
|
+
|
|
1212
|
+
const statusLine = ui.divH([
|
|
1213
|
+
modeInput.root,
|
|
1214
|
+
countText,
|
|
1215
|
+
errorBadge.root,
|
|
1216
|
+
], {style: {alignItems: 'center', gap: '16px', padding: '4px 0'}});
|
|
1217
|
+
|
|
1218
|
+
const footer = ui.div([appendToTableInput.root], {style: {padding: '2px 0'}});
|
|
1219
|
+
|
|
1220
|
+
body = ui.divV([
|
|
1221
|
+
split, statusLine, footer,
|
|
1222
|
+
], {style: {
|
|
1223
|
+
width: '100%', padding: '4px',
|
|
1224
|
+
display: 'flex', flexDirection: 'column', gap: '4px',
|
|
1225
|
+
}});
|
|
1226
|
+
}
|
|
914
1227
|
|
|
915
1228
|
refresh();
|
|
916
1229
|
return {
|
|
@@ -918,6 +1231,8 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
|
|
|
918
1231
|
state,
|
|
919
1232
|
execute: () => executeEnumeration(state, rdkit),
|
|
920
1233
|
bindActionButton: (btn) => { okButton = btn; refresh(); },
|
|
1234
|
+
refresh,
|
|
1235
|
+
appActionHost,
|
|
921
1236
|
};
|
|
922
1237
|
}
|
|
923
1238
|
|
|
@@ -932,13 +1247,17 @@ async function executeEnumeration(state: ChemEnumDialogState, _rdkit: RDModule):
|
|
|
932
1247
|
if (!results) { grok.shell.warning('Enumeration failed — check validation messages in the dialog.'); return; }
|
|
933
1248
|
if (results.length === 0) { grok.shell.warning('No molecules produced.'); return; }
|
|
934
1249
|
|
|
1250
|
+
// Record this configuration in history before any post-processing — if the user closes
|
|
1251
|
+
// mid-canonicalization, they can still recover the inputs that produced something.
|
|
1252
|
+
recordHistory(state);
|
|
1253
|
+
|
|
935
1254
|
const rNumbersUsed = new Set<number>();
|
|
936
1255
|
for (const r of results) for (const n of r.rGroupSmilesByNum.keys()) rNumbersUsed.add(n);
|
|
937
1256
|
const sortedRs = [...rNumbersUsed].sort((a, b) => a - b);
|
|
938
1257
|
|
|
939
1258
|
const smilesCol = DG.Column.fromStrings('Enumerated', results.map((r) => r.smiles));
|
|
940
1259
|
smilesCol.semType = DG.SEMTYPE.MOLECULE;
|
|
941
|
-
const coreCol = DG.Column.fromStrings('Core', results.map((r) => r.coreSmiles));
|
|
1260
|
+
const coreCol = DG.Column.fromStrings('Core', results.map((r) => normalizeRLabels(r.coreSmiles ?? '')));
|
|
942
1261
|
coreCol.semType = DG.SEMTYPE.MOLECULE;
|
|
943
1262
|
const rCols = sortedRs.map((n) =>
|
|
944
1263
|
DG.Column.fromStrings(`R${n}`, results.map((r) => r.rGroupSmilesByNum.get(n) ?? '')));
|