cnotebook 2.1.0__py3-none-any.whl
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.
- cnotebook/__init__.py +400 -0
- cnotebook/align.py +454 -0
- cnotebook/context.py +523 -0
- cnotebook/grid/__init__.py +55 -0
- cnotebook/grid/grid.py +1649 -0
- cnotebook/helpers.py +201 -0
- cnotebook/ipython_ext.py +56 -0
- cnotebook/marimo_ext.py +272 -0
- cnotebook/pandas_ext.py +1156 -0
- cnotebook/polars_ext.py +1235 -0
- cnotebook/render.py +200 -0
- cnotebook-2.1.0.dist-info/METADATA +336 -0
- cnotebook-2.1.0.dist-info/RECORD +16 -0
- cnotebook-2.1.0.dist-info/WHEEL +5 -0
- cnotebook-2.1.0.dist-info/licenses/LICENSE +21 -0
- cnotebook-2.1.0.dist-info/top_level.txt +1 -0
cnotebook/grid/grid.py
ADDED
|
@@ -0,0 +1,1649 @@
|
|
|
1
|
+
"""MolGrid class for displaying molecules in an interactive grid."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import uuid
|
|
6
|
+
from html import escape
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, Iterable, List, Optional, Union
|
|
9
|
+
|
|
10
|
+
import anywidget
|
|
11
|
+
import traitlets
|
|
12
|
+
from openeye import oechem
|
|
13
|
+
|
|
14
|
+
from cnotebook.context import CNotebookContext
|
|
15
|
+
from cnotebook.render import oemol_to_html
|
|
16
|
+
|
|
17
|
+
# Load List.js from local static file
|
|
18
|
+
_STATIC_DIR = Path(__file__).parent / "static"
|
|
19
|
+
_LIST_JS = (_STATIC_DIR / "list.min.js").read_text()
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from IPython.display import HTML, display
|
|
23
|
+
except ModuleNotFoundError:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_marimo() -> bool:
|
|
28
|
+
"""Check if running in marimo environment.
|
|
29
|
+
|
|
30
|
+
Checks both if marimo is imported AND if we're in a marimo runtime.
|
|
31
|
+
"""
|
|
32
|
+
if "marimo" not in sys.modules:
|
|
33
|
+
return False
|
|
34
|
+
try:
|
|
35
|
+
import marimo as mo
|
|
36
|
+
# Check if we're actually in a marimo runtime
|
|
37
|
+
return mo.running_in_notebook()
|
|
38
|
+
except (ImportError, AttributeError):
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MolGridWidget(anywidget.AnyWidget):
|
|
43
|
+
"""Jupyter widget for MolGrid selection synchronization.
|
|
44
|
+
|
|
45
|
+
This widget handles communication between the MolGrid iframe and the
|
|
46
|
+
Jupyter kernel for selection state and SMARTS query functionality.
|
|
47
|
+
|
|
48
|
+
:ivar grid_id: Unique identifier for this grid instance.
|
|
49
|
+
:ivar selection: JSON-encoded dictionary of selected molecule indices.
|
|
50
|
+
:ivar smarts_query: Current SMARTS query string from user input.
|
|
51
|
+
:ivar smarts_matches: JSON-encoded list of molecule indices matching the SMARTS query.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
_esm = """
|
|
55
|
+
function render({ model, el }) {
|
|
56
|
+
const gridId = model.get("grid_id");
|
|
57
|
+
const globalName = "_MOLGRID_" + gridId;
|
|
58
|
+
|
|
59
|
+
// Store model globally so iframe can access it
|
|
60
|
+
window[globalName] = model;
|
|
61
|
+
|
|
62
|
+
// Listen for postMessage from iframe
|
|
63
|
+
window.addEventListener("message", (event) => {
|
|
64
|
+
if (event.data && event.data.gridId === gridId) {
|
|
65
|
+
if (event.data.type === "MOLGRID_SELECTION") {
|
|
66
|
+
model.set("selection", JSON.stringify(event.data.selection));
|
|
67
|
+
model.save_changes();
|
|
68
|
+
} else if (event.data.type === "MOLGRID_SMARTS_QUERY") {
|
|
69
|
+
model.set("smarts_query", event.data.query);
|
|
70
|
+
model.save_changes();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Listen for SMARTS match results from Python
|
|
76
|
+
model.on("change:smarts_matches", () => {
|
|
77
|
+
const matches = model.get("smarts_matches");
|
|
78
|
+
// Broadcast to iframe
|
|
79
|
+
const iframe = document.querySelector("#molgrid-iframe-" + gridId);
|
|
80
|
+
if (iframe && iframe.contentWindow) {
|
|
81
|
+
iframe.contentWindow.postMessage({
|
|
82
|
+
type: "MOLGRID_SMARTS_RESULTS",
|
|
83
|
+
gridId: gridId,
|
|
84
|
+
matches: matches
|
|
85
|
+
}, "*");
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Listen for height updates from iframe
|
|
90
|
+
window.addEventListener("message", (event) => {
|
|
91
|
+
if (event.data && event.data.gridId === gridId && event.data.type === "MOLGRID_HEIGHT") {
|
|
92
|
+
const iframe = document.querySelector("#molgrid-iframe-" + gridId);
|
|
93
|
+
if (iframe) {
|
|
94
|
+
iframe.style.height = event.data.height + "px";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export default { render };
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
grid_id = traitlets.Unicode("").tag(sync=True)
|
|
105
|
+
selection = traitlets.Unicode("{}").tag(sync=True)
|
|
106
|
+
smarts_query = traitlets.Unicode("").tag(sync=True)
|
|
107
|
+
smarts_matches = traitlets.Unicode("[]").tag(sync=True)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# CSS matching backup styling
|
|
111
|
+
_CSS = '''
|
|
112
|
+
/* Base styles */
|
|
113
|
+
body {
|
|
114
|
+
overflow-x: hidden;
|
|
115
|
+
overflow-y: auto;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/* MolGrid Container */
|
|
119
|
+
.molgrid-container {
|
|
120
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
121
|
+
font-size: 14px;
|
|
122
|
+
color: #333;
|
|
123
|
+
max-width: 100%;
|
|
124
|
+
margin: 0 auto;
|
|
125
|
+
padding: 10px;
|
|
126
|
+
box-sizing: border-box;
|
|
127
|
+
overflow: visible;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* Toolbar */
|
|
131
|
+
.molgrid-toolbar {
|
|
132
|
+
display: flex;
|
|
133
|
+
flex-wrap: wrap;
|
|
134
|
+
align-items: center;
|
|
135
|
+
gap: 10px 15px;
|
|
136
|
+
padding: 10px;
|
|
137
|
+
margin-bottom: 15px;
|
|
138
|
+
background: #f8f9fa;
|
|
139
|
+
border-radius: 6px;
|
|
140
|
+
border: 1px solid #e9ecef;
|
|
141
|
+
overflow: visible;
|
|
142
|
+
position: relative;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* Responsive toolbar layout */
|
|
146
|
+
@media (max-width: 900px) {
|
|
147
|
+
.molgrid-toolbar {
|
|
148
|
+
gap: 8px 12px;
|
|
149
|
+
}
|
|
150
|
+
.molgrid-info {
|
|
151
|
+
font-size: 12px;
|
|
152
|
+
}
|
|
153
|
+
.toggle-text {
|
|
154
|
+
font-size: 11px;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
@media (max-width: 700px) {
|
|
159
|
+
.molgrid-search {
|
|
160
|
+
order: 1;
|
|
161
|
+
flex-basis: 100%;
|
|
162
|
+
min-width: unset;
|
|
163
|
+
}
|
|
164
|
+
.molgrid-info {
|
|
165
|
+
order: 2;
|
|
166
|
+
flex: 1;
|
|
167
|
+
min-width: 0;
|
|
168
|
+
}
|
|
169
|
+
.molgrid-actions {
|
|
170
|
+
order: 3;
|
|
171
|
+
flex-shrink: 0;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
@media (max-width: 500px) {
|
|
176
|
+
.molgrid-toolbar {
|
|
177
|
+
gap: 6px 10px;
|
|
178
|
+
}
|
|
179
|
+
.molgrid-search {
|
|
180
|
+
flex-direction: column;
|
|
181
|
+
align-items: stretch;
|
|
182
|
+
gap: 8px;
|
|
183
|
+
}
|
|
184
|
+
.toggle-switch {
|
|
185
|
+
justify-content: center;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* Search */
|
|
190
|
+
.molgrid-search {
|
|
191
|
+
display: flex;
|
|
192
|
+
align-items: center;
|
|
193
|
+
gap: 10px;
|
|
194
|
+
flex: 1;
|
|
195
|
+
min-width: 200px;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.molgrid-search-input {
|
|
199
|
+
flex: 1;
|
|
200
|
+
padding: 8px 12px;
|
|
201
|
+
border: 1px solid #ced4da;
|
|
202
|
+
border-radius: 4px;
|
|
203
|
+
font-size: 14px;
|
|
204
|
+
outline: none;
|
|
205
|
+
transition: border-color 0.2s, box-shadow 0.2s;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.molgrid-search-input:focus {
|
|
209
|
+
border-color: #80bdff;
|
|
210
|
+
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.15);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* Toggle Switch */
|
|
214
|
+
.toggle-switch {
|
|
215
|
+
display: flex;
|
|
216
|
+
align-items: center;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.toggle-label {
|
|
220
|
+
display: flex;
|
|
221
|
+
align-items: center;
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
user-select: none;
|
|
224
|
+
gap: 6px;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.toggle-label input[type="checkbox"] {
|
|
228
|
+
position: relative;
|
|
229
|
+
width: 40px;
|
|
230
|
+
height: 20px;
|
|
231
|
+
appearance: none;
|
|
232
|
+
-webkit-appearance: none;
|
|
233
|
+
background: #6c757d;
|
|
234
|
+
border-radius: 10px;
|
|
235
|
+
cursor: pointer;
|
|
236
|
+
transition: background 0.3s;
|
|
237
|
+
margin: 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.toggle-label input[type="checkbox"]:checked {
|
|
241
|
+
background: #007bff;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.toggle-label input[type="checkbox"]::before {
|
|
245
|
+
content: "";
|
|
246
|
+
position: absolute;
|
|
247
|
+
top: 2px;
|
|
248
|
+
left: 2px;
|
|
249
|
+
width: 16px;
|
|
250
|
+
height: 16px;
|
|
251
|
+
background: white;
|
|
252
|
+
border-radius: 50%;
|
|
253
|
+
transition: transform 0.3s;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.toggle-label input[type="checkbox"]:checked::before {
|
|
257
|
+
transform: translateX(20px);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.toggle-text {
|
|
261
|
+
font-size: 12px;
|
|
262
|
+
color: #6c757d;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.toggle-text.active {
|
|
266
|
+
color: #333;
|
|
267
|
+
font-weight: 500;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* Info */
|
|
271
|
+
.molgrid-info {
|
|
272
|
+
color: #6c757d;
|
|
273
|
+
font-size: 13px;
|
|
274
|
+
white-space: nowrap;
|
|
275
|
+
overflow: hidden;
|
|
276
|
+
text-overflow: ellipsis;
|
|
277
|
+
flex-shrink: 1;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* Grid List */
|
|
281
|
+
.molgrid-list {
|
|
282
|
+
display: grid;
|
|
283
|
+
grid-template-columns: repeat(auto-fill, minmax(var(--molgrid-cell-width, 220px), 1fr));
|
|
284
|
+
gap: 12px;
|
|
285
|
+
list-style: none;
|
|
286
|
+
padding: 0;
|
|
287
|
+
margin: 0;
|
|
288
|
+
overflow: visible;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* Cell */
|
|
292
|
+
.molgrid-cell {
|
|
293
|
+
position: relative;
|
|
294
|
+
display: flex;
|
|
295
|
+
flex-direction: column;
|
|
296
|
+
align-items: center;
|
|
297
|
+
padding: 10px;
|
|
298
|
+
background: white;
|
|
299
|
+
border: 1px solid #e9ecef;
|
|
300
|
+
border-radius: 6px;
|
|
301
|
+
transition: border-color 0.2s, box-shadow 0.2s;
|
|
302
|
+
cursor: pointer;
|
|
303
|
+
overflow: hidden;
|
|
304
|
+
box-sizing: border-box;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.molgrid-cell:hover {
|
|
308
|
+
border-color: #80bdff;
|
|
309
|
+
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.15);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.molgrid-cell.selected {
|
|
313
|
+
border-color: #007bff;
|
|
314
|
+
background: #f0f7ff;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* Checkbox */
|
|
318
|
+
.molgrid-checkbox {
|
|
319
|
+
position: absolute;
|
|
320
|
+
top: 8px;
|
|
321
|
+
left: 8px;
|
|
322
|
+
width: 16px;
|
|
323
|
+
height: 16px;
|
|
324
|
+
cursor: pointer;
|
|
325
|
+
z-index: 10;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* Info Button */
|
|
329
|
+
.molgrid-info-btn {
|
|
330
|
+
position: absolute;
|
|
331
|
+
top: 8px;
|
|
332
|
+
right: 8px;
|
|
333
|
+
width: 18px;
|
|
334
|
+
height: 18px;
|
|
335
|
+
border-radius: 50%;
|
|
336
|
+
background: #e9ecef;
|
|
337
|
+
border: none;
|
|
338
|
+
cursor: pointer;
|
|
339
|
+
display: flex;
|
|
340
|
+
align-items: center;
|
|
341
|
+
justify-content: center;
|
|
342
|
+
font-size: 12px;
|
|
343
|
+
font-weight: 600;
|
|
344
|
+
font-style: italic;
|
|
345
|
+
font-family: Georgia, "Times New Roman", serif;
|
|
346
|
+
color: #6c757d;
|
|
347
|
+
transition: background 0.2s, color 0.2s, transform 0.2s;
|
|
348
|
+
z-index: 10;
|
|
349
|
+
padding: 0;
|
|
350
|
+
line-height: 1;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.molgrid-info-btn:hover {
|
|
354
|
+
background: #007bff;
|
|
355
|
+
color: white;
|
|
356
|
+
transform: scale(1.1);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/* Info Tooltip */
|
|
360
|
+
.molgrid-info-tooltip {
|
|
361
|
+
display: none;
|
|
362
|
+
position: absolute;
|
|
363
|
+
top: 30px;
|
|
364
|
+
right: 8px;
|
|
365
|
+
min-width: 120px;
|
|
366
|
+
max-width: 220px;
|
|
367
|
+
background: #212529;
|
|
368
|
+
color: white;
|
|
369
|
+
padding: 8px 10px;
|
|
370
|
+
border-radius: 6px;
|
|
371
|
+
font-size: 12px;
|
|
372
|
+
z-index: 100;
|
|
373
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
.molgrid-info-tooltip::before {
|
|
377
|
+
content: "";
|
|
378
|
+
position: absolute;
|
|
379
|
+
top: -6px;
|
|
380
|
+
right: 12px;
|
|
381
|
+
border-left: 6px solid transparent;
|
|
382
|
+
border-right: 6px solid transparent;
|
|
383
|
+
border-bottom: 6px solid #212529;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/* Show on hover OR when pinned */
|
|
387
|
+
.molgrid-info-btn:hover + .molgrid-info-tooltip,
|
|
388
|
+
.molgrid-info-tooltip.pinned {
|
|
389
|
+
display: block;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/* Highlight button when tooltip is pinned */
|
|
393
|
+
.molgrid-info-btn.active {
|
|
394
|
+
background: #007bff;
|
|
395
|
+
color: white;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
.molgrid-info-tooltip-row {
|
|
399
|
+
display: flex;
|
|
400
|
+
margin-bottom: 4px;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.molgrid-info-tooltip-row:last-child {
|
|
404
|
+
margin-bottom: 0;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.molgrid-info-tooltip-label {
|
|
408
|
+
font-weight: 600;
|
|
409
|
+
margin-right: 6px;
|
|
410
|
+
color: #adb5bd;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
.molgrid-info-tooltip-value {
|
|
414
|
+
word-break: break-word;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/* Image */
|
|
418
|
+
.molgrid-image {
|
|
419
|
+
display: flex;
|
|
420
|
+
justify-content: center;
|
|
421
|
+
align-items: center;
|
|
422
|
+
width: 100%;
|
|
423
|
+
height: var(--molgrid-image-height, 150px);
|
|
424
|
+
overflow: hidden;
|
|
425
|
+
flex-shrink: 0;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
.molgrid-image svg {
|
|
429
|
+
max-width: 100%;
|
|
430
|
+
max-height: 100%;
|
|
431
|
+
width: auto !important;
|
|
432
|
+
height: auto !important;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.molgrid-image img {
|
|
436
|
+
max-width: 100%;
|
|
437
|
+
max-height: 100%;
|
|
438
|
+
width: auto;
|
|
439
|
+
height: auto;
|
|
440
|
+
object-fit: contain;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/* Title */
|
|
444
|
+
.molgrid-title {
|
|
445
|
+
margin-top: 8px;
|
|
446
|
+
font-size: 13px;
|
|
447
|
+
font-weight: 500;
|
|
448
|
+
text-align: center;
|
|
449
|
+
word-break: break-word;
|
|
450
|
+
color: #495057;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/* Pagination */
|
|
454
|
+
.molgrid-pagination {
|
|
455
|
+
display: flex;
|
|
456
|
+
justify-content: center;
|
|
457
|
+
margin-top: 20px;
|
|
458
|
+
padding: 10px;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
.molgrid-pagination .pagination {
|
|
462
|
+
display: flex;
|
|
463
|
+
list-style: none;
|
|
464
|
+
padding: 0;
|
|
465
|
+
margin: 0;
|
|
466
|
+
gap: 4px;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
.molgrid-pagination .pagination li {
|
|
470
|
+
display: inline-block;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
.molgrid-pagination .pagination li a,
|
|
474
|
+
.molgrid-pagination .pagination li span {
|
|
475
|
+
display: inline-block;
|
|
476
|
+
padding: 6px 12px;
|
|
477
|
+
border: 1px solid #dee2e6;
|
|
478
|
+
border-radius: 4px;
|
|
479
|
+
color: #007bff;
|
|
480
|
+
text-decoration: none;
|
|
481
|
+
cursor: pointer;
|
|
482
|
+
transition: background 0.2s, border-color 0.2s;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
.molgrid-pagination .pagination li a:hover {
|
|
486
|
+
background: #e9ecef;
|
|
487
|
+
border-color: #dee2e6;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
.molgrid-pagination .pagination li.active a,
|
|
491
|
+
.molgrid-pagination .pagination li.active span {
|
|
492
|
+
background: #007bff;
|
|
493
|
+
border-color: #007bff;
|
|
494
|
+
color: white;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
.molgrid-pagination .pagination li.disabled a,
|
|
498
|
+
.molgrid-pagination .pagination li.disabled span {
|
|
499
|
+
color: #6c757d;
|
|
500
|
+
cursor: not-allowed;
|
|
501
|
+
background: #f8f9fa;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/* Prev/Next Buttons */
|
|
505
|
+
.molgrid-pagination-nav {
|
|
506
|
+
display: flex;
|
|
507
|
+
justify-content: center;
|
|
508
|
+
align-items: center;
|
|
509
|
+
gap: 10px;
|
|
510
|
+
margin-top: 20px;
|
|
511
|
+
padding: 10px;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
.molgrid-pagination-nav button {
|
|
515
|
+
width: 100px;
|
|
516
|
+
padding: 6px 12px;
|
|
517
|
+
border: 1px solid #dee2e6;
|
|
518
|
+
border-radius: 4px;
|
|
519
|
+
background: white;
|
|
520
|
+
color: #007bff;
|
|
521
|
+
font-size: 14px;
|
|
522
|
+
cursor: pointer;
|
|
523
|
+
transition: background 0.2s, border-color 0.2s;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
.molgrid-pagination-nav button:hover:not(:disabled) {
|
|
527
|
+
background: #e9ecef;
|
|
528
|
+
border-color: #dee2e6;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
.molgrid-pagination-nav button:disabled {
|
|
532
|
+
color: #6c757d;
|
|
533
|
+
cursor: not-allowed;
|
|
534
|
+
background: #f8f9fa;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
.molgrid-pagination-nav .molgrid-pagination {
|
|
538
|
+
margin-top: 0;
|
|
539
|
+
padding: 0;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/* Actions Dropdown */
|
|
543
|
+
.molgrid-actions {
|
|
544
|
+
position: relative;
|
|
545
|
+
overflow: visible;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
.molgrid-actions-btn {
|
|
549
|
+
padding: 6px 12px;
|
|
550
|
+
border: 1px solid #ced4da;
|
|
551
|
+
border-radius: 4px;
|
|
552
|
+
background: white;
|
|
553
|
+
color: #495057;
|
|
554
|
+
font-size: 16px;
|
|
555
|
+
font-weight: bold;
|
|
556
|
+
cursor: pointer;
|
|
557
|
+
transition: background 0.2s, border-color 0.2s;
|
|
558
|
+
line-height: 1;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
.molgrid-actions-btn:hover {
|
|
562
|
+
background: #e9ecef;
|
|
563
|
+
border-color: #adb5bd;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
.molgrid-dropdown {
|
|
567
|
+
display: none;
|
|
568
|
+
position: fixed;
|
|
569
|
+
min-width: 180px;
|
|
570
|
+
background: white;
|
|
571
|
+
border: 1px solid #dee2e6;
|
|
572
|
+
border-radius: 6px;
|
|
573
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
574
|
+
z-index: 1000;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.molgrid-dropdown.show {
|
|
578
|
+
display: block;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
.molgrid-dropdown-item {
|
|
582
|
+
display: block;
|
|
583
|
+
width: 100%;
|
|
584
|
+
padding: 10px 14px;
|
|
585
|
+
border: none;
|
|
586
|
+
background: none;
|
|
587
|
+
text-align: left;
|
|
588
|
+
font-size: 14px;
|
|
589
|
+
color: #212529;
|
|
590
|
+
cursor: pointer;
|
|
591
|
+
transition: background 0.15s;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
.molgrid-dropdown-item:hover {
|
|
595
|
+
background: #f8f9fa;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
.molgrid-dropdown-divider {
|
|
599
|
+
height: 1px;
|
|
600
|
+
margin: 4px 0;
|
|
601
|
+
background: #e9ecef;
|
|
602
|
+
}
|
|
603
|
+
'''
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
class MolGrid:
|
|
607
|
+
"""Interactive molecule grid widget for displaying and selecting molecules."""
|
|
608
|
+
|
|
609
|
+
# Class-level selection storage
|
|
610
|
+
_selections: Dict[str, Dict[int, str]] = {}
|
|
611
|
+
|
|
612
|
+
def __init__(
|
|
613
|
+
self,
|
|
614
|
+
mols: Iterable,
|
|
615
|
+
*,
|
|
616
|
+
dataframe=None,
|
|
617
|
+
mol_col: Optional[str] = None,
|
|
618
|
+
title_field: Optional[str] = "Title",
|
|
619
|
+
tooltip_fields: Optional[List[str]] = None,
|
|
620
|
+
n_items_per_page: int = 24,
|
|
621
|
+
width: int = 200,
|
|
622
|
+
height: int = 200,
|
|
623
|
+
atom_label_font_scale: float = 1.5,
|
|
624
|
+
image_format: str = "svg",
|
|
625
|
+
select: bool = True,
|
|
626
|
+
information: bool = True,
|
|
627
|
+
data: Optional[Union[str, List[str]]] = None,
|
|
628
|
+
search_fields: Optional[List[str]] = None,
|
|
629
|
+
name: Optional[str] = None,
|
|
630
|
+
):
|
|
631
|
+
"""Create an interactive molecule grid widget.
|
|
632
|
+
|
|
633
|
+
:param mols: Iterable of OpenEye molecule objects.
|
|
634
|
+
:param dataframe: Optional DataFrame with molecule data.
|
|
635
|
+
:param mol_col: Column name containing molecules (if using DataFrame).
|
|
636
|
+
:param title_field: Molecule field to display as title (None to hide).
|
|
637
|
+
:param tooltip_fields: List of fields for tooltip display.
|
|
638
|
+
:param n_items_per_page: Number of molecules per page.
|
|
639
|
+
:param width: Image width in pixels.
|
|
640
|
+
:param height: Image height in pixels.
|
|
641
|
+
:param atom_label_font_scale: Scale factor for atom labels.
|
|
642
|
+
:param image_format: Image format ("svg" or "png").
|
|
643
|
+
:param select: Enable selection checkboxes.
|
|
644
|
+
:param information: Enable info button with hover tooltip.
|
|
645
|
+
:param data: Column(s) to display in info tooltip. If None, auto-detects
|
|
646
|
+
simple types (string, int, float) from DataFrame.
|
|
647
|
+
:param search_fields: Fields for text search.
|
|
648
|
+
:param name: Grid identifier.
|
|
649
|
+
"""
|
|
650
|
+
self._molecules = list(mols)
|
|
651
|
+
self._dataframe = dataframe
|
|
652
|
+
self._mol_col = mol_col
|
|
653
|
+
self.title_field = title_field
|
|
654
|
+
self.tooltip_fields = tooltip_fields or []
|
|
655
|
+
self.n_items_per_page = n_items_per_page
|
|
656
|
+
self.selection_enabled = select
|
|
657
|
+
self.information_enabled = information
|
|
658
|
+
self.name = name
|
|
659
|
+
|
|
660
|
+
# Handle data parameter for info tooltip columns
|
|
661
|
+
if data is not None:
|
|
662
|
+
if isinstance(data, str):
|
|
663
|
+
self.information_fields = [data]
|
|
664
|
+
else:
|
|
665
|
+
self.information_fields = list(data)
|
|
666
|
+
elif dataframe is not None:
|
|
667
|
+
# Auto-detect simple type columns
|
|
668
|
+
self.information_fields = self._auto_detect_info_fields(dataframe, mol_col)
|
|
669
|
+
else:
|
|
670
|
+
self.information_fields = []
|
|
671
|
+
|
|
672
|
+
# Auto-detect search fields from DataFrame if not provided
|
|
673
|
+
if search_fields is None and dataframe is not None:
|
|
674
|
+
self.search_fields = self._auto_detect_search_fields(dataframe, mol_col)
|
|
675
|
+
else:
|
|
676
|
+
self.search_fields = search_fields
|
|
677
|
+
|
|
678
|
+
# Rendering settings
|
|
679
|
+
self.width = width
|
|
680
|
+
self.height = height
|
|
681
|
+
self.image_format = image_format
|
|
682
|
+
self.atom_label_font_scale = atom_label_font_scale
|
|
683
|
+
|
|
684
|
+
# Generate grid name
|
|
685
|
+
if self.name is None:
|
|
686
|
+
self.name = f"molgrid-{uuid.uuid4().hex[:8]}"
|
|
687
|
+
|
|
688
|
+
# Initialize selection storage
|
|
689
|
+
MolGrid._selections[self.name] = {}
|
|
690
|
+
|
|
691
|
+
# Create widget
|
|
692
|
+
self.widget = MolGridWidget(grid_id=self.name)
|
|
693
|
+
|
|
694
|
+
# Observe selection changes
|
|
695
|
+
self.widget.observe(self._on_selection_change, names=["selection"])
|
|
696
|
+
|
|
697
|
+
# Observe SMARTS query changes
|
|
698
|
+
self.widget.observe(self._on_smarts_query, names=["smarts_query"])
|
|
699
|
+
|
|
700
|
+
# Display widget immediately (critical for model availability)
|
|
701
|
+
# In Jupyter: display widget here so model is available when iframe loads
|
|
702
|
+
# In Marimo: widget is returned from display() method instead
|
|
703
|
+
if not _is_marimo():
|
|
704
|
+
display(self.widget)
|
|
705
|
+
|
|
706
|
+
def _auto_detect_search_fields(self, dataframe, mol_col: Optional[str]) -> List[str]:
|
|
707
|
+
"""Auto-detect searchable text columns from DataFrame.
|
|
708
|
+
|
|
709
|
+
:param dataframe: DataFrame to inspect.
|
|
710
|
+
:param mol_col: Molecule column name to exclude.
|
|
711
|
+
:returns: List of searchable column names.
|
|
712
|
+
"""
|
|
713
|
+
search_fields = []
|
|
714
|
+
for col in dataframe.columns:
|
|
715
|
+
# Skip the molecule column
|
|
716
|
+
if col == mol_col:
|
|
717
|
+
continue
|
|
718
|
+
|
|
719
|
+
dtype = dataframe[col].dtype
|
|
720
|
+
dtype_str = str(dtype).lower()
|
|
721
|
+
|
|
722
|
+
# Skip molecule dtypes (oepandas MoleculeDtype)
|
|
723
|
+
if "molecule" in dtype_str:
|
|
724
|
+
continue
|
|
725
|
+
|
|
726
|
+
# Skip numeric dtypes
|
|
727
|
+
if any(t in dtype_str for t in ["int", "float", "double", "decimal"]):
|
|
728
|
+
continue
|
|
729
|
+
|
|
730
|
+
# Include string/object columns that likely contain text
|
|
731
|
+
if "object" in dtype_str or "string" in dtype_str or "str" in dtype_str:
|
|
732
|
+
search_fields.append(col)
|
|
733
|
+
continue
|
|
734
|
+
|
|
735
|
+
# Also check if column contains string values (fallback for edge cases)
|
|
736
|
+
try:
|
|
737
|
+
first_valid = dataframe[col].dropna().iloc[0] if len(dataframe[col].dropna()) > 0 else None
|
|
738
|
+
if first_valid is not None and isinstance(first_valid, str):
|
|
739
|
+
search_fields.append(col)
|
|
740
|
+
except (IndexError, KeyError):
|
|
741
|
+
pass
|
|
742
|
+
|
|
743
|
+
return search_fields
|
|
744
|
+
|
|
745
|
+
def _auto_detect_info_fields(self, dataframe, mol_col: Optional[str]) -> List[str]:
|
|
746
|
+
"""Auto-detect simple type columns from DataFrame for info tooltip.
|
|
747
|
+
|
|
748
|
+
Includes string, int, and float columns (excludes molecule columns).
|
|
749
|
+
|
|
750
|
+
:param dataframe: DataFrame to inspect.
|
|
751
|
+
:param mol_col: Molecule column name to exclude.
|
|
752
|
+
:returns: List of column names with simple types.
|
|
753
|
+
"""
|
|
754
|
+
info_fields = []
|
|
755
|
+
for col in dataframe.columns:
|
|
756
|
+
# Skip the molecule column
|
|
757
|
+
if col == mol_col:
|
|
758
|
+
continue
|
|
759
|
+
|
|
760
|
+
dtype = dataframe[col].dtype
|
|
761
|
+
dtype_str = str(dtype).lower()
|
|
762
|
+
|
|
763
|
+
# Skip molecule dtypes (oepandas MoleculeDtype)
|
|
764
|
+
if "molecule" in dtype_str:
|
|
765
|
+
continue
|
|
766
|
+
|
|
767
|
+
# Include numeric types (int, float)
|
|
768
|
+
if any(t in dtype_str for t in ["int", "float", "double", "decimal"]):
|
|
769
|
+
info_fields.append(col)
|
|
770
|
+
continue
|
|
771
|
+
|
|
772
|
+
# Include string/object columns
|
|
773
|
+
if "object" in dtype_str or "string" in dtype_str or "str" in dtype_str:
|
|
774
|
+
info_fields.append(col)
|
|
775
|
+
continue
|
|
776
|
+
|
|
777
|
+
# Include category dtype
|
|
778
|
+
if "category" in dtype_str:
|
|
779
|
+
info_fields.append(col)
|
|
780
|
+
continue
|
|
781
|
+
|
|
782
|
+
return info_fields
|
|
783
|
+
|
|
784
|
+
def _on_selection_change(self, change):
|
|
785
|
+
"""Handle selection change from widget.
|
|
786
|
+
|
|
787
|
+
:param change: Traitlet change dict with 'new' key.
|
|
788
|
+
"""
|
|
789
|
+
try:
|
|
790
|
+
selection = json.loads(change["new"])
|
|
791
|
+
MolGrid._selections[self.name] = {int(k): v for k, v in selection.items()}
|
|
792
|
+
except (json.JSONDecodeError, ValueError, TypeError):
|
|
793
|
+
pass
|
|
794
|
+
|
|
795
|
+
def _on_smarts_query(self, change):
|
|
796
|
+
"""Handle SMARTS query from widget.
|
|
797
|
+
|
|
798
|
+
Performs substructure search and sends matching indices back.
|
|
799
|
+
|
|
800
|
+
:param change: Traitlet change dict with 'new' key.
|
|
801
|
+
"""
|
|
802
|
+
query = change["new"]
|
|
803
|
+
if not query:
|
|
804
|
+
# Empty query - return all indices
|
|
805
|
+
matches = list(range(len(self._molecules)))
|
|
806
|
+
else:
|
|
807
|
+
matches = self._search_smarts(query)
|
|
808
|
+
|
|
809
|
+
# Send results back to widget
|
|
810
|
+
self.widget.smarts_matches = json.dumps(matches)
|
|
811
|
+
|
|
812
|
+
def _search_smarts(self, smarts_pattern: str) -> List[int]:
|
|
813
|
+
"""Search molecules by SMARTS pattern.
|
|
814
|
+
|
|
815
|
+
:param smarts_pattern: SMARTS pattern string.
|
|
816
|
+
:returns: List of indices of matching molecules.
|
|
817
|
+
"""
|
|
818
|
+
matches = []
|
|
819
|
+
try:
|
|
820
|
+
ss = oechem.OESubSearch(smarts_pattern)
|
|
821
|
+
if not ss.IsValid():
|
|
822
|
+
return matches
|
|
823
|
+
|
|
824
|
+
for idx, mol in enumerate(self._molecules):
|
|
825
|
+
oechem.OEPrepareSearch(mol, ss)
|
|
826
|
+
if ss.SingleMatch(mol):
|
|
827
|
+
matches.append(idx)
|
|
828
|
+
except Exception:
|
|
829
|
+
# Invalid SMARTS or other error - return empty
|
|
830
|
+
pass
|
|
831
|
+
|
|
832
|
+
return matches
|
|
833
|
+
|
|
834
|
+
def _get_field_value(self, idx: int, mol, field: str):
|
|
835
|
+
"""Get a field value from DataFrame column or molecule property.
|
|
836
|
+
|
|
837
|
+
:param idx: Row index in the dataframe.
|
|
838
|
+
:param mol: OpenEye molecule object.
|
|
839
|
+
:param field: Field name to retrieve.
|
|
840
|
+
:returns: Field value or None.
|
|
841
|
+
"""
|
|
842
|
+
# Try DataFrame column first
|
|
843
|
+
if self._dataframe is not None and field in self._dataframe.columns:
|
|
844
|
+
return self._dataframe.iloc[idx][field]
|
|
845
|
+
|
|
846
|
+
# Fall back to molecule properties
|
|
847
|
+
if field == "Title":
|
|
848
|
+
return mol.GetTitle() or None
|
|
849
|
+
else:
|
|
850
|
+
return oechem.OEGetSDData(mol, field) or None
|
|
851
|
+
|
|
852
|
+
def _prepare_data(self) -> List[dict]:
|
|
853
|
+
"""Prepare molecule data for template rendering.
|
|
854
|
+
|
|
855
|
+
:returns: List of dicts with molecule data for each item.
|
|
856
|
+
"""
|
|
857
|
+
data = []
|
|
858
|
+
ctx = CNotebookContext(
|
|
859
|
+
width=self.width,
|
|
860
|
+
height=self.height,
|
|
861
|
+
image_format=self.image_format,
|
|
862
|
+
atom_label_font_scale=self.atom_label_font_scale,
|
|
863
|
+
scope="local",
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
for idx, mol in enumerate(self._molecules):
|
|
867
|
+
item = {
|
|
868
|
+
"index": idx,
|
|
869
|
+
"title": None,
|
|
870
|
+
"mol_title": mol.GetTitle() if mol.IsValid() else None,
|
|
871
|
+
"tooltip": {},
|
|
872
|
+
"smiles": oechem.OEMolToSmiles(mol) if mol.IsValid() else "",
|
|
873
|
+
"img": oemol_to_html(mol, ctx=ctx),
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
# Extract title
|
|
877
|
+
if self.title_field:
|
|
878
|
+
item["title"] = self._get_field_value(idx, mol, self.title_field)
|
|
879
|
+
|
|
880
|
+
# Extract tooltip fields
|
|
881
|
+
for field in self.tooltip_fields:
|
|
882
|
+
item["tooltip"][field] = self._get_field_value(idx, mol, field)
|
|
883
|
+
|
|
884
|
+
# Extract search fields
|
|
885
|
+
item["search_fields"] = {}
|
|
886
|
+
if self.search_fields:
|
|
887
|
+
for field in self.search_fields:
|
|
888
|
+
item["search_fields"][field] = self._get_field_value(idx, mol, field)
|
|
889
|
+
|
|
890
|
+
# Extract information fields for tooltip
|
|
891
|
+
item["info_fields"] = {}
|
|
892
|
+
if self.information_fields:
|
|
893
|
+
for field in self.information_fields:
|
|
894
|
+
item["info_fields"][field] = self._get_field_value(idx, mol, field)
|
|
895
|
+
|
|
896
|
+
data.append(item)
|
|
897
|
+
|
|
898
|
+
return data
|
|
899
|
+
|
|
900
|
+
def _prepare_export_data(self) -> List[dict]:
|
|
901
|
+
"""Prepare molecule data for CSV/SMILES export.
|
|
902
|
+
|
|
903
|
+
:returns: List of dicts with all exportable data for each molecule.
|
|
904
|
+
"""
|
|
905
|
+
export_data = []
|
|
906
|
+
|
|
907
|
+
# Determine columns to export
|
|
908
|
+
if self._dataframe is not None:
|
|
909
|
+
columns = [c for c in self._dataframe.columns if c != self._mol_col]
|
|
910
|
+
else:
|
|
911
|
+
columns = []
|
|
912
|
+
|
|
913
|
+
for idx, mol in enumerate(self._molecules):
|
|
914
|
+
row = {
|
|
915
|
+
"index": idx,
|
|
916
|
+
"smiles": oechem.OEMolToSmiles(mol) if mol.IsValid() else "",
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
# Add DataFrame columns
|
|
920
|
+
if self._dataframe is not None:
|
|
921
|
+
for col in columns:
|
|
922
|
+
val = self._dataframe.iloc[idx][col]
|
|
923
|
+
# Convert to string for JSON serialization
|
|
924
|
+
if val is None or (hasattr(val, '__len__') and len(str(val)) == 0):
|
|
925
|
+
row[col] = ""
|
|
926
|
+
else:
|
|
927
|
+
row[col] = str(val)
|
|
928
|
+
|
|
929
|
+
export_data.append(row)
|
|
930
|
+
|
|
931
|
+
return export_data
|
|
932
|
+
|
|
933
|
+
def to_html(self) -> str:
|
|
934
|
+
"""Generate HTML representation of the grid.
|
|
935
|
+
|
|
936
|
+
:returns: Complete HTML document as string.
|
|
937
|
+
"""
|
|
938
|
+
items = self._prepare_data()
|
|
939
|
+
export_data = self._prepare_export_data()
|
|
940
|
+
grid_id = self.name
|
|
941
|
+
items_per_page = self.n_items_per_page
|
|
942
|
+
total_items = len(items)
|
|
943
|
+
|
|
944
|
+
# Prepare export data as JSON for JavaScript
|
|
945
|
+
export_data_js = json.dumps(export_data)
|
|
946
|
+
# Get column names for CSV header
|
|
947
|
+
export_columns = ["smiles"]
|
|
948
|
+
if self._dataframe is not None:
|
|
949
|
+
export_columns.extend([c for c in self._dataframe.columns if c != self._mol_col])
|
|
950
|
+
export_columns_js = json.dumps(export_columns)
|
|
951
|
+
|
|
952
|
+
# Build item HTML - IMPORTANT: data-smiles on cell element for working selection
|
|
953
|
+
items_html = ""
|
|
954
|
+
for item in items:
|
|
955
|
+
tooltip_str = ""
|
|
956
|
+
if item["tooltip"]:
|
|
957
|
+
tooltip_parts = [f"{k}: {v}" for k, v in item["tooltip"].items() if v]
|
|
958
|
+
tooltip_str = " ".join(tooltip_parts)
|
|
959
|
+
|
|
960
|
+
# Visible title display
|
|
961
|
+
title_display_html = ""
|
|
962
|
+
if item["title"]:
|
|
963
|
+
title_display_html = f'<div class="molgrid-title">{escape(str(item["title"]))}</div>'
|
|
964
|
+
|
|
965
|
+
# Hidden title span for List.js search (always present for consistent indexing)
|
|
966
|
+
title_value = escape(str(item["title"])) if item["title"] else ""
|
|
967
|
+
|
|
968
|
+
checkbox_html = ""
|
|
969
|
+
if self.selection_enabled:
|
|
970
|
+
checkbox_html = f'<input type="checkbox" class="molgrid-checkbox" data-index="{item["index"]}">'
|
|
971
|
+
|
|
972
|
+
# Info button with tooltip
|
|
973
|
+
info_html = ""
|
|
974
|
+
if self.information_enabled:
|
|
975
|
+
# Build tooltip content: Index always, Title if available, then data fields
|
|
976
|
+
info_rows = f'<div class="molgrid-info-tooltip-row"><span class="molgrid-info-tooltip-label">Index:</span><span class="molgrid-info-tooltip-value">{item["index"]}</span></div>'
|
|
977
|
+
if item.get("mol_title"):
|
|
978
|
+
info_rows += f'<div class="molgrid-info-tooltip-row"><span class="molgrid-info-tooltip-label">Title:</span><span class="molgrid-info-tooltip-value">{escape(str(item["mol_title"]))}</span></div>'
|
|
979
|
+
# Add data fields from info_fields
|
|
980
|
+
for field, value in item.get("info_fields", {}).items():
|
|
981
|
+
if value is not None:
|
|
982
|
+
display_value = escape(str(value))
|
|
983
|
+
info_rows += f'<div class="molgrid-info-tooltip-row"><span class="molgrid-info-tooltip-label">{escape(field)}:</span><span class="molgrid-info-tooltip-value">{display_value}</span></div>'
|
|
984
|
+
info_html = f'''<button class="molgrid-info-btn" type="button">i</button>
|
|
985
|
+
<div class="molgrid-info-tooltip">{info_rows}</div>'''
|
|
986
|
+
|
|
987
|
+
tooltip_attr = f'title="{tooltip_str}"' if tooltip_str else ""
|
|
988
|
+
|
|
989
|
+
# Build hidden spans for search fields
|
|
990
|
+
search_fields_html = ""
|
|
991
|
+
for field, value in item["search_fields"].items():
|
|
992
|
+
safe_value = escape(str(value)) if value else ""
|
|
993
|
+
search_fields_html += f'<span class="{escape(field)}" style="display:none;">{safe_value}</span>\n '
|
|
994
|
+
|
|
995
|
+
# Keep data-smiles on cell element (critical for selection to work)
|
|
996
|
+
# Add hidden spans for List.js valueNames (index, smiles, title, search fields)
|
|
997
|
+
items_html += f'''
|
|
998
|
+
<li class="molgrid-cell" data-index="{item["index"]}" data-smiles="{escape(item["smiles"])}" {tooltip_attr}>
|
|
999
|
+
{checkbox_html}
|
|
1000
|
+
{info_html}
|
|
1001
|
+
<div class="molgrid-image">{item["img"]}</div>
|
|
1002
|
+
{title_display_html}
|
|
1003
|
+
<span class="title" style="display:none;">{title_value}</span>
|
|
1004
|
+
<span class="index" style="display:none;">{item["index"]}</span>
|
|
1005
|
+
<span class="smiles" style="display:none;">{escape(item["smiles"])}</span>
|
|
1006
|
+
{search_fields_html}
|
|
1007
|
+
</li>
|
|
1008
|
+
'''
|
|
1009
|
+
|
|
1010
|
+
# Prepare search fields for JavaScript
|
|
1011
|
+
search_fields_js = json.dumps(self.search_fields or [])
|
|
1012
|
+
|
|
1013
|
+
# JavaScript for selection sync with List.js pagination
|
|
1014
|
+
js = f'''
|
|
1015
|
+
(function() {{
|
|
1016
|
+
var gridId = "{grid_id}";
|
|
1017
|
+
var itemsPerPage = {items_per_page};
|
|
1018
|
+
var searchFields = {search_fields_js};
|
|
1019
|
+
var exportData = {export_data_js};
|
|
1020
|
+
var exportColumns = {export_columns_js};
|
|
1021
|
+
var container = document.getElementById(gridId);
|
|
1022
|
+
var selectedIndices = new Set();
|
|
1023
|
+
var searchMode = 'properties'; // 'properties' or 'smarts'
|
|
1024
|
+
|
|
1025
|
+
// Initialize List.js with pagination
|
|
1026
|
+
var options = {{
|
|
1027
|
+
valueNames: ['title', 'index', 'smiles'].concat(searchFields),
|
|
1028
|
+
page: itemsPerPage,
|
|
1029
|
+
pagination: {{
|
|
1030
|
+
innerWindow: 1,
|
|
1031
|
+
outerWindow: 1,
|
|
1032
|
+
left: 1,
|
|
1033
|
+
right: 1,
|
|
1034
|
+
paginationClass: 'pagination',
|
|
1035
|
+
item: '<li><a class="page" href="javascript:void(0)"></a></li>'
|
|
1036
|
+
}}
|
|
1037
|
+
}};
|
|
1038
|
+
|
|
1039
|
+
var molgridList = new List(gridId, options);
|
|
1040
|
+
|
|
1041
|
+
// Prev/Next buttons
|
|
1042
|
+
var prevBtn = container.querySelector('.molgrid-prev');
|
|
1043
|
+
var nextBtn = container.querySelector('.molgrid-next');
|
|
1044
|
+
|
|
1045
|
+
// Update showing info text and prev/next button states
|
|
1046
|
+
function updateShowingInfo() {{
|
|
1047
|
+
var totalItems = molgridList.matchingItems.length;
|
|
1048
|
+
var currentPage = molgridList.i;
|
|
1049
|
+
var perPage = molgridList.page;
|
|
1050
|
+
|
|
1051
|
+
var start = totalItems > 0 ? currentPage : 0;
|
|
1052
|
+
var end = Math.min(currentPage + perPage - 1, totalItems);
|
|
1053
|
+
|
|
1054
|
+
var showingStart = container.querySelector('.showing-start');
|
|
1055
|
+
var showingEnd = container.querySelector('.showing-end');
|
|
1056
|
+
var showingTotal = container.querySelector('.showing-total');
|
|
1057
|
+
|
|
1058
|
+
if (showingStart) showingStart.textContent = start;
|
|
1059
|
+
if (showingEnd) showingEnd.textContent = end;
|
|
1060
|
+
if (showingTotal) showingTotal.textContent = totalItems;
|
|
1061
|
+
|
|
1062
|
+
// Update prev/next button states
|
|
1063
|
+
var totalPages = Math.ceil(totalItems / perPage);
|
|
1064
|
+
var currentPageNum = Math.ceil(currentPage / perPage);
|
|
1065
|
+
|
|
1066
|
+
prevBtn.disabled = currentPageNum <= 1;
|
|
1067
|
+
nextBtn.disabled = currentPageNum >= totalPages || totalPages <= 1;
|
|
1068
|
+
}}
|
|
1069
|
+
|
|
1070
|
+
// Prev button click handler
|
|
1071
|
+
prevBtn.addEventListener('click', function() {{
|
|
1072
|
+
var totalItems = molgridList.matchingItems.length;
|
|
1073
|
+
var currentPage = molgridList.i;
|
|
1074
|
+
var perPage = molgridList.page;
|
|
1075
|
+
|
|
1076
|
+
if (currentPage > 1) {{
|
|
1077
|
+
var newStart = currentPage - perPage;
|
|
1078
|
+
if (newStart < 1) newStart = 1;
|
|
1079
|
+
molgridList.show(newStart, perPage);
|
|
1080
|
+
}}
|
|
1081
|
+
}});
|
|
1082
|
+
|
|
1083
|
+
// Next button click handler
|
|
1084
|
+
nextBtn.addEventListener('click', function() {{
|
|
1085
|
+
var totalItems = molgridList.matchingItems.length;
|
|
1086
|
+
var currentPage = molgridList.i;
|
|
1087
|
+
var perPage = molgridList.page;
|
|
1088
|
+
var totalPages = Math.ceil(totalItems / perPage);
|
|
1089
|
+
var currentPageNum = Math.ceil(currentPage / perPage);
|
|
1090
|
+
|
|
1091
|
+
if (currentPageNum < totalPages) {{
|
|
1092
|
+
var newStart = currentPage + perPage;
|
|
1093
|
+
molgridList.show(newStart, perPage);
|
|
1094
|
+
}}
|
|
1095
|
+
}});
|
|
1096
|
+
|
|
1097
|
+
// Search functionality
|
|
1098
|
+
var searchInput = container.querySelector('.molgrid-search-input');
|
|
1099
|
+
var searchModeToggle = container.querySelector('.search-mode-toggle');
|
|
1100
|
+
var toggleTexts = container.querySelectorAll('.toggle-text');
|
|
1101
|
+
var smartsMatchIndices = null; // Store SMARTS match results
|
|
1102
|
+
|
|
1103
|
+
// Debounce function for search
|
|
1104
|
+
function debounce(func, wait) {{
|
|
1105
|
+
var timeout;
|
|
1106
|
+
return function() {{
|
|
1107
|
+
var context = this;
|
|
1108
|
+
var args = arguments;
|
|
1109
|
+
clearTimeout(timeout);
|
|
1110
|
+
timeout = setTimeout(function() {{
|
|
1111
|
+
func.apply(context, args);
|
|
1112
|
+
}}, wait);
|
|
1113
|
+
}};
|
|
1114
|
+
}}
|
|
1115
|
+
|
|
1116
|
+
// Apply SMARTS filter based on matching indices
|
|
1117
|
+
function applySmartsFilter(matchIndices) {{
|
|
1118
|
+
smartsMatchIndices = new Set(matchIndices);
|
|
1119
|
+
molgridList.filter(function(item) {{
|
|
1120
|
+
var idx = parseInt(item.values().index, 10);
|
|
1121
|
+
return smartsMatchIndices.has(idx);
|
|
1122
|
+
}});
|
|
1123
|
+
molgridList.i = 1; // Reset to first page
|
|
1124
|
+
updateShowingInfo();
|
|
1125
|
+
}}
|
|
1126
|
+
|
|
1127
|
+
// Clear SMARTS filter
|
|
1128
|
+
function clearSmartsFilter() {{
|
|
1129
|
+
smartsMatchIndices = null;
|
|
1130
|
+
molgridList.filter(); // Clear filter
|
|
1131
|
+
molgridList.i = 1;
|
|
1132
|
+
updateShowingInfo();
|
|
1133
|
+
}}
|
|
1134
|
+
|
|
1135
|
+
// Send SMARTS query to Python
|
|
1136
|
+
function sendSmartsQuery(query) {{
|
|
1137
|
+
window.parent.postMessage({{
|
|
1138
|
+
type: "MOLGRID_SMARTS_QUERY",
|
|
1139
|
+
gridId: gridId,
|
|
1140
|
+
query: query
|
|
1141
|
+
}}, "*");
|
|
1142
|
+
}}
|
|
1143
|
+
|
|
1144
|
+
// Listen for SMARTS results from Python
|
|
1145
|
+
window.addEventListener("message", function(event) {{
|
|
1146
|
+
if (event.data && event.data.gridId === gridId && event.data.type === "MOLGRID_SMARTS_RESULTS") {{
|
|
1147
|
+
var matches = JSON.parse(event.data.matches);
|
|
1148
|
+
applySmartsFilter(matches);
|
|
1149
|
+
}}
|
|
1150
|
+
}});
|
|
1151
|
+
|
|
1152
|
+
// Perform search based on current mode
|
|
1153
|
+
function performSearch(query) {{
|
|
1154
|
+
if (!query) {{
|
|
1155
|
+
molgridList.search(); // Clear text search
|
|
1156
|
+
clearSmartsFilter(); // Clear SMARTS filter
|
|
1157
|
+
}} else if (searchMode === 'smarts') {{
|
|
1158
|
+
// SMARTS mode - send to Python for substructure search
|
|
1159
|
+
molgridList.search(); // Clear any text search
|
|
1160
|
+
sendSmartsQuery(query);
|
|
1161
|
+
}} else {{
|
|
1162
|
+
// Properties mode - search title and configured fields
|
|
1163
|
+
clearSmartsFilter(); // Clear any SMARTS filter
|
|
1164
|
+
var fieldsToSearch = ['title'].concat(searchFields);
|
|
1165
|
+
molgridList.search(query, fieldsToSearch);
|
|
1166
|
+
molgridList.i = 1; // Reset to first page
|
|
1167
|
+
updateShowingInfo();
|
|
1168
|
+
}}
|
|
1169
|
+
}}
|
|
1170
|
+
|
|
1171
|
+
// Debounced search handler
|
|
1172
|
+
var debouncedSearch = debounce(function(e) {{
|
|
1173
|
+
performSearch(e.target.value);
|
|
1174
|
+
}}, 300);
|
|
1175
|
+
|
|
1176
|
+
searchInput.addEventListener('input', debouncedSearch);
|
|
1177
|
+
|
|
1178
|
+
// Search mode toggle handler
|
|
1179
|
+
searchModeToggle.addEventListener('change', function() {{
|
|
1180
|
+
searchMode = this.checked ? 'smarts' : 'properties';
|
|
1181
|
+
|
|
1182
|
+
// Update toggle text styling
|
|
1183
|
+
toggleTexts[0].classList.toggle('active', !this.checked);
|
|
1184
|
+
toggleTexts[1].classList.toggle('active', this.checked);
|
|
1185
|
+
|
|
1186
|
+
// Re-trigger search with current query
|
|
1187
|
+
if (searchInput.value) {{
|
|
1188
|
+
performSearch(searchInput.value);
|
|
1189
|
+
}} else {{
|
|
1190
|
+
// Clear any existing filter when switching modes with empty query
|
|
1191
|
+
clearSmartsFilter();
|
|
1192
|
+
}}
|
|
1193
|
+
}});
|
|
1194
|
+
|
|
1195
|
+
// Update checkboxes based on selection state (after page change)
|
|
1196
|
+
function updateCheckboxes() {{
|
|
1197
|
+
var checkboxes = container.querySelectorAll('.molgrid-checkbox');
|
|
1198
|
+
checkboxes.forEach(function(checkbox) {{
|
|
1199
|
+
var index = parseInt(checkbox.getAttribute('data-index'), 10);
|
|
1200
|
+
checkbox.checked = selectedIndices.has(index);
|
|
1201
|
+
var cell = checkbox.closest('.molgrid-cell');
|
|
1202
|
+
if (cell) {{
|
|
1203
|
+
cell.classList.toggle('selected', checkbox.checked);
|
|
1204
|
+
}}
|
|
1205
|
+
}});
|
|
1206
|
+
}}
|
|
1207
|
+
|
|
1208
|
+
function syncSelection() {{
|
|
1209
|
+
var selection = {{}};
|
|
1210
|
+
selectedIndices.forEach(function(idx) {{
|
|
1211
|
+
var cell = container.querySelector('.molgrid-cell[data-index="' + idx + '"]');
|
|
1212
|
+
var smiles = cell ? cell.getAttribute("data-smiles") : '';
|
|
1213
|
+
selection[idx] = smiles;
|
|
1214
|
+
}});
|
|
1215
|
+
|
|
1216
|
+
var globalName = "_MOLGRID_" + gridId;
|
|
1217
|
+
var model = window.parent[globalName];
|
|
1218
|
+
|
|
1219
|
+
if (model) {{
|
|
1220
|
+
model.set("selection", JSON.stringify(selection));
|
|
1221
|
+
model.save_changes();
|
|
1222
|
+
}} else {{
|
|
1223
|
+
window.parent.postMessage({{
|
|
1224
|
+
type: "MOLGRID_SELECTION",
|
|
1225
|
+
gridId: gridId,
|
|
1226
|
+
selection: selection
|
|
1227
|
+
}}, "*");
|
|
1228
|
+
}}
|
|
1229
|
+
}}
|
|
1230
|
+
|
|
1231
|
+
// Listen for List.js updates (page changes, filtering)
|
|
1232
|
+
molgridList.on('updated', function() {{
|
|
1233
|
+
updateShowingInfo();
|
|
1234
|
+
updateCheckboxes();
|
|
1235
|
+
}});
|
|
1236
|
+
|
|
1237
|
+
container.addEventListener("change", function(e) {{
|
|
1238
|
+
if (e.target.classList.contains("molgrid-checkbox")) {{
|
|
1239
|
+
var index = parseInt(e.target.getAttribute("data-index"), 10);
|
|
1240
|
+
var cell = e.target.closest(".molgrid-cell");
|
|
1241
|
+
|
|
1242
|
+
if (e.target.checked) {{
|
|
1243
|
+
selectedIndices.add(index);
|
|
1244
|
+
cell.classList.add("selected");
|
|
1245
|
+
}} else {{
|
|
1246
|
+
selectedIndices.delete(index);
|
|
1247
|
+
cell.classList.remove("selected");
|
|
1248
|
+
}}
|
|
1249
|
+
|
|
1250
|
+
syncSelection();
|
|
1251
|
+
}}
|
|
1252
|
+
}});
|
|
1253
|
+
|
|
1254
|
+
container.addEventListener("click", function(e) {{
|
|
1255
|
+
var cell = e.target.closest(".molgrid-cell");
|
|
1256
|
+
// Don't toggle selection when clicking checkbox, info button, or tooltip
|
|
1257
|
+
var isCheckbox = e.target.classList.contains("molgrid-checkbox");
|
|
1258
|
+
var isInfoBtn = e.target.classList.contains("molgrid-info-btn");
|
|
1259
|
+
var isInfoTooltip = e.target.closest(".molgrid-info-tooltip");
|
|
1260
|
+
if (cell && !isCheckbox && !isInfoBtn && !isInfoTooltip) {{
|
|
1261
|
+
var checkbox = cell.querySelector(".molgrid-checkbox");
|
|
1262
|
+
if (checkbox) {{
|
|
1263
|
+
checkbox.checked = !checkbox.checked;
|
|
1264
|
+
checkbox.dispatchEvent(new Event("change", {{ bubbles: true }}));
|
|
1265
|
+
}}
|
|
1266
|
+
}}
|
|
1267
|
+
}});
|
|
1268
|
+
|
|
1269
|
+
// Prevent default on pagination links (fixes Marimo navigation issue)
|
|
1270
|
+
container.querySelector('.molgrid-pagination').addEventListener('click', function(e) {{
|
|
1271
|
+
if (e.target.tagName === 'A') {{
|
|
1272
|
+
e.preventDefault();
|
|
1273
|
+
}}
|
|
1274
|
+
}});
|
|
1275
|
+
|
|
1276
|
+
// Send height to parent for proper iframe sizing
|
|
1277
|
+
function sendHeight() {{
|
|
1278
|
+
var height = document.body.scrollHeight;
|
|
1279
|
+
window.parent.postMessage({{
|
|
1280
|
+
type: "MOLGRID_HEIGHT",
|
|
1281
|
+
gridId: gridId,
|
|
1282
|
+
height: height
|
|
1283
|
+
}}, "*");
|
|
1284
|
+
}}
|
|
1285
|
+
|
|
1286
|
+
// Initial update
|
|
1287
|
+
updateShowingInfo();
|
|
1288
|
+
|
|
1289
|
+
// Send initial height after a short delay for rendering
|
|
1290
|
+
setTimeout(sendHeight, 100);
|
|
1291
|
+
|
|
1292
|
+
// Also send height after List.js updates
|
|
1293
|
+
molgridList.on('updated', function() {{
|
|
1294
|
+
setTimeout(sendHeight, 50);
|
|
1295
|
+
}});
|
|
1296
|
+
|
|
1297
|
+
// ========================================
|
|
1298
|
+
// Info Button Click-to-Pin
|
|
1299
|
+
// ========================================
|
|
1300
|
+
|
|
1301
|
+
// Handle info button clicks to pin/unpin tooltips
|
|
1302
|
+
container.addEventListener('click', function(e) {{
|
|
1303
|
+
if (e.target.classList.contains('molgrid-info-btn')) {{
|
|
1304
|
+
e.stopPropagation();
|
|
1305
|
+
var btn = e.target;
|
|
1306
|
+
var tooltip = btn.nextElementSibling;
|
|
1307
|
+
if (tooltip && tooltip.classList.contains('molgrid-info-tooltip')) {{
|
|
1308
|
+
var isPinned = tooltip.classList.contains('pinned');
|
|
1309
|
+
if (isPinned) {{
|
|
1310
|
+
// Unpin this tooltip
|
|
1311
|
+
tooltip.classList.remove('pinned');
|
|
1312
|
+
btn.classList.remove('active');
|
|
1313
|
+
}} else {{
|
|
1314
|
+
// Pin this tooltip (don't unpin others - allow comparison)
|
|
1315
|
+
tooltip.classList.add('pinned');
|
|
1316
|
+
btn.classList.add('active');
|
|
1317
|
+
}}
|
|
1318
|
+
}}
|
|
1319
|
+
}}
|
|
1320
|
+
}});
|
|
1321
|
+
|
|
1322
|
+
// ========================================
|
|
1323
|
+
// Actions Dropdown
|
|
1324
|
+
// ========================================
|
|
1325
|
+
|
|
1326
|
+
var actionsBtn = container.querySelector('.molgrid-actions-btn');
|
|
1327
|
+
var dropdown = container.querySelector('.molgrid-dropdown');
|
|
1328
|
+
|
|
1329
|
+
// Position and toggle dropdown
|
|
1330
|
+
function positionDropdown() {{
|
|
1331
|
+
var rect = actionsBtn.getBoundingClientRect();
|
|
1332
|
+
var dropdownHeight = dropdown.offsetHeight || 250;
|
|
1333
|
+
var viewportHeight = window.innerHeight;
|
|
1334
|
+
|
|
1335
|
+
// Check if there's room below the button
|
|
1336
|
+
var spaceBelow = viewportHeight - rect.bottom;
|
|
1337
|
+
var spaceAbove = rect.top;
|
|
1338
|
+
|
|
1339
|
+
if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {{
|
|
1340
|
+
// Position below
|
|
1341
|
+
dropdown.style.top = rect.bottom + 4 + 'px';
|
|
1342
|
+
dropdown.style.bottom = 'auto';
|
|
1343
|
+
}} else {{
|
|
1344
|
+
// Position above
|
|
1345
|
+
dropdown.style.bottom = (viewportHeight - rect.top + 4) + 'px';
|
|
1346
|
+
dropdown.style.top = 'auto';
|
|
1347
|
+
}}
|
|
1348
|
+
// Always align to right edge of viewport with 10px margin
|
|
1349
|
+
dropdown.style.right = '10px';
|
|
1350
|
+
dropdown.style.left = 'auto';
|
|
1351
|
+
}}
|
|
1352
|
+
|
|
1353
|
+
// Toggle dropdown
|
|
1354
|
+
actionsBtn.addEventListener('click', function(e) {{
|
|
1355
|
+
e.stopPropagation();
|
|
1356
|
+
var isShowing = dropdown.classList.contains('show');
|
|
1357
|
+
if (!isShowing) {{
|
|
1358
|
+
positionDropdown();
|
|
1359
|
+
}}
|
|
1360
|
+
dropdown.classList.toggle('show');
|
|
1361
|
+
}});
|
|
1362
|
+
|
|
1363
|
+
// Reposition on scroll/resize
|
|
1364
|
+
window.addEventListener('scroll', function() {{
|
|
1365
|
+
if (dropdown.classList.contains('show')) {{
|
|
1366
|
+
positionDropdown();
|
|
1367
|
+
}}
|
|
1368
|
+
}});
|
|
1369
|
+
|
|
1370
|
+
// Close dropdown when clicking outside
|
|
1371
|
+
document.addEventListener('click', function(e) {{
|
|
1372
|
+
if (!dropdown.contains(e.target) && e.target !== actionsBtn) {{
|
|
1373
|
+
dropdown.classList.remove('show');
|
|
1374
|
+
}}
|
|
1375
|
+
}});
|
|
1376
|
+
|
|
1377
|
+
// Get all item indices (respecting current filter)
|
|
1378
|
+
function getAllIndices() {{
|
|
1379
|
+
return molgridList.items.map(function(item) {{
|
|
1380
|
+
return parseInt(item.values().index, 10);
|
|
1381
|
+
}});
|
|
1382
|
+
}}
|
|
1383
|
+
|
|
1384
|
+
// Get matching item indices (respecting current filter/search)
|
|
1385
|
+
function getMatchingIndices() {{
|
|
1386
|
+
return molgridList.matchingItems.map(function(item) {{
|
|
1387
|
+
return parseInt(item.values().index, 10);
|
|
1388
|
+
}});
|
|
1389
|
+
}}
|
|
1390
|
+
|
|
1391
|
+
// Select All action
|
|
1392
|
+
function selectAll() {{
|
|
1393
|
+
var indices = getMatchingIndices();
|
|
1394
|
+
indices.forEach(function(idx) {{
|
|
1395
|
+
selectedIndices.add(idx);
|
|
1396
|
+
}});
|
|
1397
|
+
updateCheckboxes();
|
|
1398
|
+
syncSelection();
|
|
1399
|
+
}}
|
|
1400
|
+
|
|
1401
|
+
// Clear Selection action
|
|
1402
|
+
function clearSelection() {{
|
|
1403
|
+
selectedIndices.clear();
|
|
1404
|
+
updateCheckboxes();
|
|
1405
|
+
syncSelection();
|
|
1406
|
+
}}
|
|
1407
|
+
|
|
1408
|
+
// Invert Selection action
|
|
1409
|
+
function invertSelection() {{
|
|
1410
|
+
var indices = getMatchingIndices();
|
|
1411
|
+
indices.forEach(function(idx) {{
|
|
1412
|
+
if (selectedIndices.has(idx)) {{
|
|
1413
|
+
selectedIndices.delete(idx);
|
|
1414
|
+
}} else {{
|
|
1415
|
+
selectedIndices.add(idx);
|
|
1416
|
+
}}
|
|
1417
|
+
}});
|
|
1418
|
+
updateCheckboxes();
|
|
1419
|
+
syncSelection();
|
|
1420
|
+
}}
|
|
1421
|
+
|
|
1422
|
+
// CSV escape helper
|
|
1423
|
+
function csvEscape(val) {{
|
|
1424
|
+
if (val === null || val === undefined) return '';
|
|
1425
|
+
var str = String(val);
|
|
1426
|
+
if (str.indexOf(',') !== -1 || str.indexOf('"') !== -1 || str.indexOf('\\n') !== -1) {{
|
|
1427
|
+
return '"' + str.replace(/"/g, '""') + '"';
|
|
1428
|
+
}}
|
|
1429
|
+
return str;
|
|
1430
|
+
}}
|
|
1431
|
+
|
|
1432
|
+
// Get data for export (selected or all)
|
|
1433
|
+
function getExportRows() {{
|
|
1434
|
+
var indices = selectedIndices.size > 0
|
|
1435
|
+
? Array.from(selectedIndices).sort(function(a, b) {{ return a - b; }})
|
|
1436
|
+
: getMatchingIndices();
|
|
1437
|
+
return indices.map(function(idx) {{
|
|
1438
|
+
return exportData[idx];
|
|
1439
|
+
}});
|
|
1440
|
+
}}
|
|
1441
|
+
|
|
1442
|
+
// Generate CSV content
|
|
1443
|
+
function generateCSV() {{
|
|
1444
|
+
var rows = getExportRows();
|
|
1445
|
+
var lines = [];
|
|
1446
|
+
// Header
|
|
1447
|
+
lines.push(exportColumns.map(csvEscape).join(','));
|
|
1448
|
+
// Data rows
|
|
1449
|
+
rows.forEach(function(row) {{
|
|
1450
|
+
var values = exportColumns.map(function(col) {{
|
|
1451
|
+
return csvEscape(row[col] || '');
|
|
1452
|
+
}});
|
|
1453
|
+
lines.push(values.join(','));
|
|
1454
|
+
}});
|
|
1455
|
+
return lines.join('\\n');
|
|
1456
|
+
}}
|
|
1457
|
+
|
|
1458
|
+
// Generate SMILES content
|
|
1459
|
+
function generateSMILES() {{
|
|
1460
|
+
var rows = getExportRows();
|
|
1461
|
+
return rows.map(function(row) {{
|
|
1462
|
+
return row.smiles || '';
|
|
1463
|
+
}}).filter(function(s) {{ return s; }}).join('\\n');
|
|
1464
|
+
}}
|
|
1465
|
+
|
|
1466
|
+
// Copy to Clipboard action
|
|
1467
|
+
function copyToClipboard() {{
|
|
1468
|
+
var csv = generateCSV();
|
|
1469
|
+
navigator.clipboard.writeText(csv).then(function() {{
|
|
1470
|
+
// Optional: show feedback
|
|
1471
|
+
}}).catch(function(err) {{
|
|
1472
|
+
console.error('Failed to copy:', err);
|
|
1473
|
+
}});
|
|
1474
|
+
}}
|
|
1475
|
+
|
|
1476
|
+
// Save file helper
|
|
1477
|
+
function saveFile(content, filename, mimeType) {{
|
|
1478
|
+
var blob = new Blob([content], {{ type: mimeType }});
|
|
1479
|
+
var url = URL.createObjectURL(blob);
|
|
1480
|
+
var a = document.createElement('a');
|
|
1481
|
+
a.href = url;
|
|
1482
|
+
a.download = filename;
|
|
1483
|
+
document.body.appendChild(a);
|
|
1484
|
+
a.click();
|
|
1485
|
+
document.body.removeChild(a);
|
|
1486
|
+
URL.revokeObjectURL(url);
|
|
1487
|
+
}}
|
|
1488
|
+
|
|
1489
|
+
// Save to SMILES action
|
|
1490
|
+
function saveToSMILES() {{
|
|
1491
|
+
var content = generateSMILES();
|
|
1492
|
+
saveFile(content, 'molgrid.smi', 'chemical/x-daylight-smiles');
|
|
1493
|
+
}}
|
|
1494
|
+
|
|
1495
|
+
// Save to CSV action
|
|
1496
|
+
function saveToCSV() {{
|
|
1497
|
+
var content = generateCSV();
|
|
1498
|
+
saveFile(content, 'molgrid.csv', 'text/csv');
|
|
1499
|
+
}}
|
|
1500
|
+
|
|
1501
|
+
// Handle dropdown item clicks
|
|
1502
|
+
dropdown.addEventListener('click', function(e) {{
|
|
1503
|
+
var item = e.target.closest('.molgrid-dropdown-item');
|
|
1504
|
+
if (!item) return;
|
|
1505
|
+
|
|
1506
|
+
var action = item.dataset.action;
|
|
1507
|
+
dropdown.classList.remove('show');
|
|
1508
|
+
|
|
1509
|
+
switch (action) {{
|
|
1510
|
+
case 'select-all':
|
|
1511
|
+
selectAll();
|
|
1512
|
+
break;
|
|
1513
|
+
case 'clear-selection':
|
|
1514
|
+
clearSelection();
|
|
1515
|
+
break;
|
|
1516
|
+
case 'invert-selection':
|
|
1517
|
+
invertSelection();
|
|
1518
|
+
break;
|
|
1519
|
+
case 'copy-clipboard':
|
|
1520
|
+
copyToClipboard();
|
|
1521
|
+
break;
|
|
1522
|
+
case 'save-smiles':
|
|
1523
|
+
saveToSMILES();
|
|
1524
|
+
break;
|
|
1525
|
+
case 'save-csv':
|
|
1526
|
+
saveToCSV();
|
|
1527
|
+
break;
|
|
1528
|
+
}}
|
|
1529
|
+
}});
|
|
1530
|
+
}})();
|
|
1531
|
+
'''
|
|
1532
|
+
|
|
1533
|
+
return f'''<!DOCTYPE html>
|
|
1534
|
+
<html lang="en">
|
|
1535
|
+
<head>
|
|
1536
|
+
<meta charset="UTF-8">
|
|
1537
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
1538
|
+
<title>MolGrid</title>
|
|
1539
|
+
<script>{_LIST_JS}</script>
|
|
1540
|
+
<style>
|
|
1541
|
+
:root {{
|
|
1542
|
+
--molgrid-image-width: {self.width}px;
|
|
1543
|
+
--molgrid-image-height: {self.height}px;
|
|
1544
|
+
--molgrid-cell-width: {self.width + 24}px;
|
|
1545
|
+
}}
|
|
1546
|
+
{_CSS}
|
|
1547
|
+
</style>
|
|
1548
|
+
</head>
|
|
1549
|
+
<body>
|
|
1550
|
+
<div id="{grid_id}" class="molgrid-container">
|
|
1551
|
+
<div class="molgrid-toolbar">
|
|
1552
|
+
<div class="molgrid-search">
|
|
1553
|
+
<input type="text" class="molgrid-search-input" placeholder="Search...">
|
|
1554
|
+
<div class="toggle-switch">
|
|
1555
|
+
<label class="toggle-label">
|
|
1556
|
+
<span class="toggle-text active">Properties</span>
|
|
1557
|
+
<input type="checkbox" class="search-mode-toggle">
|
|
1558
|
+
<span class="toggle-text">SMARTS</span>
|
|
1559
|
+
</label>
|
|
1560
|
+
</div>
|
|
1561
|
+
</div>
|
|
1562
|
+
<div class="molgrid-info">
|
|
1563
|
+
Showing <span class="showing-start">1</span>-<span class="showing-end">{min(items_per_page, total_items)}</span> of <span class="showing-total">{total_items}</span> molecules
|
|
1564
|
+
</div>
|
|
1565
|
+
<div class="molgrid-actions">
|
|
1566
|
+
<button class="molgrid-actions-btn" title="Actions">⋯</button>
|
|
1567
|
+
<div class="molgrid-dropdown">
|
|
1568
|
+
<button class="molgrid-dropdown-item" data-action="select-all">Select All</button>
|
|
1569
|
+
<button class="molgrid-dropdown-item" data-action="clear-selection">Clear Selection</button>
|
|
1570
|
+
<button class="molgrid-dropdown-item" data-action="invert-selection">Invert Selection</button>
|
|
1571
|
+
<div class="molgrid-dropdown-divider"></div>
|
|
1572
|
+
<button class="molgrid-dropdown-item" data-action="copy-clipboard">Copy to Clipboard</button>
|
|
1573
|
+
<button class="molgrid-dropdown-item" data-action="save-smiles">Save to SMILES</button>
|
|
1574
|
+
<button class="molgrid-dropdown-item" data-action="save-csv">Save to CSV</button>
|
|
1575
|
+
</div>
|
|
1576
|
+
</div>
|
|
1577
|
+
</div>
|
|
1578
|
+
<ul class="molgrid-list list">
|
|
1579
|
+
{items_html}
|
|
1580
|
+
</ul>
|
|
1581
|
+
<div class="molgrid-pagination-nav">
|
|
1582
|
+
<button class="molgrid-prev" disabled>« Previous</button>
|
|
1583
|
+
<div class="molgrid-pagination">
|
|
1584
|
+
<ul class="pagination"></ul>
|
|
1585
|
+
</div>
|
|
1586
|
+
<button class="molgrid-next">Next »</button>
|
|
1587
|
+
</div>
|
|
1588
|
+
</div>
|
|
1589
|
+
<script>
|
|
1590
|
+
{js}
|
|
1591
|
+
</script>
|
|
1592
|
+
</body>
|
|
1593
|
+
</html>'''
|
|
1594
|
+
|
|
1595
|
+
def get_selection(self) -> List:
|
|
1596
|
+
"""Get list of selected molecules.
|
|
1597
|
+
|
|
1598
|
+
:returns: List of selected OEMol objects.
|
|
1599
|
+
"""
|
|
1600
|
+
selection = MolGrid._selections.get(self.name, {})
|
|
1601
|
+
return [self._molecules[idx] for idx in sorted(selection.keys())]
|
|
1602
|
+
|
|
1603
|
+
def get_selection_indices(self) -> List[int]:
|
|
1604
|
+
"""Get indices of selected molecules.
|
|
1605
|
+
|
|
1606
|
+
:returns: List of selected indices.
|
|
1607
|
+
"""
|
|
1608
|
+
return sorted(MolGrid._selections.get(self.name, {}).keys())
|
|
1609
|
+
|
|
1610
|
+
def display(self):
|
|
1611
|
+
"""Display the grid in the notebook.
|
|
1612
|
+
|
|
1613
|
+
Automatically detects Jupyter vs Marimo environment.
|
|
1614
|
+
Note: The widget was already displayed in __init__ to ensure
|
|
1615
|
+
the anywidget model is available when the iframe loads.
|
|
1616
|
+
|
|
1617
|
+
:returns: Displayable HTML object.
|
|
1618
|
+
"""
|
|
1619
|
+
html_content = self.to_html()
|
|
1620
|
+
iframe_id = f"molgrid-iframe-{self.name}"
|
|
1621
|
+
|
|
1622
|
+
iframe_html = f'''<iframe
|
|
1623
|
+
id="{iframe_id}"
|
|
1624
|
+
class="molgrid-iframe"
|
|
1625
|
+
style="width: 100%; border: none; height: 500px; overflow: hidden;"
|
|
1626
|
+
srcdoc="{escape(html_content)}"
|
|
1627
|
+
></iframe>'''
|
|
1628
|
+
|
|
1629
|
+
if _is_marimo():
|
|
1630
|
+
import marimo as mo
|
|
1631
|
+
return mo.vstack([self.widget, mo.Html(iframe_html)])
|
|
1632
|
+
else:
|
|
1633
|
+
return HTML(iframe_html)
|
|
1634
|
+
|
|
1635
|
+
def get_marimo_selection(self):
|
|
1636
|
+
"""Get marimo reactive state for selection.
|
|
1637
|
+
|
|
1638
|
+
Only available in marimo environment.
|
|
1639
|
+
|
|
1640
|
+
:returns: State getter function.
|
|
1641
|
+
:raises RuntimeError: If not in marimo environment.
|
|
1642
|
+
"""
|
|
1643
|
+
if not _is_marimo():
|
|
1644
|
+
raise RuntimeError("This method is only available in a marimo notebook.")
|
|
1645
|
+
|
|
1646
|
+
def get_state():
|
|
1647
|
+
return list(MolGrid._selections.get(self.name, {}).keys())
|
|
1648
|
+
|
|
1649
|
+
return get_state
|