lhasa-ligand-builder 0.1.0

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.
Files changed (67) hide show
  1. package/.eslintrc.cjs +18 -0
  2. package/LICENSE +674 -0
  3. package/README.md +41 -0
  4. package/index.html +49 -0
  5. package/package.json +37 -0
  6. package/public/.gitkeep +0 -0
  7. package/public/Components-inchikey.ich +48167 -0
  8. package/public/icons/README +7 -0
  9. package/public/icons/dark/layla_3c.svg +82 -0
  10. package/public/icons/dark/layla_4c.svg +82 -0
  11. package/public/icons/dark/layla_5c.svg +82 -0
  12. package/public/icons/dark/layla_6arom.svg +89 -0
  13. package/public/icons/dark/layla_6c.svg +82 -0
  14. package/public/icons/dark/layla_7c.svg +82 -0
  15. package/public/icons/dark/layla_8c.svg +82 -0
  16. package/public/icons/dark/layla_charge_tool.svg +78 -0
  17. package/public/icons/dark/layla_delete_hydrogens_tool.svg +384 -0
  18. package/public/icons/dark/layla_double_bond.svg +78 -0
  19. package/public/icons/dark/layla_format_tool.svg +283 -0
  20. package/public/icons/dark/layla_geometry_tool.svg +105 -0
  21. package/public/icons/dark/layla_key.svg +76 -0
  22. package/public/icons/dark/layla_move_tool.svg +110 -0
  23. package/public/icons/dark/layla_single_bond.svg +73 -0
  24. package/public/icons/dark/layla_triple_bond.svg +87 -0
  25. package/public/icons/dark/lhasa_delete_tool.svg +401 -0
  26. package/public/icons/dark/lhasa_flip_x_tool.svg +106 -0
  27. package/public/icons/dark/lhasa_flip_y_tool.svg +106 -0
  28. package/public/icons/dark/lhasa_rotate_tool.svg +112 -0
  29. package/public/icons/icons/hicolor_apps_scalable_coot-layla.svg +105 -0
  30. package/public/icons/layla_3c.svg +82 -0
  31. package/public/icons/layla_4c.svg +82 -0
  32. package/public/icons/layla_5c.svg +82 -0
  33. package/public/icons/layla_6arom.svg +89 -0
  34. package/public/icons/layla_6c.svg +82 -0
  35. package/public/icons/layla_7c.svg +82 -0
  36. package/public/icons/layla_8c.svg +82 -0
  37. package/public/icons/layla_charge_tool.svg +78 -0
  38. package/public/icons/layla_delete_hydrogens_tool.svg +384 -0
  39. package/public/icons/layla_double_bond.svg +78 -0
  40. package/public/icons/layla_format_tool.svg +283 -0
  41. package/public/icons/layla_geometry_tool-dark.svg +105 -0
  42. package/public/icons/layla_geometry_tool.svg +105 -0
  43. package/public/icons/layla_key.svg +76 -0
  44. package/public/icons/layla_move_tool.svg +110 -0
  45. package/public/icons/layla_single_bond.svg +73 -0
  46. package/public/icons/layla_triple_bond.svg +87 -0
  47. package/public/icons/lhasa_delete_tool.svg +401 -0
  48. package/public/icons/lhasa_flip_x_tool.svg +106 -0
  49. package/public/icons/lhasa_flip_y_tool.svg +106 -0
  50. package/public/icons/lhasa_rotate_tool.svg +112 -0
  51. package/public/lhasa.js +2 -0
  52. package/public/lhasa.wasm +0 -0
  53. package/public/react.svg +1 -0
  54. package/src/Lhasa.tsx +1452 -0
  55. package/src/assets/.gitkeep +0 -0
  56. package/src/bansu_integration.tsx +315 -0
  57. package/src/customize_mui.scss +97 -0
  58. package/src/inchikey_database_parse.tsx +20 -0
  59. package/src/index.d.ts +11 -0
  60. package/src/index.scss +352 -0
  61. package/src/main.tsx +79 -0
  62. package/src/qed_property_infobox.tsx +14 -0
  63. package/src/types.d.ts +375 -0
  64. package/src/vite-env.d.ts +1 -0
  65. package/tsconfig.json +25 -0
  66. package/tsconfig.node.json +10 -0
  67. package/vite.config.ts +55 -0
package/src/Lhasa.tsx ADDED
@@ -0,0 +1,1452 @@
1
+ import { useEffect, useId, useRef, useState, createContext, useMemo } from 'react'
2
+ import { HotKeys } from "react-hotkeys"
3
+ //import * as d3 from "d3";
4
+ import { create as D3Create } from 'd3';
5
+ import './index.scss';
6
+ import './customize_mui.scss';
7
+ import { Canvas, Color, DisplayMode, MainModule, QEDInfo, TextMeasurementCache } from './types';
8
+ import { ToggleButton, Button, Switch, FormGroup, FormControlLabel, FormControl, RadioGroup, Radio, Slider, TextField, Menu, MenuItem, Accordion, AccordionSummary, AccordionDetails, Popover, StyledEngineProvider, IconButton, Tabs, Tab, Tooltip } from '@mui/material';
9
+ import { Redo, Undo } from '@mui/icons-material';
10
+ import { QedPropertyInfobox } from './qed_property_infobox';
11
+ import { BansuButton } from './bansu_integration';
12
+ import { parseInchikeyDatabase } from './inchikey_database_parse';
13
+
14
+ class ToolButtonProps {
15
+ onclick?: () => void;
16
+ action_name: string | undefined;
17
+ caption: string | undefined;
18
+ caption_optional?: boolean = false;
19
+ icon: string | undefined | null;
20
+ tooltip_body?: React.JSX.Element | null = null;
21
+ }
22
+
23
+ class ActiveToolContextData {
24
+ active_tool_name?: string = '';
25
+ show_optional_captions?: boolean = true;
26
+ }
27
+
28
+ const ActiveToolContext = createContext<ActiveToolContextData>(new ActiveToolContextData());
29
+
30
+ function ToolButton(props:ToolButtonProps) {
31
+ // console.log(props.caption);
32
+ return (
33
+ <ActiveToolContext.Consumer>
34
+ {context => (
35
+ <Tooltip
36
+ title={props.tooltip_body}
37
+ enterDelay={1000}
38
+ enterNextDelay={1000}
39
+ disableInteractive
40
+ >
41
+ <ToggleButton
42
+ selected={context.active_tool_name == props.action_name}
43
+ onChange={props.onclick}
44
+ value={'dummy'}
45
+ // Doesn't work: autoCapitalize='false'
46
+ style={{textTransform: 'none', padding: '0px'}}
47
+ >
48
+ <div className='tool_button'>
49
+ {props.icon &&
50
+ <>
51
+ <img src={props.icon} className="lhasa_icon" />
52
+ </>}
53
+ {(context.show_optional_captions || !props.caption_optional) && props.caption}
54
+ </div>
55
+ </ToggleButton>
56
+ </Tooltip>
57
+ )}
58
+ </ActiveToolContext.Consumer>
59
+ )
60
+ }
61
+
62
+ // Parameters for scaling the slider
63
+ // It's made so that leftmost edge is min_scale
64
+ // rightmost edge is max_scale
65
+ // and the value of 1 is in the middle
66
+
67
+ const max_scale = 4;
68
+ const min_scale = 0.4;
69
+ const c_const = (min_scale*max_scale-1) / (min_scale+max_scale-2);
70
+ const d_bottom = (1 - max_scale) / (min_scale - 1);
71
+ const d_top= (min_scale-1)**2 / (min_scale+max_scale-2);
72
+ const d_const = (Math.log(d_top))/(2 * Math.log(d_bottom));
73
+ const theta_const = (max_scale -1)**2 / (min_scale - 1)**2;
74
+
75
+ class LhasaComponentProps {
76
+ Lhasa: MainModule | any;
77
+ show_top_panel?: boolean;
78
+ show_footer?: boolean;
79
+ icons_path_prefix?: string;
80
+ /// Base64-encoded pickles
81
+ rdkit_molecule_pickle_list?: { pickle: string; id: string }[];
82
+ /// When Lhasa is embedded, what is it embedded in?
83
+ name_of_host_program?: string;
84
+ /// Called when a molecule changes.
85
+ /// Can be provided to get updates when a molecule changes
86
+ smiles_callback?: (internal_id: number, id_from_prop: string | null, smiles: string) => void;
87
+ bansu_endpoint?: string | undefined;
88
+ data_path_prefix?: string;
89
+ dark_mode?: boolean;
90
+ }
91
+
92
+
93
+ export function LhasaComponent({
94
+ Lhasa,
95
+ show_top_panel = false,
96
+ show_footer = true,
97
+ icons_path_prefix = '',
98
+ rdkit_molecule_pickle_list,
99
+ name_of_host_program = 'Moorhen',
100
+ smiles_callback,
101
+ bansu_endpoint = 'https://www.ccp4.ac.uk/bansu',
102
+ data_path_prefix = '',
103
+ dark_mode = false,
104
+ } : LhasaComponentProps) {
105
+ function on_render(lh: Canvas, text_measurement_cache: TextMeasurementCache, text_measurement_worker_div: string) {
106
+ console.debug("on_render() called.");
107
+
108
+ const css_color_from_lhasa_color = (lhasa_color: Color) => {
109
+ return 'rgba(' + lhasa_color.r * 255 + ','+ lhasa_color.g * 255 + ',' + lhasa_color.b * 255 + ',' + lhasa_color.a + ')';
110
+ }
111
+ const lhasa_text_to_d3js = (msvg, text) => {
112
+ const style_to_attrstring = (style) => {
113
+ let style_string = "";
114
+ if(style.specifies_color) {
115
+ style_string += "fill:";
116
+ style_string += css_color_from_lhasa_color(style.color);
117
+ style_string += ";";
118
+ }
119
+ // font-size:75%;baseline-shift:sub / super
120
+ switch(style.positioning) {
121
+ case Lhasa.TextPositioning.Normal:
122
+ if(style.size != "") {
123
+ style_string += "font-size:";
124
+ style_string += style.size;
125
+ style_string += ";";
126
+ }
127
+ break;
128
+ case Lhasa.TextPositioning.Sub:
129
+ style_string += "font-size:75%;baseline-shift:sub;";
130
+ break;
131
+ case Lhasa.TextPositioning.Super:
132
+ style_string += "font-size:75%;baseline-shift:super;";
133
+ break;
134
+ }
135
+ if(style.weight != "") {
136
+ style_string += "font-weight:";
137
+ style_string += style.weight;
138
+ style_string += ";";
139
+ }
140
+ return style_string;
141
+ };
142
+ const append_spans_to_node = (spans, text_node, text_origin) => {
143
+ for(let i = 0; i < spans.size(); i++) {
144
+ const span = spans.get(i);
145
+ let child = text_node
146
+ // .enter()
147
+ .append("tspan");
148
+ if(span.specifies_style) {
149
+ child.attr("style", style_to_attrstring(span.style));
150
+ }
151
+ if(span.has_subspans()) {
152
+ append_spans_to_node(span.as_subspans(), child, text_origin);
153
+ } else if(span.is_newline()) {
154
+ child.attr("dy", "1em");
155
+ child.attr("x", text_origin.x);
156
+ // Invisible U+2063 to force tspan to be rendered
157
+ child.text("⁣");
158
+ } else {
159
+ const caption = span.as_caption();
160
+ child.text(caption);
161
+ }
162
+ }
163
+ // return ret;
164
+ }
165
+ const ret = msvg.append("text")
166
+ .attr("x", text.origin.x)
167
+ .attr("y", text.origin.y);
168
+
169
+ const style_string = style_to_attrstring(text.style);
170
+ if(style_string != "") {
171
+ ret.attr("style", style_string);
172
+ } else {
173
+ // console.warn("Empty style string!");
174
+ }
175
+ if(text.spans.size() == 0) {
176
+ console.warn("Text contains no spans!");
177
+ }
178
+ append_spans_to_node(text.spans, ret, text.origin);
179
+ return ret;
180
+ };
181
+ const text_measure_function = (text) => {
182
+ // let size_info = new Lhasa.TextSize;
183
+ let size_info = {
184
+ 'width': 0,
185
+ 'height': 0
186
+ };
187
+ try{
188
+ //const domNode = document.createElement("div");
189
+ const domNode = document.getElementById(text_measurement_worker_div);
190
+ if (domNode == null) {
191
+ // todo: do something with this.
192
+ return size_info;
193
+ }
194
+ const msvg = D3Create("svg")
195
+ .attr("width", 100)
196
+ .attr("height", 100)
197
+ .attr("id", "measurement_temporary");
198
+ const text_elem = lhasa_text_to_d3js(msvg, text);
199
+ domNode.append(msvg.node());
200
+ const node = text_elem.node();
201
+ // This has awful performance but I don't really have a choice
202
+ const bbox = node.getBBox();
203
+ size_info.width = Math.ceil(bbox.width);
204
+ size_info.height = Math.ceil(bbox.height);
205
+ // console.log("measurement returning:", size_info);
206
+ msvg.remove();
207
+ } catch(err) {
208
+ console.error('Error occured in text measurement: ', err);
209
+ } finally {
210
+ return size_info;
211
+ }
212
+
213
+ };
214
+ const ren = new Lhasa.Renderer(text_measure_function, text_measurement_cache);
215
+ lh.render(ren);
216
+ const commands = ren.get_commands();
217
+ const get_width = () => {
218
+ const measured = lh.measure(Lhasa.MeasurementDirection.HORIZONTAL).requested_size;
219
+ const min_size = 480;
220
+ if(measured < min_size) {
221
+ return min_size;
222
+ }
223
+ return measured;
224
+ };
225
+ const get_height = () => {
226
+ const measured = lh.measure(Lhasa.MeasurementDirection.VERTICAL).requested_size;
227
+ const min_size = 270;
228
+ if(measured < min_size) {
229
+ return min_size;
230
+ }
231
+ return measured;
232
+ };
233
+ const svg = D3Create("svg")
234
+ .attr("class", "lhasa_drawing")
235
+ .attr("width", get_width())
236
+ .attr("height", get_height());
237
+
238
+ for(var i = 0; i < commands.size(); i++) {
239
+ const command = commands.get(i);
240
+ if(command.is_path()) {
241
+ const path = command.as_path();
242
+ // this causes a crash
243
+ // if(path.commands.empty()) {
244
+ // // console.warn("Empty path!");
245
+ // // return;
246
+ // }
247
+ // TODO: COMPLETE REWRITE
248
+ const path_node = svg.append("path");
249
+ let path_started = false;
250
+ let d_string = "";
251
+ // This should be just a reference
252
+ const elements = path.get_elements();
253
+ for(var j = 0; j < elements.size(); j++) {
254
+ const element = elements.get(j);
255
+ if(element.is_arc()) {
256
+ const arc = element.as_arc();
257
+
258
+ // Thanks to: https://stackoverflow.com/questions/5736398/how-to-calculate-the-svg-path-for-an-arc-of-a-circle
259
+ function polarToCartesian(centerX, centerY, radius, angleInRadians) {
260
+ //var angleInRadians = (angleInDegrees-90) * Math.PI / 180.0;
261
+
262
+ return {
263
+ x: centerX + (radius * Math.cos(angleInRadians)),
264
+ y: centerY + (radius * Math.sin(angleInRadians))
265
+ };
266
+ }
267
+
268
+ function describeArc(x, y, radius, startAngle, endAngle){
269
+
270
+ var start = polarToCartesian(x, y, radius, endAngle);
271
+ var end = polarToCartesian(x, y, radius, startAngle);
272
+
273
+ var largeArcFlag = endAngle - startAngle <= Math.PI ? "0" : "1";
274
+ //var largeArcFlag = "1";
275
+
276
+ // From the SVG reference:
277
+ //
278
+ // If sweep-flag is '1',
279
+ // then the arc will be drawn in a "positive-angle" direction
280
+ // (i.e., the ellipse formula x=cx+rx*cos(theta) and y=cy+ry*sin(theta)
281
+ // is evaluated such that theta starts at an angle corresponding
282
+ // to the current point and increases positively until the arc reaches (x,y)).
283
+ // A value of 0 causes the arc to be drawn in a "negative-angle" direction
284
+ // (i.e., theta starts at an angle value corresponding to the current point and decreases until the arc reaches (x,y)).
285
+ const sweep = 1;
286
+ var p = path_started ? [] : ["M", start.x, start.y];
287
+ var d = p.concat([
288
+ "A", radius, radius, 0, largeArcFlag, sweep, end.x, end.y,
289
+ // "L", x,y,
290
+ // "L", start.x, start.y,
291
+ // "Z"
292
+ ]).join(" ");
293
+ // console.log("d", d);
294
+
295
+ return d;
296
+ }
297
+
298
+ d_string += describeArc(arc.origin.x, arc.origin.y, arc.radius, arc.angle_one - 0.001, arc.angle_two) + " ";
299
+
300
+ } else if(element.is_line()) {
301
+ const line = element.as_line();
302
+
303
+ function describeLine(x1, y1, x2, y2) {
304
+ var p = path_started ? [] : ["M", x1, y1];
305
+ var d = p.concat(["L", x2, y2]).join(" ");
306
+ return d;
307
+ }
308
+
309
+ d_string += describeLine(line.start.x, line.start.y, line.end.x, line.end.y) + " ";
310
+ } else {
311
+ console.error("Unknown path element type");
312
+ console.log(element);
313
+ }
314
+
315
+ path_started = true;
316
+ }
317
+
318
+ //Breaks wavy bond
319
+ //d_string += "Z";
320
+
321
+ path_node.attr("d", d_string);
322
+
323
+ let style_str = '';
324
+
325
+ if(path.has_fill) {
326
+ style_str += "fill: " + css_color_from_lhasa_color(path.fill_color) + ";";
327
+ } else {
328
+ style_str += "fill: none;";
329
+ }
330
+ if(path.has_stroke) {
331
+ style_str += "stroke:" + css_color_from_lhasa_color(path.stroke_style.color) + ";";
332
+ style_str += "stroke-width:" + path.stroke_style.line_width + ";";
333
+ }
334
+
335
+ path_node.attr("style", style_str);
336
+
337
+
338
+ } else if(command.is_text()) {
339
+ const text = command.as_text();
340
+ lhasa_text_to_d3js(svg, text);
341
+ }
342
+ }
343
+ commands.delete();
344
+ ren.delete();
345
+
346
+ const node = svg.node();
347
+ return node;
348
+ };
349
+ const text_measurement_worker_div = useId();
350
+ const smiles_input = useId();
351
+ const x_element_symbol_input = useId();
352
+
353
+ // Text measurement relies on elements with certain IDs being in the DOM already.
354
+ // This means that text measurement will be incorrect (zeroed) for the first render
355
+ // meaning that if 'first_render' is true, we should re-render immediately
356
+ const isFirstRenderRef = useRef<boolean>(false);
357
+ // Assigns internal molecule IDs to external pickle IDs as given by rdkit_molecule_pickle_map
358
+ const canvasIdsToPropsIdsRef = useRef<Map<number, string>>(new Map<number, string>());
359
+ /// Database of InChIKey -> [Monomer code, Chemical name], fetched asynchronously
360
+ const inchiKeyDatabase = useRef<Map<string, [string, string]> | null>(null);
361
+
362
+ const [svgNode, setSvgNode] = useState(null);
363
+ /// [molecule_id, smiles, [monomer_id, chem_name]?]]
364
+ const [smiles, setSmiles] = useState<[number, string, [string, string]?][]>([]);
365
+ const [scale, setScale] = useState<number>(1.0);
366
+ const [statusText, setStatusText] = useState<string>('');
367
+ const [xElementInputShown, setXElementInputShown] = useState<boolean>(false);
368
+ const [activeToolName, setActiveToolName] = useState<string>('');
369
+
370
+ const [smiles_error_string, setSmilesErrorString] = useState<null | string>(null);
371
+ const [x_element_error_string, setXElementErrorString] = useState<null | string>(null);
372
+ const [qedInfo, setQedInfo] = useState<Map<number, QEDInfo>>(new Map<number, QEDInfo>());
373
+
374
+ const setupLhasaCanvas = (tmc: TextMeasurementCache) => {
375
+ const lh = new Lhasa.Canvas();
376
+ lh.connect("queue_redraw", () => {
377
+ const node = on_render(lh, tmc, text_measurement_worker_div);
378
+ setSvgNode(node);
379
+ });
380
+
381
+ const on_status_updated = function (status_txt: string) {
382
+ setStatusText(status_txt);
383
+ };
384
+ lh.connect("status_updated", on_status_updated);
385
+ lh.connect("smiles_changed", function () {
386
+ const smiles_array: [number, string, [string, string]?][] = [];
387
+ const smiles_map = lh.get_smiles();
388
+ const smiles_keys = smiles_map.keys();
389
+
390
+ const inchikey_map = lh.get_inchi_keys();
391
+
392
+ for(let i = 0; i < smiles_keys.size(); i++) {
393
+ const mol_id = smiles_keys.get(i);
394
+ const inchi_key = inchikey_map.get(mol_id) as string;
395
+ let inchi_lookup_result : [string, string] | null = null;
396
+ if (inchiKeyDatabase.current?.has(inchi_key)) {
397
+ inchi_lookup_result = inchiKeyDatabase.current?.get(inchi_key) as [string, string];
398
+ }
399
+ const smiles_tuple = [mol_id, smiles_map.get(mol_id), inchi_lookup_result] as [number, string, [string, string]?];
400
+ console.log(`Inchi lookup: mol_id=${mol_id} key=${inchi_key} ${inchi_lookup_result ? `monomer_id=${inchi_lookup_result[0]} chem_name=${inchi_lookup_result[1]}` : inchiKeyDatabase.current != null ? ` not found in database` : ` (database not loaded)`}`);
401
+ smiles_array.push(smiles_tuple);
402
+ }
403
+ smiles_keys.delete();
404
+ smiles_map.delete();
405
+ setSmiles(smiles_array);
406
+ // const inchikey_map_keys = inchikey_map.keys();
407
+ // inchikey_map.delete();
408
+ // inchikey_map_keys.delete();
409
+ });
410
+ lh.connect("molecule_deleted", function (mol_id: number) {
411
+ console.log("Molecule with id " + mol_id + " has been deleted.");
412
+ const newQedInfo = qedInfo;
413
+ newQedInfo.delete(mol_id);
414
+ setQedInfo(newQedInfo);
415
+ });
416
+ lh.connect("scale_changed", function (new_scale: number) {
417
+ console.log('new scale: ', new_scale);
418
+ setScale(new_scale);
419
+ });
420
+ lh.connect("qed_info_updated", function (mol_id: number, qed_info_for_mol: QEDInfo) {
421
+ const newQedInfo = qedInfo;
422
+ newQedInfo.set(mol_id, qed_info_for_mol);
423
+ setQedInfo(newQedInfo);
424
+ });
425
+
426
+ const default_scale = 1.0;
427
+ lh.set_scale(default_scale);
428
+
429
+ //console.log('Adding demo molecule.');
430
+ //Lhasa.append_from_smiles(lh, "O=C(C)Oc1ccccc1C(=O)O");
431
+
432
+ return lh;
433
+ };
434
+
435
+ const lh = useRef<any>(null);
436
+ // Text measurement cache
437
+ const tmc = useRef<any>(null);
438
+
439
+ useEffect(() => {
440
+ if (tmc.current === null || tmc.current?.isDeleted()) {
441
+ console.log("Creating text measurement cache.");
442
+ tmc.current = new Lhasa.TextMeasurementCache();
443
+ }
444
+ if (lh.current === null || lh.current?.isDeleted()) {
445
+ console.log("Setting up LhasaCanvas.");
446
+ lh.current = setupLhasaCanvas(tmc.current);
447
+ }
448
+
449
+ const InchiKeyDatabaseLoaderTask = async () => {
450
+ if (inchiKeyDatabase.current !== null) {
451
+ return;
452
+ }
453
+ let retries_remaining = 15;
454
+ while (retries_remaining > 0) {
455
+ try {
456
+ let begin_time = performance.now();
457
+ console.log("Fetching InchiKeyDatabase...");
458
+ const response: Response = await fetch(data_path_prefix + "Components-inchikey.ich");
459
+ if (!response.ok) {
460
+ throw new Error(`InchiKeyDatabase fetch status: ${response.status}`);
461
+ }
462
+ const raw_data = await response.text();
463
+ let end_time = performance.now();
464
+ console.log(`InchiKeyDatabase fetched successfully in ${(end_time - begin_time).toFixed(2)} ms. Size = ${raw_data.length} bytes. Parsing...`);
465
+ begin_time = performance.now();
466
+ const db = parseInchikeyDatabase(raw_data);
467
+ end_time = performance.now();
468
+ inchiKeyDatabase.current = db;
469
+ console.log(`InchiKeyDatabase parsed successfully in ${(end_time - begin_time).toFixed(2)} ms. Entries = ${db.size}.`);
470
+ break;
471
+ } catch (err) {
472
+ console.error(`Could not fetch InchiKeyDatabase: ${err}\n ${retries_remaining > 0 ? `\nRetrying again in two seconds (retries remaining: ${retries_remaining})...` : "\nGiving up."}`);
473
+ retries_remaining -= 1;
474
+ // Sleep for 2 seconds before retrying
475
+ await new Promise(r => setTimeout(r, 2000));
476
+ continue;
477
+ }
478
+ }
479
+ };
480
+
481
+ InchiKeyDatabaseLoaderTask();
482
+
483
+ return () => {
484
+ if (lh.current !== null && !lh.current?.isDeleted()) {
485
+ console.warn("Cleaning up component upon unmounting.");
486
+ lh.current?.delete();
487
+ }
488
+ if (tmc.current !== null && !tmc.current?.isDeleted()) {
489
+ console.warn("Deleting text measurement cache.");
490
+ tmc.current?.delete();
491
+ }
492
+ canvasIdsToPropsIdsRef.current = new Map<number, string>();
493
+ };
494
+ }, []); // Empty dependency array: run only once on mount and cleanup on unmount
495
+
496
+ useEffect(() => {
497
+ if(rdkit_molecule_pickle_list !== undefined) {
498
+ rdkit_molecule_pickle_list.forEach(item => {
499
+ if(! Array.from(canvasIdsToPropsIdsRef.current.values()).some(id => id === item.id) ) {
500
+ const internalId = Lhasa.append_from_pickle_base64(lh.current, item.pickle);
501
+ canvasIdsToPropsIdsRef.current.set(internalId, item.id);
502
+ }
503
+ })
504
+ }
505
+ }, [rdkit_molecule_pickle_list]);
506
+
507
+ function switch_tool(tool : any) {
508
+ lh.current?.set_active_tool(Lhasa.make_active_tool(tool));
509
+ };
510
+
511
+ function on_x_element_button() {
512
+ setXElementInputShown(prev => !prev);
513
+ }
514
+
515
+ function on_smiles_import_button() {
516
+ const smiles_input_el = document.getElementById(smiles_input) as HTMLInputElement;
517
+ try {
518
+ Lhasa.append_from_smiles(lh.current, smiles_input_el.value);
519
+ setSmilesErrorString(null);
520
+ } catch(err) {
521
+ console.warn("Could not import molecule from SMILES: ", err);
522
+ setSmilesErrorString("Could not load import molecule from SMILES. Is your SMILES valid?");
523
+ }
524
+ }
525
+
526
+ function on_x_element_submit_button() {
527
+ const symbol_input = document.getElementById(x_element_symbol_input) as HTMLInputElement;
528
+ try {
529
+ const el_ins = Lhasa.element_insertion_from_symbol(symbol_input.value);
530
+ switch_tool(el_ins);
531
+ setXElementInputShown(false);
532
+ setXElementErrorString(null);
533
+ }catch(err) {
534
+ console.warn("Could not set custom element: ", err);
535
+ setXElementErrorString("Could not custom element. The symbol must be invalid.");
536
+ }
537
+ }
538
+
539
+ function switch_display_mode(value: string) {
540
+ switch(value) {
541
+ default:
542
+ case 'standard':
543
+ lh.current?.set_display_mode(Lhasa.DisplayMode.Standard);
544
+ break;
545
+ case 'atom_indices':
546
+ lh.current?.set_display_mode(Lhasa.DisplayMode.AtomIndices);
547
+ break;
548
+ case 'atom_names':
549
+ lh.current?.set_display_mode(Lhasa.DisplayMode.AtomNames);
550
+ break;
551
+ }
552
+ };
553
+
554
+ function display_mode_to_value_name(value: DisplayMode) {
555
+ switch(value) {
556
+ default:
557
+ case Lhasa.DisplayMode.Standard:
558
+ return 'standard';
559
+ case Lhasa.DisplayMode.AtomIndices:
560
+ return 'atom_indices';
561
+ case Lhasa.DisplayMode.AtomNames:
562
+ return 'atom_names';
563
+ }
564
+ }
565
+
566
+ // From what I understand, this becomes non-null
567
+ // after the first render at which point it
568
+ // should point to the "lhasa_editor" div.
569
+ const editorRef = useRef<HTMLDivElement>(null);
570
+ // From what I understand, this becomes non-null
571
+ // after the first render at which point it
572
+ // should point to the "editor_canvas_container" div.
573
+ const svgRef = useRef<HTMLDivElement>(null);
574
+ // defers the callback to run after render, which is crucial for text measurement
575
+ // to work after the first render (we need to render it again after the first render)
576
+ useEffect(()=>{
577
+ if(svgRef.current && svgNode) {
578
+ svgRef.current.replaceChildren(svgNode);
579
+ if(isFirstRenderRef.current === true) {
580
+ isFirstRenderRef.current = false;
581
+ const newNode = on_render(lh.current, tmc.current, text_measurement_worker_div);
582
+ setSvgNode(newNode);
583
+ }
584
+ }
585
+ }, [svgNode]);
586
+
587
+ const icons_path_final = dark_mode ? icons_path_prefix + "/dark" : icons_path_prefix;
588
+
589
+ const carbon_ring_tooltip = useRef<React.JSX.Element>(<div>
590
+ <b>Carbon Ring Tool</b><br/>
591
+ Insert a carbon ring.<br/>
592
+ <br/>
593
+ <b>Left click on a bond</b> - Insert a carbon ring (of the selected kind) adjacent to the bond.<br/>
594
+ <b>Left click on an atom</b> - Attach a carbon ring (of the selected kind) to the atom.<br/>
595
+ <b>Control + Left click on an atom</b> - Attach a carbon ring (of the selected kind) to the atom (spiro mode).<br/>
596
+ <b>Left click on empty canvas</b> - Initialize a new molecule with the selected kind of carbon ring.
597
+ </div>);
598
+
599
+ const element_tool_tooltip = useRef<React.JSX.Element>(<div>
600
+ <b>Element Tool</b><br/>
601
+ Add a new atom or replace an existent atom with the chosen element.<br/>
602
+ <br/>
603
+ <b>Left click on a bond</b> - Insert a new atom (of the selected kind) in between the atoms of the bond.<br/>
604
+ <b>Left click on an atom</b> - Replace the atom with the chosen element.<br/>
605
+ <br/>
606
+ <b><i>X</i></b> allows to manually type in a custom symbol.
607
+ </div>);
608
+
609
+ const tool_button_data = useMemo(() => {return {
610
+ Move: {
611
+ caption:"Move",
612
+ raw_handler:() => switch_tool(new Lhasa.TransformTool(Lhasa.TransformMode.Translation)),
613
+ icon: icons_path_final + "/layla_move_tool.svg",
614
+ hotkey:"m",
615
+ caption_optional: true,
616
+ tooltip_core: <div>
617
+ <b>Move Tool</b><br/>
618
+ Move molecules around the screen.<br/>
619
+ <b>Left click</b> on a molecule (and drag) to move it.<br/>
620
+ <br/>
621
+ Move mode is also triggered by pressing the <b>Alt</b> key.
622
+ </div>
623
+ },
624
+ Rotate: {
625
+ caption:"Rotate",
626
+ raw_handler:() => switch_tool(new Lhasa.TransformTool(Lhasa.TransformMode.Rotation)),
627
+ icon: icons_path_final + "/lhasa_rotate_tool.svg",
628
+ hotkey:"r",
629
+ caption_optional: true,
630
+ tooltip_core: <div>
631
+ <b>Rotate Tool</b><br/>
632
+ Rotate molecules.<br/>
633
+ <br/>
634
+ <b>Left click</b> on a molecule (and drag) to rotate it.<br/>
635
+ Press <b>Alt</b> while dragging to snap angles (by 15 degrees).<br/>
636
+ <br/>
637
+ Rotation mode is also triggered by pressing the <b>Shift</b> key.
638
+ </div>
639
+ },
640
+ Flip_around_X: {
641
+ caption:"Flip around X",
642
+ raw_handler:() => switch_tool(new Lhasa.FlipTool(Lhasa.FlipMode.Horizontal)),
643
+ icon: icons_path_final + "/lhasa_flip_x_tool.svg",
644
+ hotkey:"alt+f",
645
+ caption_optional: true,
646
+ tooltip_core: <div>
647
+ <b>Flip Horizontal</b><br/>
648
+ Flip molecule in the X axis.<br/>
649
+ <br/>
650
+ <b>Left click</b> on a molecule to flip it.<br/>
651
+ If there's a single molecule on the screen, it will get flipped when the tool is selected.
652
+ </div>
653
+ },
654
+ Flip_around_Y: {
655
+ caption:"Flip around Y",
656
+ raw_handler:() => switch_tool(new Lhasa.FlipTool(Lhasa.FlipMode.Vertical)),
657
+ icon: icons_path_final + "/lhasa_flip_y_tool.svg",
658
+ hotkey:"ctrl+alt+f",
659
+ caption_optional: true,
660
+ tooltip_core: <div>
661
+ <b>Flip Vertical</b><br/>
662
+ Flip molecule in the Y axis.<br/>
663
+ <br/>
664
+ <b>Left click</b> on a molecule to flip it.<br/>
665
+ If there's a single molecule on the screen, it will get flipped when the tool is selected.
666
+ </div>
667
+ },
668
+ Delete_hydrogens: {
669
+ caption:"Delete hydrogens",
670
+ raw_handler:() => switch_tool(new Lhasa.RemoveHydrogensTool()),
671
+ icon: icons_path_final + "/layla_delete_hydrogens_tool.svg",
672
+ hotkey:"alt+delete",
673
+ caption_optional: true,
674
+ tooltip_core: <div>
675
+ <b>Remove Hydrogens Tool</b><br/>
676
+ Removes explicit non-polar hydrogens from the selected molecule.<br/>
677
+ <br/>
678
+ <b>Left click</b> on a molecule to remove explicit non-polar hydrogens.<br/>
679
+ If there's a single molecule on the screen, it will get processed automatically when the tool is selected.
680
+ </div>
681
+ },
682
+ Format: {
683
+ caption:"Format",
684
+ raw_handler:() => switch_tool(new Lhasa.FormatTool()),
685
+ icon: icons_path_final + "/layla_format_tool.svg",
686
+ hotkey:"f",
687
+ caption_optional: true,
688
+ tooltip_core: <div>
689
+ <b>Format Tool</b><br/>
690
+ "Formatting" re-computes atom positions of the selected molecule on the screen.<br/>
691
+ <br/>
692
+ <b>Left click</b> on a molecule to format it.<br/>
693
+ If there's a single molecule on the screen, it will get formatted when the tool is selected.
694
+ </div>
695
+ },
696
+ Single_Bond: {
697
+ caption:"Single Bond",
698
+ raw_handler:() => switch_tool(new Lhasa.BondModifier(Lhasa.BondModifierMode.Single)),
699
+ icon: icons_path_final + "/layla_single_bond.svg",
700
+ hotkey:"s",
701
+ caption_optional: true,
702
+ tooltip_core: <div>
703
+ <b>Single Bond Tool</b><br/>
704
+ Add bond (of the selected kind) or replace an existing one.<br/>
705
+ <br/>
706
+ <b>Left click on a bond</b> - Replace it with the selected kind of bond.<br/>
707
+ <b>Left click on an atom</b> - Append a new bond to the atom (along with a new carbon atom).<br/>
708
+ <b>Left click on an atom and drag</b> - Begin creating new bond via dragging. Release mouse at the final atom to complete the now bond.
709
+ </div>
710
+ },
711
+ Double_Bond: {
712
+ caption:"Double Bond",
713
+ raw_handler:() => switch_tool(new Lhasa.BondModifier(Lhasa.BondModifierMode.Double)),
714
+ icon: icons_path_final + "/layla_double_bond.svg",
715
+ hotkey:"d",
716
+ caption_optional: true,
717
+ tooltip_core: <div>
718
+ <b>Double Bond Tool</b><br/>
719
+ Add bond (of the selected kind) or replace an existing one.<br/>
720
+ <br/>
721
+ <b>Left click on a bond</b> - Replace it with the selected kind of bond.<br/>
722
+ <b>Left click on an atom</b> - Append a new bond to the atom (along with a new carbon atom).<br/>
723
+ <b>Left click on an atom and drag</b> - Begin creating new bond via dragging. Release mouse at the final atom to complete the now bond.
724
+ </div>
725
+ },
726
+ Triple_Bond: {
727
+ caption:"Triple Bond",
728
+ raw_handler:() => switch_tool(new Lhasa.BondModifier(Lhasa.BondModifierMode.Triple)),
729
+ icon: icons_path_final + "/layla_triple_bond.svg",
730
+ hotkey:"t",
731
+ caption_optional: true,
732
+ tooltip_core: <div>
733
+ <b>Triple Bond Tool</b><br/>
734
+ Add bond (of the selected kind) or replace an existing one.<br/>
735
+ <br/>
736
+ <b>Left click on a bond</b> - Replace it with the selected kind of bond.<br/>
737
+ <b>Left click on an atom</b> - Append a new bond to the atom (along with a new carbon atom).<br/>
738
+ <b>Left click on an atom and drag</b> - Begin creating new bond via dragging. Release mouse at the final atom to complete the now bond.
739
+ </div>
740
+ },
741
+ Geometry: {
742
+ caption:"Geometry",
743
+ raw_handler:() => switch_tool(new Lhasa.GeometryModifier()),
744
+ icon: icons_path_final + "/layla_geometry_tool.svg",
745
+ hotkey:"g",
746
+ caption_optional: true,
747
+ tooltip_core: <div>
748
+ <b>Bond Geometry Tool</b><br/>
749
+ Alter geometry of a bond.<br/>
750
+ <br/>
751
+ <b>Left click</b> on a bond cycles between <i>"wedged"</i>, <i>"dashed"</i> and unspecified (<i>"wavy"</i>) bond geometry.<br/>
752
+ Bond geometry is only represented for single bonds.
753
+ </div>
754
+ },
755
+ Charge: {
756
+ caption:"Charge",
757
+ raw_handler:() => switch_tool(new Lhasa.ChargeModifier()),
758
+ icon: icons_path_final + "/layla_charge_tool.svg",
759
+ hotkey:"v",
760
+ caption_optional: true,
761
+ tooltip_core: <div>
762
+ <b>Atom Charge Tool</b><br/>
763
+ Alter charge of an atom.<br/>
764
+ <br/>
765
+ <b>Left click on an atom</b> - Add electrons (more negative charge).<br/>
766
+ <b>Right click on an atom</b> - Remove electrons (more positive charge).
767
+ </div>
768
+ },
769
+ Delete: {
770
+ caption:"Delete",
771
+ raw_handler:() => switch_tool(new Lhasa.DeleteTool()),
772
+ icon: icons_path_final + "/lhasa_delete_tool.svg",
773
+ hotkey:"delete",
774
+ caption_optional: true,
775
+ tooltip_core: <div>
776
+ <b>Delete Tool</b><br/>
777
+ Delete atoms, bonds or molecules.<br/>
778
+ <br/>
779
+ <b>Left click on a bond</b> - Removes the bond (or the whole R-chain).<br/>
780
+ <b>Left click on an atom</b> - Removes the atom (or the whole R-chain).<br/>
781
+ <br/>
782
+ <b>Control + Alt + Left click on a bond</b> - Removes just the bond (without any R-chains).<br/>
783
+ <b>Control + Alt + Left click on an atom</b> - Removes just the atom (without any R-chains).<br/>
784
+ <br/>
785
+ <b>Control + Left click on a molecule</b> - Remove the selected molecule
786
+ </div>
787
+ },
788
+ C3: {
789
+ caption:"3-C",
790
+ raw_handler:() => switch_tool(new Lhasa.StructureInsertion(Lhasa.LhasaStructure.CycloPropaneRing)),
791
+ icon: icons_path_final + "/layla_3c.svg",
792
+ hotkey:"3",
793
+ caption_optional: true,
794
+ tooltip_core: carbon_ring_tooltip.current
795
+ },
796
+ C4: {
797
+ caption:"4-C",
798
+ raw_handler:() => switch_tool(new Lhasa.StructureInsertion(Lhasa.LhasaStructure.CycloButaneRing)),
799
+ icon: icons_path_final + "/layla_4c.svg",
800
+ hotkey:"4",
801
+ caption_optional: true,
802
+ tooltip_core: carbon_ring_tooltip.current
803
+ },
804
+ C5: {
805
+ caption:"5-C",
806
+ raw_handler:() => switch_tool(new Lhasa.StructureInsertion(Lhasa.LhasaStructure.CycloPentaneRing)),
807
+ icon: icons_path_final + "/layla_5c.svg",
808
+ hotkey:"5",
809
+ caption_optional: true,
810
+ tooltip_core: carbon_ring_tooltip.current
811
+ },
812
+ C6: {
813
+ caption:"6-C",
814
+ raw_handler:() => switch_tool(new Lhasa.StructureInsertion(Lhasa.LhasaStructure.CycloHexaneRing)),
815
+ icon: icons_path_final + "/layla_6c.svg",
816
+ hotkey:"6",
817
+ caption_optional: true,
818
+ tooltip_core: carbon_ring_tooltip.current
819
+ },
820
+ Arom6: {
821
+ caption:"6-Arom",
822
+ raw_handler:() => switch_tool(new Lhasa.StructureInsertion(Lhasa.LhasaStructure.BenzeneRing)),
823
+ icon: icons_path_final + "/layla_6arom.svg",
824
+ hotkey:["b","alt+6"],
825
+ caption_optional: true,
826
+ tooltip_core: carbon_ring_tooltip.current
827
+ },
828
+ C7: {
829
+ caption:"7-C",
830
+ raw_handler:() => switch_tool(new Lhasa.StructureInsertion(Lhasa.LhasaStructure.CycloHeptaneRing)),
831
+ icon: icons_path_final + "/layla_7c.svg",
832
+ hotkey:"7",
833
+ caption_optional: true,
834
+ tooltip_core: carbon_ring_tooltip.current
835
+ },
836
+ C8: {
837
+ caption:"8-C",
838
+ raw_handler:() => switch_tool(new Lhasa.StructureInsertion(Lhasa.LhasaStructure.CycloOctaneRing)),
839
+ icon: icons_path_final + "/layla_8c.svg",
840
+ hotkey:"8",
841
+ caption_optional: true,
842
+ tooltip_core: carbon_ring_tooltip.current
843
+ },
844
+ C: {
845
+ caption:"C",
846
+ raw_handler:() => switch_tool(new Lhasa.ElementInsertion(Lhasa.LhasaElement.C)),
847
+ icon: null,
848
+ hotkey:"c",
849
+ caption_optional: false,
850
+ tooltip_core: element_tool_tooltip.current
851
+ },
852
+ N: {
853
+ caption:"N",
854
+ raw_handler:() => switch_tool(new Lhasa.ElementInsertion(Lhasa.LhasaElement.N)),
855
+ icon: null,
856
+ hotkey:"n",
857
+ caption_optional: false,
858
+ tooltip_core: element_tool_tooltip.current
859
+ },
860
+ O: {
861
+ caption:"O",
862
+ raw_handler:() => switch_tool(new Lhasa.ElementInsertion(Lhasa.LhasaElement.O)),
863
+ icon: null,
864
+ hotkey:"o",
865
+ caption_optional: false,
866
+ tooltip_core: element_tool_tooltip.current
867
+ },
868
+ S: {
869
+ caption:"S",
870
+ raw_handler:() => switch_tool(new Lhasa.ElementInsertion(Lhasa.LhasaElement.S)),
871
+ icon: null,
872
+ hotkey:"alt+s",
873
+ caption_optional: false,
874
+ tooltip_core: element_tool_tooltip.current
875
+ },
876
+ P: {
877
+ caption:"P",
878
+ raw_handler:() => switch_tool(new Lhasa.ElementInsertion(Lhasa.LhasaElement.P)),
879
+ icon: null,
880
+ hotkey:"p",
881
+ caption_optional: false,
882
+ tooltip_core: element_tool_tooltip.current
883
+ },
884
+ H: {
885
+ caption:"H",
886
+ raw_handler:() => switch_tool(new Lhasa.ElementInsertion(Lhasa.LhasaElement.H)),
887
+ icon: null,
888
+ hotkey:"h",
889
+ caption_optional: false,
890
+ tooltip_core: element_tool_tooltip.current
891
+ },
892
+ F: {
893
+ caption:"F",
894
+ raw_handler:() => switch_tool(new Lhasa.ElementInsertion(Lhasa.LhasaElement.F)),
895
+ icon: null,
896
+ hotkey:"alt+i",
897
+ caption_optional: false,
898
+ tooltip_core: element_tool_tooltip.current
899
+ },
900
+ Cl: {
901
+ caption:"Cl",
902
+ raw_handler:() => switch_tool(new Lhasa.ElementInsertion(Lhasa.LhasaElement.Cl)),
903
+ icon: null,
904
+ hotkey:"alt+c",
905
+ caption_optional: false,
906
+ tooltip_core: element_tool_tooltip.current
907
+ },
908
+ Br: {
909
+ caption:"Br",
910
+ raw_handler:() => switch_tool(new Lhasa.ElementInsertion(Lhasa.LhasaElement.Br)),
911
+ icon: null,
912
+ hotkey:"alt+b",
913
+ caption_optional: false,
914
+ tooltip_core: element_tool_tooltip.current
915
+ },
916
+ I: {
917
+ caption:"I",
918
+ raw_handler:() => switch_tool(new Lhasa.ElementInsertion(Lhasa.LhasaElement.I)),
919
+ icon: null,
920
+ hotkey:"i",
921
+ caption_optional: false,
922
+ tooltip_core: element_tool_tooltip.current
923
+ },
924
+ X: {
925
+ caption:"X",
926
+ raw_handler:() => on_x_element_button(),
927
+ icon: null,
928
+ hotkey:"x",
929
+ caption_optional: false,
930
+ tooltip_core: element_tool_tooltip.current
931
+ }
932
+ }}, [icons_path_final]);
933
+
934
+ function wrap_handler(action_name: string, raw_handler: () => void) : () => void {
935
+ return () => {
936
+ setActiveToolName(action_name);
937
+ raw_handler();
938
+ };
939
+ }
940
+
941
+ const handler_map = useMemo(() => {
942
+ let ret = Object.fromEntries(
943
+ Object.entries(tool_button_data)
944
+ .map(([k,v]) => [k, wrap_handler(k,v["raw_handler"])])
945
+ );
946
+ // Those are not tool buttons. We handle them manually.
947
+ ret['Undo'] = () => lh.current?.undo_edition();
948
+ ret['Redo'] = () => lh.current?.redo_edition();
949
+ return ret;
950
+ }, [tool_button_data]);
951
+
952
+ let tool_buttons = useMemo(() => {
953
+ let m_tool_buttons = new Map<string,React.JSX.Element>();
954
+ for(const [k,v] of Object.entries(tool_button_data)) {
955
+ const hotkey_to_infoblock = (hotkey: string | string[]) => {
956
+ let hotkey_arr = typeof hotkey === 'string' ? [hotkey] : hotkey;
957
+ return (<div className='lhasa_tooltip_keybind_infoblock'>
958
+ <b>Hotkeys:</b><br/><br/>
959
+ {hotkey_arr.map((hotkey_text) => <span key={hotkey_text} className='lhasa_tooltip_keybind_info'>
960
+ {hotkey_text}
961
+ </span>)}
962
+ </div>);
963
+ };
964
+ m_tool_buttons.set(k, ToolButton({
965
+ onclick: () => {handler_map[k]()},
966
+ caption: v.caption,
967
+ caption_optional: v.caption_optional,
968
+ tooltip_body: <div className='lhasa_tooltip'>
969
+ {v.tooltip_core}
970
+ {v.hotkey &&
971
+ hotkey_to_infoblock(v.hotkey)
972
+ }
973
+ </div>,
974
+ icon: v.icon,
975
+ action_name: k
976
+ }));
977
+ }
978
+ return m_tool_buttons;
979
+ }, [tool_button_data, handler_map]);
980
+
981
+
982
+ let key_map = useMemo(() => {
983
+ let ret = Object.fromEntries(
984
+ Object.entries(tool_button_data)
985
+ .filter(([k,v]) => 'hotkey' in v)
986
+ .map(([k,v]) => [k, v['hotkey']])
987
+ );
988
+ // Those are not tool buttons. We handle them manually.
989
+ ret['Undo'] = 'ctrl+z';
990
+ ret['Redo'] = ['ctrl+r','ctrl+shift+z'];
991
+ return ret;
992
+ }, [tool_button_data]);
993
+
994
+ const editButtonRef = useRef<HTMLButtonElement | null>(null)
995
+ const [editOpened, setEditOpen] = useState<boolean>(false);
996
+
997
+ const optionButtonRef = useRef<HTMLButtonElement | null>(null)
998
+ const [optionOpened, setOptionOpen] = useState<boolean>(false);
999
+
1000
+ const displayModeButtonRef = useRef<HTMLLIElement | null>(null);
1001
+ const [displayModeOpened, setDisplayModeOpen] = useState<boolean>(false);
1002
+
1003
+ const [aimChecked, setAimChecked] = useState<boolean>(() => lh.current?.get_allow_invalid_molecules());
1004
+
1005
+ const [showToolButtonLabels, setShowToolButtonLabels] = useState<boolean>(true);
1006
+
1007
+ const [showQedChecked, setShowQedChecked] = useState<boolean>(false);
1008
+ const [qedTab, setQedTab] = useState<number>(0);
1009
+
1010
+ const [editedSmiles, setEditedSmiles] = useState<number | null>(null);
1011
+
1012
+
1013
+ const scale_mapper = (x) => {
1014
+ return theta_const ** (x + d_const) + c_const;
1015
+ };
1016
+
1017
+ const reverse_scale_mapper = (f) => {
1018
+ return Math.log(f - c_const) / Math.log(theta_const) - d_const;
1019
+ };
1020
+
1021
+ const getTargetOffset = (touch: React.Touch, event: React.TouchEvent<HTMLDivElement>) => {
1022
+ const boundingRect = (event.target as Element).getBoundingClientRect();
1023
+ return {
1024
+ x: touch.clientX - boundingRect.left,
1025
+ y: touch.clientY - boundingRect.top
1026
+ };
1027
+ }
1028
+
1029
+ return (
1030
+ <>
1031
+ <ActiveToolContext.Provider value={{active_tool_name: activeToolName, show_optional_captions: showToolButtonLabels}}>
1032
+ <HotKeys keyMap={key_map} handlers={handler_map}>
1033
+ <StyledEngineProvider injectFirst>
1034
+ <div className={"lhasa_editor LhasaMuiStyling" + (dark_mode ? " lhasa_dark_mode" : "")} ref={editorRef}>
1035
+ {show_top_panel &&
1036
+ <div className="horizontal_container">
1037
+ <img src={icons_path_prefix + "/icons/hicolor_apps_scalable_coot-layla.svg"} />
1038
+ <div /*id_="lhasa_hello"*/ >
1039
+ <h3>Welcome to Lhasa!</h3>
1040
+ <p>
1041
+ Lhasa is a WebAssemby port of Layla - Coot's Ligand Editor.<br/>
1042
+ </p>
1043
+ </div>
1044
+ </div>
1045
+ }
1046
+ <div className="horizontal_toolbar">
1047
+ <Button
1048
+ ref={editButtonRef}
1049
+ disableElevation
1050
+ onClick={(_evt) => setEditOpen((prev) => !prev)}
1051
+ >
1052
+ Edit
1053
+ </Button>
1054
+ <Menu
1055
+ open={editOpened}
1056
+ anchorEl={editButtonRef.current}
1057
+ onClose={() => setEditOpen(false)}
1058
+ className={"LhasaMuiStyling" + (dark_mode ? " lhasa_dark_mode" : "")}
1059
+ >
1060
+ <MenuItem onClick={() => handler_map["Undo"]()} >
1061
+ <Undo />
1062
+ Undo <div className="keybind_hint">Ctrl+Z</div>
1063
+ </MenuItem>
1064
+ <MenuItem onClick={() => handler_map["Redo"]()} >
1065
+ <Redo />
1066
+ Redo <div className="keybind_hint">Ctrl+Shift+Z</div>
1067
+ </MenuItem>
1068
+ </Menu>
1069
+ <Button
1070
+ ref={optionButtonRef}
1071
+ disableElevation
1072
+ onClick={(_evt) => setOptionOpen((prev) => !prev)}
1073
+
1074
+ >
1075
+ Options
1076
+ </Button>
1077
+ <Menu
1078
+ open={optionOpened}
1079
+ anchorEl={optionButtonRef.current}
1080
+ onClose={() => setOptionOpen(false)}
1081
+ className={"LhasaMuiStyling" + (dark_mode ? " lhasa_dark_mode" : "")}
1082
+ >
1083
+ <MenuItem>
1084
+ <FormGroup>
1085
+ <FormControlLabel
1086
+ label="Allow Invalid Molecules"
1087
+ control={<Switch />}
1088
+ checked={aimChecked}
1089
+ onChange={(_e) => {
1090
+ const new_val = !lh.current?.get_allow_invalid_molecules();
1091
+ lh.current?.set_allow_invalid_molecules(new_val);
1092
+ setAimChecked(new_val);
1093
+ }}
1094
+ />
1095
+ <FormControlLabel
1096
+ label="Show QED"
1097
+ control={<Switch />}
1098
+ checked={showQedChecked}
1099
+ onChange={(_e) => {
1100
+ setShowQedChecked(!showQedChecked);
1101
+ }}
1102
+ />
1103
+ <FormControlLabel
1104
+ label="Show Tool Button Labels"
1105
+ control={<Switch />}
1106
+ checked={showToolButtonLabels}
1107
+ onChange={(_e) => {
1108
+ setShowToolButtonLabels(!showToolButtonLabels);
1109
+ }}
1110
+ />
1111
+ </FormGroup>
1112
+ </MenuItem>
1113
+ <MenuItem
1114
+ ref={displayModeButtonRef}
1115
+ onClick={(_evt) => setDisplayModeOpen((prev) => !prev)}
1116
+ >
1117
+ Display Mode...
1118
+ </MenuItem>
1119
+ <Popover
1120
+ open={displayModeOpened}
1121
+ anchorEl={displayModeButtonRef.current}
1122
+ anchorOrigin={{horizontal: 'right', vertical: 'top'}}
1123
+ onClose={() => setDisplayModeOpen(false)}
1124
+ className={"LhasaMuiStyling" + (dark_mode ? " lhasa_dark_mode" : "")}
1125
+ // onMouseOut={(_ev) => setDisplayModeAnchorEl(null)}
1126
+ >
1127
+ <FormControl>
1128
+ <RadioGroup
1129
+ name="display_mode"
1130
+ onChange={(_event, value) => switch_display_mode(value)}
1131
+ value={display_mode_to_value_name(lh.current?.get_display_mode())}
1132
+ >
1133
+ <FormControlLabel
1134
+ label="Standard"
1135
+ control={<Radio/>}
1136
+ value="standard"
1137
+ />
1138
+ <FormControlLabel
1139
+ label="Atom Indices"
1140
+ control={<Radio/>}
1141
+ value="atom_indices"
1142
+ />
1143
+ <FormControlLabel
1144
+ label="Atom Names"
1145
+ control={<Radio/>}
1146
+ value="atom_names"
1147
+ />
1148
+ </RadioGroup>
1149
+ </FormControl>
1150
+ </Popover>
1151
+ </Menu>
1152
+ </div>
1153
+ <div /*id_="molecule_tools_toolbar"*/ className="horizontal_toolbar">
1154
+ { tool_buttons.get("Move") }
1155
+ { tool_buttons.get("Rotate") }
1156
+ { tool_buttons.get("Flip_around_X") }
1157
+ { tool_buttons.get("Flip_around_Y") }
1158
+ { tool_buttons.get("Delete_hydrogens") }
1159
+ { tool_buttons.get("Format") }
1160
+ </div>
1161
+ <div /*id_="main_tools_toolbar"*/ className="horizontal_toolbar">
1162
+ { tool_buttons.get("Single_Bond") }
1163
+ { tool_buttons.get("Double_Bond") }
1164
+ { tool_buttons.get("Triple_Bond") }
1165
+ { tool_buttons.get("Geometry") }
1166
+ { tool_buttons.get("Charge") }
1167
+ { tool_buttons.get("Delete") }
1168
+ </div>
1169
+ <div /*id_="structure_toolbar"*/ className="horizontal_toolbar">
1170
+ { tool_buttons.get("C3") }
1171
+ { tool_buttons.get("C4") }
1172
+ { tool_buttons.get("C5") }
1173
+ { tool_buttons.get("C6") }
1174
+ { tool_buttons.get("Arom6") }
1175
+ { tool_buttons.get("C7") }
1176
+ { tool_buttons.get("C8") }
1177
+ <div className="scale_panel vertical_panel">
1178
+ <div className='horizontal_container'>
1179
+ <b>SCALE</b>
1180
+ <div className="scale_display">
1181
+ {scale.toFixed(2)}
1182
+ </div>
1183
+ </div>
1184
+ <div className="horizontal_panel" style={{border: "0px", padding: "0px"}}>
1185
+ <IconButton
1186
+ onClick={() => {const s = lh.current?.get_scale(); lh.current?.set_scale(s-0.05);}}
1187
+ >
1188
+ <b>-</b>
1189
+ </IconButton>
1190
+ <Slider
1191
+ value={reverse_scale_mapper(lh.current?.get_scale() ?? 1.0)}
1192
+ max={1}
1193
+ min={0}
1194
+ step={0.0001}
1195
+ // marks={[0.5,1,2]}
1196
+ scale={scale_mapper}
1197
+ // valueLabelDisplay="auto"
1198
+ // valueLabelFormat={(v) => v.toFixed(2)}
1199
+ onChange={(_ev, scale)=>{ lh.current?.set_scale(scale_mapper(scale))}}
1200
+ />
1201
+ <IconButton
1202
+ onClick={() => {const s = lh.current?.get_scale(); lh.current?.set_scale(s+0.05);}}
1203
+ >
1204
+ <b>+</b>
1205
+ </IconButton>
1206
+ </div>
1207
+ </div>
1208
+ </div>
1209
+ {xElementInputShown &&
1210
+ <>
1211
+ <div className="x_element_panel horizontal_panel" >
1212
+ <TextField
1213
+ label="Custom element symbol"
1214
+ id={x_element_symbol_input}
1215
+ variant="outlined"
1216
+ error={x_element_error_string != null}
1217
+ helperText={x_element_error_string}
1218
+ style={{alignSelf: "center", flexGrow: "1"}}
1219
+ />
1220
+ <Button
1221
+ variant='contained'
1222
+ // className='x_element_submit_button'
1223
+ onClick={() => on_x_element_submit_button()}
1224
+ >
1225
+ Submit
1226
+ </Button>
1227
+ </div>
1228
+ </>
1229
+ }
1230
+ <div /*id_="main_horizontal_container"*/ className="horizontal_panel">
1231
+ <div /*id_="element_toolbar"*/ className="vertical_toolbar">
1232
+ { tool_buttons.get("C") }
1233
+ { tool_buttons.get("N") }
1234
+ { tool_buttons.get("O") }
1235
+ { tool_buttons.get("S") }
1236
+ { tool_buttons.get("P") }
1237
+ { tool_buttons.get("H") }
1238
+ { tool_buttons.get("F") }
1239
+ { tool_buttons.get("Cl") }
1240
+ { tool_buttons.get("Br") }
1241
+ { tool_buttons.get("I") }
1242
+ { tool_buttons.get("X") }
1243
+ </div>
1244
+ <div
1245
+ className={"editor_canvas_container" + (dark_mode ? " lhasa_dark_mode" : "")}
1246
+ onContextMenu={(e) => {e.preventDefault();}}
1247
+ onMouseMove={(event) => {
1248
+ // console.log('Mousemove');
1249
+ lh.current?.on_hover(event.nativeEvent.offsetX, event.nativeEvent.offsetY, event.altKey, event.ctrlKey);
1250
+ }}
1251
+ onMouseDown={(event) => {
1252
+ if(event.button == 0) {
1253
+ //console.log('lclick');
1254
+ lh.current?.on_left_click(event.nativeEvent.offsetX, event.nativeEvent.offsetY, event.altKey, event.ctrlKey, event.shiftKey);
1255
+ } else if(event.button == 2) {
1256
+ //console.log('rclick');
1257
+ lh.current?.on_right_click(event.nativeEvent.offsetX, event.nativeEvent.offsetY, event.altKey, event.ctrlKey, event.shiftKey);
1258
+ }
1259
+ }}
1260
+ onMouseUp={(event) => {
1261
+ if(event.button == 0) {
1262
+ //console.log('lreleased');
1263
+ lh.current?.on_left_click_released(event.nativeEvent.offsetX, event.nativeEvent.offsetY, event.altKey, event.ctrlKey, event.shiftKey);
1264
+ } else if(event.button == 2) {
1265
+ //console.log('rreleased');
1266
+ lh.current?.on_right_click_released(event.nativeEvent.offsetX, event.nativeEvent.offsetY, event.altKey, event.ctrlKey, event.shiftKey);
1267
+ }
1268
+ }}
1269
+ onWheel={(event) => {
1270
+ lh.current?.on_scroll(event.deltaX, event.deltaY, event.altKey);
1271
+ }}
1272
+ onTouchStart={(event) => {
1273
+ // console.log('touchstart');
1274
+ if (event.touches.length === 1) {
1275
+ const touch = event.touches[0];
1276
+ const offset = getTargetOffset(touch, event);
1277
+ lh.current?.on_left_click(offset.x, offset.y, event.altKey, event.ctrlKey, event.shiftKey);
1278
+ }
1279
+ }}
1280
+ onTouchEnd={(event) => {
1281
+ // console.log('touchend');
1282
+ if (event.touches.length === 1) {
1283
+ const touch = event.touches[0];
1284
+ const offset = getTargetOffset(touch, event);
1285
+ lh.current?.on_left_click_released(offset.x, offset.y, event.altKey, event.ctrlKey, event.shiftKey);
1286
+ }
1287
+ }}
1288
+ onTouchMove={(event) => {
1289
+ // console.log('touchmove');
1290
+ if (event.touches.length === 1) {
1291
+ const touch = event.touches[0];
1292
+ const offset = getTargetOffset(touch, event);
1293
+ lh.current?.on_hover(offset.x, offset.y, event.altKey, event.ctrlKey);
1294
+ }
1295
+ }}
1296
+
1297
+ ref={svgRef}
1298
+
1299
+ >
1300
+ <div className="pre_render_message">Lhasa not rendered.</div>
1301
+ </div>
1302
+ <div id={text_measurement_worker_div} className="text_measurement_worker_div">
1303
+ {/* Ugly, I know */}
1304
+ </div>
1305
+ </div>
1306
+ <div className="status_display_panel horizontal_panel">
1307
+ <span>▶</span>
1308
+ <span /*id_="status_display"*/>{ statusText }</span>
1309
+ </div>
1310
+ <Accordion>
1311
+ <AccordionSummary>
1312
+ <b>SMILES</b>
1313
+ </AccordionSummary>
1314
+ <AccordionDetails>
1315
+ <div className="smiles_display vertical_panel">
1316
+ {smiles.map((smiles_tuple) => <div key={smiles_tuple[0]} className='horizontal_container'>
1317
+ {smiles_callback && <Button variant="contained" onClick={() => {
1318
+ const lookup_result = canvasIdsToPropsIdsRef.current.get(smiles_tuple[0]);
1319
+ let external_id = null;
1320
+ if(lookup_result !== undefined) {
1321
+ external_id = lookup_result;
1322
+ }
1323
+ smiles_callback(smiles_tuple[0], external_id, smiles_tuple[1])
1324
+ }}>Send to {name_of_host_program}</Button>}
1325
+ {bansu_endpoint &&
1326
+ <BansuButton
1327
+ smiles={smiles_tuple[1]}
1328
+ anchorEl={editorRef.current}
1329
+ bansu_endpoint={bansu_endpoint}
1330
+ dark_mode={dark_mode}
1331
+ />
1332
+ }
1333
+ <TextField
1334
+ variant="standard"
1335
+ value={editedSmiles !== smiles_tuple[0] ? smiles_tuple[1] : undefined}
1336
+ onFocus={(_event) => setEditedSmiles(smiles_tuple[0])}
1337
+ onBlur={(_event) => setEditedSmiles(null)}
1338
+ onChange={(event) => lh.current.update_molecule_from_smiles(smiles_tuple[0], event.target.value)}
1339
+ />
1340
+ {smiles_tuple[2] &&
1341
+ <div style={{alignSelf: "center"}}>
1342
+ {smiles_tuple[2][0]} {smiles_tuple[2][1]}
1343
+ </div>
1344
+ }
1345
+ </div>)}
1346
+ </div>
1347
+ {/* <Divider /> */}
1348
+ <div className="horizontal_toolbar">
1349
+ {/* SMILES: */}
1350
+ <TextField
1351
+ label="SMILES"
1352
+ id={smiles_input}
1353
+ variant="outlined"
1354
+ error={smiles_error_string != null}
1355
+ helperText={smiles_error_string}
1356
+ style={{"flexGrow": 1}}
1357
+ />
1358
+ <Button
1359
+ variant="contained"
1360
+ onClick={() => on_smiles_import_button()}
1361
+ >
1362
+ Import SMILES
1363
+ </Button>
1364
+ </div>
1365
+ </AccordionDetails>
1366
+ </Accordion>
1367
+ {showQedChecked &&
1368
+ <div className="vertical_toolbar qed_panel">
1369
+ <b>QED</b>
1370
+ <Tabs value={qedTab} onChange={(event, value) => setQedTab(value)}>
1371
+
1372
+ {Array.from(qedInfo.keys()).map((mol_id) => {
1373
+ // Counter-intuitively, a "Tab" here is what Gtk considers to be a tab label
1374
+ return <Tab
1375
+ key={mol_id}
1376
+ label={mol_id.toString()}
1377
+ value={mol_id}
1378
+ />;
1379
+ })}
1380
+ </Tabs>
1381
+ {Array.from(qedInfo.keys()).map((mol_id) => {
1382
+ // This is the proper tab
1383
+ return <div hidden={qedTab !== mol_id} role="tabpanel" key={mol_id}>
1384
+ <div className="horizontal_container">
1385
+ <div className="vertical_panel" style={{flexGrow: 1}}>
1386
+ <QedPropertyInfobox
1387
+ property_name='QED score:'
1388
+ display_value={qedInfo.get(mol_id)?.qed_score.toFixed(4)}
1389
+ progressbar_value={qedInfo.get(mol_id)?.qed_score}
1390
+ />
1391
+ <QedPropertyInfobox
1392
+ property_name='MW'
1393
+ display_value={qedInfo.get(mol_id)?.molecular_weight.toFixed(4)}
1394
+ progressbar_value={qedInfo.get(mol_id)?.ads_mw}
1395
+ />
1396
+ <QedPropertyInfobox
1397
+ property_name='PSA'
1398
+ display_value={qedInfo.get(mol_id)?.molecular_polar_surface_area.toFixed(4)}
1399
+ progressbar_value={qedInfo.get(mol_id)?.ads_psa}
1400
+ />
1401
+ </div>
1402
+ <div className="vertical_panel" style={{flexGrow: 1}}>
1403
+ <QedPropertyInfobox
1404
+ property_name='cLogP'
1405
+ display_value={qedInfo.get(mol_id)?.alogp.toFixed(4)}
1406
+ progressbar_value={qedInfo.get(mol_id)?.ads_alogp}
1407
+ />
1408
+ <QedPropertyInfobox
1409
+ property_name='#ALERTS'
1410
+ display_value={qedInfo.get(mol_id)?.number_of_alerts}
1411
+ progressbar_value={qedInfo.get(mol_id)?.ads_alert}
1412
+ />
1413
+ <QedPropertyInfobox
1414
+ property_name='#HBA'
1415
+ display_value={qedInfo.get(mol_id)?.number_of_hydrogen_bond_acceptors}
1416
+ progressbar_value={qedInfo.get(mol_id)?.ads_hba}
1417
+ />
1418
+ </div>
1419
+ <div className="vertical_panel" style={{flexGrow: 1}}>
1420
+ <QedPropertyInfobox
1421
+ property_name='#HBD'
1422
+ display_value={qedInfo.get(mol_id)?.number_of_hydrogen_bond_donors}
1423
+ progressbar_value={qedInfo.get(mol_id)?.ads_hbd}
1424
+ />
1425
+ <QedPropertyInfobox
1426
+ property_name='#AROM'
1427
+ display_value={qedInfo.get(mol_id)?.number_of_aromatic_rings}
1428
+ progressbar_value={qedInfo.get(mol_id)?.ads_arom}
1429
+ />
1430
+ <QedPropertyInfobox
1431
+ property_name='#RotBonds'
1432
+ display_value={qedInfo.get(mol_id)?.number_of_rotatable_bonds}
1433
+ progressbar_value={qedInfo.get(mol_id)?.ads_rotb}
1434
+ />
1435
+ </div>
1436
+ </div>
1437
+ </div>;
1438
+ })}
1439
+ </div>
1440
+ }
1441
+ {show_footer &&
1442
+ <div className="lhasa_footer">
1443
+ <i>Written by Jakub Smulski</i>
1444
+ </div>
1445
+ }
1446
+ </div>
1447
+ </StyledEngineProvider>
1448
+ </HotKeys>
1449
+ </ActiveToolContext.Provider>
1450
+ </>
1451
+ )
1452
+ }