@datagrok/sequence-translator 1.10.18 → 1.10.20
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/oligo-renderer/canvas-renderer.ts +4 -3
- package/src/oligo-renderer/cell-renderer.ts +19 -20
- 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 +366 -55
- package/test-console-output-1.log +154 -154
- 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';
|
|
@@ -23,7 +24,7 @@ import {
|
|
|
23
24
|
import {_package} from '../package';
|
|
24
25
|
import {defaultErrorHandler} from '../utils/err-info';
|
|
25
26
|
|
|
26
|
-
const DIALOG_TITLE = '
|
|
27
|
+
const DIALOG_TITLE = 'Markush Enumerator';
|
|
27
28
|
|
|
28
29
|
const MODE_TOOLTIPS: Record<ChemEnumMode, string> = {
|
|
29
30
|
[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.',
|
|
@@ -478,6 +479,149 @@ interface ChemEnumDialogState {
|
|
|
478
479
|
appendToTable: DG.DataFrame | null;
|
|
479
480
|
}
|
|
480
481
|
|
|
482
|
+
// ─── History (localStorage-backed, shared by dialog + app) ──────────────────
|
|
483
|
+
|
|
484
|
+
const HISTORY_KEY = 'd4-markush-enumeration-history';
|
|
485
|
+
const HISTORY_MAX = 10;
|
|
486
|
+
|
|
487
|
+
interface ChemEnumHistoryEntry {
|
|
488
|
+
/** ISO timestamp of when the enumeration was recorded. */
|
|
489
|
+
date: string;
|
|
490
|
+
/** One-line summary: `"6 cores · R1:6 · R2:4"`. */
|
|
491
|
+
summary: string;
|
|
492
|
+
mode: ChemEnumMode;
|
|
493
|
+
/** Original (unnormalized) SMILES for each core. */
|
|
494
|
+
cores: string[];
|
|
495
|
+
/** R-number → list of original R-group SMILES. JSON object so we can round-trip. */
|
|
496
|
+
rGroups: { [rNumber: string]: string[] };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function readHistory(): ChemEnumHistoryEntry[] {
|
|
500
|
+
try {
|
|
501
|
+
const raw = localStorage.getItem(HISTORY_KEY);
|
|
502
|
+
if (!raw) return [];
|
|
503
|
+
const arr = JSON.parse(raw);
|
|
504
|
+
return Array.isArray(arr) ? arr : [];
|
|
505
|
+
} catch {
|
|
506
|
+
return [];
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function writeHistory(entries: ChemEnumHistoryEntry[]): void {
|
|
511
|
+
try {
|
|
512
|
+
localStorage.setItem(HISTORY_KEY, JSON.stringify(entries));
|
|
513
|
+
} catch {
|
|
514
|
+
// Quota exceeded or storage unavailable — silently skip; history is best-effort.
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function summarizeState(state: ChemEnumDialogState): string {
|
|
519
|
+
const partsR: string[] = [];
|
|
520
|
+
const sortedNums = [...state.rGroupsByNum.keys()].sort((a, b) => a - b);
|
|
521
|
+
for (const n of sortedNums) partsR.push(`R${n}:${state.rGroupsByNum.get(n)!.length}`);
|
|
522
|
+
return `${state.cores.length} core${state.cores.length === 1 ? '' : 's'}` +
|
|
523
|
+
(partsR.length ? ' · ' + partsR.join(' · ') : '');
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function buildHistoryEntry(state: ChemEnumDialogState): ChemEnumHistoryEntry {
|
|
527
|
+
const rGroups: { [n: string]: string[] } = {};
|
|
528
|
+
for (const [n, list] of state.rGroupsByNum)
|
|
529
|
+
rGroups[String(n)] = list.map((rg) => rg.originalSmiles);
|
|
530
|
+
return {
|
|
531
|
+
date: new Date().toISOString(),
|
|
532
|
+
summary: summarizeState(state),
|
|
533
|
+
mode: state.mode,
|
|
534
|
+
cores: state.cores.map((c) => c.originalSmiles),
|
|
535
|
+
rGroups,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Prepends `state` as a new entry, keeps at most {@link HISTORY_MAX}. */
|
|
540
|
+
function recordHistory(state: ChemEnumDialogState): void {
|
|
541
|
+
const entry = buildHistoryEntry(state);
|
|
542
|
+
const prev = readHistory();
|
|
543
|
+
writeHistory([entry, ...prev].slice(0, HISTORY_MAX));
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** Reparses SMILES through `makeCore` / `makeRGroup` so validation runs with the current rdkit. */
|
|
547
|
+
function applyHistoryEntry(entry: ChemEnumHistoryEntry, state: ChemEnumDialogState, rdkit: RDModule): void {
|
|
548
|
+
state.cores = entry.cores.map((smi) => makeCore(smi, '', rdkit));
|
|
549
|
+
state.rGroupsByNum = new Map();
|
|
550
|
+
for (const [nStr, list] of Object.entries(entry.rGroups ?? {})) {
|
|
551
|
+
const num = parseInt(nStr, 10);
|
|
552
|
+
if (!Number.isFinite(num)) continue;
|
|
553
|
+
state.rGroupsByNum.set(num, list.map((smi) => makeRGroup(smi, num, '', rdkit)));
|
|
554
|
+
}
|
|
555
|
+
if (entry.mode === ChemEnumModes.Zip || entry.mode === ChemEnumModes.Cartesian)
|
|
556
|
+
state.mode = entry.mode;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function formatHistoryDate(iso: string): string {
|
|
560
|
+
try {
|
|
561
|
+
const d = new Date(iso);
|
|
562
|
+
return `${d.toLocaleDateString()} ${d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}`;
|
|
563
|
+
} catch {
|
|
564
|
+
return iso;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Pops a context menu listing history entries. `onPick` applies + refreshes. */
|
|
569
|
+
function showHistoryMenu(onPick: (entry: ChemEnumHistoryEntry) => void): void {
|
|
570
|
+
const entries = readHistory();
|
|
571
|
+
const menu = DG.Menu.popup();
|
|
572
|
+
if (entries.length === 0) {
|
|
573
|
+
menu.item('No entries', () => {});
|
|
574
|
+
} else {
|
|
575
|
+
for (const entry of entries) {
|
|
576
|
+
const label = `${formatHistoryDate(entry.date)} — ${entry.summary}`;
|
|
577
|
+
menu.item(label, () => onPick(entry));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
menu.show();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ─── CSV preload (app mode only, when history is empty) ─────────────────────
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Seeds `state` from the shipped `files/enumeration/{cores,rgroups}.csv`. The cores CSV has a
|
|
587
|
+
* single "Core" column; the R-groups CSV has one column per R-number (`R1`, `R2`, …).
|
|
588
|
+
* Returns whether anything was loaded.
|
|
589
|
+
*/
|
|
590
|
+
async function preloadFromFiles(state: ChemEnumDialogState, rdkit: RDModule): Promise<boolean> {
|
|
591
|
+
try {
|
|
592
|
+
const coresText = await _package.files.readAsText('enumeration/chem_enum_cores.csv');
|
|
593
|
+
const coresDf = DG.DataFrame.fromCsv(coresText);
|
|
594
|
+
const coreCol = coresDf.col('Core') ?? coresDf.columns.byIndex(0);
|
|
595
|
+
if (coreCol) {
|
|
596
|
+
for (let i = 0; i < coreCol.length; i++) {
|
|
597
|
+
if (coreCol.isNone(i)) continue;
|
|
598
|
+
const v = String(coreCol.get(i) ?? '').trim();
|
|
599
|
+
if (!v) continue;
|
|
600
|
+
state.cores.push(makeCore(v, '', rdkit));
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const rgText = await _package.files.readAsText('enumeration/chem_enum_rgroups.csv');
|
|
605
|
+
const rgDf = DG.DataFrame.fromCsv(rgText);
|
|
606
|
+
for (const col of rgDf.columns.toList()) {
|
|
607
|
+
const m = col.name.match(/^r\s*(\d+)$/i);
|
|
608
|
+
if (!m) continue;
|
|
609
|
+
const num = parseInt(m[1], 10);
|
|
610
|
+
const list: ChemEnumRGroup[] = [];
|
|
611
|
+
for (let i = 0; i < col.length; i++) {
|
|
612
|
+
if (col.isNone(i)) continue;
|
|
613
|
+
const v = String(col.get(i) ?? '').trim();
|
|
614
|
+
if (!v) continue;
|
|
615
|
+
list.push(makeRGroup(v, num, '', rdkit));
|
|
616
|
+
}
|
|
617
|
+
if (list.length > 0) state.rGroupsByNum.set(num, list);
|
|
618
|
+
}
|
|
619
|
+
return state.cores.length > 0 || state.rGroupsByNum.size > 0;
|
|
620
|
+
} catch {
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
481
625
|
/** Extracts a molfile string from a cell whose column is of MOLECULE semtype. */
|
|
482
626
|
async function cellToMolfile(cell: DG.Cell): Promise<string | null> {
|
|
483
627
|
try {
|
|
@@ -522,9 +666,22 @@ export async function polyToolEnumerateChemUI(cell?: DG.Cell): Promise<void> {
|
|
|
522
666
|
const panel = buildChemEnumPanel(rdkit, preloadCore);
|
|
523
667
|
const dialog = ui.dialog({title: DIALOG_TITLE})
|
|
524
668
|
.add(panel.root)
|
|
669
|
+
.addButton('History', () => {
|
|
670
|
+
showHistoryMenu((entry) => {
|
|
671
|
+
applyHistoryEntry(entry, panel.state, rdkit);
|
|
672
|
+
panel.refresh();
|
|
673
|
+
});
|
|
674
|
+
})
|
|
525
675
|
.onOK(async () => { await panel.execute(); });
|
|
526
676
|
panel.bindActionButton(dialog.getButton('OK') as HTMLButtonElement);
|
|
527
677
|
dialog.show({resizable: true, width: 960});
|
|
678
|
+
const historyButton = dialog.getButton('History') as HTMLButtonElement;
|
|
679
|
+
if (historyButton) {
|
|
680
|
+
historyButton.style.order = '1';
|
|
681
|
+
historyButton.style.marginRight = 'auto';
|
|
682
|
+
ui.tooltip.bind(historyButton, 'View and apply past enumerations');
|
|
683
|
+
historyButton.innerHTML = ui.iconFA('history', () => {}).outerHTML;
|
|
684
|
+
}
|
|
528
685
|
} catch (err: any) {
|
|
529
686
|
defaultErrorHandler(err);
|
|
530
687
|
}
|
|
@@ -535,15 +692,29 @@ export async function polyToolEnumerateChemApp(): Promise<DG.View | null> {
|
|
|
535
692
|
await _package.initPromise;
|
|
536
693
|
try {
|
|
537
694
|
const rdkit = await getRdKitModule();
|
|
538
|
-
const panel = buildChemEnumPanel(rdkit, null);
|
|
539
|
-
const runBtn = ui.
|
|
695
|
+
const panel = buildChemEnumPanel(rdkit, null, 'app');
|
|
696
|
+
const runBtn = ui.button('Enumerate', async () => { await panel.execute(); });
|
|
540
697
|
panel.bindActionButton(runBtn as HTMLButtonElement);
|
|
698
|
+
panel.appActionHost?.appendChild(runBtn);
|
|
699
|
+
|
|
700
|
+
// Seed the panel: most-recent history wins; if there is none, fall back to the shipped
|
|
701
|
+
// demo CSVs so the app never opens to an empty screen. Both paths mutate panel.state and
|
|
702
|
+
// panel.refresh() picks the changes up.
|
|
703
|
+
const history = readHistory();
|
|
704
|
+
if (history.length > 0) {
|
|
705
|
+
applyHistoryEntry(history[0], panel.state, rdkit);
|
|
706
|
+
panel.refresh();
|
|
707
|
+
} else {
|
|
708
|
+
preloadFromFiles(panel.state, rdkit).then((loaded) => {
|
|
709
|
+
if (loaded) panel.refresh();
|
|
710
|
+
});
|
|
711
|
+
}
|
|
541
712
|
|
|
542
713
|
const view = DG.View.create();
|
|
543
714
|
view.name = DIALOG_TITLE;
|
|
544
715
|
view.box = true;
|
|
545
|
-
view.root.appendChild(ui.
|
|
546
|
-
{style: {height: '100%', width: '100%', padding: '8px'}}));
|
|
716
|
+
view.root.appendChild(ui.div([panel.root],
|
|
717
|
+
{style: {height: '100%', width: '100%', padding: '8px', boxSizing: 'border-box'}}));
|
|
547
718
|
return view;
|
|
548
719
|
} catch (err: any) {
|
|
549
720
|
defaultErrorHandler(err);
|
|
@@ -557,9 +728,20 @@ interface ChemEnumPanel {
|
|
|
557
728
|
execute: () => Promise<void>;
|
|
558
729
|
/** Bind the action (OK / Enumerate) button so it tracks validation state. */
|
|
559
730
|
bindActionButton: (btn: HTMLButtonElement) => void;
|
|
731
|
+
/** Re-render after mutating `state` from outside (e.g. applying a history entry). */
|
|
732
|
+
refresh: () => void;
|
|
733
|
+
/**
|
|
734
|
+
* Bottom-right "controls" cell host for the caller's Enumerate button.
|
|
735
|
+
* Only populated in app layout; undefined in dialog layout.
|
|
736
|
+
*/
|
|
737
|
+
appActionHost?: HTMLElement;
|
|
560
738
|
}
|
|
561
739
|
|
|
562
|
-
|
|
740
|
+
type ChemEnumLayout = 'dialog' | 'app';
|
|
741
|
+
|
|
742
|
+
function buildChemEnumPanel(
|
|
743
|
+
rdkit: RDModule, preloadCore: ChemEnumCore | null, layout: ChemEnumLayout = 'dialog',
|
|
744
|
+
): ChemEnumPanel {
|
|
563
745
|
const state: ChemEnumDialogState = {
|
|
564
746
|
cores: preloadCore ? [preloadCore] : [],
|
|
565
747
|
rGroupsByNum: new Map(),
|
|
@@ -567,10 +749,14 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
|
|
|
567
749
|
appendToTable: null,
|
|
568
750
|
};
|
|
569
751
|
|
|
570
|
-
// ── Cores: single-row horizontal virtualView
|
|
752
|
+
// ── Cores: single-row horizontal virtualView (dialog) or wrapped flow (app) ─
|
|
571
753
|
const ROW_H = CARD_H + 16; // card height + horizontal scrollbar breathing room
|
|
572
754
|
const coresEmpty = ui.divText('No cores — draw or import at least one.', {style: {color: 'var(--grey-4)', padding: '20px 12px', fontSize: '12px'}});
|
|
573
|
-
|
|
755
|
+
// App layout puts cores into the top-left grid cell — fill height, scroll vertically as cards wrap.
|
|
756
|
+
// Dialog layout keeps the original single-row horizontal scroller.
|
|
757
|
+
const coresVvHost = layout === 'app' ?
|
|
758
|
+
ui.div([], {style: {width: '100%', flex: '1 1 auto', minHeight: '0', overflowY: 'auto', overflowX: 'hidden'}}) :
|
|
759
|
+
ui.div([], {style: {width: '100%', height: `${ROW_H}px`, overflow: 'hidden'}});
|
|
574
760
|
let coresVv: DG.VirtualView | null = null;
|
|
575
761
|
|
|
576
762
|
const coresRenderer = (i: number): HTMLElement => {
|
|
@@ -604,6 +790,20 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
|
|
|
604
790
|
}
|
|
605
791
|
coresVvHost.style.display = 'block';
|
|
606
792
|
coresEmpty.style.display = 'none';
|
|
793
|
+
if (layout === 'app') {
|
|
794
|
+
// Cores are realistically dozens at most — render all directly in a wrapped flow so
|
|
795
|
+
// the top-left cell uses its full area instead of constraining cards to one row.
|
|
796
|
+
ui.empty(coresVvHost);
|
|
797
|
+
coresVv = null;
|
|
798
|
+
const wrap = ui.div([], {style: {
|
|
799
|
+
display: 'flex', flexWrap: 'wrap', gap: '4px',
|
|
800
|
+
width: '100%', alignContent: 'flex-start', padding: '2px 0',
|
|
801
|
+
}});
|
|
802
|
+
for (let i = 0; i < state.cores.length; i++)
|
|
803
|
+
wrap.appendChild(coresRenderer(i));
|
|
804
|
+
coresVvHost.appendChild(wrap);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
607
807
|
if (!coresVv) {
|
|
608
808
|
coresVv = ui.virtualView(state.cores.length, coresRenderer, false, 1);
|
|
609
809
|
applyHorizontalRowStyle(coresVv.root);
|
|
@@ -701,9 +901,14 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
|
|
|
701
901
|
|
|
702
902
|
// ── Preview (up to 12 random samples, wrapped flex) ───────────────────────
|
|
703
903
|
const PREVIEW_COUNT = 12;
|
|
704
|
-
|
|
904
|
+
// Dialog caps preview height at 450px (fits next to the cores/r-groups column).
|
|
905
|
+
// App layout drops the cap so preview fills its bottom-left cell.
|
|
906
|
+
const previewHost = ui.div([], {style: layout === 'app' ? {
|
|
907
|
+
width: '100%', height: '100%', display: 'flex', flexWrap: 'wrap', gap: '6px',
|
|
908
|
+
alignContent: 'flex-start', padding: '2px 0', flex: '1 1 auto', minHeight: '0', overflow: 'auto',
|
|
909
|
+
} : {
|
|
705
910
|
width: '100%', display: 'flex', flexWrap: 'wrap', gap: '6px',
|
|
706
|
-
alignContent: 'flex-start', padding: '2px 0', maxHeight: '450px', overflow: 'scroll'
|
|
911
|
+
alignContent: 'flex-start', padding: '2px 0', maxHeight: '450px', overflow: 'scroll',
|
|
707
912
|
}});
|
|
708
913
|
|
|
709
914
|
const redrawPreview = () => {
|
|
@@ -848,69 +1053,169 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
|
|
|
848
1053
|
minHeight: '150px', padding: '4px 0',
|
|
849
1054
|
} as const;
|
|
850
1055
|
|
|
851
|
-
// ──
|
|
1056
|
+
// ── Section headers (shared between layouts) ──────────────────────────────
|
|
852
1057
|
const coresHeader = sectionHeader(
|
|
853
1058
|
'Cores', addCoreFromSketcher, addCoresFromImport,
|
|
854
1059
|
() => { state.cores.splice(0, state.cores.length); refresh(); },
|
|
855
1060
|
);
|
|
856
1061
|
coresClearBtn = coresHeader.clearBtn;
|
|
857
|
-
const coresSection = ui.divV([
|
|
858
|
-
coresHeader.root,
|
|
859
|
-
ui.div([coresVvHost, coresEmpty], {style: {padding: '0'}}),
|
|
860
|
-
], {style: sectionStyle});
|
|
861
1062
|
|
|
862
1063
|
const rGroupsHeader = sectionHeader(
|
|
863
1064
|
'R-Groups', addRGroupFromSketcher, addRGroupsFromImport,
|
|
864
1065
|
() => { state.rGroupsByNum.clear(); refresh(); },
|
|
865
1066
|
);
|
|
866
1067
|
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
1068
|
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1069
|
+
let body: HTMLElement;
|
|
1070
|
+
let appActionHost: HTMLElement | undefined;
|
|
1071
|
+
|
|
1072
|
+
if (layout === 'app') {
|
|
1073
|
+
// ── App: two flex columns. Left = cores + preview. Right = r-groups (grows) + controls (compact). ──
|
|
1074
|
+
const cellBaseStyle = {
|
|
1075
|
+
display: 'flex', flexDirection: 'column',
|
|
1076
|
+
minHeight: '0', minWidth: '0',
|
|
1077
|
+
padding: '8px', boxSizing: 'border-box',
|
|
1078
|
+
border: '1px solid var(--grey-2)', borderRadius: '4px',
|
|
1079
|
+
background: 'var(--white)', overflow: 'hidden',
|
|
1080
|
+
} as const;
|
|
1081
|
+
const growCellStyle = {...cellBaseStyle, flex: '1 1 0'} as const;
|
|
1082
|
+
|
|
1083
|
+
countText.style.fontSize = '11px';
|
|
1084
|
+
countText.style.color = 'var(--grey-5)';
|
|
1085
|
+
|
|
1086
|
+
const coresCell = ui.divV([
|
|
1087
|
+
coresHeader.root,
|
|
1088
|
+
coresVvHost,
|
|
1089
|
+
coresEmpty,
|
|
1090
|
+
], {style: growCellStyle});
|
|
1091
|
+
|
|
1092
|
+
const rGroupsCell = ui.divV([
|
|
1093
|
+
rGroupsHeader.root,
|
|
1094
|
+
rGroupsHost,
|
|
1095
|
+
], {style: growCellStyle});
|
|
1096
|
+
|
|
1097
|
+
const previewCell = ui.divV([
|
|
1098
|
+
sectionHeader('Preview').root,
|
|
1099
|
+
previewHost,
|
|
1100
|
+
], {style: growCellStyle});
|
|
883
1101
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1102
|
+
// Run header carries the live status — count + issues — inline on the right.
|
|
1103
|
+
const runLabel = ui.divText('Run', {style: {
|
|
1104
|
+
fontWeight: '600', fontSize: '12px', color: 'var(--grey-6)', alignSelf: 'center', minWidth: '40px',
|
|
1105
|
+
}});
|
|
1106
|
+
const runHeaderStats = ui.divH([countText, errorBadge.root], {style: {
|
|
1107
|
+
alignItems: 'center', gap: '8px', marginLeft: 'auto',
|
|
1108
|
+
}});
|
|
1109
|
+
const runHeader = ui.divH([runLabel, runHeaderStats], {style: {
|
|
1110
|
+
alignItems: 'center', gap: '8px', margin: '0 0 6px',
|
|
1111
|
+
flex: '0 0 auto', width: '100%',
|
|
1112
|
+
}});
|
|
889
1113
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1114
|
+
// History icon — sits next to Enumerate. Reads localStorage on click and pops a menu.
|
|
1115
|
+
const historyBtn = ui.iconFA('history', () => {
|
|
1116
|
+
showHistoryMenu((entry) => {
|
|
1117
|
+
applyHistoryEntry(entry, state, rdkit);
|
|
1118
|
+
refresh();
|
|
1119
|
+
});
|
|
1120
|
+
}, 'Show recent enumerations');
|
|
1121
|
+
historyBtn.style.fontSize = '16px';
|
|
1122
|
+
historyBtn.style.padding = '4px 6px';
|
|
1123
|
+
historyBtn.style.cursor = 'pointer';
|
|
1124
|
+
historyBtn.style.color = 'var(--blue-3)';
|
|
1125
|
+
|
|
1126
|
+
const clearAllBtn = ui.button('Clear all', () => {
|
|
1127
|
+
state.cores.splice(0, state.cores.length);
|
|
1128
|
+
state.rGroupsByNum.clear();
|
|
1129
|
+
refresh();
|
|
1130
|
+
});
|
|
1131
|
+
ui.tooltip.bind(clearAllBtn, 'Remove all cores and R-groups');
|
|
895
1132
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1133
|
+
appActionHost = ui.div([], {style: {flex: '0 0 auto'}});
|
|
1134
|
+
const actionRow = ui.divH([historyBtn, appActionHost], {style: {
|
|
1135
|
+
alignItems: 'center', gap: '8px', marginTop: '8px', flex: '0 0 auto',
|
|
1136
|
+
}});
|
|
899
1137
|
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1138
|
+
const controlsCell = ui.divV([
|
|
1139
|
+
runHeader,
|
|
1140
|
+
modeInput.root,
|
|
1141
|
+
appendToTableInput.root,
|
|
1142
|
+
clearAllBtn,
|
|
1143
|
+
actionRow,
|
|
1144
|
+
], {style: {
|
|
1145
|
+
...cellBaseStyle, flex: '0 0 auto', overflow: 'visible',
|
|
1146
|
+
}});
|
|
905
1147
|
|
|
906
|
-
|
|
1148
|
+
const leftColumn = ui.divV([coresCell, previewCell], {style: {
|
|
1149
|
+
flex: '1 1 50%', minWidth: '0',
|
|
1150
|
+
display: 'flex', flexDirection: 'column', gap: '8px',
|
|
1151
|
+
}});
|
|
907
1152
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1153
|
+
const rightColumn = ui.divV([rGroupsCell, controlsCell], {style: {
|
|
1154
|
+
flex: '1 1 50%', minWidth: '0',
|
|
1155
|
+
display: 'flex', flexDirection: 'column', gap: '8px',
|
|
1156
|
+
}});
|
|
1157
|
+
|
|
1158
|
+
body = ui.divH([leftColumn, rightColumn], {style: {
|
|
1159
|
+
width: '100%', height: '100%',
|
|
1160
|
+
display: 'flex', flexDirection: 'row',
|
|
1161
|
+
gap: '8px',
|
|
1162
|
+
padding: '4px', boxSizing: 'border-box',
|
|
1163
|
+
}});
|
|
1164
|
+
} else {
|
|
1165
|
+
// ── Dialog: horizontal split — cores + r-groups (left 60%) | preview (right 40%) ──
|
|
1166
|
+
const coresSection = ui.divV([
|
|
1167
|
+
coresHeader.root,
|
|
1168
|
+
ui.div([coresVvHost, coresEmpty], {style: {padding: '0'}}),
|
|
1169
|
+
], {style: sectionStyle});
|
|
1170
|
+
|
|
1171
|
+
const rGroupsSection = ui.divV([
|
|
1172
|
+
rGroupsHeader.root,
|
|
1173
|
+
rGroupsHost,
|
|
1174
|
+
], {style: {
|
|
1175
|
+
display: 'flex', flexDirection: 'column',
|
|
1176
|
+
maxHeight: '300px', padding: '4px 0',
|
|
1177
|
+
}});
|
|
1178
|
+
|
|
1179
|
+
const previewSection = ui.divV([
|
|
1180
|
+
sectionHeader(`Preview`).root,
|
|
1181
|
+
previewHost,
|
|
1182
|
+
], {style: {
|
|
1183
|
+
...sectionStyle,
|
|
1184
|
+
width: '100%', height: '100%',
|
|
1185
|
+
overflowY: 'auto', overflowX: 'hidden',
|
|
1186
|
+
}});
|
|
1187
|
+
|
|
1188
|
+
const leftColumn = ui.divV([coresSection, rGroupsSection], {style: {
|
|
1189
|
+
flex: '0 0 60%', width: '60%',
|
|
1190
|
+
display: 'flex', flexDirection: 'column', gap: '4px',
|
|
1191
|
+
paddingRight: '8px', borderRight: '1px solid var(--grey-2)',
|
|
1192
|
+
}});
|
|
1193
|
+
|
|
1194
|
+
const rightColumn = ui.divV([previewSection], {style: {
|
|
1195
|
+
flex: '0 0 40%', width: '40%',
|
|
1196
|
+
display: 'flex', flexDirection: 'column',
|
|
1197
|
+
paddingLeft: '8px',
|
|
1198
|
+
}});
|
|
1199
|
+
|
|
1200
|
+
const split = ui.divH([leftColumn, rightColumn], {style: {
|
|
1201
|
+
width: '100%', alignItems: 'stretch', gap: '0',
|
|
1202
|
+
}});
|
|
1203
|
+
|
|
1204
|
+
const statusLine = ui.divH([
|
|
1205
|
+
modeInput.root,
|
|
1206
|
+
countText,
|
|
1207
|
+
errorBadge.root,
|
|
1208
|
+
], {style: {alignItems: 'center', gap: '16px', padding: '4px 0'}});
|
|
1209
|
+
|
|
1210
|
+
const footer = ui.div([appendToTableInput.root], {style: {padding: '2px 0'}});
|
|
1211
|
+
|
|
1212
|
+
body = ui.divV([
|
|
1213
|
+
split, statusLine, footer,
|
|
1214
|
+
], {style: {
|
|
1215
|
+
width: '100%', padding: '4px',
|
|
1216
|
+
display: 'flex', flexDirection: 'column', gap: '4px',
|
|
1217
|
+
}});
|
|
1218
|
+
}
|
|
914
1219
|
|
|
915
1220
|
refresh();
|
|
916
1221
|
return {
|
|
@@ -918,6 +1223,8 @@ function buildChemEnumPanel(rdkit: RDModule, preloadCore: ChemEnumCore | null):
|
|
|
918
1223
|
state,
|
|
919
1224
|
execute: () => executeEnumeration(state, rdkit),
|
|
920
1225
|
bindActionButton: (btn) => { okButton = btn; refresh(); },
|
|
1226
|
+
refresh,
|
|
1227
|
+
appActionHost,
|
|
921
1228
|
};
|
|
922
1229
|
}
|
|
923
1230
|
|
|
@@ -932,6 +1239,10 @@ async function executeEnumeration(state: ChemEnumDialogState, _rdkit: RDModule):
|
|
|
932
1239
|
if (!results) { grok.shell.warning('Enumeration failed — check validation messages in the dialog.'); return; }
|
|
933
1240
|
if (results.length === 0) { grok.shell.warning('No molecules produced.'); return; }
|
|
934
1241
|
|
|
1242
|
+
// Record this configuration in history before any post-processing — if the user closes
|
|
1243
|
+
// mid-canonicalization, they can still recover the inputs that produced something.
|
|
1244
|
+
recordHistory(state);
|
|
1245
|
+
|
|
935
1246
|
const rNumbersUsed = new Set<number>();
|
|
936
1247
|
for (const r of results) for (const n of r.rGroupSmilesByNum.keys()) rNumbersUsed.add(n);
|
|
937
1248
|
const sortedRs = [...rNumbersUsed].sort((a, b) => a - b);
|