linny-r 1.4.2 → 1.4.4

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 (50) hide show
  1. package/README.md +162 -74
  2. package/package.json +1 -1
  3. package/server.js +145 -49
  4. package/static/images/check-off-not-same-changed.png +0 -0
  5. package/static/images/check-off-not-same-not-changed.png +0 -0
  6. package/static/images/check-off-same-changed.png +0 -0
  7. package/static/images/check-off-same-not-changed.png +0 -0
  8. package/static/images/check-on-not-same-changed.png +0 -0
  9. package/static/images/check-on-not-same-not-changed.png +0 -0
  10. package/static/images/check-on-same-changed.png +0 -0
  11. package/static/images/check-on-same-not-changed.png +0 -0
  12. package/static/images/eq-not-same-changed.png +0 -0
  13. package/static/images/eq-not-same-not-changed.png +0 -0
  14. package/static/images/eq-same-changed.png +0 -0
  15. package/static/images/eq-same-not-changed.png +0 -0
  16. package/static/images/ne-not-same-changed.png +0 -0
  17. package/static/images/ne-not-same-not-changed.png +0 -0
  18. package/static/images/ne-same-changed.png +0 -0
  19. package/static/images/ne-same-not-changed.png +0 -0
  20. package/static/images/octaeder.svg +993 -0
  21. package/static/images/sort-asc-lead.png +0 -0
  22. package/static/images/sort-asc.png +0 -0
  23. package/static/images/sort-desc-lead.png +0 -0
  24. package/static/images/sort-desc.png +0 -0
  25. package/static/images/sort-not.png +0 -0
  26. package/static/index.html +72 -647
  27. package/static/linny-r.css +199 -417
  28. package/static/scripts/linny-r-gui-actor-manager.js +340 -0
  29. package/static/scripts/linny-r-gui-chart-manager.js +944 -0
  30. package/static/scripts/linny-r-gui-constraint-editor.js +681 -0
  31. package/static/scripts/linny-r-gui-controller.js +4005 -0
  32. package/static/scripts/linny-r-gui-dataset-manager.js +1176 -0
  33. package/static/scripts/linny-r-gui-documentation-manager.js +739 -0
  34. package/static/scripts/linny-r-gui-equation-manager.js +307 -0
  35. package/static/scripts/linny-r-gui-experiment-manager.js +1944 -0
  36. package/static/scripts/linny-r-gui-expression-editor.js +449 -0
  37. package/static/scripts/linny-r-gui-file-manager.js +392 -0
  38. package/static/scripts/linny-r-gui-finder.js +727 -0
  39. package/static/scripts/linny-r-gui-model-autosaver.js +230 -0
  40. package/static/scripts/linny-r-gui-monitor.js +448 -0
  41. package/static/scripts/linny-r-gui-paper.js +2789 -0
  42. package/static/scripts/linny-r-gui-receiver.js +323 -0
  43. package/static/scripts/linny-r-gui-repository-browser.js +819 -0
  44. package/static/scripts/linny-r-gui-scale-unit-manager.js +244 -0
  45. package/static/scripts/linny-r-gui-sensitivity-analysis.js +778 -0
  46. package/static/scripts/linny-r-gui-undo-redo.js +560 -0
  47. package/static/scripts/linny-r-model.js +27 -11
  48. package/static/scripts/linny-r-utils.js +17 -2
  49. package/static/scripts/linny-r-vm.js +31 -12
  50. package/static/scripts/linny-r-gui.js +0 -16761
@@ -0,0 +1,4005 @@
1
+ /*
2
+ Linny-R is an executable graphical specification language for (mixed integer)
3
+ linear programming (MILP) problems, especially unit commitment problems (UCP).
4
+ The Linny-R language and tool have been developed by Pieter Bots at Delft
5
+ University of Technology, starting in 2009. The project to develop a browser-
6
+ based version started in 2017. See https://linny-r.org for more information.
7
+
8
+ This JavaScript file (linny-r-gui-controller.js) provides the GUI controller
9
+ functionality for the Linny-R model editor: buttons on the main tool bars,
10
+ the associated modal dialogs (class ModalDialog), and the related event
11
+ handler functions.
12
+
13
+ */
14
+
15
+ /*
16
+ Copyright (c) 2017-2023 Delft University of Technology
17
+
18
+ Permission is hereby granted, free of charge, to any person obtaining a copy
19
+ of this software and associated documentation files (the "Software"), to deal
20
+ in the Software without restriction, including without limitation the rights to
21
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
22
+ of the Software, and to permit persons to whom the Software is furnished to do
23
+ so, subject to the following conditions:
24
+
25
+ The above copyright notice and this permission notice shall be included in
26
+ all copies or substantial portions of the Software.
27
+
28
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
29
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
30
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
31
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
32
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
33
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
34
+ SOFTWARE.
35
+ */
36
+
37
+ // CLASS ModalDialog provides basic modal dialog functionality.
38
+ class ModalDialog {
39
+ constructor(id) {
40
+ this.id = id;
41
+ this.modal = document.getElementById(id + '-modal');
42
+ this.dialog = document.getElementById(id + '-dlg');
43
+ // NOTE: Dialog title and button properties will be `undefined` if
44
+ // not in the header DIV child of the dialog DIV element.
45
+ this.title = this.dialog.getElementsByClassName('dlg-title')[0];
46
+ this.ok = this.dialog.getElementsByClassName('ok-btn')[0];
47
+ this.cancel = this.dialog.getElementsByClassName('cancel-btn')[0];
48
+ this.info = this.dialog.getElementsByClassName('info-btn')[0];
49
+ this.close = this.dialog.getElementsByClassName('close-btn')[0];
50
+ // NOTE: Reset function is called on hide() and can be redefined.
51
+ this.reset = () => {};
52
+ }
53
+
54
+ element(name) {
55
+ // Return the DOM element within this dialog that is identified by
56
+ // `name`. In the file `index.html`, modal dialogs are defined as
57
+ // DIV elements with id="xxx-modal", "xxx-dlg", etc., and all input
58
+ // fields then must have id="xxx-name".
59
+ return document.getElementById(`${this.id}-${name}`);
60
+ }
61
+
62
+ selectedOption(name) {
63
+ // Return the selected option element of the named selector.
64
+ const sel = document.getElementById(`${this.id}-${name}`);
65
+ return sel.options[sel.selectedIndex];
66
+ }
67
+
68
+ show(name='') {
69
+ // Make dialog visible and set focus on the name element.
70
+ this.modal.style.display = 'block';
71
+ if(name) this.element(name).focus();
72
+ }
73
+
74
+ hide() {
75
+ // Make this modal dialog invisible.
76
+ this.modal.style.display = 'none';
77
+ }
78
+
79
+ } // END of class ModalDialog
80
+
81
+
82
+ // CLASS GroupPropertiesDialog
83
+ // This type of dialog supports "group editing". The `fields` that must
84
+ // be specified when creating it must be a "dictionary" object with
85
+ // such that fields[name] is the entity property name that corresponds
86
+ // with the DOM input element for that property. For example, for the
87
+ // process group properties dialog, fields['LB'] = 'lower_bound' to
88
+ // indicate that the DOM element having id="process_LB" corresponds to
89
+ // the property `p.lower_bound` of process `p`.
90
+ class GroupPropertiesDialog extends ModalDialog {
91
+ constructor(id, fields) {
92
+ super(id);
93
+ this.fields = fields;
94
+ // `group` holds the entities (all of the same type) that should be
95
+ // updated when the OK-button of the dialog is clicked.
96
+ this.group = [];
97
+ // `initial_values` is a "dictionary" with (field name, value) entries
98
+ // that hold the initial values of the group-editable properties.
99
+ this.initial = {};
100
+ // `same` is a "dictionary" with (field name, Boolean) entries such
101
+ // that same[name] = TRUE iff the initial values of all entities in
102
+ // the group were identical.
103
+ this.same = {};
104
+ // NOTE: The `group`, `same` and `initial_values` properties must be
105
+ // set before the dialog is shown.
106
+
107
+ // Add event listeners that detect if changes are made to the input
108
+ // fields. For toggle items, this means `onclick` events, for text
109
+ // input fields this means `onkeydown` events.
110
+ const fnc = (event) => {
111
+ const id = event.target.id.split('-').shift();
112
+ // NOTE: add a short delay to permit checkboxes to update their
113
+ // status first, before checking for change.
114
+ setTimeout(() => UI.modals[id].highlightModifiedFields(), 100);
115
+ };
116
+ for(let name in this.fields) if(this.fields.hasOwnProperty(name)) {
117
+ const e = this.element(name);
118
+ if(e.classList.contains('box') || e.classList.contains('bbtn')) {
119
+ e.addEventListener('click', fnc);
120
+ } else if(e.nodeName === 'SELECT') {
121
+ e.addEventListener('change', fnc);
122
+ } else {
123
+ e.addEventListener('keydown', fnc);
124
+ }
125
+ }
126
+ }
127
+
128
+ resetFields() {
129
+ // Remove all class names from fields that relate to their "same"
130
+ // and "changed status, and reset group-related properties.
131
+ for(let name in this.fields) if(this.initial.hasOwnProperty(name)) {
132
+ const cl = this.element(name).classList;
133
+ while(cl.length > 0 && cl.item(cl.length - 1).indexOf('same-') >= 0) {
134
+ cl.remove(cl.item(cl.length - 1));
135
+ }
136
+ }
137
+ this.element('group').innerText = '';
138
+ let e = this.element('name');
139
+ if(e) e.disabled = false;
140
+ e = this.element('actor');
141
+ if(e) e.disabled = false;
142
+ e = this.element('cluster');
143
+ if(e) e.disabled = false;
144
+ this.group.length = 0;
145
+ this.initial = {};
146
+ this.same = {};
147
+ this.changed = {};
148
+ }
149
+
150
+ setFields(obj) {
151
+ // Use the properties of `obj` as initial values, and infer for each
152
+ // field whether all entities in the group have the same value for
153
+ // this property.
154
+ this.initial = {};
155
+ this.same = {};
156
+ this.changed = {};
157
+ for(let name in this.fields) if(this.fields.hasOwnProperty(name)) {
158
+ const
159
+ el = this.element(name),
160
+ cl = el.classList,
161
+ token = cl.item(0),
162
+ propname = this.fields[name],
163
+ prop = obj[propname];
164
+ if(prop instanceof Expression) {
165
+ this.initial[name] = prop.text;
166
+ el.value = prop.text;
167
+ } else {
168
+ this.initial[name] = prop;
169
+ if(token === 'bbtn') {
170
+ el.className = (prop ? 'bbtn eq' : 'bbtn ne');
171
+ } else if(token === 'box') {
172
+ el.className = (prop ? 'box checked' : 'box clear');
173
+ } else if(propname === 'share_of_cost') {
174
+ // NOTE: Share of cost is input as a percentage, but stored as
175
+ // a floating point value between 0 and 1.
176
+ el.value = VM.sig4Dig(100 * prop);
177
+ } else {
178
+ el.value = prop;
179
+ }
180
+ }
181
+ if(this.group.length > 0) {
182
+ let same = true;
183
+ for(let i = 0; same && i < this.group.length; i++) {
184
+ const
185
+ ge = this.group[i],
186
+ gprop = ge[propname];
187
+ // NOTE: Ignore links for which property os not meaningful.
188
+ if(!(ge instanceof Link) ||
189
+ this.validLinkProperty(ge, propname, prop)) {
190
+ same = (gprop instanceof Expression ?
191
+ gprop.text === prop.text : gprop === prop);
192
+ }
193
+ }
194
+ this.same[name] = same;
195
+ }
196
+ }
197
+ }
198
+
199
+ show(attr, obj) {
200
+ // Make dialog visible with same/changed status and disabled name,
201
+ // actor and cluster fields.
202
+ // NOTE: Cluster dialog is also used to *add* a new cluster, and in
203
+ // that case no fields should be set
204
+ if(obj) this.setFields(obj);
205
+ if(obj && this.group.length > 0) {
206
+ this.element('group').innerText = `(N=${this.group.length})`;
207
+ // Disable name, actor and cluster fields if they exist.
208
+ let e = this.element('name');
209
+ if(e) e.disabled = true;
210
+ e = this.element('actor');
211
+ if(e) e.disabled = true;
212
+ e = this.element('cluster');
213
+ if(e) e.disabled = true;
214
+ // Set the right colors to reflect same and changed status.
215
+ this.highlightModifiedFields();
216
+ }
217
+ this.modal.style.display = 'block';
218
+ if(attr) this.element(attr).focus();
219
+ }
220
+
221
+ hide() {
222
+ // Reset group-related attributes and then make this modal dialog
223
+ // invisible.
224
+ this.resetFields();
225
+ this.modal.style.display = 'none';
226
+ }
227
+
228
+ highlightModifiedFields() {
229
+ // Set the CSS classes of fields so that they reflect their "same"
230
+ // and "changed" status.
231
+ if(this.group.length === 0) return;
232
+ for(let name in this.initial) if(this.initial.hasOwnProperty(name)) {
233
+ const
234
+ iv = this.initial[name],
235
+ // A "group editing" dialog will also have the property `same`
236
+ // for which `same[name]` is TRUE iff all entities had identical
237
+ // values for the property identified by `name` when the dialog
238
+ // was opened.
239
+ not = {false: 'not-', true: ''},
240
+ same = `${not[this.same[name]]}same`,
241
+ el = this.element(name);
242
+ let changed = false,
243
+ type = '',
244
+ state = '';
245
+ if(el.nodeName === 'INPUT' || el.nodeName === 'SELECT') {
246
+ if(name === 'share-of-cost') {
247
+ // NOTE: Share of cost is input as percentage, but stored as a
248
+ // floating point number. Use != for comparison (not !==).
249
+ changed = (el.value != VM.sig4Dig(100 * iv));
250
+ } else {
251
+ // Text input field; `iv` is a string or a number (for select),
252
+ // so use != and not !== for comparison.
253
+ changed = (el.value != iv);
254
+ }
255
+ } else {
256
+ // Toggle element; `iv` is either TRUE or FALSE.
257
+ type = el.classList.item(0);
258
+ state = el.classList.item(1);
259
+ // Compute current value as Boolean.
260
+ const v = (type === 'box' ? state ==='checked' : state === 'eq');
261
+ changed = (v !== iv);
262
+ }
263
+ this.changed[name] = changed;
264
+ el.className = `${type} ${state} ${same}-${not[changed]}changed`.trim();
265
+ }
266
+ }
267
+
268
+ validLinkProperty(link, property, value=0) {
269
+ // Returns TRUE if for `link` it is meaningful to have `property`,
270
+ // and if so, whether this is TRUE for the (optionally specified)
271
+ // `value` for that property.
272
+ if(property === 'multiplier') {
273
+ // No special multipliers on non-data links.
274
+ if(value > 0 && !link.to_node.is_data) return false;
275
+ // Throughput data only from products.
276
+ if(value === VM.LM_THROUGHPUT &&
277
+ !(link.from_node instanceof Product)) return false;
278
+ // Spinning reserve data only from processes.
279
+ if(value === VM.LM_SPINNING_RESERVE &&
280
+ !(link.from_node instanceof Process)) return false;
281
+ } else if(property === 'flow_delay' || property === 'share_of_cost') {
282
+ // Delay and SoC only on process output links.
283
+ return link.from_node instanceof Process;
284
+ }
285
+ return true;
286
+ }
287
+
288
+ updateModifiedProperties(obj) {
289
+ // For all entities in the group, set the properties associated with
290
+ // fields that have been changed to those of `obj`, as these will
291
+ // have been validated by the "update entity properties" dialog.
292
+ if(!obj || this.group.length === 0) return;
293
+ // Update `changed` so it reflects the final changes.
294
+ this.highlightModifiedFields();
295
+ for(let name in this.fields) if(this.changed[name]) {
296
+ const
297
+ propname = this.fields[name],
298
+ prop = obj[propname];
299
+ for(let i = 0; i < this.group.length; i++) {
300
+ const ge = this.group[i];
301
+ // NOTE: For links, special care must be taken.
302
+ if(!(ge instanceof Link) ||
303
+ this.validLinkProperty(ge, propname, prop)) {
304
+ if(prop instanceof Expression) {
305
+ const x = ge[propname];
306
+ x.text = prop.text;
307
+ x.compile();
308
+ } else {
309
+ ge[propname] = prop;
310
+ }
311
+ }
312
+ }
313
+ }
314
+ }
315
+
316
+ } // END of class GroupPropertiesDialog
317
+
318
+
319
+ // CLASS GUIController implements the Linny-R GUI
320
+ class GUIController extends Controller {
321
+ constructor() {
322
+ super();
323
+ this.console = false;
324
+ // Identify the type of browser in which Linny-R is running.
325
+ const
326
+ ua = window.navigator.userAgent.toLowerCase(),
327
+ browsers = [
328
+ ['edg', 'Edge'],
329
+ ['opr', 'Opera'],
330
+ ['chrome', 'Chrome'],
331
+ ['firefox', 'Firefox'],
332
+ ['safari', 'Safari']];
333
+ for(let i = 0; i < browsers.length; i++) {
334
+ const b = browsers[i];
335
+ if(ua.indexOf(b[0]) >= 0) {
336
+ this.browser_name = b[1];
337
+ break;
338
+ }
339
+ }
340
+ // Display version number as clickable link just below the Linny-R logo.
341
+ this.version_number = LINNY_R_VERSION;
342
+ this.version_div = document.getElementById('linny-r-version-number');
343
+ this.version_div.innerHTML = 'Version ' + this.version_number;
344
+ // Initialize the "paper" for drawing the model diagram.
345
+ this.paper = new Paper();
346
+ // Block arrows on nodes come in three types:
347
+ this.BLOCK_IN = 1;
348
+ this.BLOCK_OUT = 2;
349
+ this.BLOCK_IO = 3;
350
+ // The properties below are used to avoid too frequent redrawing of
351
+ // the SVG model diagram.
352
+ this.busy_drawing = false;
353
+ this.draw_requests = 0;
354
+ this.busy_drawing_selection = false;
355
+ this.selection_draw_requests = 0;
356
+ // The "edited object" is set when the properties modal of the selected
357
+ // entity is opened with double-click or Alt-click.
358
+ this.edited_object = null;
359
+ // Initialize mouse/cursor control properties.
360
+ this.mouse_x = 0;
361
+ this.mouse_y = 0;
362
+ this.mouse_down_x = 0;
363
+ this.mouse_down_y = 0;
364
+ this.move_dx = 0;
365
+ this.move_dy = 0;
366
+ this.start_sel_x = -1;
367
+ this.start_sel_y = -1;
368
+ this.add_x = 0;
369
+ this.add_y = 0;
370
+ this.on_node = null;
371
+ this.on_arrow = null;
372
+ this.on_link = null;
373
+ this.on_constraint = null;
374
+ this.on_cluster = null;
375
+ this.on_cluster_edge = false;
376
+ this.on_note = null;
377
+ this.on_block_arrow = null;
378
+ this.linking_node = null;
379
+ this.dragged_node = null;
380
+ this.node_to_move = null;
381
+ this.constraining_node = null;
382
+ this.dbl_clicked_node = null;
383
+ this.target_cluster = null;
384
+ this.constraint_under_cursor = null;
385
+ this.last_up_down_without_move = Date.now();
386
+ // Keyboard shortcuts: Ctrl-x associates with menu button ID.
387
+ this.shortcuts = {
388
+ 'A': 'actors',
389
+ 'B': 'repository', // B for "Browse"
390
+ 'C': 'clone', // button and Ctrl-C now copies; Alt-C clones
391
+ 'D': 'dataset',
392
+ 'E': 'equation',
393
+ 'F': 'finder',
394
+ 'G': 'savediagram', // G for "Graph" (as Scalable Vector Graphics image)
395
+ 'H': 'receiver', // activate receiver (H for "Host")
396
+ 'I': 'documentation',
397
+ 'J': 'sensitivity', // J for "Jitter"
398
+ 'K': 'reset', // reset model and clear results from graph
399
+ 'L': 'load',
400
+ 'M': 'monitor', // Alt-M will open the model settings dialog
401
+ // Ctrl-N will still open a new browser window.
402
+ 'O': 'chart', // O for "Output", as it can be charts as wel as data
403
+ 'P': 'diagram', // P for PNG (Portable Network Graphics image)
404
+ 'Q': 'stop',
405
+ 'R': 'solve', // runs the simulation
406
+ 'S': 'save',
407
+ // Ctrl-T will still open a new browser tab.
408
+ 'U': 'parent', // U for "move UP in cluster hierarchy"
409
+ 'V': 'paste',
410
+ // Ctrl-W will still close the browser window.
411
+ 'X': 'experiment',
412
+ 'Y': 'redo',
413
+ 'Z': 'undo',
414
+ };
415
+
416
+ // Initialize controller buttons.
417
+ this.node_btns = ['process', 'product', 'link', 'constraint',
418
+ 'cluster', 'module', 'note'];
419
+ this.edit_btns = ['clone', 'paste', 'delete', 'undo', 'redo'];
420
+ this.model_btns = ['settings', 'save', 'repository', 'actors',
421
+ 'dataset', 'equation', 'chart', 'sensitivity', 'experiment',
422
+ 'diagram', 'savediagram', 'finder', 'monitor', 'solve'];
423
+ this.other_btns = ['new', 'load', 'receiver', 'documentation',
424
+ 'parent', 'lift', 'solve', 'stop', 'reset', 'zoomin', 'zoomout',
425
+ 'stepback', 'stepforward', 'autosave', 'recall'];
426
+ this.all_btns = this.node_btns.concat(
427
+ this.edit_btns, this.model_btns, this.other_btns);
428
+
429
+ // Add all button DOM elements as controller properties.
430
+ for(let i = 0; i < this.all_btns.length; i++) {
431
+ const b = this.all_btns[i];
432
+ this.buttons[b] = document.getElementById(b + '-btn');
433
+ }
434
+ this.active_button = null;
435
+
436
+ // Also identify the elements related to the focal cluster.
437
+ this.focal_cluster = document.getElementById('focal-cluster');
438
+ this.focal_black_box = document.getElementById('focal-black-box');
439
+ this.focal_name = document.getElementById('focal-name');
440
+
441
+ // Keep track of time since last message displayed on the infoline.
442
+ this.time_last_message = new Date('01 Jan 2001 00:00:00 GMT');
443
+ this.message_display_time = 3000;
444
+
445
+ // Initialize "main" modals, i.e., those that relate to the controller,
446
+ // not to other dialog objects.
447
+ const main_modals = ['logon', 'model', 'load', 'password', 'settings',
448
+ 'actors', 'add-process', 'add-product', 'move', 'note', 'clone',
449
+ 'replace', 'expression'];
450
+ for(let i = 0; i < main_modals.length; i++) {
451
+ this.modals[main_modals[i]] = new ModalDialog(main_modals[i]);
452
+ }
453
+
454
+ this.modals.cluster = new GroupPropertiesDialog('cluster', {
455
+ 'collapsed': 'collapsed',
456
+ 'ignore': 'ignore',
457
+ 'black-box': 'black_box'
458
+ });
459
+ this.modals.constraint = new GroupPropertiesDialog('constraint', {
460
+ 'soc-direct': 'soc_direction',
461
+ 'share-of-cost': 'share_of_cost',
462
+ 'no-slack': 'no_slack'
463
+ });
464
+ this.modals.link = new GroupPropertiesDialog('link', {
465
+ 'multiplier': 'multiplier',
466
+ 'R': 'relative_rate',
467
+ 'D': 'flow_delay',
468
+ 'share-of-cost': 'share_of_cost'
469
+ });
470
+ this.modals.process = new GroupPropertiesDialog('process', {
471
+ 'LB': 'lower_bound',
472
+ 'UB': 'upper_bound',
473
+ 'UB-equal': 'equal_bounds',
474
+ 'IL': 'initial_level',
475
+ 'integer': 'integer_level',
476
+ 'shut-down': 'level_to_zero',
477
+ 'LCF': 'pace_expression',
478
+ 'collapsed': 'collapsed'
479
+ });
480
+ this.modals.product = new GroupPropertiesDialog('product', {
481
+ 'unit': 'scale_unit',
482
+ 'source': 'is_source',
483
+ 'sink': 'is_sink',
484
+ 'stock': 'is_buffer',
485
+ 'data': 'is_data',
486
+ 'LB': 'lower_bound',
487
+ 'UB': 'upper_bound',
488
+ 'UB-equal': 'equal_bounds',
489
+ 'IL': 'initial_level',
490
+ 'P': 'price',
491
+ 'integer': 'integer_level',
492
+ 'no-slack': 'no_slack',
493
+ 'no-links': 'no_links'
494
+ });
495
+
496
+ // Initially, no dialog being dragged or resized.
497
+ this.dr_dialog = null;
498
+
499
+ // Visible draggable dialogs are sorted by their z-index.
500
+ this.dr_dialog_order = [];
501
+ }
502
+
503
+ get color() {
504
+ // Permit shorthand "UI.color.xxx" without the ".paper" part.
505
+ return this.paper.palette;
506
+ }
507
+
508
+ removeListeners(el) {
509
+ // Remove all event listeners from DOM element `el`.
510
+ const clone = el.cloneNode(true);
511
+ el.parentNode.replaceChild(clone, el);
512
+ return clone;
513
+ }
514
+
515
+ addListeners() {
516
+ // NOTE: "cc" stands for "canvas container"; this DOM element holds
517
+ // the model diagram SVG.
518
+ this.cc = document.getElementById('cc');
519
+ this.cc.addEventListener('mousemove', (event) => UI.mouseMove(event));
520
+ this.cc.addEventListener('mouseup', (event) => UI.mouseUp(event));
521
+ this.cc.addEventListener('mousedown', (event) => UI.mouseDown(event));
522
+ // NOTE: Responding to `mouseenter` is needed to update the cursor
523
+ // position after closing a modal dialog.
524
+ this.cc.addEventListener('mouseenter', (event) => UI.mouseMove(event));
525
+ // Products can be dragged from the Finder to add a placeholder for
526
+ // it to the focal cluster.
527
+ this.cc.addEventListener('dragover', (event) => UI.dragOver(event));
528
+ this.cc.addEventListener('drop', (event) => UI.drop(event));
529
+
530
+ // Disable dragging on all images.
531
+ const
532
+ imgs = document.getElementsByTagName('img'),
533
+ nodrag = (event) => { event.preventDefault(); return false; };
534
+ for(let i = 0; i < imgs.length; i++) {
535
+ imgs[i].addEventListener('dragstart', nodrag);
536
+ }
537
+
538
+ // Make all buttons respond to a mouse click.
539
+ this.buttons['new'].addEventListener('click',
540
+ () => UI.promptForNewModel());
541
+ this.buttons.load.addEventListener('click',
542
+ () => FILE_MANAGER.promptToLoad());
543
+ this.buttons.settings.addEventListener('click',
544
+ () => UI.showSettingsDialog(MODEL));
545
+ this.buttons.save.addEventListener('click',
546
+ () => FILE_MANAGER.saveModel());
547
+ this.buttons.actors.addEventListener('click',
548
+ () => ACTOR_MANAGER.showDialog());
549
+ this.buttons.diagram.addEventListener('click',
550
+ () => FILE_MANAGER.renderDiagramAsPNG());
551
+ this.buttons.savediagram.addEventListener('click',
552
+ () => FILE_MANAGER.saveDiagramAsSVG());
553
+ this.buttons.receiver.addEventListener('click',
554
+ () => RECEIVER.toggle());
555
+ // NOTE: All draggable & resizable dialogs "toggle" show/hide.
556
+ const tdf = (event) => UI.toggleDialog(event);
557
+ this.buttons.repository.addEventListener('click', tdf);
558
+ this.buttons.dataset.addEventListener('click', tdf);
559
+ this.buttons.equation.addEventListener('click', tdf);
560
+ this.buttons.chart.addEventListener('click', tdf);
561
+ this.buttons.sensitivity.addEventListener('click', tdf);
562
+ this.buttons.experiment.addEventListener('click', tdf);
563
+ this.buttons.finder.addEventListener('click', tdf);
564
+ this.buttons.monitor.addEventListener('click', tdf);
565
+ this.buttons.documentation.addEventListener('click', tdf);
566
+ // Cluster navigation elements:
567
+ this.focal_name.addEventListener('click',
568
+ () => UI.showClusterPropertiesDialog(MODEL.focal_cluster));
569
+ this.focal_name.addEventListener('mousemove',
570
+ () => DOCUMENTATION_MANAGER.update(MODEL.focal_cluster, true));
571
+ this.buttons.parent.addEventListener('click',
572
+ () => UI.showParentCluster());
573
+ this.buttons.lift.addEventListener('click',
574
+ () => UI.moveSelectionToParentCluster());
575
+
576
+ // Local host button (on far right of top horizontal tool bar).
577
+ if(!SOLVER.user_id) {
578
+ // NOTE: When user name is specified, solver is not on local host.
579
+ const hl = document.getElementById('host-logo');
580
+ hl.classList.add('local-server');
581
+ hl.addEventListener('click', () => UI.shutDownServer());
582
+ }
583
+
584
+ // Vertical tool bar buttons:
585
+ this.buttons.clone.addEventListener('click',
586
+ (event) => {
587
+ if(event.altKey) {
588
+ UI.promptForCloning();
589
+ } else {
590
+ UI.copySelection();
591
+ }
592
+ });
593
+ this.buttons.paste.addEventListener('click',
594
+ () => UI.pasteSelection());
595
+ this.buttons['delete'].addEventListener('click',
596
+ () => {
597
+ UNDO_STACK.push('delete');
598
+ MODEL.deleteSelection();
599
+ UI.updateButtons();
600
+ });
601
+ this.buttons.undo.addEventListener('click',
602
+ () => {
603
+ if(UI.buttons.undo.classList.contains('enab')) {
604
+ UNDO_STACK.undo();
605
+ UI.updateButtons();
606
+ }
607
+ });
608
+ this.buttons.redo.addEventListener('click',
609
+ () => {
610
+ if(UI.buttons.redo.classList.contains('enab')) {
611
+ UNDO_STACK.redo();
612
+ UI.updateButtons();
613
+ }
614
+ });
615
+ this.buttons.solve.addEventListener('click', () => VM.solveModel());
616
+ this.buttons.stop.addEventListener('click', () => VM.halt());
617
+ this.buttons.reset.addEventListener('click', () => UI.resetModel());
618
+
619
+ // Bottom-line GUI elements:
620
+ this.buttons.zoomin.addEventListener('click', () => UI.paper.zoomIn());
621
+ this.buttons.zoomout.addEventListener('click', () => UI.paper.zoomOut());
622
+ this.buttons.stepback.addEventListener('click',
623
+ (event) => UI.stepBack(event));
624
+ this.buttons.stepforward.addEventListener('click',
625
+ (event) => UI.stepForward(event));
626
+ document.getElementById('prev-issue').addEventListener('click',
627
+ () => UI.updateIssuePanel(-1));
628
+ document.getElementById('issue-nr').addEventListener('click',
629
+ () => UI.jumpToIssue());
630
+ document.getElementById('next-issue').addEventListener('click',
631
+ () => UI.updateIssuePanel(1));
632
+ this.buttons.recall.addEventListener('click',
633
+ // Recall button toggles the documentation dialog.
634
+ () => UI.buttons.documentation.dispatchEvent(new Event('click')));
635
+ this.buttons.autosave.addEventListener('click',
636
+ // NOTE: TRUE indicates "show dialog after obtaining the model list".
637
+ () => AUTO_SAVE.getAutoSavedModels(true));
638
+ this.buttons.autosave.addEventListener('mouseover',
639
+ () => AUTO_SAVE.getAutoSavedModels());
640
+
641
+ // Make "stay active" buttons respond to Shift-click.
642
+ const
643
+ tbs = document.getElementsByClassName('toggle'),
644
+ tf = (event) => UI.toggleButton(event);
645
+ for(let i = 0; i < tbs.length; i++) {
646
+ tbs[i].addEventListener('click', tf);
647
+ }
648
+
649
+ // Add listeners to OK and CANCEL buttons on main modal dialogs.
650
+ this.modals.logon.ok.addEventListener('click',
651
+ () => {
652
+ const
653
+ usr = UI.modals.logon.element('name').value,
654
+ pwd = UI.modals.logon.element('password').value;
655
+ // Always hide the modal dialog.
656
+ UI.modals.logon.hide();
657
+ MONITOR.logOnToServer(usr, pwd);
658
+ });
659
+ this.modals.logon.cancel.addEventListener('click',
660
+ () => {
661
+ UI.modals.logon.hide();
662
+ UI.warn('Not connected to solver');
663
+ });
664
+
665
+ this.modals.model.ok.addEventListener('click',
666
+ () => UI.createNewModel());
667
+ this.modals.model.cancel.addEventListener('click',
668
+ () => UI.modals.model.hide());
669
+
670
+ this.modals.load.ok.addEventListener('click',
671
+ () => FILE_MANAGER.loadModel());
672
+ this.modals.load.cancel.addEventListener('click',
673
+ () => UI.modals.load.hide());
674
+ this.modals.load.element('autosaved-btn').addEventListener('click',
675
+ () => AUTO_SAVE.showRestoreDialog());
676
+
677
+ // NOTE: Encryption-related variables are stored as properties of
678
+ // the password modal dialog.
679
+ this.modals.password.encryption_code = '';
680
+ this.modals.password.encrypted_msg = null;
681
+ this.modals.password.post_decrypt_action = null;
682
+ this.modals.password.cancel.addEventListener('click',
683
+ () => UI.modals.password.hide());
684
+ this.modals.password.element('code').addEventListener('input',
685
+ () => FILE_MANAGER.updateStrength());
686
+
687
+ this.modals.settings.ok.addEventListener('click',
688
+ () => UI.updateSettings(MODEL));
689
+ // NOTE: Model Settings dialog has an information button in its header.
690
+ this.modals.settings.info.addEventListener('click',
691
+ () => {
692
+ // Open the documentation manager if still closed.
693
+ if(!DOCUMENTATION_MANAGER.visible) {
694
+ UI.buttons.documentation.dispatchEvent(new Event('click'));
695
+ }
696
+ DOCUMENTATION_MANAGER.update(MODEL, true);
697
+ });
698
+ this.modals.settings.cancel.addEventListener('click',
699
+ () => {
700
+ UI.modals.settings.hide();
701
+ // Ensure that model documentation can no longer be edited.
702
+ DOCUMENTATION_MANAGER.clearEntity([MODEL]);
703
+ });
704
+ // Make the scale units button of the settings dialog responsive.
705
+ this.modals.settings.element('scale-units-btn').addEventListener('click',
706
+ // Open the scale units modal dialog on top of the settings dialog.
707
+ () => SCALE_UNIT_MANAGER.show());
708
+
709
+ // Modals related to vertical toolbar buttons.
710
+ this.modals['add-process'].ok.addEventListener('click',
711
+ () => UI.addNode('process'));
712
+ this.modals['add-process'].cancel.addEventListener('click',
713
+ () => UI.modals['add-process'].hide());
714
+ this.modals['add-product'].ok.addEventListener('click',
715
+ () => UI.addNode('product'));
716
+ this.modals['add-product'].cancel.addEventListener('click',
717
+ () => UI.modals['add-product'].hide());
718
+ this.modals.cluster.ok.addEventListener('click',
719
+ () => UI.addNode('cluster'));
720
+ this.modals.cluster.cancel.addEventListener('click',
721
+ () => UI.modals.cluster.hide());
722
+
723
+ // NOTES:
724
+ // (1) Use shared functions for process & product dialog events.
725
+ // (2) The "edit expression" buttons provide sufficient info via `event`.
726
+ const
727
+ eoxedit = (event) => X_EDIT.editExpression(event),
728
+ eodocu = () => DOCUMENTATION_MANAGER.update(UI.edited_object, true),
729
+ eoteqb = (event) => UI.toggleEqualBounds(event);
730
+
731
+ this.modals.note.ok.addEventListener('click',
732
+ () => UI.addNode('note'));
733
+ this.modals.note.cancel.addEventListener('click',
734
+ () => UI.modals.note.hide());
735
+ // Notes have 1 expression property (color).
736
+ this.modals.note.element('C-x').addEventListener('click', eoxedit);
737
+ // NOTE: The properties dialog for process, product, cluster and link
738
+ // also respond to `mousemove` to show documentation.
739
+ this.modals.process.ok.addEventListener('click',
740
+ () => UI.updateProcessProperties());
741
+ this.modals.process.cancel.addEventListener('click',
742
+ () => UI.modals.process.hide());
743
+ this.modals.process.dialog.addEventListener('mousemove', eodocu);
744
+ this.modals.process.element('UB-equal').addEventListener('click', eoteqb);
745
+ // Processes have 4 expression properties
746
+ this.modals.process.element('LB-x').addEventListener('click', eoxedit);
747
+ this.modals.process.element('UB-x').addEventListener('click', eoxedit);
748
+ this.modals.process.element('IL-x').addEventListener('click', eoxedit);
749
+ this.modals.process.element('LCF-x').addEventListener('click', eoxedit);
750
+
751
+ this.modals.product.ok.addEventListener('click',
752
+ () => UI.updateProductProperties());
753
+ this.modals.product.cancel.addEventListener('click',
754
+ () => UI.modals.product.hide());
755
+ this.modals.product.dialog.addEventListener('mousemove', eodocu);
756
+ this.modals.product.element('UB-equal').addEventListener('click', eoteqb);
757
+ // Product stock box performs action => wait for box to update its state.
758
+ document.getElementById('stock').addEventListener('click',
759
+ () => setTimeout(() => UI.toggleProductStock(), 10));
760
+ // Products have 4 expression properties.
761
+ this.modals.product.element('LB-x').addEventListener('click', eoxedit);
762
+ this.modals.product.element('UB-x').addEventListener('click', eoxedit);
763
+ this.modals.product.element('IL-x').addEventListener('click', eoxedit);
764
+ this.modals.product.element('P-x').addEventListener('click', eoxedit);
765
+
766
+ // Products have an import/export togglebox.
767
+ this.modals.product.element('io').addEventListener('click',
768
+ () => UI.toggleImportExportBox('product'));
769
+
770
+ this.modals.link.ok.addEventListener('click',
771
+ () => UI.updateLinkProperties());
772
+ this.modals.link.cancel.addEventListener('click',
773
+ () => UI.modals.link.hide());
774
+ this.modals.link.dialog.addEventListener('mousemove',
775
+ () => DOCUMENTATION_MANAGER.update(UI.on_link, true));
776
+ this.modals.link.element('multiplier').addEventListener('change',
777
+ () => UI.updateLinkDataArrows());
778
+
779
+ // Links have 2 expression properties: rate and delay.
780
+ this.modals.link.element('R-x').addEventListener('click', eoxedit);
781
+ this.modals.link.element('D-x').addEventListener('click', eoxedit);
782
+
783
+ this.modals.clone.ok.addEventListener('click',
784
+ () => UI.cloneSelection());
785
+ this.modals.clone.cancel.addEventListener('click',
786
+ () => UI.cancelCloneSelection());
787
+
788
+ // The MOVE dialog can appear when a process or cluster is added.
789
+ this.modals.move.ok.addEventListener('click',
790
+ () => UI.moveNodeToFocalCluster());
791
+ this.modals.move.cancel.addEventListener('click',
792
+ () => UI.doNotMoveNode());
793
+
794
+ // The REPLACE dialog appears when a product is Ctrl-clicked.
795
+ this.modals.replace.ok.addEventListener('click',
796
+ () => UI.replaceProduct());
797
+ this.modals.replace.cancel.addEventListener('click',
798
+ () => UI.modals.replace.hide());
799
+
800
+ // The PASTE dialog appears when name conflicts must be resolved.
801
+ this.paste_modal = new ModalDialog('paste');
802
+ this.paste_modal.ok.addEventListener('click',
803
+ () => UI.setPasteMapping());
804
+ this.paste_modal.cancel.addEventListener('click',
805
+ () => UI.paste_modal.hide());
806
+
807
+ // The CHECK UPDATE dialog appears when a new version is detected.
808
+ this.check_update_modal = new ModalDialog('check-update');
809
+ this.check_update_modal.ok.addEventListener('click',
810
+ () => UI.shutDownToUpdate());
811
+ this.check_update_modal.cancel.addEventListener('click',
812
+ () => UI.preventUpdate());
813
+
814
+ // The UPDATING modal appears when updating has started.
815
+ // NOTE: This modal has no OK or CANCEL buttons.
816
+ this.updating_modal = new ModalDialog('updating');
817
+
818
+ // Add all draggable stay-on-top dialogs as controller properties.
819
+
820
+ // Make checkboxes respond to click
821
+ // NOTE: checkbox-specific events must be bound AFTER this general setting
822
+ const
823
+ cbs = document.getElementsByClassName('box'),
824
+ cbf = (event) => UI.toggleBox(event);
825
+ for(let i = 0; i < cbs.length; i++) {
826
+ cbs[i].addEventListener('click', cbf);
827
+ }
828
+ // Make infoline respond to `mouseenter`
829
+ this.info_line = document.getElementById('info-line');
830
+ this.info_line.addEventListener('mouseenter',
831
+ (event) => DOCUMENTATION_MANAGER.showInfoMessages(event.shiftKey));
832
+ // Ensure that all modal windows respond to ESCape
833
+ // (and more in general to other special keys)
834
+ document.addEventListener('keydown', (event) => UI.checkModals(event));
835
+ }
836
+
837
+ setConstraintUnderCursor(c) {
838
+ // Sets constraint under cursor (CUC) (if any) and records time of event
839
+ this.constraint_under_cursor = c;
840
+ this.cuc_x = this.mouse_x;
841
+ this.cuc_y = this.mouse_y;
842
+ this.last_cuc_change = new Date().getTime();
843
+ }
844
+
845
+ constraintStillUnderCursor() {
846
+ // Returns CUC, but possibly after setting it to NULL because mouse has
847
+ // moved significantly and CUC was detected more than 300 msec ago
848
+ // NOTE: this elaborate check was added to deal with constraint shapes
849
+ // not always generating mouseout events (due to rapid mouse movements?)
850
+ const
851
+ dx = Math.abs(this.cuc_x - this.mouse_x),
852
+ dy = Math.abs(this.cuc_y - this.mouse_y);
853
+ if(dx + dy > 5 && new Date().getTime() - this.last_cuc_change > 300) {
854
+ this.constraint_under_cursor = null;
855
+ }
856
+ return this.constraint_under_cursor;
857
+ }
858
+
859
+ updateControllerDialogs(letters) {
860
+ if(letters.indexOf('B') >= 0) REPOSITORY_BROWSER.updateDialog();
861
+ if(letters.indexOf('C') >= 0) CHART_MANAGER.updateDialog();
862
+ if(letters.indexOf('D') >= 0) DATASET_MANAGER.updateDialog();
863
+ if(letters.indexOf('E') >= 0) EQUATION_MANAGER.updateDialog();
864
+ if(letters.indexOf('F') >= 0) FINDER.updateDialog();
865
+ if(letters.indexOf('I') >= 0) DOCUMENTATION_MANAGER.updateDialog();
866
+ if(letters.indexOf('J') >= 0) SENSITIVITY_ANALYSIS.updateDialog();
867
+ if(letters.indexOf('X') >= 0) EXPERIMENT_MANAGER.updateDialog();
868
+ }
869
+
870
+ loadModelFromXML(xml) {
871
+ // Parses `xml` and updates the GUI
872
+ const loaded = MODEL.parseXML(xml);
873
+ // If not a valid Linny-R model, ensure that the current model is clean
874
+ if(!loaded) MODEL = new LinnyRModel();
875
+ this.updateScaleUnitList();
876
+ this.drawDiagram(MODEL);
877
+ // Cursor may have been set to `waiting` when decrypting
878
+ this.normalCursor();
879
+ this.setMessage('');
880
+ this.updateButtons();
881
+ // Undoable operations no longer apply!
882
+ UNDO_STACK.clear();
883
+ // Autosaving should start anew
884
+ AUTO_SAVE.setAutoSaveInterval();
885
+ // Signal success or failure
886
+ return loaded;
887
+ }
888
+
889
+ makeFocalCluster(c) {
890
+ if(c.is_black_boxed) {
891
+ this.notify('Black-boxed clusters cannot be viewed');
892
+ return;
893
+ }
894
+ let fc = MODEL.focal_cluster;
895
+ MODEL.focal_cluster = c;
896
+ MODEL.clearSelection();
897
+ this.paper.drawModel(MODEL);
898
+ this.updateButtons();
899
+ // NOTE: when "moving up" in the cluster hierarchy, bring the former focal
900
+ // cluster into view
901
+ if(fc.cluster == MODEL.focal_cluster) {
902
+ this.scrollIntoView(fc.shape.element.childNodes[0]);
903
+ }
904
+ }
905
+
906
+ drawDiagram(mdl) {
907
+ // "Queue" a draw request (to avoid redrawing too often)
908
+ if(this.busy_drawing) {
909
+ this.draw_requests += 1;
910
+ } else {
911
+ this.draw_requests = 0;
912
+ this.busy_drawing = true;
913
+ this.paper.drawModel(mdl);
914
+ this.busy_drawing = false;
915
+ }
916
+ }
917
+
918
+ drawSelection(mdl) {
919
+ // "Queue" a draw request (to avoid redrawing too often)
920
+ if(this.busy_drawing_selection) {
921
+ this.selection_draw_requests += 1;
922
+ } else {
923
+ this.selection_draw_requests = 0;
924
+ this.busy_drawing_selection = true;
925
+ this.paper.drawSelection(mdl);
926
+ this.busy_drawing_selection = false;
927
+ }
928
+ }
929
+
930
+ drawObject(obj) {
931
+ if(obj instanceof Process) {
932
+ this.paper.drawProcess(obj);
933
+ } else if(obj instanceof Product) {
934
+ this.paper.drawProduct(obj);
935
+ } else if(obj instanceof Cluster) {
936
+ this.paper.drawCluster(obj);
937
+ } else if(obj instanceof Arrow) {
938
+ this.paper.drawArrow(obj);
939
+ } else if(obj instanceof Constraint) {
940
+ this.paper.drawConstraint(obj);
941
+ } else if(obj instanceof Note) {
942
+ this.paper.drawNote(obj);
943
+ }
944
+ }
945
+
946
+ drawLinkArrows(cluster, link) {
947
+ // Draw all arrows in `cluster` that represent `link`.
948
+ for(let i = 0; i < cluster.arrows.length; i++) {
949
+ const a = cluster.arrows[i];
950
+ if(a.links.indexOf(link) >= 0) this.paper.drawArrow(a);
951
+ }
952
+ }
953
+
954
+ shutDownServer() {
955
+ // This terminates the local host server script and display a plain
956
+ // HTML message in the browser with a restart button.
957
+ if(!SOLVER.user_id) window.open('./shutdown', '_self');
958
+ }
959
+
960
+ shutDownToUpdate() {
961
+ // Sisgnal server that an update is required. This will close the
962
+ // local host Linny-R server. If this server was started by the
963
+ // standard OS batch script, this script will proceed to update
964
+ // Linny-R via npm and then restart the server again. If not, the
965
+ // fetch request will time out, anf the user will be warned.
966
+ if(SOLVER.user_id) return;
967
+ fetch('update/')
968
+ .then((response) => {
969
+ if(!response.ok) {
970
+ UI.alert(`ERROR ${response.status}: ${response.statusText}`);
971
+ }
972
+ return response.text();
973
+ })
974
+ .then((data) => {
975
+ if(UI.postResponseOK(data, true)) {
976
+ UI.check_update_modal.hide();
977
+ if(data.startsWith('Installing')) UI.waitToRestart();
978
+ }
979
+ })
980
+ .catch((err) => {
981
+ UI.warn(UI.WARNING.NO_CONNECTION, err);
982
+ });
983
+ }
984
+
985
+ waitToRestart() {
986
+ // Shows the "update in progress" dialog and then fetches the current
987
+ // version page from the server. Always wait for 5 seconds to permit
988
+ // reading the text, and ensure that the server has been stopped.
989
+ // Only then try to restart.
990
+ if(SOLVER.user_id) return;
991
+ UI.updating_modal.show();
992
+ setTimeout(() => UI.tryToRestart(0), 5000);
993
+ }
994
+
995
+ tryToRestart(trials) {
996
+ // Fetch the current version number from the server. This may take
997
+ // a wile, as the server was shut down and restarts only after npm
998
+ // has updated the Linny-R software. Typically, this takes only a few
999
+ // seconds, but the connection with the npm server may be slow.
1000
+ // Default timeout on Firefox (90 seconds) and Chrome (300 seconds)
1001
+ // should amply suffice, though, hence no provision for a second attempt.
1002
+ fetch('version/')
1003
+ .then((response) => {
1004
+ if(!response.ok) {
1005
+ UI.alert(`ERROR ${response.status}: ${response.statusText}`);
1006
+ }
1007
+ return response.text();
1008
+ })
1009
+ .then((data) => {
1010
+ if(UI.postResponseOK(data)) {
1011
+ // Change the dialog text in case the user does not confirm
1012
+ // when prompted by the browser to leave the page.
1013
+ const
1014
+ m = data.match(/(\d+\.\d+\.\d+)/),
1015
+ md = UI.updating_modal;
1016
+ md.title.innerText = 'Update terminated';
1017
+ let msg = [];
1018
+ if(m) {
1019
+ msg.push(
1020
+ `Linny-R version ${m[1]} has been installed.`,
1021
+ 'To continue, you must reload this page, and',
1022
+ 'confirm when prompted by your browser.');
1023
+ } else {
1024
+ // Inform user that install appears to have failed.
1025
+ msg.push(
1026
+ 'Installation of new version may <strong>not</strong> have',
1027
+ 'been successful. Please check the CLI for',
1028
+ 'error messages or warnings.');
1029
+ }
1030
+ md.element('msg').innerHTML = msg.join('<br>');
1031
+ // Reload `index.html`. This will start Linny-R anew.
1032
+ window.open('./', '_self');
1033
+ }
1034
+ })
1035
+ .catch((err) => {
1036
+ if(trials < 10) {
1037
+ setTimeout(() => UI.tryToRestart(trials + 1), 5000);
1038
+ } else {
1039
+ UI.warn(UI.WARNING.NO_CONNECTION, err);
1040
+ }
1041
+ });
1042
+ }
1043
+
1044
+ preventUpdate() {
1045
+ // Signal server that no update is required.
1046
+ if(SOLVER.user_id) return;
1047
+ fetch('no-update/')
1048
+ .then((response) => {
1049
+ if(!response.ok) {
1050
+ UI.alert(`ERROR ${response.status}: ${response.statusText}`);
1051
+ }
1052
+ return response.text();
1053
+ })
1054
+ .then((data) => {
1055
+ if(UI.postResponseOK(data, true)) UI.check_update_modal.hide();
1056
+ })
1057
+ .catch((err) => {
1058
+ UI.warn(UI.WARNING.NO_CONNECTION, err);
1059
+ UI.check_update_modal.hide();
1060
+ });
1061
+ }
1062
+
1063
+ loginPrompt() {
1064
+ // Show the server logon modal.
1065
+ this.modals.logon.element('name').value = SOLVER.user_id;
1066
+ this.modals.logon.element('password').value = '';
1067
+ this.modals.logon.show('password');
1068
+ }
1069
+
1070
+ rotatingIcon(rotate=false) {
1071
+ // Controls the appearance of the Linny-R icon in the top-left
1072
+ // corner of the browser window.
1073
+ const
1074
+ si = document.getElementById('static-icon'),
1075
+ ri = document.getElementById('rotating-icon');
1076
+ if(rotate) {
1077
+ si.style.display = 'none';
1078
+ ri.style.display = 'block';
1079
+ } else {
1080
+ ri.style.display = 'none';
1081
+ si.style.display = 'block';
1082
+ }
1083
+ }
1084
+
1085
+ updateTimeStep(t=MODEL.simulationTimeStep) {
1086
+ // Display `t` as the current time step.
1087
+ // NOTE: The Virtual Machine passes its relative time `VM.t`.
1088
+ document.getElementById('step').innerHTML = t;
1089
+ }
1090
+
1091
+ stopSolving() {
1092
+ // Reset solver-related GUI elements and notify modeler.
1093
+ super.stopSolving();
1094
+ this.buttons.solve.classList.remove('off');
1095
+ this.buttons.stop.classList.remove('blink');
1096
+ this.buttons.stop.classList.add('off');
1097
+ this.rotatingIcon(false);
1098
+ // Update the time step on the status bar.
1099
+ this.updateTimeStep();
1100
+ }
1101
+
1102
+ readyToSolve() {
1103
+ // Set Stop and Reset buttons to their initial state.
1104
+ UI.buttons.stop.classList.remove('blink');
1105
+ // Hide the reset button
1106
+ UI.buttons.reset.classList.add('off');
1107
+ }
1108
+
1109
+ startSolving() {
1110
+ // Hide Start button and show Stop button.
1111
+ UI.buttons.solve.classList.add('off');
1112
+ UI.buttons.stop.classList.remove('off');
1113
+ }
1114
+
1115
+ waitToStop() {
1116
+ // Make Stop button blink to indicate "halting -- please wait".
1117
+ UI.buttons.stop.classList.add('blink');
1118
+ }
1119
+
1120
+ readyToReset() {
1121
+ // Show the Reset button.
1122
+ UI.buttons.reset.classList.remove('off');
1123
+ }
1124
+
1125
+ reset() {
1126
+ // Reset properties related to cursor position on diagram.
1127
+ this.on_node = null;
1128
+ this.on_arrow = null;
1129
+ this.on_cluster = null;
1130
+ this.on_cluster_edge = false;
1131
+ this.on_link = null;
1132
+ this.on_constraint = null;
1133
+ this.on_note = null;
1134
+ this.on_block_arrow = false;
1135
+ this.dragged_node = null;
1136
+ this.linking_node = null;
1137
+ this.constraining_node = null;
1138
+ this.start_sel_x = -1;
1139
+ this.start_sel_y = -1;
1140
+ }
1141
+
1142
+ updateIssuePanel(change=0) {
1143
+ const
1144
+ count = VM.issue_list.length,
1145
+ panel = document.getElementById('issue-panel');
1146
+ if(count > 0) {
1147
+ const
1148
+ prev = document.getElementById('prev-issue'),
1149
+ next = document.getElementById('next-issue'),
1150
+ nr = document.getElementById('issue-nr');
1151
+ panel.title = pluralS(count, 'issue') +
1152
+ ' occurred - click on number, \u25C1 or \u25B7 to view what and when';
1153
+ if(VM.issue_index === -1) {
1154
+ VM.issue_index = 0;
1155
+ } else if(change) {
1156
+ VM.issue_index = Math.min(VM.issue_index + change, count - 1);
1157
+ }
1158
+ nr.innerText = VM.issue_index + 1;
1159
+ if(VM.issue_index <= 0) {
1160
+ prev.classList.add('disab');
1161
+ } else {
1162
+ prev.classList.remove('disab');
1163
+ }
1164
+ if(VM.issue_index >= count - 1) {
1165
+ next.classList.add('disab');
1166
+ } else {
1167
+ next.classList.remove('disab');
1168
+ }
1169
+ panel.style.display = 'table-cell';
1170
+ if(change) UI.jumpToIssue();
1171
+ } else {
1172
+ panel.style.display = 'none';
1173
+ VM.issue_index = -1;
1174
+ }
1175
+ }
1176
+
1177
+ jumpToIssue() {
1178
+ // Set time step to the one of the warning message for the issue
1179
+ // index, redraw the diagram if needed, and display the message
1180
+ // on the infoline.
1181
+ if(VM.issue_index >= 0) {
1182
+ const
1183
+ issue = VM.issue_list[VM.issue_index],
1184
+ po = issue.indexOf('(t='),
1185
+ pc = issue.indexOf(')', po),
1186
+ t = parseInt(issue.substring(po + 3, pc - 1));
1187
+ if(MODEL.t !== t) {
1188
+ MODEL.t = t;
1189
+ this.updateTimeStep();
1190
+ this.drawDiagram(MODEL);
1191
+ }
1192
+ this.info_line.classList.remove('error', 'notification');
1193
+ this.info_line.classList.add('warning');
1194
+ this.info_line.innerHTML = issue.substring(pc + 2);
1195
+ }
1196
+ }
1197
+
1198
+ get doubleClicked() {
1199
+ // Return TRUE when a "double-click" occurred
1200
+ const
1201
+ now = Date.now(),
1202
+ dt = now - this.last_up_down_without_move;
1203
+ this.last_up_down_without_move = now;
1204
+ // Consider click to be "double" if it occurred less than 300 ms ago
1205
+ if(dt < 300) {
1206
+ this.last_up_down_without_move = 0;
1207
+ return true;
1208
+ }
1209
+ return false;
1210
+ }
1211
+
1212
+ hidden(id) {
1213
+ // Returns TRUE if element is not shown
1214
+ const el = document.getElementById(id);
1215
+ return window.getComputedStyle(el).display === 'none';
1216
+ }
1217
+
1218
+ toggle(id, display='block') {
1219
+ // Hides element if shown; otherwise sets display mode
1220
+ const
1221
+ el = document.getElementById(id),
1222
+ h = window.getComputedStyle(el).display === 'none';
1223
+ el.style.display = (h ? display : 'none');
1224
+ }
1225
+
1226
+ scrollIntoView(e) {
1227
+ // Scrolls container of DOM element `e` such that it becomes visible
1228
+ if(e) e.scrollIntoView({block: 'nearest', inline: 'nearest'});
1229
+ }
1230
+
1231
+ //
1232
+ // Methods related to draggable & resizable dialogs
1233
+ //
1234
+
1235
+ draggableDialog(d) {
1236
+ // Make dialog draggable
1237
+ const
1238
+ dlg = document.getElementById(d + '-dlg'),
1239
+ hdr = document.getElementById(d + '-hdr');
1240
+ let cx = 0,
1241
+ cy = 0;
1242
+ if(dlg && hdr) {
1243
+ // NOTE: dialogs are draggable only by their header
1244
+ hdr.onmousedown = dialogHeaderMouseDown;
1245
+ dlg.onmousedown = dialogMouseDown;
1246
+ return dlg;
1247
+ } else {
1248
+ console.log('ERROR: No draggable header element');
1249
+ return null;
1250
+ }
1251
+
1252
+ function dialogMouseDown(e) {
1253
+ e = e || window.event;
1254
+ // NOTE: no `preventDefault` so the header will also receive it
1255
+ // Find the dialog element
1256
+ let de = e.target;
1257
+ while(de && !de.id.endsWith('-dlg')) { de = de.parentElement; }
1258
+ // Moves the dialog (`this`) to the top of the order
1259
+ const doi = UI.dr_dialog_order.indexOf(de);
1260
+ // NOTE: do not reorder when already at end of list (= at top)
1261
+ if(doi >= 0 && doi !== UI.dr_dialog_order.length - 1) {
1262
+ UI.dr_dialog_order.splice(doi, 1);
1263
+ UI.dr_dialog_order.push(de);
1264
+ UI.reorderDialogs();
1265
+ }
1266
+ }
1267
+
1268
+ function dialogHeaderMouseDown(e) {
1269
+ e = e || window.event;
1270
+ e.preventDefault();
1271
+ // Find the dialog element
1272
+ let de = e.target;
1273
+ while(de && !de.id.endsWith('-dlg')) { de = de.parentElement; }
1274
+ // Record the affected dialog
1275
+ UI.dr_dialog = de;
1276
+ // Get the mouse cursor position at startup
1277
+ cx = e.clientX;
1278
+ cy = e.clientY;
1279
+ document.onmouseup = stopDragDialog;
1280
+ document.onmousemove = dialogDrag;
1281
+ }
1282
+
1283
+ function dialogDrag(e) {
1284
+ e = e || window.event;
1285
+ e.preventDefault();
1286
+ // Calculate the relative movement of the mouse cursor...
1287
+ const
1288
+ dx = cx - e.clientX,
1289
+ dy = cy - e.clientY;
1290
+ // ... and record the new mouse cursor position
1291
+ cx = e.clientX;
1292
+ cy = e.clientY;
1293
+ // Move the entire dialog, but prevent it from being moved outside the window
1294
+ UI.dr_dialog.style.top = Math.min(
1295
+ window.innerHeight - 40, Math.max(0, UI.dr_dialog.offsetTop - dy)) + 'px';
1296
+ UI.dr_dialog.style.left = Math.min(
1297
+ window.innerWidth - 40,
1298
+ Math.max(-210, UI.dr_dialog.offsetLeft - dx)) + 'px';
1299
+ }
1300
+
1301
+ function stopDragDialog() {
1302
+ // Stop moving when mouse button is released
1303
+ document.onmouseup = null;
1304
+ document.onmousemove = null;
1305
+ // Preserve position as data attributes
1306
+ UI.dr_dialog.setAttribute('data-top', UI.dr_dialog.style.top);
1307
+ UI.dr_dialog.setAttribute('data-left', UI.dr_dialog.style.left);
1308
+ }
1309
+ }
1310
+
1311
+ resizableDialog(d, mgr=null) {
1312
+ // Make dialog resizable (similar to dragElement above)
1313
+ const
1314
+ dlg = document.getElementById(d + '-dlg'),
1315
+ rsz = document.getElementById(d + '-resize');
1316
+ let w = 0,
1317
+ h = 0,
1318
+ minw = 0,
1319
+ minh = 0,
1320
+ cx = 0,
1321
+ cy = 0;
1322
+ if(dlg && rsz) {
1323
+ if(mgr) dlg.setAttribute('data-manager', mgr);
1324
+ rsz.onmousedown = resizeMouseDown;
1325
+ } else {
1326
+ console.log('ERROR: No resizing corner element');
1327
+ return false;
1328
+ }
1329
+
1330
+ function resizeMouseDown(e) {
1331
+ e = e || window.event;
1332
+ e.preventDefault();
1333
+ // Find the dialog element
1334
+ let de = e.target;
1335
+ while(de && !de.id.endsWith('-dlg')) { de = de.parentElement; }
1336
+ UI.dr_dialog = de;
1337
+ // Get the (min.) weight, (min.) height and mouse cursor position at startup
1338
+ const cs = window.getComputedStyle(UI.dr_dialog);
1339
+ w = parseFloat(cs.width);
1340
+ h = parseFloat(cs.height);
1341
+ minw = parseFloat(cs.minWidth);
1342
+ minh = parseFloat(cs.minHeight);
1343
+ cx = e.clientX;
1344
+ cy = e.clientY;
1345
+ document.onmouseup = stopResizeDialog;
1346
+ document.onmousemove = dialogResize;
1347
+ }
1348
+
1349
+ function dialogResize(e) {
1350
+ e = e || window.event;
1351
+ e.preventDefault();
1352
+ // Calculate the relative mouse cursor movement
1353
+ const
1354
+ dw = e.clientX - cx,
1355
+ dh = e.clientY - cy;
1356
+ // Set the dialog's new size
1357
+ UI.dr_dialog.style.width = Math.max(minw, w + dw) + 'px';
1358
+ UI.dr_dialog.style.height = Math.max(minh, h + dh) + 'px';
1359
+ // Update the dialog if its manager has been specified
1360
+ const mgr = UI.dr_dialog.dataset.manager;
1361
+ if(mgr) window[mgr].updateDialog();
1362
+ }
1363
+
1364
+ function stopResizeDialog() {
1365
+ // Stop moving when mouse button is released
1366
+ document.onmouseup = null;
1367
+ document.onmousemove = null;
1368
+ }
1369
+ }
1370
+
1371
+ toggleDialog(e) {
1372
+ // Hide dialog if visible, or show it if not, and update the
1373
+ // order of appearance so that this dialog appears on top
1374
+ e = e || window.event;
1375
+ e.preventDefault();
1376
+ e.stopImmediatePropagation();
1377
+ // Infer dialog identifier from target element
1378
+ const
1379
+ dlg = e.target.id.split('-')[0],
1380
+ tde = document.getElementById(dlg + '-dlg');
1381
+ // NOTE: manager attribute is a string, e.g. 'MONITOR' or 'CHART_MANAGER'
1382
+ let mgr = tde.dataset.manager,
1383
+ was_hidden = this.hidden(tde.id);
1384
+ if(mgr) {
1385
+ // Dialog has a manager object => let `mgr` point to it
1386
+ mgr = window[mgr];
1387
+ // Manager object attributes are more reliable than DOM element
1388
+ // style attributes, so update the visibility status
1389
+ was_hidden = !mgr.visible;
1390
+ }
1391
+ // NOTE: modeler should not view charts while an experiment is
1392
+ // running, so do NOT toggle when the Chart Manager is NOT visible
1393
+ if(dlg === 'chart' && was_hidden && MODEL.running_experiment) {
1394
+ UI.notify(UI.NOTICE.NO_CHARTS);
1395
+ return;
1396
+ }
1397
+ // Otherwise, toggle the dialog visibility
1398
+ this.toggle(tde.id);
1399
+ UI.buttons[dlg].classList.toggle('stay-activ');
1400
+ if(mgr) mgr.visible = was_hidden;
1401
+ let t, l;
1402
+ if(top in tde.dataset && left in tde.dataset) {
1403
+ // Open at position after last drag (recorded in DOM data attributes)
1404
+ t = tde.dataset.top;
1405
+ l = tde.dataset.left;
1406
+ } else {
1407
+ // Make dialog appear in screen center the first time it is shown
1408
+ const cs = window.getComputedStyle(tde);
1409
+ t = ((window.innerHeight - parseFloat(cs.height)) / 2) + 'px';
1410
+ l = ((window.innerWidth - parseFloat(cs.width)) / 2) + 'px';
1411
+ tde.style.top = t;
1412
+ tde.style.left = l;
1413
+ }
1414
+ if(was_hidden) {
1415
+ // Add activated dialog to "showing" list, and adjust z-indices
1416
+ this.dr_dialog_order.push(tde);
1417
+ this.reorderDialogs();
1418
+ // Update the diagram if its manager has been specified
1419
+ if(mgr) {
1420
+ mgr.updateDialog();
1421
+ if(mgr === DOCUMENTATION_MANAGER) {
1422
+ if(this.info_line.innerHTML.length === 0) {
1423
+ mgr.title.innerHTML = 'About Linny-R';
1424
+ mgr.viewer.innerHTML = mgr.about_linny_r;
1425
+ mgr.edit_btn.classList.remove('enab');
1426
+ mgr.edit_btn.classList.add('disab');
1427
+ }
1428
+ UI.drawDiagram(MODEL);
1429
+ }
1430
+ }
1431
+ } else {
1432
+ const doi = this.dr_dialog_order.indexOf(tde);
1433
+ // NOTE: doi should ALWAYS be >= 0 because dialog WAS showing
1434
+ if(doi >= 0) {
1435
+ this.dr_dialog_order.splice(doi, 1);
1436
+ this.reorderDialogs();
1437
+ }
1438
+ if(mgr === DOCUMENTATION_MANAGER) {
1439
+ mgr.title.innerHTML = 'Documentation';
1440
+ UI.drawDiagram(MODEL);
1441
+ }
1442
+ }
1443
+ }
1444
+
1445
+ reorderDialogs() {
1446
+ // Set z-index of draggable dialogs according to their order
1447
+ // (most recently shown or clicked on top)
1448
+ let z = 10;
1449
+ for(let i = 0; i < this.dr_dialog_order.length; i++) {
1450
+ this.dr_dialog_order[i].style.zIndex = z;
1451
+ z += 5;
1452
+ }
1453
+ }
1454
+
1455
+ //
1456
+ // Button functionality
1457
+ //
1458
+
1459
+ enableButtons(btns) {
1460
+ btns = btns.trim().split(/\s+/);
1461
+ for(let i = 0; i < btns.length; i++) {
1462
+ const b = document.getElementById(btns[i] + '-btn');
1463
+ b.classList.remove('disab', 'activ');
1464
+ b.classList.add('enab');
1465
+ }
1466
+ }
1467
+
1468
+ disableButtons(btns) {
1469
+ btns = btns.trim().split(/\s+/);
1470
+ for(let i = 0; i < btns.length; i++) {
1471
+ const b = document.getElementById(btns[i] + '-btn');
1472
+ b.classList.remove('enab', 'activ', 'stay-activ');
1473
+ b.classList.add('disab');
1474
+ }
1475
+ }
1476
+
1477
+ updateButtons() {
1478
+ // Updates the buttons on the main GUI toolbars
1479
+ const
1480
+ node_btns = 'process product link constraint cluster note ',
1481
+ edit_btns = 'clone paste delete undo redo ',
1482
+ model_btns = 'settings save actors dataset equation chart ' +
1483
+ 'diagram savediagram finder monitor solve';
1484
+ if(MODEL === null) {
1485
+ this.disableButtons(node_btns + edit_btns + model_btns);
1486
+ return;
1487
+ }
1488
+ if(MODEL.focal_cluster === MODEL.top_cluster) {
1489
+ this.focal_cluster.style.display = 'none';
1490
+ } else {
1491
+ this.focal_name.innerHTML = MODEL.focal_cluster.displayName;
1492
+ if(MODEL.focal_cluster.black_box) {
1493
+ this.focal_black_box.style.display = 'inline-block';
1494
+ } else {
1495
+ this.focal_black_box.style.display = 'none';
1496
+ }
1497
+ if(MODEL.selection.length > 0) {
1498
+ this.enableButtons('lift');
1499
+ } else {
1500
+ this.disableButtons('lift');
1501
+ }
1502
+ this.focal_cluster.style.display = 'inline-block';
1503
+ }
1504
+ this.enableButtons(node_btns + model_btns);
1505
+ this.active_button = this.stayActiveButton;
1506
+ this.disableButtons(edit_btns);
1507
+ if(MODEL.selection.length > 0) this.enableButtons('clone delete');
1508
+ if(this.canPaste) this.enableButtons('paste');
1509
+ // Only allow target seeking when some target or process constraint is defined
1510
+ if(MODEL.hasTargets) this.enableButtons('solve');
1511
+ var u = UNDO_STACK.canUndo;
1512
+ if(u) {
1513
+ this.enableButtons('undo');
1514
+ this.buttons.undo.title = u;
1515
+ } else {
1516
+ this.buttons.undo.title = 'Undo not possible';
1517
+ }
1518
+ u = UNDO_STACK.canRedo;
1519
+ if(u) {
1520
+ this.enableButtons('redo');
1521
+ this.buttons.redo.title = u;
1522
+ } else {
1523
+ this.buttons.redo.title = 'Redo not possible';
1524
+ }
1525
+ }
1526
+
1527
+ // NOTE: Active buttons allow repeated "clicks" on the canvas
1528
+
1529
+ get stayActive() {
1530
+ if(this.active_button) {
1531
+ return this.active_button.classList.contains('stay-activ');
1532
+ }
1533
+ return false;
1534
+ }
1535
+
1536
+ resetActiveButton() {
1537
+ if(this.active_button) {
1538
+ this.active_button.classList.remove('activ', 'stay-activ');
1539
+ }
1540
+ this.active_button = null;
1541
+ }
1542
+
1543
+ get stayActiveButton() {
1544
+ // Return the button that is "stay active", or NULL if none
1545
+ const btns = ['process', 'product', 'link', 'constraint', 'cluster', 'note'];
1546
+ for(let i = 0; i < btns.length; i++) {
1547
+ const b = document.getElementById(btns[i] + '-btn');
1548
+ if(b.classList.contains('stay-activ')) return b;
1549
+ }
1550
+ return null;
1551
+ }
1552
+
1553
+ toggleButton(e) {
1554
+ if(e.target.classList.contains('disab')) return;
1555
+ let other = true;
1556
+ if(this.active_button) {
1557
+ other = (e.target !== this.active_button);
1558
+ this.resetActiveButton();
1559
+ }
1560
+ if(other && (e.target.classList.contains('enab'))) {
1561
+ e.target.classList.add((e.shiftKey ? 'stay-activ' : 'activ'));
1562
+ this.active_button = e.target;
1563
+ }
1564
+ }
1565
+
1566
+ //
1567
+ // Handlers for mouse/cursor events
1568
+ //
1569
+
1570
+ updateCursorPosition(e) {
1571
+ // Updates the cursor coordinates and displays them on the status bar
1572
+ const cp = this.paper.cursorPosition(e.pageX, e.pageY);
1573
+ this.mouse_x = cp[0];
1574
+ this.mouse_y = cp[1];
1575
+ document.getElementById('pos-x').innerHTML = 'X = ' + this.mouse_x;
1576
+ document.getElementById('pos-y').innerHTML = 'Y = ' + this.mouse_y;
1577
+ this.on_note = null;
1578
+ this.on_node = null;
1579
+ this.on_cluster = null;
1580
+ this.on_cluster_edge = false;
1581
+ this.on_arrow = null;
1582
+ this.on_link = null;
1583
+ this.on_constraint = false;
1584
+ }
1585
+
1586
+ mouseMove(e) {
1587
+ // Responds to mouse cursor moving over Linny-R diagram area
1588
+ this.updateCursorPosition(e);
1589
+
1590
+ // NOTE: check, as MODEL might still be undefined
1591
+ if(!MODEL) return;
1592
+
1593
+ //console.log(e);
1594
+ const fc = MODEL.focal_cluster;
1595
+ for(let i = fc.processes.length-1; i >= 0; i--) {
1596
+ const obj = fc.processes[i];
1597
+ if(obj.containsPoint(this.mouse_x, this.mouse_y)) {
1598
+ this.on_node = obj;
1599
+ break;
1600
+ }
1601
+ }
1602
+ if(!this.on_node) {
1603
+ for(let i = fc.product_positions.length-1; i >= 0; i--) {
1604
+ const obj = fc.product_positions[i].product.setPositionInFocalCluster();
1605
+ if(obj.product.containsPoint(this.mouse_x, this.mouse_y)) {
1606
+ this.on_node = obj.product;
1607
+ break;
1608
+ }
1609
+ }
1610
+ }
1611
+ for(let i = 0; i < fc.arrows.length; i++) {
1612
+ const arr = fc.arrows[i];
1613
+ if(arr) {
1614
+ this.on_arrow = arr;
1615
+ // NOTE: arrow may represent multiple links, so find out which one
1616
+ const obj = arr.containsPoint(this.mouse_x, this.mouse_y);
1617
+ if(obj) {
1618
+ this.on_link = obj;
1619
+ break;
1620
+ }
1621
+ }
1622
+ }
1623
+ this.on_constraint = this.constraintStillUnderCursor();
1624
+ if(fc.related_constraints != null) {
1625
+ for(let i = 0; i < fc.related_constraints.length; i++) {
1626
+ const obj = fc.related_constraints[i];
1627
+ if(obj.containsPoint(this.mouse_x, this.mouse_y)) {
1628
+ this.on_constraint = obj;
1629
+ break;
1630
+ }
1631
+ }
1632
+ }
1633
+ for(let i = fc.sub_clusters.length-1; i >= 0; i--) {
1634
+ const obj = fc.sub_clusters[i];
1635
+ // NOTE: ignore cluster that is being dragged, so that a cluster it is
1636
+ // being dragged over will be detected instead
1637
+ if(obj != this.dragged_node &&
1638
+ obj.containsPoint(this.mouse_x, this.mouse_y)) {
1639
+ this.on_cluster = obj;
1640
+ this.on_cluster_edge = obj.onEdge(this.mouse_x, this.mouse_y);
1641
+ break;
1642
+ }
1643
+ }
1644
+ // unset and redraw target cluster if cursor no longer over it
1645
+ if(!this.on_cluster && this.target_cluster) {
1646
+ const c = this.target_cluster;
1647
+ this.target_cluster = null;
1648
+ UI.paper.drawCluster(c);
1649
+ // NOTE: element is persistent, so semi-transparency must also be undone
1650
+ c.shape.element.setAttribute('opacity', 1);
1651
+ }
1652
+ for(let i = fc.notes.length-1; i >= 0; i--) {
1653
+ const obj = fc.notes[i];
1654
+ if(obj.containsPoint(this.mouse_x, this.mouse_y)) {
1655
+ this.on_note = obj;
1656
+ break;
1657
+ }
1658
+ }
1659
+ if(this.active_button === this.buttons.link && this.linking_node) {
1660
+ // Draw red dotted line from linking node to cursor
1661
+ this.paper.dragLineToCursor(this.linking_node, this.mouse_x, this.mouse_y);
1662
+ } else if(this.start_sel_x >= 0 && this.start_sel_y >= 0) {
1663
+ // Draw selecting rectangle in red dotted lines
1664
+ this.paper.dragRectToCursor(this.start_sel_x, this.start_sel_y,
1665
+ this.mouse_x, this.mouse_y);
1666
+ } else if(this.active_button === this.buttons.constraint &&
1667
+ this.constraining_node) {
1668
+ // Draw red dotted line from constraining node to cursor
1669
+ this.paper.dragLineToCursor(this.constraining_node,
1670
+ this.mouse_x, this.mouse_y);
1671
+ } else if(this.dragged_node) {
1672
+ MODEL.moveSelection(this.mouse_x - this.move_dx - this.dragged_node.x,
1673
+ this.mouse_y - this.move_dy - this.dragged_node.y);
1674
+ }
1675
+ let cr = 'pointer';
1676
+ // NOTE: first check ON_CONSTRAINT because constraint thumbnails overlap
1677
+ // with nodes
1678
+ if(this.on_constraint) {
1679
+ DOCUMENTATION_MANAGER.update(this.on_constraint, e.shiftKey);
1680
+ // NOTE: skip the "on node" check if the node is being dragged
1681
+ } else if(this.on_node && this.on_node !== this.dragged_node) {
1682
+ if((this.active_button === this.buttons.link) && this.linking_node) {
1683
+ // Cannot link process to process
1684
+ cr = (MODEL.canLink(this.linking_node, this.on_node) ?
1685
+ 'crosshair' : 'not-allowed');
1686
+ } else if(this.active_button === this.buttons.constraint) {
1687
+ if(this.constraining_node) {
1688
+ cr = (this.constraining_node.canConstrain(this.on_node) ?
1689
+ 'crosshair' : 'not-allowed');
1690
+ } else if(!this.on_node.hasBounds) {
1691
+ // Products can only constrain when they have bounds
1692
+ cr = 'not-allowed';
1693
+ }
1694
+ }
1695
+ // NOTE: do not overwite status line when cursor is on a block arrow
1696
+ if(!this.on_block_arrow) {
1697
+ DOCUMENTATION_MANAGER.update(this.on_node, e.shiftKey);
1698
+ }
1699
+ } else if(this.on_note) {
1700
+ // When shift-moving over a note, show the model's documentation
1701
+ DOCUMENTATION_MANAGER.update(MODEL, e.shiftKey);
1702
+ } else {
1703
+ if((this.active_button === this.buttons.link && this.linking_node) ||
1704
+ (this.active_button === this.buttons.constraint && this.constraining_node)) {
1705
+ // Cannot link to clusters or notes
1706
+ cr = (this.on_cluster || this.on_note ? 'not-allowed' : 'crosshair');
1707
+ } else if(!this.on_note && !this.on_constraint && !this.on_link &&
1708
+ !this.on_cluster_edge) {
1709
+ cr = 'default';
1710
+ }
1711
+ if(!this.on_block_arrow) {
1712
+ if(this.on_link) {
1713
+ DOCUMENTATION_MANAGER.update(this.on_link, e.shiftKey);
1714
+ } else if(this.on_cluster) {
1715
+ DOCUMENTATION_MANAGER.update(this.on_cluster, e.shiftKey);
1716
+ } else if(!this.on_arrow) {
1717
+ this.setMessage('');
1718
+ }
1719
+ }
1720
+ // When dragging selection that contains a process, change cursor to
1721
+ // indicate that selected process(es) will be moved into the cluster
1722
+ if(this.dragged_node && this.on_cluster) {
1723
+ cr = 'cell';
1724
+ this.target_cluster = this.on_cluster;
1725
+ // Redraw the target cluster so it will appear on top (and highlighted)
1726
+ UI.paper.drawCluster(this.target_cluster);
1727
+ }
1728
+ }
1729
+ this.paper.container.style.cursor = cr;
1730
+ }
1731
+
1732
+ mouseDown(e) {
1733
+ // Responds to mousedown event in model diagram area
1734
+ // In case mouseup event occurred outside drawing area,ignore this
1735
+ // mousedown event, so that only the mouseup will be processed
1736
+ if(this.start_sel_x >= 0 && this.start_sel_y >= 0) return;
1737
+ const cp = this.paper.cursorPosition(e.pageX, e.pageY);
1738
+ this.mouse_down_x = cp[0];
1739
+ this.mouse_down_y = cp[1];
1740
+ // De-activate "stay active" buttons if dysfunctional, or if SHIFT,
1741
+ // ALT or CTRL is pressed
1742
+ if((e.shiftKey || e.altKey || e.ctrlKey ||
1743
+ this.on_note || this.on_cluster || this.on_link || this.on_constraint ||
1744
+ (this.on_node && this.active_button !== this.buttons.link &&
1745
+ this.active_button !== this.buttons.constraint)) && this.stayActive) {
1746
+ resetActiveButton();
1747
+ }
1748
+ // NOTE: only left button is detected (browser catches right menu button)
1749
+ if(e.ctrlKey) {
1750
+ // Remove clicked item from selection
1751
+ if(MODEL.selection) {
1752
+ // NOTE: first check constraints -- see mouseMove() for motivation
1753
+ if(this.on_constraint) {
1754
+ if(MODEL.selection.indexOf(this.on_constraint) >= 0) {
1755
+ MODEL.deselect(this.on_constraint);
1756
+ } else {
1757
+ MODEL.select(this.on_constraint);
1758
+ }
1759
+ } else if(this.on_node){
1760
+ if(MODEL.selection.indexOf(this.on_node) >= 0) {
1761
+ MODEL.deselect(this.on_node);
1762
+ } else {
1763
+ MODEL.select(this.on_node);
1764
+ }
1765
+ } else if(this.on_cluster) {
1766
+ if(MODEL.selection.indexOf(this.on_cluster) >= 0) {
1767
+ MODEL.deselect(this.on_cluster);
1768
+ } else {
1769
+ MODEL.select(this.on_cluster);
1770
+ }
1771
+ } else if(this.on_note) {
1772
+ if(MODEL.selection.indexOf(this.on_note) >= 0) {
1773
+ MODEL.deselect(this.on_note);
1774
+ } else {
1775
+ MODEL.select(this.on_note);
1776
+ }
1777
+ } else if(this.on_link) {
1778
+ if(MODEL.selection.indexOf(this.on_link) >= 0) {
1779
+ MODEL.deselect(this.on_link);
1780
+ } else {
1781
+ MODEL.select(this.on_link);
1782
+ }
1783
+ }
1784
+ UI.drawDiagram(MODEL);
1785
+ }
1786
+ this.updateButtons();
1787
+ return;
1788
+ } // END IF Ctrl
1789
+
1790
+ // Clear selection unless SHIFT pressed or mouseDown while hovering
1791
+ // over a SELECTED node or link
1792
+ if(!(e.shiftKey ||
1793
+ (this.on_node && MODEL.selection.indexOf(this.on_node) >= 0) ||
1794
+ (this.on_cluster && MODEL.selection.indexOf(this.on_cluster) >= 0) ||
1795
+ (this.on_note && MODEL.selection.indexOf(this.on_note) >= 0) ||
1796
+ (this.on_link && MODEL.selection.indexOf(this.on_link) >= 0) ||
1797
+ (this.on_constraint && MODEL.selection.indexOf(this.on_constraint) >= 0))) {
1798
+ MODEL.clearSelection();
1799
+ UI.drawDiagram(MODEL);
1800
+ }
1801
+
1802
+ // If one of the top six sidebar buttons is active, prompt for new node
1803
+ // (not link or constraint)
1804
+ if(this.active_button && this.active_button !== this.buttons.link &&
1805
+ this.active_button !== this.buttons.constraint) {
1806
+ this.add_x = this.mouse_x;
1807
+ this.add_y = this.mouse_y;
1808
+ const obj = this.active_button.id.split('-')[0];
1809
+ if(!this.stayActive) this.resetActiveButton();
1810
+ if(obj === 'process') {
1811
+ setTimeout(() => {
1812
+ const md = UI.modals['add-process'];
1813
+ md.element('name').value = '';
1814
+ md.element('actor-name').value = '';
1815
+ md.show('name');
1816
+ });
1817
+ } else if(obj === 'product') {
1818
+ setTimeout(() => {
1819
+ const md = UI.modals['add-product'];
1820
+ md.element('name').value = '';
1821
+ md.element('unit').value = MODEL.default_unit;
1822
+ UI.setBox('add-product-data', false);
1823
+ md.show('name');
1824
+ });
1825
+ } else if(obj === 'cluster') {
1826
+ setTimeout(() => {
1827
+ const md = UI.modals.cluster;
1828
+ md.element('name').value = '';
1829
+ md.element('actor').value = '';
1830
+ md.show('name');
1831
+ });
1832
+ } else if(obj === 'note') {
1833
+ setTimeout(() => {
1834
+ const md = UI.modals.note;
1835
+ md.element('action').innerHTML = 'Add';
1836
+ md.element('C').value = '';
1837
+ md.element('text').value = '';
1838
+ md.show('text');
1839
+ });
1840
+ }
1841
+ return;
1842
+ }
1843
+
1844
+ // ALT key pressed => open properties dialog if cursor hovers over
1845
+ // some element
1846
+ if(e.altKey) {
1847
+ // NOTE: first check constraints -- see mouseMove() for motivation
1848
+ if(this.on_constraint) {
1849
+ this.showConstraintPropertiesDialog(this.on_constraint);
1850
+ } else if(this.on_node) {
1851
+ if(this.on_node instanceof Process) {
1852
+ this.showProcessPropertiesDialog(this.on_node);
1853
+ } else if(e.shiftKey) {
1854
+ // Shift-Alt on product is like Shift-Double-click
1855
+ this.showReplaceProductDialog(this.on_node);
1856
+ } else {
1857
+ this.showProductPropertiesDialog(this.on_node);
1858
+ }
1859
+ } else if(this.on_note) {
1860
+ this.showNotePropertiesDialog(this.on_note);
1861
+ } else if(this.on_cluster) {
1862
+ this.showClusterPropertiesDialog(this.on_cluster);
1863
+ } else if(this.on_link) {
1864
+ this.showLinkPropertiesDialog(this.on_link);
1865
+ }
1866
+ // NOTE: first check constraints -- see mouseMove() for motivation
1867
+ } else if(this.on_constraint) {
1868
+ MODEL.select(this.on_constraint);
1869
+ } else if(this.on_note) {
1870
+ this.dragged_node = this.on_note;
1871
+ this.move_dx = this.mouse_x - this.on_note.x;
1872
+ this.move_dy = this.mouse_y - this.on_note.y;
1873
+ MODEL.select(this.on_note);
1874
+ UNDO_STACK.push('move', this.dragged_node, true);
1875
+ // Cursor on node => add link or constraint, or start moving
1876
+ } else if(this.on_node) {
1877
+ if(this.active_button === this.buttons.link) {
1878
+ this.linking_node = this.on_node;
1879
+ // NOTE: return without updating buttons
1880
+ return;
1881
+ } else if(this.active_button === this.buttons.constraint) {
1882
+ // Allow constraints only on nodes having upper bounds defined
1883
+ if(this.on_node.upper_bound.defined) {
1884
+ this.constraining_node = this.on_node;
1885
+ // NOTE: here, too, return without updating buttons
1886
+ return;
1887
+ }
1888
+ } else {
1889
+ this.dragged_node = this.on_node;
1890
+ this.move_dx = this.mouse_x - this.on_node.x;
1891
+ this.move_dy = this.mouse_y - this.on_node.y;
1892
+ if(MODEL.selection.indexOf(this.on_node) < 0) MODEL.select(this.on_node);
1893
+ // Pass dragged node for UNDO
1894
+ UNDO_STACK.push('move', this.dragged_node, true);
1895
+ }
1896
+ } else if(this.on_cluster) {
1897
+ this.dragged_node = this.on_cluster;
1898
+ this.move_dx = this.mouse_x - this.on_cluster.x;
1899
+ this.move_dy = this.mouse_y - this.on_cluster.y;
1900
+ MODEL.select(this.on_cluster);
1901
+ UNDO_STACK.push('move', this.dragged_node, true);
1902
+ } else if(this.on_link) {
1903
+ MODEL.select(this.on_link);
1904
+ } else {
1905
+ this.start_sel_x = this.mouse_x;
1906
+ this.start_sel_y = this.mouse_y;
1907
+ }
1908
+ this.updateButtons();
1909
+ }
1910
+
1911
+ mouseUp(e) {
1912
+ // Responds to mouseup event
1913
+ const cp = this.paper.cursorPosition(e.pageX, e.pageY);
1914
+ this.mouse_up_x = cp[0];
1915
+ this.mouse_up_y = cp[1];
1916
+ // First check whether user is selecting a rectangle
1917
+ if(this.start_sel_x >= 0 && this.start_sel_y >= 0) {
1918
+ // Clear previous selection unless user is adding to it (by still
1919
+ // holding SHIFT button down)
1920
+ if(!e.shiftKey) MODEL.clearSelection();
1921
+ // Compute defining points of rectangle (top left and bottom right)
1922
+ const
1923
+ tlx = Math.min(this.start_sel_x, this.mouse_up_x),
1924
+ tly = Math.min(this.start_sel_y, this.mouse_up_y),
1925
+ brx = Math.max(this.start_sel_x, this.mouse_up_x),
1926
+ bry = Math.max(this.start_sel_y, this.mouse_up_y);
1927
+ // If rectangle has size greater than 2x2 pixels, select all elements
1928
+ // having their center inside the selection rectangle
1929
+ if(brx - tlx > 2 && bry - tly > 2) {
1930
+ const ol = [], fc = MODEL.focal_cluster;
1931
+ for(let i = 0; i < fc.processes.length; i++) {
1932
+ const obj = fc.processes[i];
1933
+ if(obj.x >= tlx && obj.x <= brx && obj.y >= tly && obj.y < bry) {
1934
+ ol.push(obj);
1935
+ }
1936
+ }
1937
+ for(let i = 0; i < fc.product_positions.length; i++) {
1938
+ const obj = fc.product_positions[i];
1939
+ if(obj.x >= tlx && obj.x <= brx && obj.y >= tly && obj.y < bry) {
1940
+ ol.push(obj.product);
1941
+ }
1942
+ }
1943
+ for(let i = 0; i < fc.sub_clusters.length; i++) {
1944
+ const obj = fc.sub_clusters[i];
1945
+ if(obj.x >= tlx && obj.x <= brx && obj.y >= tly && obj.y < bry) {
1946
+ ol.push(obj);
1947
+ }
1948
+ }
1949
+ for(let i = 0; i < fc.notes.length; i++) {
1950
+ const obj = fc.notes[i];
1951
+ if(obj.x >= tlx && obj.x <= brx && obj.y >= tly && obj.y < bry) {
1952
+ ol.push(obj);
1953
+ }
1954
+ }
1955
+ for(let i in MODEL.links) if(MODEL.links.hasOwnProperty(i)) {
1956
+ const obj = MODEL.links[i];
1957
+ // Only add a link if both its nodes are selected as well
1958
+ if(fc.linkInList(obj, ol)) {
1959
+ ol.push(obj);
1960
+ }
1961
+ }
1962
+ for(let i in MODEL.constraints) if(MODEL.constraints.hasOwnProperty(i)) {
1963
+ const obj = MODEL.constraints[i];
1964
+ // Only add a constraint if both its nodes are selected as well
1965
+ if(fc.linkInList(obj, ol)) {
1966
+ ol.push(obj);
1967
+ }
1968
+ }
1969
+ // Having compiled the object list, actually select them
1970
+ MODEL.selectList(ol);
1971
+ this.paper.drawSelection(MODEL);
1972
+ }
1973
+ this.start_sel_x = -1;
1974
+ this.start_sel_y = -1;
1975
+ this.paper.hideDragRect();
1976
+
1977
+ // Then check whether user is drawing a flow link
1978
+ // (by dragging its endpoint)
1979
+ } else if(this.linking_node) {
1980
+ // If so, check whether the cursor is over a node of the appropriate type
1981
+ if(this.on_node && MODEL.canLink(this.linking_node, this.on_node)) {
1982
+ const obj = MODEL.addLink(this.linking_node, this.on_node);
1983
+ UNDO_STACK.push('add', obj);
1984
+ MODEL.select(obj);
1985
+ this.paper.drawModel(MODEL);
1986
+ }
1987
+ this.linking_node = null;
1988
+ if(!this.stayActive) this.resetActiveButton();
1989
+ this.paper.hideDragLine();
1990
+
1991
+ // Then check whether user is drawing a constraint link
1992
+ // (again: by dragging its endpoint)
1993
+ } else if(this.constraining_node) {
1994
+ if(this.on_node && this.constraining_node.canConstrain(this.on_node)) {
1995
+ // display constraint editor
1996
+ CONSTRAINT_EDITOR.from_name.innerHTML = this.constraining_node.displayName;
1997
+ CONSTRAINT_EDITOR.to_name.innerHTML = this.on_node.displayName;
1998
+ CONSTRAINT_EDITOR.showDialog();
1999
+ }
2000
+ this.linking_node = null;
2001
+ this.constraining_node = null;
2002
+ if(!this.stayActive) this.resetActiveButton();
2003
+ UI.drawDiagram(MODEL);
2004
+
2005
+ // Then check whether the user is moving a node (possibly part of a
2006
+ // larger selection)
2007
+ } else if(this.dragged_node) {
2008
+ // Always perform the move operation (this will do nothing if the
2009
+ // cursor did not move)
2010
+ MODEL.moveSelection(
2011
+ this.mouse_up_x - this.mouse_x, this.mouse_up_y - this.mouse_y);
2012
+ // @@TO DO: if on top of a cluster, move it there
2013
+ // NOTE: cursor will always be over the selected cluster (while dragging)
2014
+ if(this.on_cluster && !this.on_cluster.selected) {
2015
+ UNDO_STACK.push('drop', this.on_cluster);
2016
+ MODEL.dropSelectionIntoCluster(this.on_cluster);
2017
+ this.on_node = null;
2018
+ this.on_note = null;
2019
+ this.target_cluster = null;
2020
+ // Redraw cluster to erase its "target corona"
2021
+ UI.paper.drawCluster(this.on_cluster);
2022
+ }
2023
+
2024
+ // Check wether the cursor has been moved
2025
+ const
2026
+ absdx = Math.abs(this.mouse_down_x - this.mouse_x),
2027
+ absdy = Math.abs(this.mouse_down_y - this.mouse_y);
2028
+ // If no *significant* move made, remove the move undo
2029
+ if(absdx + absdy === 0) UNDO_STACK.pop('move');
2030
+ if(this.doubleClicked && absdx + absdy < 3) {
2031
+ // Double-clicking opens properties dialog, except for clusters;
2032
+ // then "drill down", i.e., make the double-clicked cluster focal
2033
+ if(this.dragged_node instanceof Cluster) {
2034
+ // NOTE: bottom & right cluster edges remain sensitive!
2035
+ if(this.on_cluster_edge) {
2036
+ this.showClusterPropertiesDialog(this.dragged_node);
2037
+ } else {
2038
+ this.makeFocalCluster(this.dragged_node);
2039
+ }
2040
+ } else if(this.dragged_node instanceof Product) {
2041
+ if(e.shiftKey) {
2042
+ // Shift-double-clicking on a *product* prompts for "remapping"
2043
+ // the product position to another product (and potentially
2044
+ // deleting the original one if it has no more occurrences)
2045
+ this.showReplaceProductDialog(this.dragged_node);
2046
+ } else {
2047
+ this.showProductPropertiesDialog(this.dragged_node);
2048
+ }
2049
+ } else if(this.dragged_node instanceof Process) {
2050
+ this.showProcessPropertiesDialog(this.dragged_node);
2051
+ } else {
2052
+ this.showNotePropertiesDialog(this.dragged_node);
2053
+ }
2054
+ }
2055
+ this.dragged_node = null;
2056
+
2057
+ // Then check whether the user is clicking on a link
2058
+ } else if(this.on_link) {
2059
+ if(this.doubleClicked) {
2060
+ this.showLinkPropertiesDialog(this.on_link);
2061
+ }
2062
+ } else if(this.on_constraint) {
2063
+ if(this.doubleClicked) {
2064
+ this.showConstraintPropertiesDialog(this.on_constraint);
2065
+ }
2066
+ }
2067
+ this.start_sel_x = -1;
2068
+ this.start_sel_y = -1;
2069
+ this.updateButtons();
2070
+ }
2071
+
2072
+ dragOver(e) {
2073
+ // Accepts products that are dragged from the Finder and do not have
2074
+ // a placeholder in the focal cluster
2075
+ this.updateCursorPosition(e);
2076
+ const p = MODEL.products[e.dataTransfer.getData('text')];
2077
+ if(p && MODEL.focal_cluster.indexOfProduct(p) < 0) e.preventDefault();
2078
+ }
2079
+
2080
+ drop(e) {
2081
+ // Adds a product that is dragged from the Finder to the focal cluster
2082
+ // at the cursor position if it does not have a placeholder yet
2083
+ const p = MODEL.products[e.dataTransfer.getData('text')];
2084
+ if(p && MODEL.focal_cluster.indexOfProduct(p) < 0) {
2085
+ e.preventDefault();
2086
+ MODEL.focal_cluster.addProductPosition(p, this.mouse_x, this.mouse_y);
2087
+ UNDO_STACK.push('add', p);
2088
+ this.selectNode(p);
2089
+ this.drawDiagram(MODEL);
2090
+ }
2091
+ // NOTE: update afterwards, as the modeler may target a precise (X, Y)
2092
+ this.updateCursorPosition(e);
2093
+ }
2094
+
2095
+ //
2096
+ // Handler for keyboard events
2097
+ //
2098
+
2099
+ checkModals(e) {
2100
+ // Respond to Escape, Enter and shortcut keys
2101
+ const
2102
+ ttype = e.target.type,
2103
+ ttag = e.target.tagName,
2104
+ modals = document.getElementsByClassName('modal');
2105
+ // Modal dialogs: hide on ESC and move to next input on ENTER
2106
+ let maxz = 0,
2107
+ topmod = null;
2108
+ for(let i = 0; i < modals.length; i++) {
2109
+ const
2110
+ m = modals[i],
2111
+ cs = window.getComputedStyle(m),
2112
+ z = parseInt(cs.zIndex);
2113
+ if(cs.display !== 'none' && z > maxz) {
2114
+ topmod = m;
2115
+ maxz = z;
2116
+ }
2117
+ }
2118
+ // NOTE: consider only the top modal (if any)
2119
+ if(e.keyCode === 27) {
2120
+ e.stopImmediatePropagation();
2121
+ if(topmod) topmod.style.display = 'none';
2122
+ } else if(e.keyCode === 13 && ttype !== 'textarea') {
2123
+ e.preventDefault();
2124
+ if(topmod) {
2125
+ const inp = Array.from(topmod.getElementsByTagName('input'));
2126
+ let i = inp.indexOf(e.target) + 1;
2127
+ while(i < inp.length && inp[i].disabled) i++;
2128
+ if(i < inp.length) {
2129
+ inp[i].focus();
2130
+ } else if('constraint-modal xp-clusters-modal'.indexOf(topmod.id) >= 0) {
2131
+ // NOTE: constraint modal and "ignore clusters" modal must NOT close
2132
+ // when Enter is pressed; just de-focus the input field
2133
+ e.target.blur();
2134
+ } else {
2135
+ const btns = topmod.getElementsByClassName('ok-btn');
2136
+ if(btns.length > 0) btns[0].dispatchEvent(new Event('click'));
2137
+ }
2138
+ } else if(this.dr_dialog_order.length > 0) {
2139
+ // Send ENTER key event to the top draggable dialog
2140
+ const last = this.dr_dialog_order.length - 1;
2141
+ if(last >= 0) {
2142
+ const mgr = window[this.dr_dialog_order[last].dataset.manager];
2143
+ if(mgr && 'enterKey' in mgr) mgr.enterKey();
2144
+ }
2145
+ }
2146
+ } else if(e.keyCode === 8 &&
2147
+ ttype !== 'text' && ttype !== 'password' && ttype !== 'textarea') {
2148
+ // Prevent backspace to be interpreted (by FireFox) as "go back in browser"
2149
+ e.preventDefault();
2150
+ } else if(ttag === 'BODY') {
2151
+ // Constraint Editor accepts arrow keys
2152
+ if(topmod && topmod.id === 'constraint-modal') {
2153
+ if([37, 38, 39, 40].indexOf(e.keyCode) >= 0) {
2154
+ e.preventDefault();
2155
+ CONSTRAINT_EDITOR.arrowKey(e.keyCode);
2156
+ return;
2157
+ }
2158
+ }
2159
+ // Up and down arrow keys
2160
+ if([38, 40].indexOf(e.keyCode) >= 0) {
2161
+ e.preventDefault();
2162
+ // Send event to the top draggable dialog
2163
+ const last = this.dr_dialog_order.length - 1;
2164
+ if(last >= 0) {
2165
+ const mgr = window[this.dr_dialog_order[last].dataset.manager];
2166
+ // NOTE: pass key direction as -1 for UP and +1 for DOWN
2167
+ if(mgr && 'upDownKey' in mgr) mgr.upDownKey(e.keyCode - 39);
2168
+ }
2169
+ }
2170
+ // end, home, Left and right arrow keys
2171
+ if([35, 36, 37, 39].indexOf(e.keyCode) >= 0) e.preventDefault();
2172
+ if(e.keyCode === 35) {
2173
+ MODEL.t = MODEL.end_period - MODEL.start_period + 1;
2174
+ UI.updateTimeStep();
2175
+ UI.drawDiagram(MODEL);
2176
+ } else if(e.keyCode === 36) {
2177
+ MODEL.t = 1;
2178
+ UI.updateTimeStep();
2179
+ UI.drawDiagram(MODEL);
2180
+ } else if(e.keyCode === 37) {
2181
+ this.stepBack(e);
2182
+ } else if(e.keyCode === 39) {
2183
+ this.stepForward(e);
2184
+ } else if(e.altKey && [67, 77].indexOf(e.keyCode) >= 0) {
2185
+ // Special shortcut keys for "clone selection" and "model settings"
2186
+ const be = new Event('click');
2187
+ be.altKey = true;
2188
+ if(e.keyCode === 67) {
2189
+ this.buttons.clone.dispatchEvent(be);
2190
+ } else {
2191
+ this.buttons.settings.dispatchEvent(be);
2192
+ }
2193
+ } else if(!e.shiftKey && !e.altKey &&
2194
+ (!topmod || [65, 67, 86].indexOf(e.keyCode) < 0)) {
2195
+ // Interpret special keys as shortcuts unless a modal dialog is open
2196
+ if(e.keyCode === 46) {
2197
+ // DEL button => delete selection
2198
+ e.preventDefault();
2199
+ if(!this.hidden('constraint-modal')) {
2200
+ CONSTRAINT_EDITOR.deleteBoundLine();
2201
+ } else if(!this.hidden('variable-modal')) {
2202
+ CHART_MANAGER.deleteVariable();
2203
+ } else {
2204
+ this.buttons['delete'].dispatchEvent(new Event('click'));
2205
+ }
2206
+ } else if (e.keyCode === 190 && (e.ctrlKey || e.metaKey)) {
2207
+ // Ctrl-. (dot) moves entire diagram to upper-left corner
2208
+ e.preventDefault();
2209
+ this.paper.fitToSize();
2210
+ MODEL.alignToGrid();
2211
+ } else if (e.keyCode >= 65 && e.keyCode <= 90 && (e.ctrlKey || e.metaKey)) {
2212
+ // ALWAYS prevent browser to do respond to Ctrl-letter commands
2213
+ // NOTE: this cannot prevent a new tab from opening on Ctrl-T
2214
+ e.preventDefault();
2215
+ let shortcut = String.fromCharCode(e.keyCode);
2216
+ if(shortcut === 'Z' && e.shiftKey) {
2217
+ // Interpret Shift-Ctrl-Z as Ctrl-Y (redo last undone operation)
2218
+ shortcut = 'Y';
2219
+ }
2220
+ if(this.shortcuts.hasOwnProperty(shortcut)) {
2221
+ const btn = this.buttons[this.shortcuts[shortcut]];
2222
+ if(!this.hidden(btn.id) && !btn.classList.contains('disab')) {
2223
+ btn.dispatchEvent(new Event('click'));
2224
+ }
2225
+ }
2226
+ }
2227
+ }
2228
+ }
2229
+ }
2230
+
2231
+ //
2232
+ // Handlers for checkbox events.
2233
+ //
2234
+ // Checkboxes may have different colors, which should be preserved
2235
+ // while (un)checking. The first item in the classlist of a checkbox
2236
+ // element will always be "box", the second item may just be "checked"
2237
+ // or "clear", but also something like "checked-same-not-changed".
2238
+ // Hence the state change operations should only affect the first part.
2239
+
2240
+ toggleBox(event) {
2241
+ // Change "checked" to "clear" or vice versa.
2242
+ const el = event.target;
2243
+ if(!el.classList.contains('disab')) {
2244
+ const
2245
+ state = el.classList.item(1),
2246
+ list = state.split('-'),
2247
+ change = {clear: 'checked', checked: 'clear'};
2248
+ list[0] = change[list[0]];
2249
+ el.classList.replace(state, list.join('-'));
2250
+ }
2251
+ }
2252
+
2253
+ setBox(id, checked) {
2254
+ // Set the box identified by `id` to the state indicated by the
2255
+ // Boolean parameter `checked`.
2256
+ const
2257
+ box = document.getElementById(id),
2258
+ state = box.classList.item(1),
2259
+ list = state.split('-');
2260
+ list[0] = (checked ? 'checked' : 'clear');
2261
+ box.classList.replace(state, list.join('-'));
2262
+ }
2263
+
2264
+ boxChecked(id) {
2265
+ // Return TRUE if the box identified by `id` is checked.
2266
+ return document.getElementById(id).classList.item(1).startsWith('checked');
2267
+ }
2268
+
2269
+ //
2270
+ // Handlers for "equal bounds" togglebox events
2271
+ //
2272
+ // Like checkboxes, an "equal bounds" togglebox may have different colors,
2273
+ // which should be preserved while toggling. See explanation above.
2274
+
2275
+ setEqualBounds(type, equal) {
2276
+ // Set "equal bounds" button.
2277
+ // `type` should be 'process' or 'product', `equal` TRUE or FALSE.
2278
+ const
2279
+ el = document.getElementById(type + '-UB-equal'),
2280
+ cl = el.classList,
2281
+ token = cl.item(1);
2282
+ cl.replace(token, equal ? 'eq' : 'ne');
2283
+ this.updateEqualBounds(type);
2284
+ }
2285
+
2286
+ updateEqualBounds(type) {
2287
+ // Enable/disable UB input fields, depending on button status
2288
+ // NOTE: `type` should be 'process' or 'product'
2289
+ const
2290
+ prefix = type + '-UB',
2291
+ inp = document.getElementById(prefix),
2292
+ eql = document.getElementById(prefix + '-equal'),
2293
+ edx = document.getElementById(prefix + '-x'),
2294
+ lbl = document.getElementById(prefix + '-lbl');
2295
+ if(eql.classList.contains('ne')) {
2296
+ inp.disabled = false;
2297
+ edx.classList.remove('disab');
2298
+ edx.classList.add('enab');
2299
+ lbl.style.color = 'black';
2300
+ lbl.style.textShadow = 'none';
2301
+ } else {
2302
+ inp.disabled = true;
2303
+ edx.classList.remove('enab');
2304
+ edx.classList.add('disab');
2305
+ lbl.style.color = 'gray';
2306
+ lbl.style.textShadow = '1px 1px white';
2307
+ }
2308
+ }
2309
+
2310
+ toggleEqualBounds(event) {
2311
+ // Toggle the "equal bounds" button state.
2312
+ // NOTE: `type` should be 'process' or 'product'
2313
+ const
2314
+ el = event.target,
2315
+ type = el.id.split('-')[0];
2316
+ this.setEqualBounds(type, el.classList.contains('ne'));
2317
+ }
2318
+
2319
+ getEqualBounds(id) {
2320
+ return document.getElementById(id).classList.contains('eq');
2321
+ }
2322
+
2323
+ //
2324
+ // Handlers for integer level events
2325
+ //
2326
+
2327
+ toggleIntegerLevel(event) {
2328
+ const el = event.target;
2329
+ if(el.classList.contains('intbtn')) {
2330
+ el.classList.remove('intbtn');
2331
+ el.classList.add('contbtn');
2332
+ } else {
2333
+ el.classList.remove('contbtn');
2334
+ el.classList.add('intbtn');
2335
+ }
2336
+ }
2337
+
2338
+ setIntegerLevel(id, set) {
2339
+ const box = document.getElementById(id);
2340
+ if(set) {
2341
+ box.classList.remove('contbtn');
2342
+ box.classList.add('intbtn');
2343
+ } else {
2344
+ box.classList.remove('intbtn');
2345
+ box.classList.add('contbtn');
2346
+ }
2347
+ }
2348
+
2349
+ hasIntegerLevel(id) {
2350
+ return document.getElementById(id).classList.contains('intbtn');
2351
+ }
2352
+
2353
+ //
2354
+ // Handlers for import/export togglebox events
2355
+ //
2356
+
2357
+ toggleImportExportBox(id) {
2358
+ const
2359
+ io = document.getElementById(id + '-io'),
2360
+ bi = document.getElementById(id + '-import'),
2361
+ be = document.getElementById(id + '-export');
2362
+ if(window.getComputedStyle(bi).display !== 'none') {
2363
+ bi.style.display = 'none';
2364
+ be.style.display = 'block';
2365
+ io.style.color = '#0000b0';
2366
+ } else if(window.getComputedStyle(be).display !== 'none') {
2367
+ be.style.display = 'none';
2368
+ io.style.color = 'silver';
2369
+ } else {
2370
+ bi.style.display = 'block';
2371
+ io.style.color = '#b00000';
2372
+ }
2373
+ }
2374
+
2375
+ getImportExportBox(id) {
2376
+ if(window.getComputedStyle(
2377
+ document.getElementById(id + '-import')).display !== 'none') return 1;
2378
+ if(window.getComputedStyle(
2379
+ document.getElementById(id + '-export')).display !== 'none') return 2;
2380
+ return 0;
2381
+ }
2382
+
2383
+ setImportExportBox(id, s) {
2384
+ const
2385
+ io = document.getElementById(id + '-io'),
2386
+ bi = document.getElementById(id + '-import'),
2387
+ be = document.getElementById(id + '-export');
2388
+ bi.style.display = 'none';
2389
+ be.style.display = 'none';
2390
+ if(s === 1) {
2391
+ bi.style.display = 'block';
2392
+ io.style.color = '#b00000';
2393
+ } else if(s === 2) {
2394
+ be.style.display = 'block';
2395
+ io.style.color = '#0000b0';
2396
+ } else {
2397
+ io.style.color = 'silver';
2398
+ }
2399
+ }
2400
+
2401
+ //
2402
+ // Input field validation
2403
+ //
2404
+
2405
+ validNames(nn, an='') {
2406
+ // Check whether names meet conventions; if not, warn user
2407
+ if(!UI.validName(nn) || nn.indexOf(UI.BLACK_BOX) >= 0) {
2408
+ UI.warn(`Invalid name "${nn}"`);
2409
+ return false;
2410
+ }
2411
+ if(an === '' || an === UI.NO_ACTOR) return true;
2412
+ if(!UI.validName(an)) {
2413
+ UI.warn(`Invalid actor name "${an}"`);
2414
+ return false;
2415
+ }
2416
+ return true;
2417
+ }
2418
+
2419
+ validNumericInput(id, name) {
2420
+ // Returns number if input field with identifier `id` contains a number;
2421
+ // otherwise returns FALSE; if error, focuses on the field and shows
2422
+ // the error while specifying the name of the field
2423
+ // NOTE: accept both . and , as decimal point
2424
+ const
2425
+ inp = document.getElementById(id),
2426
+ txt = inp.value.trim().replace(',', '.');
2427
+ // NOTE: for some fields, empty strings denote default values, typically 0
2428
+ if(txt === '') {
2429
+ if(['initial level', 'delay', 'share of cost', 'Delta'].indexOf(name) >= 0) {
2430
+ return 0;
2431
+ }
2432
+ }
2433
+ const n = parseFloat(txt);
2434
+ // NOTE: any valid number ends with a digit (e.g., 100, 100.0, 1E+2),
2435
+ // but parseFloat is more tolerant; however, Linny-R should not accept
2436
+ // input such as "100x" nor even "100."
2437
+ if(isNaN(n) || '0123456789'.indexOf(txt[txt.length - 1]) < 0) {
2438
+ this.warn(`Invalid number "${txt}" for ${name}`);
2439
+ inp.focus();
2440
+ return false;
2441
+ }
2442
+ return n;
2443
+ }
2444
+
2445
+ updateExpressionInput(id, name, x) {
2446
+ // Updates expression object `x` if input field identified by `id`
2447
+ // contains a well-formed expression. If error, focuses on the field
2448
+ // and shows the error while specifying the name of the field.
2449
+ const
2450
+ inp = document.getElementById(id),
2451
+ xp = new ExpressionParser(inp.value.trim(), x.object, x.attribute);
2452
+ if(xp.error) {
2453
+ inp.focus();
2454
+ this.warn(`Invalid expression for ${name}: ${xp.error}`);
2455
+ return false;
2456
+ } else if(xp.is_level_based && name !== 'note color') {
2457
+ this.warn(`Expression for ${name} contains a solution-dependent variable`);
2458
+ }
2459
+ x.update(xp);
2460
+ // NOTE: overrule `is_static` to make that IL is always evaluated for t=1
2461
+ if(name === 'initial level') x.is_static = true;
2462
+ return true;
2463
+ }
2464
+
2465
+ updateScaleUnitList() {
2466
+ // Update the HTML datalist element to reflect all scale units
2467
+ const
2468
+ ul = [],
2469
+ keys = Object.keys(MODEL.scale_units).sort(ciCompare);
2470
+ for(let i = 0; i < keys.length; i++) {
2471
+ ul.push(`<option value="${MODEL.scale_units[keys[i]].name}">`);
2472
+ }
2473
+ document.getElementById('units-data').innerHTML = ul.join('');
2474
+ }
2475
+
2476
+ //
2477
+ // Navigation in the cluster hierarchy
2478
+ //
2479
+
2480
+ showParentCluster() {
2481
+ if(MODEL.focal_cluster.cluster) {
2482
+ this.makeFocalCluster(MODEL.focal_cluster.cluster);
2483
+ this.updateButtons();
2484
+ }
2485
+ }
2486
+
2487
+ moveSelectionToParentCluster() {
2488
+ if(MODEL.focal_cluster.cluster) {
2489
+ UNDO_STACK.push('lift', MODEL.focal_cluster.cluster);
2490
+ MODEL.focal_cluster.clearAllProcesses();
2491
+ MODEL.dropSelectionIntoCluster(MODEL.focal_cluster.cluster);
2492
+ this.updateButtons();
2493
+ }
2494
+ }
2495
+
2496
+ //
2497
+ // Moving backwards and forwards in time
2498
+ //
2499
+
2500
+ stepBack(e) {
2501
+ if(e.target.classList.contains('disab')) return;
2502
+ if(MODEL.simulationTimeStep > MODEL.start_period) {
2503
+ const dt = (e.shiftKey ? 10 : 1) * (e.ctrlKey || e.metaKey ? 100 : 1);
2504
+ MODEL.t = Math.max(1, MODEL.t - dt);
2505
+ UI.updateTimeStep();
2506
+ UI.drawDiagram(MODEL);
2507
+ }
2508
+ }
2509
+
2510
+ stepForward(e) {
2511
+ if(e.target.classList.contains('disab')) return;
2512
+ if(MODEL.simulationTimeStep < MODEL.end_period) {
2513
+ const dt = (e.shiftKey ? 10 : 1) * (e.ctrlKey || e.metaKey ? 100 : 1);
2514
+ MODEL.t = Math.min(MODEL.end_period - MODEL.start_period + 1, MODEL.t + dt);
2515
+ UI.updateTimeStep();
2516
+ UI.drawDiagram(MODEL);
2517
+ }
2518
+ }
2519
+
2520
+ //
2521
+ // Special features that may not work in all browsers
2522
+ //
2523
+
2524
+ copyStringToClipboard(string) {
2525
+ // Copies string to clipboard and notifies user of #lines copied
2526
+ let msg = pluralS(string.split('\n').length, 'line') +
2527
+ ' copied to clipboard',
2528
+ type = 'notification';
2529
+ if(navigator.clipboard) {
2530
+ navigator.clipboard.writeText(string).catch(
2531
+ () => UI.setMessage('Failed to copy to clipboard', 'warning'));
2532
+ } else {
2533
+ // Workaround using deprecated execCommand
2534
+ const ta = document.createElement('textarea');
2535
+ document.body.appendChild(ta);
2536
+ ta.value = string;
2537
+ ta.select();
2538
+ document.execCommand('copy');
2539
+ document.body.removeChild(ta);
2540
+ }
2541
+ UI.setMessage(msg, type);
2542
+ }
2543
+
2544
+ copyHtmlToClipboard(html) {
2545
+ // Copy HTML to clipboard
2546
+ function listener(event) {
2547
+ event.clipboardData.setData('text/html', html);
2548
+ event.preventDefault();
2549
+ }
2550
+ document.addEventListener('copy', listener);
2551
+ document.execCommand('copy');
2552
+ document.removeEventListener('copy', listener);
2553
+ }
2554
+
2555
+ logHeapSize(msg='') {
2556
+ // Logs MB's of used heap memory to console (to detect memory leaks)
2557
+ // NOTE: this feature is supported only by Chrome
2558
+ if(msg) msg += ' -- ';
2559
+ if(performance.memory !== undefined) {
2560
+ console.log(msg + 'Allocated memory: ' + Math.round(
2561
+ performance.memory.usedJSHeapSize/1048576.0).toFixed(1) + ' MB');
2562
+ }
2563
+ }
2564
+
2565
+ //
2566
+ // Informing the modeler via the status line
2567
+ //
2568
+
2569
+ setMessage(msg, type=null) {
2570
+ // Displays message on infoline unless no type (= plain text) and some
2571
+ // info, warning or error message is already displayed
2572
+ super.setMessage(msg, type);
2573
+ let d = new Date(),
2574
+ t = d.getTime(),
2575
+ dt = t - this.time_last_message;
2576
+ if(type) {
2577
+ // Update global variable (and force display) only for "real" messages
2578
+ this.time_last_message = t;
2579
+ dt = this.message_display_time;
2580
+ SOUNDS[type].play().catch(() => {
2581
+ console.log('NOTICE: Sounds will only play after first user action');
2582
+ });
2583
+ const
2584
+ now = [d.getHours(), d.getMinutes().toString().padStart(2, '0'),
2585
+ d.getSeconds().toString().padStart(2, '0')].join(':'),
2586
+ im = {time: now, text: msg, status: type};
2587
+ DOCUMENTATION_MANAGER.addMessage(im);
2588
+ // When receiver is active, add message to its log
2589
+ if(RECEIVER.active) RECEIVER.log(`[${now}] ${msg}`);
2590
+ }
2591
+ // Display text only if previous message has "timed out" or was plain text
2592
+ if(dt >= this.message_display_time) {
2593
+ const il = document.getElementById('info-line');
2594
+ il.classList.remove('error', 'warning', 'notification');
2595
+ il.classList.add(type);
2596
+ il.innerHTML = msg;
2597
+ }
2598
+ }
2599
+
2600
+ // Visual feedback for time-consuming actions
2601
+ waitingCursor() {
2602
+ document.body.className = 'waiting';
2603
+ }
2604
+
2605
+ normalCursor() {
2606
+ document.body.className = '';
2607
+ }
2608
+
2609
+ setProgressNeedle(fraction) {
2610
+ // Shows a thin purple line just above the status line to indicate progress
2611
+ const el = document.getElementById('set-up-progress-bar');
2612
+ el.style.width = Math.round(Math.max(0, Math.min(1, fraction)) * 100) + '%';
2613
+ }
2614
+
2615
+ hideStayOnTopDialogs() {
2616
+ // Hide and reset all stay-on-top dialogs (even when not showing)
2617
+ // NOTE: this routine is called when a new model is loaded
2618
+ DATASET_MANAGER.dialog.style.display = 'none';
2619
+ this.buttons.dataset.classList.remove('stay-activ');
2620
+ DATASET_MANAGER.reset();
2621
+ EQUATION_MANAGER.dialog.style.display = 'none';
2622
+ this.buttons.equation.classList.remove('stay-activ');
2623
+ EQUATION_MANAGER.reset();
2624
+ CHART_MANAGER.dialog.style.display = 'none';
2625
+ this.buttons.chart.classList.remove('stay-activ');
2626
+ CHART_MANAGER.reset();
2627
+ REPOSITORY_BROWSER.dialog.style.display = 'none';
2628
+ this.buttons.repository.classList.remove('stay-activ');
2629
+ REPOSITORY_BROWSER.reset();
2630
+ SENSITIVITY_ANALYSIS.dialog.style.display = 'none';
2631
+ this.buttons.sensitivity.classList.remove('stay-activ');
2632
+ SENSITIVITY_ANALYSIS.reset();
2633
+ EXPERIMENT_MANAGER.dialog.style.display = 'none';
2634
+ this.buttons.experiment.classList.remove('stay-activ');
2635
+ EXPERIMENT_MANAGER.reset();
2636
+ DOCUMENTATION_MANAGER.dialog.style.display = 'none';
2637
+ this.buttons.documentation.classList.remove('stay-activ');
2638
+ DOCUMENTATION_MANAGER.reset();
2639
+ FINDER.dialog.style.display = 'none';
2640
+ this.buttons.finder.classList.remove('stay-activ');
2641
+ FINDER.reset();
2642
+ MONITOR.dialog.style.display = 'none';
2643
+ this.buttons.monitor.classList.remove('stay-activ');
2644
+ MONITOR.reset();
2645
+ // No more visible dialogs, so clear their z-index ordering array
2646
+ this.dr_dialog_order.length = 0;
2647
+ }
2648
+
2649
+ //
2650
+ // Operations that affect the current Linny-R model
2651
+ //
2652
+
2653
+ promptForNewModel() {
2654
+ // Prompt for model name and author name
2655
+ // @@TO DO: warn user if unsaved changes to current model
2656
+ this.hideStayOnTopDialogs();
2657
+ // Clear name, but not author field, as it is likely the same modeler
2658
+ this.modals.model.element('name').value = '';
2659
+ this.modals.model.show('name');
2660
+ }
2661
+
2662
+ createNewModel() {
2663
+ const md = this.modals.model;
2664
+ // Create a brand new model with (optionally) specified name and author
2665
+ MODEL = new LinnyRModel(
2666
+ md.element('name').value.trim(), md.element('author').value.trim());
2667
+ MODEL.addPreconfiguredScaleUnits();
2668
+ md.hide();
2669
+ this.updateTimeStep(MODEL.simulationTimeStep);
2670
+ this.drawDiagram(MODEL);
2671
+ UNDO_STACK.clear();
2672
+ VM.reset();
2673
+ this.updateButtons();
2674
+ AUTO_SAVE.setAutoSaveInterval();
2675
+ }
2676
+
2677
+ addNode(type) {
2678
+ let n = null,
2679
+ nn,
2680
+ an,
2681
+ md;
2682
+ if(type === 'note') {
2683
+ md = this.modals.note;
2684
+ n = this.dbl_clicked_node;
2685
+ const
2686
+ editing = md.element('action').innerHTML === 'Edit',
2687
+ cx = new Expression(editing ? n : null, '', 'C');
2688
+ if(this.updateExpressionInput('note-C', 'note color', cx)) {
2689
+ if(editing) {
2690
+ n = this.dbl_clicked_node;
2691
+ this.dbl_clicked_node = null;
2692
+ UNDO_STACK.push('modify', n);
2693
+ n.contents = md.element('text').value;
2694
+ n.color.owner = n;
2695
+ n.color.text = md.element('C').value;
2696
+ n.color.compile();
2697
+ n.parsed = false;
2698
+ n.resize();
2699
+ } else {
2700
+ n = MODEL.addNote();
2701
+ n.x = this.add_x;
2702
+ n.y = this.add_y;
2703
+ n.contents = md.element('text').value;
2704
+ n.color.text = md.element('C').value;
2705
+ n.parsed = false;
2706
+ n.resize();
2707
+ n.color.compile();
2708
+ UNDO_STACK.push('add', n);
2709
+ }
2710
+ }
2711
+ } else if(type === 'cluster') {
2712
+ // NOTE: Originally, the cluster dialog had no fields other than
2713
+ // `name` and `actor`, hence no separate dialog for adding and
2714
+ // editing. Now that group editing is possible, a separate method
2715
+ // updateClusterProperties is called when the `action` element is
2716
+ // set to "Edit".
2717
+ md = this.modals.cluster;
2718
+ nn = md.element('name').value;
2719
+ an = md.element('actor').value;
2720
+ if(!this.validNames(nn, an)) {
2721
+ UNDO_STACK.pop();
2722
+ return;
2723
+ }
2724
+ if(md.element('action').innerHTML === 'Edit') {
2725
+ this.edited_object = this.dbl_clicked_node;
2726
+ this.dbl_clicked_node = null;
2727
+ this.updateClusterProperties();
2728
+ } else {
2729
+ // New cluster should be added.
2730
+ n = MODEL.addCluster(nn, an);
2731
+ if(n) {
2732
+ // If X and Y are set, cluster exists => ask whether to move it.
2733
+ if(n.x !== 0 || n.y !== 0) {
2734
+ if(n.cluster !== MODEL.focal_cluster) {
2735
+ this.confirmToMoveNode(n);
2736
+ } else {
2737
+ this.warningEntityExists(n);
2738
+ }
2739
+ } else {
2740
+ n.x = this.add_x;
2741
+ n.y = this.add_y;
2742
+ UNDO_STACK.push('add', n);
2743
+ }
2744
+ }
2745
+ }
2746
+ } else if(type === 'process' || type === 'product') {
2747
+ if(this.dbl_clicked_node) {
2748
+ n = this.dbl_clicked_node;
2749
+ md = this.modals['add-' + type];
2750
+ this.dbl_clicked_node = null;
2751
+ } else {
2752
+ if(type === 'process') {
2753
+ md = this.modals['add-process'];
2754
+ nn = md.element('name').value;
2755
+ an = md.element('actor').value;
2756
+ if(!this.validNames(nn, an)) {
2757
+ UNDO_STACK.pop();
2758
+ return false;
2759
+ }
2760
+ n = MODEL.addProcess(nn, an);
2761
+ } else {
2762
+ md = this.modals['add-product'];
2763
+ nn = md.element('name').value;
2764
+ if(!this.validNames(nn)) {
2765
+ UNDO_STACK.pop();
2766
+ return false;
2767
+ }
2768
+ // NOTE: pre-check if product exists
2769
+ const pp = MODEL.objectByName(nn);
2770
+ n = MODEL.addProduct(nn);
2771
+ if(n) {
2772
+ if(pp) {
2773
+ // Do not change unit or data type of existing product
2774
+ this.notify(`Added existing product <em>${pp.displayName}</em>`);
2775
+ } else {
2776
+ n.scale_unit = MODEL.addScaleUnit(md.element('unit').value);
2777
+ n.is_data = this.boxChecked('add-product-data');
2778
+ }
2779
+ MODEL.focal_cluster.addProductPosition(n, this.add_x, this.add_y);
2780
+ }
2781
+ }
2782
+ if(n) {
2783
+ // If process, and X and Y are set, it exists; then if not in the
2784
+ // focal cluster, ask whether to move it there
2785
+ if(n instanceof Process && (n.x !== 0 || n.y !== 0)) {
2786
+ if(n.cluster !== MODEL.focal_cluster) {
2787
+ this.confirmToMoveNode(n);
2788
+ } else {
2789
+ this.warningEntityExists(n);
2790
+ }
2791
+ } else {
2792
+ n.x = this.add_x;
2793
+ n.y = this.add_y;
2794
+ UNDO_STACK.push('add', n);
2795
+ }
2796
+ }
2797
+ }
2798
+ }
2799
+ MODEL.inferIgnoredEntities();
2800
+ if(n) {
2801
+ md.hide();
2802
+ // Select the newly added entity
2803
+ // NOTE: If the focal cluster was selected (via the top tool bar), it
2804
+ // cannot be selected
2805
+ if(n !== MODEL.focal_cluster) this.selectNode(n);
2806
+ }
2807
+ }
2808
+
2809
+ selectNode(n) {
2810
+ // Make `n` the current selection, and redraw so that it appears in red
2811
+ if(n) {
2812
+ MODEL.select(n);
2813
+ UI.drawDiagram(MODEL);
2814
+ // Generate a mousemove event for the drawing canvas to update the cursor etc.
2815
+ this.cc.dispatchEvent(new Event('mousemove'));
2816
+ this.updateButtons();
2817
+ }
2818
+ }
2819
+
2820
+ confirmToMoveNode(n) {
2821
+ // Store node `n` in global variable, and open confirm dialog
2822
+ const md = this.modals.move;
2823
+ this.node_to_move = n;
2824
+ md.element('node-type').innerHTML = n.type.toLowerCase();
2825
+ md.element('node-name').innerHTML = n.displayName;
2826
+ md.element('from-cluster').innerHTML = n.cluster.displayName;
2827
+ md.show();
2828
+ }
2829
+
2830
+ doNotMoveNode() {
2831
+ // Cancel the "move node to focal cluster" operation
2832
+ this.node_to_move = null;
2833
+ this.modals.move.hide();
2834
+ }
2835
+
2836
+ moveNodeToFocalCluster() {
2837
+ // Perform the "move node to focal cluster" operation
2838
+ const n = this.node_to_move;
2839
+ this.node_to_move = null;
2840
+ this.modals.move.hide();
2841
+ if(n instanceof Process || n instanceof Cluster) {
2842
+ // Keep track of the old parent cluster
2843
+ const pc = n.cluster;
2844
+ // TO DO: prepare for undo
2845
+ n.setCluster(MODEL.focal_cluster);
2846
+ n.x = this.add_x;
2847
+ n.y = this.add_y;
2848
+ // Prepare both affected parent clusters for redraw
2849
+ pc.clearAllProcesses();
2850
+ MODEL.focal_cluster.clearAllProcesses();
2851
+ this.selectNode(n);
2852
+ }
2853
+ }
2854
+
2855
+ promptForCloning() {
2856
+ // Opens CLONE modal
2857
+ const n = MODEL.selection.length;
2858
+ if(n > 0) {
2859
+ const md = UI.modals.clone;
2860
+ md.element('prefix').value = '';
2861
+ md.element('actor').value = '';
2862
+ md.element('count').innerHTML = `(${pluralS(n, 'element')})`;
2863
+ md.show('prefix');
2864
+ }
2865
+ }
2866
+
2867
+ cloneSelection() {
2868
+ const md = UI.modals.clone;
2869
+ if(MODEL.selection.length) {
2870
+ const
2871
+ p_prompt = md.element('prefix'),
2872
+ a_prompt = md.element('actor'),
2873
+ renumber = this.boxChecked('clone-renumbering'),
2874
+ actor_name = a_prompt.value.trim();
2875
+ let prefix = p_prompt.value.trim();
2876
+ // Perform basic validation of combination prefix + actor
2877
+ let msg = '';
2878
+ p_prompt.focus();
2879
+ if(!prefix && !actor_name && !(renumber && MODEL.canRenumberSelection)) {
2880
+ msg = 'Prefix and actor name cannot both be empty';
2881
+ } else if(prefix && !UI.validName(prefix)) {
2882
+ msg = `Invalid prefix "${prefix}"`;
2883
+ } else if(actor_name && !UI.validName(actor_name)) {
2884
+ msg = `Invalid actor name "${actor_name}"`;
2885
+ a_prompt.focus();
2886
+ }
2887
+ if(msg) {
2888
+ this.warn(msg);
2889
+ return;
2890
+ }
2891
+ const err = MODEL.cloneSelection(prefix, actor_name, renumber);
2892
+ if(err) {
2893
+ // Something went wrong, so do not hide the modal, but focus on the
2894
+ // DOM element returned by the model's cloning method
2895
+ const el = md.element(err);
2896
+ if(el) {
2897
+ el.focus();
2898
+ } else {
2899
+ UI.warn(`Unexpected clone result "${err}"`);
2900
+ }
2901
+ return;
2902
+ }
2903
+ }
2904
+ md.hide();
2905
+ this.updateButtons();
2906
+ }
2907
+
2908
+ cancelCloneSelection() {
2909
+ this.modals.clone.hide();
2910
+ this.updateButtons();
2911
+ }
2912
+
2913
+ copySelection() {
2914
+ // Save selection as XML in local storage of the browser
2915
+ const xml = MODEL.selectionAsXML;
2916
+ if(xml) {
2917
+ window.localStorage.setItem('Linny-R-selection-XML', xml);
2918
+ this.updateButtons();
2919
+ const bn = (this.browser_name ? ` of ${this.browser_name}` : '');
2920
+ this.notify('Selection copied to local storage' + bn);
2921
+ }
2922
+ }
2923
+
2924
+ get canPaste() {
2925
+ const xml = window.localStorage.getItem('Linny-R-selection-XML');
2926
+ if(xml) {
2927
+ const timestamp = xml.match(/<copy timestamp="(\d+)"/);
2928
+ if(timestamp) {
2929
+ if(Date.now() - parseInt(timestamp[1]) < 8*3600000) return true;
2930
+ }
2931
+ // Remove XML from local storage if older than 8 hours
2932
+ window.localStorage.removeItem('Linny-R-selection-XML');
2933
+ }
2934
+ return false;
2935
+ }
2936
+
2937
+ promptForMapping(mapping) {
2938
+ // Prompt user to specify name conflict resolution strategy
2939
+ const md = this.paste_modal;
2940
+ md.mapping = mapping;
2941
+ md.element('from-prefix').innerText = mapping.from_prefix || '';
2942
+ md.element('to-prefix').innerText = mapping.to_prefix || '';
2943
+ md.element('ftp').style.display = (mapping.from_prefix ? 'block' : 'none');
2944
+ md.element('from-actor').innerText = mapping.from_actor || '';
2945
+ md.element('to-actor').innerText = mapping.to_actor || '';
2946
+ md.element('fta').style.display = (mapping.from_actor ? 'block' : 'none');
2947
+ md.element('actor').value = mapping.actor || '';
2948
+ md.element('prefix').value = mapping.prefix || '';
2949
+ const
2950
+ tc = (mapping.top_clusters ?
2951
+ Object.keys(mapping.top_clusters).sort(ciCompare) : []),
2952
+ ft = (mapping.from_to ?
2953
+ Object.keys(mapping.from_to).sort(ciCompare) : []),
2954
+ sl = [];
2955
+ if(tc.length) {
2956
+ sl.push('<div style="font-weight: bold; margin:4px 2px 2px 2px">',
2957
+ 'Names for top-level clusters:</div>');
2958
+ // Add text inputs for selected cluster nodes
2959
+ for(let i = 0; i < tc.length; i++) {
2960
+ const
2961
+ ti = mapping.top_clusters[tc[i]],
2962
+ state = (ti === tc[i] ? 'color: #e09000; ' :
2963
+ this.validName(ti) ? 'color: #0000c0; ' :
2964
+ 'font-style: italic; color: red; ');
2965
+ sl.push('<div class="paste-option"><span>', tc[i], '</span> ',
2966
+ '<div class="paste-select"><input id="paste-selc-', i,
2967
+ '" type="text" style="', state, 'font-size: 12px" value="',
2968
+ ti, '"></div></div>');
2969
+ }
2970
+ }
2971
+ if(ft.length) {
2972
+ sl.push('<div style="font-weight: bold; margin:4px 2px 2px 2px">',
2973
+ 'Mapping of nodes to link from/to:</div>');
2974
+ // Add selectors for unresolved FROM/TO nodes
2975
+ for(let i = 0; i < ft.length; i++) {
2976
+ const ti = mapping.from_to[ft[i]];
2977
+ if(ft[i] === ti) {
2978
+ const elig = MODEL.eligibleFromToNodes(mapping.from_to_type[ti]);
2979
+ sl.push('<div class="paste-option"><span>', ft[i], '</span> ');
2980
+ if(elig.length) {
2981
+ sl.push('<div class="paste-select"><select id="paste-ft-', i,
2982
+ '" style="font-size: 12px">');
2983
+ for(let j = 0; j < elig.length; j++) {
2984
+ const dn = elig[j].displayName;
2985
+ sl.push('<option value="', dn, '">', dn, '</option>');
2986
+ }
2987
+ sl.push('</select></div>');
2988
+ } else {
2989
+ sl.push('<span><em>(no eligible node)</em></span');
2990
+ }
2991
+ sl.push('</div>');
2992
+ }
2993
+ }
2994
+ }
2995
+ md.element('scroll-area').innerHTML = sl.join('');
2996
+ // Open dialog, which will call pasteSelection(...) on OK
2997
+ this.paste_modal.show();
2998
+ }
2999
+
3000
+ setPasteMapping() {
3001
+ // Updates the paste mapping as specified by the modeler and then
3002
+ // proceeds to paste
3003
+ const
3004
+ md = this.paste_modal,
3005
+ mapping = Object.assign(md.mapping, {}),
3006
+ tc = (mapping.top_clusters ?
3007
+ Object.keys(mapping.top_clusters).sort(ciCompare) : []),
3008
+ ft = (mapping.from_to ?
3009
+ Object.keys(mapping.from_to).sort(ciCompare) : []);
3010
+ mapping.actor = md.element('actor').value;
3011
+ mapping.prefix = md.element('prefix').value.trim();
3012
+ mapping.increment = true;
3013
+ for(let i = 0; i < tc.length; i++) {
3014
+ const cn = md.element('selc-' + i).value.trim();
3015
+ if(this.validName(cn)) mapping.top_clusters[tc[i]] = cn;
3016
+ }
3017
+ for(let i = 0; i < ft.length; i++) if(mapping.from_to[ft[i]] === ft[i]) {
3018
+ const
3019
+ ftn = md.element('ft-' + i).value,
3020
+ fto = MODEL.objectByName(ftn);
3021
+ if(fto) mapping.from_to[ft[i]] = ftn;
3022
+ }
3023
+ this.pasteSelection(mapping);
3024
+ }
3025
+
3026
+ pasteSelection(mapping={}) {
3027
+ // If selection has been saved as XML in local storage, test to
3028
+ // see whether PASTE would result in name conflicts, and if so,
3029
+ // open the name conflict resolution window
3030
+ let xml = window.localStorage.getItem('Linny-R-selection-XML');
3031
+ try {
3032
+ xml = parseXML(xml);
3033
+ } catch(e) {
3034
+ console.log(e);
3035
+ this.alert('Paste failed due to invalid XML');
3036
+ return;
3037
+ }
3038
+
3039
+ const
3040
+ entities_node = childNodeByTag(xml, 'entities'),
3041
+ from_tos_node = childNodeByTag(xml, 'from-tos'),
3042
+ extras_node = childNodeByTag(xml, 'extras'),
3043
+ selc_node = childNodeByTag(xml, 'selected-clusters'),
3044
+ selection_node = childNodeByTag(xml, 'selection'),
3045
+ actor_names = [],
3046
+ new_entities = [],
3047
+ name_map = {},
3048
+ name_conflicts = [];
3049
+
3050
+ // AUXILIARY FUNCTIONS
3051
+
3052
+ function fullName(node) {
3053
+ // Returns full entity name inferred from XML node data
3054
+ if(node.nodeName === 'from-to' || node.nodeName === 'selc') {
3055
+ const
3056
+ n = xmlDecoded(nodeParameterValue(node, 'name')),
3057
+ an = xmlDecoded(nodeParameterValue(node, 'actor-name'));
3058
+ if(an && an !== UI.NO_ACTOR) {
3059
+ addDistinct(an, actor_names);
3060
+ return `${n} (${an})`;
3061
+ }
3062
+ return n;
3063
+ }
3064
+ if(node.nodeName !== 'link' && node.nodeName !== 'constraint') {
3065
+ const
3066
+ n = xmlDecoded(nodeContentByTag(node, 'name')),
3067
+ an = xmlDecoded(nodeContentByTag(node, 'actor-name'));
3068
+ if(an && an !== UI.NO_ACTOR) {
3069
+ addDistinct(an, actor_names);
3070
+ return `${n} (${an})`;
3071
+ }
3072
+ return n;
3073
+ } else {
3074
+ let fn = xmlDecoded(nodeContentByTag(node, 'from-name')),
3075
+ fa = xmlDecoded(nodeContentByTag(node, 'from-owner')),
3076
+ tn = xmlDecoded(nodeContentByTag(node, 'to-name')),
3077
+ ta = xmlDecoded(nodeContentByTag(node, 'to-owner')),
3078
+ arrow = (node.nodeName === 'link' ? UI.LINK_ARROW : UI.CONSTRAINT_ARROW);
3079
+ if(fa && fa !== UI.NO_ACTOR) {
3080
+ addDistinct(fa, actor_names);
3081
+ fn = `${fn} (${fa})`;
3082
+ }
3083
+ if(ta && ta !== UI.NO_ACTOR) {
3084
+ addDistinct(ta, actor_names);
3085
+ tn = `${tn} (${ta})`;
3086
+ }
3087
+ return `${fn}${arrow}${tn}`;
3088
+ }
3089
+ }
3090
+
3091
+ function nameAndActor(name) {
3092
+ // Returns tuple [entity name, actor name] if `name` ends with
3093
+ // a parenthesized string that identifies an actor in the selection
3094
+ const ai = name.lastIndexOf(' (');
3095
+ if(ai < 0) return [name, ''];
3096
+ let actor = name.slice(ai + 2, -1);
3097
+ // Test whether parenthesized string denotes an actor
3098
+ if(actor_names.indexOf(actor) >= 0 || actor === mapping.actor ||
3099
+ actor === mapping.from_actor || actor === mapping.to_actor) {
3100
+ name = name.substring(0, ai);
3101
+ } else {
3102
+ actor = '';
3103
+ }
3104
+ return [name, actor];
3105
+ }
3106
+
3107
+ function mappedName(n) {
3108
+ // Returns full name `n` modified according to the mapping
3109
+ // NOTE: links and constraints require two mappings (recursion!)
3110
+ if(n.indexOf(UI.LINK_ARROW) > 0) {
3111
+ const ft = n.split(UI.LINK_ARROW);
3112
+ return mappedName(ft[0]) + UI.LINK_ARROW + mappedName(ft[1]);
3113
+ }
3114
+ if(n.indexOf(UI.CONSTRAINT_ARROW) > 0) {
3115
+ const ft = n.split(UI.CONSTRAINT_ARROW);
3116
+ return mappedName(ft[0]) + UI.CONSTRAINT_ARROW + mappedName(ft[1]);
3117
+ }
3118
+ // Mapping precedence order:
3119
+ // (1) prefix inherited from cluster
3120
+ // (2) actor name inherited from cluster
3121
+ // (3) actor name specified by modeler
3122
+ // (4) prefix specified by modeler
3123
+ // (5) auto-increment tail number
3124
+ // (6) nearest eligible node
3125
+ if(mapping.from_prefix && n.startsWith(mapping.from_prefix)) {
3126
+ return n.replace(mapping.from_prefix, mapping.to_prefix);
3127
+ }
3128
+ if(mapping.from_actor) {
3129
+ const ai = n.lastIndexOf(mapping.from_actor);
3130
+ if(ai > 0) return n.substring(0, ai) + mapping.to_actor;
3131
+ }
3132
+ // NOTE: specified actor cannot override existing actor
3133
+ if(mapping.actor && !nameAndActor(n)[1]) {
3134
+ return `${n} (${mapping.actor})`;
3135
+ }
3136
+ if(mapping.prefix) {
3137
+ return mapping.prefix + UI.PREFIXER + n;
3138
+ }
3139
+ let nr = endsWithDigits(n);
3140
+ if(mapping.increment && nr) {
3141
+ return n.replace(new RegExp(nr + '$'), parseInt(nr) + 1);
3142
+ }
3143
+ if(mapping.top_clusters && mapping.top_clusters[n]) {
3144
+ return mapping.top_clusters[n];
3145
+ }
3146
+ if(mapping.from_to && mapping.from_to[n]) {
3147
+ return mapping.from_to[n];
3148
+ }
3149
+ // No mapping => return original name
3150
+ return n;
3151
+ }
3152
+
3153
+ function nameConflicts(node) {
3154
+ // Maps names of entities defined by the child nodes of `node`
3155
+ // while detecting name conflicts
3156
+ for(let i = 0; i < node.childNodes.length; i++) {
3157
+ const c = node.childNodes[i];
3158
+ if(c.nodeName !== 'link' && c.nodeName !== 'constraint') {
3159
+ const
3160
+ fn = fullName(c),
3161
+ mn = mappedName(fn);
3162
+ // Name conflict occurs when the mapped name is already in use
3163
+ // in the target model, or when the original name is mapped onto
3164
+ // different names (this might occur due to modeler input)
3165
+ if(MODEL.objectByName(mn) || (name_map[fn] && name_map[fn] !== mn)) {
3166
+ addDistinct(fn, name_conflicts);
3167
+ } else {
3168
+ name_map[fn] = mn;
3169
+ }
3170
+ }
3171
+ }
3172
+ }
3173
+
3174
+ function addEntityFromNode(node) {
3175
+ // Adds entity to model based on XML node data and mapping
3176
+ // NOTE: do not add if an entity having this type and mapped name
3177
+ // already exists; name conflicts accross entity types may occur
3178
+ // and result in error messages
3179
+ const
3180
+ et = node.nodeName,
3181
+ fn = fullName(node),
3182
+ mn = mappedName(fn);
3183
+ let obj;
3184
+ if(et === 'process' && !MODEL.processByID(UI.nameToID(mn))) {
3185
+ const
3186
+ na = nameAndActor(mn),
3187
+ new_actor = !MODEL.actorByID(UI.nameToID(na[1]));
3188
+ obj = MODEL.addProcess(na[0], na[1], node);
3189
+ if(obj) {
3190
+ obj.code = '';
3191
+ obj.setCode();
3192
+ if(new_actor) new_entities.push(obj.actor);
3193
+ new_entities.push(obj);
3194
+ }
3195
+ } else if(et === 'product' && !MODEL.productByID(UI.nameToID(mn))) {
3196
+ obj = MODEL.addProduct(mn, node);
3197
+ if(obj) {
3198
+ obj.code = '';
3199
+ obj.setCode();
3200
+ new_entities.push(obj);
3201
+ }
3202
+ } else if(et === 'cluster' && !MODEL.clusterByID(UI.nameToID(mn))) {
3203
+ const
3204
+ na = nameAndActor(mn),
3205
+ new_actor = !MODEL.actorByID(UI.nameToID(na[1]));
3206
+ obj = MODEL.addCluster(na[0], na[1], node);
3207
+ if(obj) {
3208
+ if(new_actor) new_entities.push(obj.actor);
3209
+ new_entities.push(obj);
3210
+ }
3211
+ } else if(et === 'dataset' && !MODEL.datasetByID(UI.nameToID(mn))) {
3212
+ obj = MODEL.addDataset(mn, node);
3213
+ if(obj) new_entities.push(obj);
3214
+ } else if(et === 'link' || et === 'constraint') {
3215
+ const
3216
+ ft = mn.split(et === 'link' ? UI.LINK_ARROW : UI.CONSTRAINT_ARROW),
3217
+ fl = MODEL.objectByName(ft[0]),
3218
+ tl = MODEL.objectByName(ft[1]);
3219
+ if(fl && tl) {
3220
+ obj = (et === 'link' ?
3221
+ MODEL.addLink(fl, tl, node) :
3222
+ MODEL.addConstraint(fl, tl, node));
3223
+ if(obj) new_entities.push(obj);
3224
+ } else {
3225
+ UI.alert(`Failed to paste ${et} ${fn} as ${mn}`);
3226
+ }
3227
+ }
3228
+ }
3229
+
3230
+ const
3231
+ mts = nodeParameterValue(xml, 'model-timestamp'),
3232
+ cn = nodeParameterValue(xml, 'cluster-name'),
3233
+ ca = nodeParameterValue(xml, 'cluster-actor'),
3234
+ fc = MODEL.focal_cluster,
3235
+ fcn = fc.name,
3236
+ fca = fc.actor.name,
3237
+ sp = this.sharedPrefix(cn, fcn),
3238
+ fpn = (cn === UI.TOP_CLUSTER_NAME ? '' : cn.replace(sp, '')),
3239
+ tpn = (fcn === UI.TOP_CLUSTER_NAME ? '' : fcn.replace(sp, ''));
3240
+ // Infer mapping from XML data and focal cluster name & actor name
3241
+ mapping.shared_prefix = sp;
3242
+ mapping.from_prefix = (fpn ? sp + fpn + UI.PREFIXER : sp);
3243
+ mapping.to_prefix = (tpn ? sp + tpn + UI.PREFIXER : sp);
3244
+ mapping.from_actor = (ca === UI.NO_ACTOR ? '' : ca);
3245
+ mapping.to_actor = (fca === UI.NO_ACTOR ? '' : fca);
3246
+ // Prompt for mapping when pasting to the same model and cluster
3247
+ if(parseInt(mts) === MODEL.time_created.getTime() &&
3248
+ ca === fca && mapping.from_prefix === mapping.to_prefix &&
3249
+ !(mapping.prefix || mapping.actor || mapping.increment)) {
3250
+ // Prompt for names of selected cluster nodes
3251
+ if(selc_node.childNodes.length && !mapping.prefix) {
3252
+ mapping.top_clusters = {};
3253
+ for(let i = 0; i < selc_node.childNodes.length; i++) {
3254
+ const
3255
+ c = selc_node.childNodes[i],
3256
+ fn = fullName(c),
3257
+ mn = mappedName(fn);
3258
+ mapping.top_clusters[fn] = mn;
3259
+ }
3260
+ }
3261
+ this.promptForMapping(mapping);
3262
+ return;
3263
+ }
3264
+ // Also prompt if FROM and/or TO nodes are not selected, and map to
3265
+ // existing entities
3266
+ if(from_tos_node.childNodes.length && !mapping.from_to) {
3267
+ const
3268
+ ft_map = {},
3269
+ ft_type = {};
3270
+ for(let i = 0; i < from_tos_node.childNodes.length; i++) {
3271
+ const
3272
+ c = from_tos_node.childNodes[i],
3273
+ fn = fullName(c),
3274
+ mn = mappedName(fn);
3275
+ if(MODEL.objectByName(mn)) {
3276
+ ft_map[fn] = mn;
3277
+ ft_type[fn] = (nodeParameterValue(c, 'is-data') === '1' ?
3278
+ 'Data' : nodeParameterValue(c, 'type'));
3279
+ }
3280
+ }
3281
+ // Prompt only for FROM/TO nodes that map to existing nodes
3282
+ if(Object.keys(ft_map).length) {
3283
+ mapping.from_to = ft_map;
3284
+ mapping.from_to_type = ft_type;
3285
+ this.promptForMapping(mapping);
3286
+ return;
3287
+ }
3288
+ }
3289
+
3290
+ // Only check for selected entities; from-to's and extra's should be
3291
+ // used if they exist, or should be created when copying to a different
3292
+ // model
3293
+ name_map.length = 0;
3294
+ nameConflicts(entities_node);
3295
+ if(name_conflicts.length) {
3296
+ UI.warn(pluralS(name_conflicts.length, 'name conflict'));
3297
+ console.log('HERE name conflicts', name_conflicts, mapping);
3298
+ return;
3299
+ }
3300
+
3301
+ // No conflicts => add all
3302
+ for(let i = 0; i < extras_node.childNodes.length; i++) {
3303
+ addEntityFromNode(extras_node.childNodes[i]);
3304
+ }
3305
+ for(let i = 0; i < from_tos_node.childNodes.length; i++) {
3306
+ addEntityFromNode(from_tos_node.childNodes[i]);
3307
+ }
3308
+ for(let i = 0; i < entities_node.childNodes.length; i++) {
3309
+ addEntityFromNode(entities_node.childNodes[i]);
3310
+ }
3311
+ // Update diagram, showing newly added nodes as selection
3312
+ MODEL.clearSelection();
3313
+ for(let i = 0; i < selection_node.childNodes.length; i++) {
3314
+ const
3315
+ n = xmlDecoded(nodeContent(selection_node.childNodes[i])),
3316
+ obj = MODEL.objectByName(mappedName(n));
3317
+ if(obj) {
3318
+ // NOTE: selected products must be positioned
3319
+ if(obj instanceof Product) MODEL.focal_cluster.addProductPosition(obj);
3320
+ MODEL.select(obj);
3321
+ }
3322
+ }
3323
+ // Force redrawing the selection to ensure that links to positioned
3324
+ // products are displayed as arrows instead of block arrows
3325
+ fc.clearAllProcesses();
3326
+ UI.drawDiagram(MODEL);
3327
+ this.paste_modal.hide();
3328
+ }
3329
+
3330
+ //
3331
+ // Interaction with modal dialogs to modify model or entity properties
3332
+ //
3333
+
3334
+ // Settings modal
3335
+
3336
+ showSettingsDialog(model) {
3337
+ const md = this.modals.settings;
3338
+ md.element('name').value = model.name;
3339
+ md.element('author').value = model.author;
3340
+ md.element('product-unit').value = model.default_unit;
3341
+ md.element('currency-unit').value = model.currency_unit;
3342
+ md.element('grid-pixels').value = model.grid_pixels;
3343
+ md.element('time-scale').value = model.time_scale;
3344
+ md.element('time-unit').value = model.time_unit;
3345
+ md.element('period-start').value = model.start_period;
3346
+ md.element('period-end').value = model.end_period;
3347
+ md.element('block-length').value = model.block_length;
3348
+ md.element('look-ahead').value = model.look_ahead;
3349
+ md.element('time-limit').value = model.timeout_period;
3350
+ this.setBox('settings-encrypt', model.encrypt);
3351
+ this.setBox('settings-decimal-comma', model.decimal_comma);
3352
+ this.setBox('settings-align-to-grid', model.align_to_grid);
3353
+ this.setBox('settings-cost-prices', model.infer_cost_prices);
3354
+ this.setBox('settings-report-results', model.report_results);
3355
+ this.setBox('settings-block-arrows', model.show_block_arrows);
3356
+ md.show('name');
3357
+ }
3358
+
3359
+ updateSettings(model) {
3360
+ // Valdidate inputs
3361
+ const px = this.validNumericInput('settings-grid-pixels', 'grid resolution');
3362
+ if(px === false) return false;
3363
+ const ts = this.validNumericInput('settings-time-scale', 'time step');
3364
+ if(ts === false) return false;
3365
+ let ps = this.validNumericInput('settings-period-start', 'first time step');
3366
+ if(ps === false) return false;
3367
+ const md = UI.modals.settings;
3368
+ if(ps < 1) {
3369
+ this.warn('Simulation cannot start earlier than at t=1');
3370
+ md.element('period-start').focus();
3371
+ return false;
3372
+ }
3373
+ let pe = this.validNumericInput('settings-period-end', 'last time step');
3374
+ if(pe === false) return false;
3375
+ if(pe < ps) {
3376
+ this.warn('End time cannot precede start time');
3377
+ md.element('period-end').focus();
3378
+ return false;
3379
+ }
3380
+ const bl = this.validNumericInput('settings-block-length', 'block length');
3381
+ if(bl === false) return false;
3382
+ const la = this.validNumericInput('settings-look-ahead', 'look-ahead');
3383
+ if(la === false) return false;
3384
+ if(la < 0) {
3385
+ this.warn('Look-ahead must be non-negative');
3386
+ md.element('look-ahead').focus();
3387
+ return false;
3388
+ }
3389
+ const tl = UI.validNumericInput('settings-time-limit', 'solver time limit');
3390
+ if(tl === false) return false;
3391
+ if(tl < 0) {
3392
+ // NOTE: time limit 0 is interpreted as "no limit"
3393
+ this.warn('Impractical solver time limit');
3394
+ md.element('time-limit').focus();
3395
+ return false;
3396
+ }
3397
+ const
3398
+ e = md.element('product-unit'),
3399
+ dsu = UI.cleanName(e.value) || '1';
3400
+ model.name = md.element('name').value.trim();
3401
+ // Display model name in browser unless blank
3402
+ document.title = model.name || 'Linny-R';
3403
+ model.author = md.element('author').value.trim();
3404
+ if(!model.scale_units.hasOwnProperty(dsu)) model.addScaleUnit(dsu);
3405
+ model.default_unit = dsu;
3406
+ model.currency_unit = md.element('currency-unit').value.trim();
3407
+ model.report_results = UI.boxChecked('settings-report-results');
3408
+ model.encrypt = UI.boxChecked('settings-encrypt');
3409
+ model.decimal_comma = UI.boxChecked('settings-decimal-comma');
3410
+ // Some changes may necessitate redrawing the diagram
3411
+ let cb = UI.boxChecked('settings-align-to-grid'),
3412
+ redraw = !model.align_to_grid && cb;
3413
+ model.align_to_grid = cb;
3414
+ model.grid_pixels = Math.floor(px);
3415
+ cb = UI.boxChecked('settings-cost-prices');
3416
+ redraw = redraw || cb !== model.infer_cost_prices;
3417
+ model.infer_cost_prices = cb;
3418
+ cb = UI.boxChecked('settings-block-arrows');
3419
+ redraw = redraw || cb !== model.show_block_arrows;
3420
+ model.show_block_arrows = cb;
3421
+ // Changes affecting run length (hence vector lengths) require a model reset
3422
+ let reset = false;
3423
+ reset = reset || (ts != model.time_scale);
3424
+ model.time_scale = ts;
3425
+ const tu = md.element('time-unit').value;
3426
+ reset = reset || (tu != model.time_unit);
3427
+ model.time_unit = (tu || CONFIGURATION.default_time_unit);
3428
+ ps = Math.floor(ps);
3429
+ reset = reset || (ps != model.start_period);
3430
+ model.start_period = ps;
3431
+ pe = Math.floor(pe);
3432
+ reset = reset || (pe != model.end_period);
3433
+ model.end_period = pe;
3434
+ reset = reset || (bl != model.block_length);
3435
+ model.block_length = Math.floor(bl);
3436
+ reset = reset || (la != model.look_ahead);
3437
+ model.look_ahead = Math.floor(la);
3438
+ // Solver settings do not affect vector length
3439
+ model.timeout_period = tl;
3440
+ // Update currencies in other dialogs
3441
+ this.modals.product.element('currency').innerHTML = model.currency_unit;
3442
+ // Close the dialog
3443
+ md.hide();
3444
+ // Ensure that model documentation can no longer be edited
3445
+ DOCUMENTATION_MANAGER.clearEntity([model]);
3446
+ // Reset model if needed
3447
+ if(reset) {
3448
+ model.resetExpressions();
3449
+ this.notify('To update datasets and results, run the simulation (again)');
3450
+ CHART_MANAGER.updateDialog();
3451
+ redraw = true;
3452
+ }
3453
+ // Adjust current time step if it falls outside (new) interval
3454
+ if(model.t < ps || model.t > pe) {
3455
+ model.t = (model.t < ps ? ps : pe);
3456
+ UI.updateTimeStep();
3457
+ redraw = true;
3458
+ }
3459
+ if(redraw) this.drawDiagram(model);
3460
+ }
3461
+
3462
+ // Note modal
3463
+
3464
+ showNotePropertiesDialog(n=null) {
3465
+ this.dbl_clicked_node = n;
3466
+ const md = this.modals.note;
3467
+ if(n) {
3468
+ md.element('action').innerHTML = 'Edit';
3469
+ const nr = n.number;
3470
+ md.element('number').innerHTML = (nr ? '#' + nr : '');
3471
+ md.element('text').value = n.contents;
3472
+ md.element('C').value = n.color.text;
3473
+ } else {
3474
+ md.element('action').innerHTML = 'Add';
3475
+ }
3476
+ md.show('text');
3477
+ }
3478
+
3479
+ // Process modal
3480
+
3481
+ showProcessPropertiesDialog(p, attr='name', alt=false, group=[]) {
3482
+ // Opens the process modal and sets its fields to properties of `p`.
3483
+ const md = this.modals.process;
3484
+ // In the Finder, multiple processes may be edited as a group.
3485
+ md.group = group;
3486
+ md.element('name').value = p.name;
3487
+ md.element('actor').value = (p.hasActor ? p.actor.name : '');
3488
+ // Focus on lower bound when showing the dialog for a group.
3489
+ if(group.length > 0) {
3490
+ attr = 'LB';
3491
+ } else if (!attr) {
3492
+ // Focus on the name input if `attr` was not specified.
3493
+ attr = 'name';
3494
+ }
3495
+ md.show(attr, p);
3496
+ this.edited_object = p;
3497
+ // NOTE: Special shortcut Alt-click on an expression property in the
3498
+ // Finder dialog means that this expression should be opened in the
3499
+ // Expression Editor; this is effectuated via a "click" event on the
3500
+ // edit button next to the attribute input field.
3501
+ if(alt && !md.group) {
3502
+ md.element(attr + '-x').dispatchEvent(new Event('click'));
3503
+ }
3504
+ }
3505
+
3506
+ updateProcessProperties() {
3507
+ // Validates process properties, and only updates the edited process
3508
+ // if all input is OK.
3509
+ // @@TO DO: prepare for undo
3510
+ const
3511
+ md = this.modals.process,
3512
+ p = this.edited_object;
3513
+ // Rename object if name and/or actor have changed
3514
+ let pn = md.element('name').value.trim(),
3515
+ an = md.element('actor').value.trim(),
3516
+ n = p.rename(pn, an);
3517
+ // NOTE: When rename returns FALSE, a warning is already shown.
3518
+ if(n !== true && n !== false) {
3519
+ this.warningEntityExists(n);
3520
+ return false;
3521
+ }
3522
+ // Update expression properties.
3523
+ if(!this.updateExpressionInput(
3524
+ 'process-LB', 'lower bound', p.lower_bound)) return false;
3525
+ if(!this.updateExpressionInput(
3526
+ 'process-UB', 'upper bound', p.upper_bound)) return false;
3527
+ // If process is constrained, its upper bound must be defined
3528
+ if(!p.upper_bound.defined) {
3529
+ const c = MODEL.isConstrained(p);
3530
+ if(c) {
3531
+ n = (c.from_node === p ? c.to_node : c.from_node);
3532
+ this.warningSetUpperBound(n);
3533
+ return false;
3534
+ }
3535
+ }
3536
+ if(!this.updateExpressionInput(
3537
+ 'process-IL', 'initial level', p.initial_level)) return false;
3538
+ // Store original expression string.
3539
+ const
3540
+ px = p.pace_expression,
3541
+ pxt = p.pace_expression.text;
3542
+ // Validate expression.
3543
+ if(!this.updateExpressionInput('process-LCF', 'level change frequency',
3544
+ px)) return false;
3545
+ // NOTE: Level change frequency expression must be *static* and >= 1.
3546
+ n = px.result(1);
3547
+ if(!px.isStatic || n < 1) {
3548
+ md.element('LCF').focus();
3549
+ this.warn('Level change frequency must be static and &ge; 1');
3550
+ // Restore original expression string.
3551
+ px.text = pxt;
3552
+ px.code = null;
3553
+ return false;
3554
+ }
3555
+ // Ignore level change frequency fraction if a real number was entered.
3556
+ p.pace = Math.floor(n);
3557
+ if(n - p.pace > VM.SIG_DIF_LIMIT) this.notify(
3558
+ 'Level change frequency set to ' + p.pace);
3559
+ // At this point, all input has been validated, so entity properties
3560
+ // can be modified.
3561
+ p.equal_bounds = this.getEqualBounds('process-UB-equal');
3562
+ p.integer_level = this.boxChecked('process-integer');
3563
+ p.level_to_zero = this.boxChecked('process-shut-down');
3564
+ p.collapsed = this.boxChecked('process-collapsed');
3565
+ if(md.group.length > 1) {
3566
+ // Redraw the entire diagram, as multiple processes may have changed.
3567
+ md.updateModifiedProperties(p);
3568
+ MODEL.focal_cluster.clearAllProcesses();
3569
+ UI.drawDiagram(MODEL);
3570
+ } else {
3571
+ // Redraw the shape, as its appearance and/or associated link types
3572
+ // may have changed.
3573
+ p.drawWithLinks();
3574
+ }
3575
+ md.hide();
3576
+ return true;
3577
+ }
3578
+
3579
+ // Product modal
3580
+
3581
+ showProductPropertiesDialog(p, attr='name', alt=false, group=[]) {
3582
+ const md = this.modals.product;
3583
+ // In the Finder, multiple products may be edited as a group.
3584
+ md.group = group;
3585
+ md.element('name').value = p.name;
3586
+ // NOTE: price label includes the currency unit and the product unit,
3587
+ // e.g., EUR/ton
3588
+ md.element('P-unit').innerHTML =
3589
+ (p.scale_unit === '1' ? '' : '/' + p.scale_unit);
3590
+ md.element('currency').innerHTML = MODEL.currency_unit;
3591
+ // NOTE: IO parameter status is not "group-edited"!
3592
+ this.setImportExportBox('product', MODEL.ioType(p));
3593
+ // Focus on lower bound when showing the dialog for a group.
3594
+ if(group.length > 0) {
3595
+ attr = 'LB';
3596
+ } else if (!attr) {
3597
+ // Focus on the name input if `attr` was not specified.
3598
+ attr = 'name';
3599
+ }
3600
+ md.show(attr, p);
3601
+ this.edited_object = p;
3602
+ this.toggleProductStock();
3603
+ // NOTE: special shortcut Alt-click on an expression property in the Finder
3604
+ // dialog means that this expression should be opened in the Expression
3605
+ // Editor; this is effectuated via a "click" event on the edit button next
3606
+ // to the attribute input field
3607
+ if(alt) md.element(attr + '-x').dispatchEvent(new Event('click'));
3608
+ }
3609
+
3610
+ toggleProductStock() {
3611
+ // Enables/disables initial level input in the Product modal, depending on
3612
+ // the Stock check box status
3613
+ const
3614
+ lb = document.getElementById('product-LB'),
3615
+ il = document.getElementById('product-IL'),
3616
+ lbl = document.getElementById('product-IL-lbl'),
3617
+ edx = document.getElementById('product-IL-x');
3618
+ if(this.boxChecked('product-stock')) {
3619
+ // Set lower bound to 0 unless already specified
3620
+ if(lb.value.trim().length === 0) lb.value = 0;
3621
+ il.disabled = false;
3622
+ lbl.style.color = 'black';
3623
+ lbl.style.textShadow = 'none';
3624
+ edx.classList.remove('disab');
3625
+ edx.classList.add('enab');
3626
+ } else {
3627
+ il.value = 0;
3628
+ il.disabled = true;
3629
+ lbl.style.color = 'gray';
3630
+ lbl.style.textShadow = '1px 1px white';
3631
+ edx.classList.remove('enab');
3632
+ edx.classList.add('disab');
3633
+ }
3634
+ }
3635
+
3636
+ updateProductProperties() {
3637
+ // Validates product properties, and updates only if all input is OK
3638
+ const
3639
+ md = this.modals.product,
3640
+ p = this.edited_object;
3641
+ // @@TO DO: prepare for undo
3642
+ // Rename object if name has changed.
3643
+ const nn = md.element('name').value.trim();
3644
+ let n = p.rename(nn, '');
3645
+ if(n !== true && n !== p) {
3646
+ this.warningEntityExists(n);
3647
+ return false;
3648
+ }
3649
+ // Update expression properties.
3650
+ // NOTE: For stocks, set lower bound to zero if undefined.
3651
+ const
3652
+ stock = this.boxChecked('product-stock'),
3653
+ l = md.element('LB');
3654
+ if(stock && l.value.trim().length === 0) {
3655
+ l.value = '0';
3656
+ }
3657
+ if(!this.updateExpressionInput('product-LB', 'lower bound',
3658
+ p.lower_bound)) return false;
3659
+ if(!this.updateExpressionInput('product-UB', 'upper bound',
3660
+ p.upper_bound)) return false;
3661
+ if(!this.updateExpressionInput('product-IL', 'initial level',
3662
+ p.initial_level)) return false;
3663
+ if(!this.updateExpressionInput('product-P', 'market price',
3664
+ p.price)) return false;
3665
+ // If product is constrained, its upper bound must be defined.
3666
+ if(!p.upper_bound.defined) {
3667
+ const c = MODEL.isConstrained(p);
3668
+ if(c) {
3669
+ n = (c.from_node === this.edited_object ? c.to_node : c.from_node);
3670
+ this.warningSetUpperBound(n);
3671
+ return false;
3672
+ }
3673
+ }
3674
+ // At this point, all input has been validated, so entity properties
3675
+ // can be modified.
3676
+ p.changeScaleUnit(md.element('unit').value);
3677
+ p.equal_bounds = this.getEqualBounds('product-UB-equal');
3678
+ p.is_source = this.boxChecked('product-source');
3679
+ p.is_sink = this.boxChecked('product-sink');
3680
+ // NOTE: Do not unset is_data if product has ingoing data arrows.
3681
+ p.is_data = p.hasDataInputs || this.boxChecked('product-data');
3682
+ p.is_buffer = this.boxChecked('product-stock');
3683
+ p.integer_level = this.boxChecked('product-integer');
3684
+ p.no_slack = this.boxChecked('product-no-slack');
3685
+ const pnl = p.no_links;
3686
+ p.no_links = this.boxChecked('product-no-links');
3687
+ let must_redraw = (pnl !== p.no_links);
3688
+ MODEL.ioUpdate(p, this.getImportExportBox('product'));
3689
+ // If a group was edited, update all entities in this group.
3690
+ if(md.group.length > 0) md.updateModifiedProperties(p);
3691
+ if(must_redraw || md.group.length > 1) {
3692
+ // Hide or show links => redraw (with new arrows).
3693
+ MODEL.focal_cluster.clearAllProcesses();
3694
+ UI.drawDiagram(MODEL);
3695
+ } else {
3696
+ UI.paper.drawProduct(p);
3697
+ }
3698
+ md.hide();
3699
+ return true;
3700
+ }
3701
+
3702
+ // Cluster modal
3703
+
3704
+ showClusterPropertiesDialog(c, group=[]) {
3705
+ let bb = false;
3706
+ for(let i = 0; !bb && i < group.length; i++) {
3707
+ bb = group[i].is_black_boxed;
3708
+ }
3709
+ if(bb || c.is_black_boxed) {
3710
+ this.notify('Black-boxed clusters cannot be edited');
3711
+ return;
3712
+ }
3713
+ this.dbl_clicked_node = c;
3714
+ const md = this.modals.cluster;
3715
+ md.group = group;
3716
+ md.element('action').innerText = 'Edit';
3717
+ md.element('name').value = c.name;
3718
+ md.element('actor').value = (c.actor.name == UI.NO_ACTOR ?
3719
+ '' : c.actor.name);
3720
+ md.element('options').style.display = 'block';
3721
+ this.setBox('cluster-collapsed', c.collapsed);
3722
+ this.setBox('cluster-ignore', c.ignore);
3723
+ this.setBox('cluster-black-box', c.black_box);
3724
+ md.show('name', c);
3725
+ }
3726
+
3727
+ updateClusterProperties() {
3728
+ // Validates cluster properties, and only updates the edited cluster
3729
+ // if all input is OK.
3730
+ // @@TO DO: prepare for undo
3731
+ const
3732
+ md = this.modals.cluster,
3733
+ c = this.edited_object;
3734
+ // Rename object if name and/or actor have changed
3735
+ let cn = md.element('name').value.trim(),
3736
+ an = md.element('actor').value.trim(),
3737
+ n = c.rename(cn, an);
3738
+ // NOTE: When rename returns FALSE, a warning is already shown.
3739
+ if(n !== true && n !== false) {
3740
+ this.warningEntityExists(n);
3741
+ return false;
3742
+ }
3743
+ // Input is validated => modify cluster properties.
3744
+ c.collapsed = this.boxChecked('cluster-collapsed');
3745
+ c.ignore = this.boxChecked('cluster-ignore');
3746
+ c.black_box = this.boxChecked('cluster-black-box');
3747
+ if(md.group.length > 1) md.updateModifiedProperties(c);
3748
+ // Always redraw the entire diagram, as multiple clusters may have
3749
+ // changed, and 'drawWithLinks' does not work (yet) for clusters.
3750
+ MODEL.focal_cluster.clearAllProcesses();
3751
+ UI.drawDiagram(MODEL);
3752
+ // Restore default dialog title, and hide the options to
3753
+ // collapse, ignore or "black-box" the cluster.
3754
+ md.element('action').innerHTML = 'Add';
3755
+ md.element('options').style.display = 'none';
3756
+ md.hide();
3757
+ return true;
3758
+ }
3759
+
3760
+ // Link modal
3761
+
3762
+ showLinkPropertiesDialog(l, attr='R', alt=false, group=[]) {
3763
+ const
3764
+ from_process = l.from_node instanceof Process,
3765
+ to_process = l.to_node instanceof Process,
3766
+ md = this.modals.link;
3767
+ md.group = group;
3768
+ md.element('from-name').innerHTML = l.from_node.displayName;
3769
+ md.element('to-name').innerHTML = l.to_node.displayName;
3770
+ md.show(attr, l);
3771
+ // NOTE: counter-intuitive, but "level" must always be the "from-unit", as
3772
+ // it is the "per" unit
3773
+ const
3774
+ fu = md.element('from-unit'),
3775
+ tu = md.element('to-unit');
3776
+ if(from_process) {
3777
+ fu.innerHTML = 'level';
3778
+ tu.innerHTML = l.to_node.scale_unit;
3779
+ } else if(to_process) {
3780
+ fu.innerHTML = 'level';
3781
+ tu.innerHTML = l.from_node.scale_unit;
3782
+ } else {
3783
+ // Product-to-product link, so both products have a scale unit
3784
+ fu.innerHTML = l.from_node.scale_unit;
3785
+ tu.innerHTML = l.to_node.scale_unit;
3786
+ }
3787
+ if(l.to_node.is_data) {
3788
+ // Spinning reserve can be "read" only from processes.
3789
+ md.element('spinning').disabled = !from_process;
3790
+ // Throughput can be "read" only from products.
3791
+ md.element('throughput').disabled = from_process;
3792
+ // Allow link type.
3793
+ md.element('multiplier-row').classList.remove('off');
3794
+ } else {
3795
+ // Disallow if TO-node is not a data product
3796
+ md.element('multiplier-row').classList.add('off');
3797
+ }
3798
+ md.element('multiplier').value = l.multiplier;
3799
+ this.updateLinkDataArrows();
3800
+ md.element('D').value = l.flow_delay.text;
3801
+ md.element('R').value = l.relative_rate.text;
3802
+ // NOTE: share of cost is input as a percentage
3803
+ md.element('share-of-cost').value = VM.sig4Dig(100 * l.share_of_cost);
3804
+ // No delay or share of cost for inputs of a process
3805
+ if(to_process) {
3806
+ md.element('output-row').style.display = 'none';
3807
+ } else {
3808
+ md.element('output-row').style.display = 'block';
3809
+ // Share of cost only for outputs of a process
3810
+ if(from_process) {
3811
+ md.element('output-soc').style.display = 'inline-block';
3812
+ } else {
3813
+ md.element('output-soc').style.display = 'none';
3814
+ }
3815
+ }
3816
+ this.edited_object = l;
3817
+ if(alt) md.element(attr + '-x').dispatchEvent(new Event('click'));
3818
+ }
3819
+
3820
+ updateLinkDataArrows() {
3821
+ // Sets the two link arrow symbols in the Link modal header
3822
+ const
3823
+ a1 = document.getElementById('link-arrow-1'),
3824
+ a2 = document.getElementById('link-arrow-2'),
3825
+ lm = document.getElementById('link-multiplier').value,
3826
+ d = document.getElementById('link-D'),
3827
+ deb = document.getElementById('link-D-x');
3828
+ // NOTE: selector value is a string, not a number
3829
+ if(lm === '0') {
3830
+ // Default link symbol is a solid arrow
3831
+ a1.innerHTML = '&#x279D;';
3832
+ a2.innerHTML = '&#x279D;';
3833
+ } else {
3834
+ // Data link symbol is a three-dash arrow
3835
+ a1.innerHTML = '&#x290F;';
3836
+ a2.innerHTML = '&#x290F;';
3837
+ }
3838
+ // NOTE: use == as `lm` is a string.
3839
+ if(lm == VM.LM_PEAK_INC) {
3840
+ // Peak increase data link has no delay.
3841
+ d.disabled = true;
3842
+ d.value = '0';
3843
+ // Also disable its "edit expression" button
3844
+ deb.classList.remove('enab');
3845
+ deb.classList.add('disab');
3846
+ } else {
3847
+ d.disabled = false;
3848
+ deb.classList.remove('disab');
3849
+ deb.classList.add('enab');
3850
+ }
3851
+ }
3852
+
3853
+ updateLinkProperties() {
3854
+ // @@TO DO: prepare for undo
3855
+ const
3856
+ md = this.modals.link,
3857
+ l = this.edited_object;
3858
+ // Check whether all input fields are valid
3859
+ if(!this.updateExpressionInput('link-R', 'rate', l.relative_rate)) {
3860
+ return false;
3861
+ }
3862
+ let soc = this.validNumericInput('link-share-of-cost', 'share of cost');
3863
+ if(soc === false) return false;
3864
+ if(soc < 0 || soc > 100) {
3865
+ md.element('share-of-cost').focus();
3866
+ UI.warn('Share of cost can range from 0 to 100%');
3867
+ return false;
3868
+ }
3869
+ if(!this.updateExpressionInput('link-D', 'delay', l.flow_delay)) {
3870
+ return false;
3871
+ }
3872
+ const
3873
+ m = parseInt(md.element('multiplier').value),
3874
+ redraw = m !== l.multiplier &&
3875
+ (m === VM.LM_FIRST_COMMIT || l.multiplier === VM.LM_FIRST_COMMIT);
3876
+ l.multiplier = m;
3877
+ l.relative_rate.text = md.element('R').value.trim();
3878
+ if(l.multiplier !== VM.LM_LEVEL && soc > 0) {
3879
+ soc = 0;
3880
+ this.warn('Cost can only be attributed to level-based links');
3881
+ }
3882
+ // NOTE: share of cost is input as a percentage, but stored as a floating
3883
+ // point value between 0 and 1
3884
+ l.share_of_cost = soc / 100;
3885
+ if(md.group.length > 1) {
3886
+ // NOTE: Special care must be taken to not set special multipliers
3887
+ // on non-data links, or delay or SoC on process output links.
3888
+ // The groupPropertiesDialog should do this.
3889
+ md.updateModifiedProperties(l);
3890
+ // Redraw the entire diagram, as many arrows may have changed.
3891
+ MODEL.focal_cluster.clearAllProcesses();
3892
+ UI.drawDiagram(MODEL);
3893
+ } else {
3894
+ // Redraw the arrow shape that represents the edited link
3895
+ this.paper.drawArrow(this.on_arrow);
3896
+ // Redraw the FROM node if link has become (or no longer is) "first commit"
3897
+ if(redraw) this.drawObject(this.on_arrow.from_node);
3898
+ }
3899
+ md.hide();
3900
+ }
3901
+
3902
+ // NOTE: The constraint modal is controlled by the global instance of
3903
+ // class ConstraintEditor.
3904
+
3905
+ showConstraintPropertiesDialog(c) {
3906
+ // Display the constraint editor
3907
+ document.getElementById(
3908
+ 'constraint-from-name').innerHTML = c.from_node.displayName;
3909
+ document.getElementById(
3910
+ 'constraint-to-name').innerHTML = c.to_node.displayName;
3911
+ CONSTRAINT_EDITOR.showDialog();
3912
+ }
3913
+
3914
+ showReplaceProductDialog(p) {
3915
+ // Prompts for a product (different from `p`) by which `p` should be
3916
+ // replaced for the selected product position
3917
+ const pp = MODEL.focal_cluster.indexOfProduct(p);
3918
+ if(pp >= 0) {
3919
+ MODEL.clearSelection();
3920
+ MODEL.selectList([p]);
3921
+ this.drawObject(p);
3922
+ // Make list of nodes related to P by links
3923
+ const rel_nodes = [];
3924
+ for(let i = 0; i < p.inputs.length; i++) {
3925
+ rel_nodes.push(p.inputs[i].from_node);
3926
+ }
3927
+ for(let i = 0; i < p.outputs.length; i++) {
3928
+ rel_nodes.push(p.outputs[i].to_node);
3929
+ }
3930
+ const options = [];
3931
+ for(let i in MODEL.products) if(MODEL.products.hasOwnProperty(i) &&
3932
+ // NOTE: do not show "black-boxed" products
3933
+ !i.startsWith(UI.BLACK_BOX)) {
3934
+ const po = MODEL.products[i];
3935
+ // Skip the product that is to be replaced, an also products having a
3936
+ // different type (regular product or data product)
3937
+ if(po !== p && po.is_data === p.is_data) {
3938
+ // NOTE: also skip products PO that are linked to a node Q that is
3939
+ // already linked to P (as replacing would then create a two-way link)
3940
+ let no_rel = true;
3941
+ for(let j = 0; j < po.inputs.length; j++) {
3942
+ if(rel_nodes.indexOf(po.inputs[j].from_node) >= 0) {
3943
+ no_rel = false;
3944
+ break;
3945
+ }
3946
+ }
3947
+ for(let j = 0; j < po.outputs.length; j++) {
3948
+ if(rel_nodes.indexOf(po.outputs[j].to_node) >= 0) {
3949
+ no_rel = false;
3950
+ break;
3951
+ }
3952
+ }
3953
+ if(no_rel) options.push('<option text="', po.displayName, '">',
3954
+ po.displayName, '</option>');
3955
+ }
3956
+ }
3957
+ const md = this.modals.replace;
3958
+ if(options.length > 0) {
3959
+ md.element('by-name').innerHTML = options.join('');
3960
+ const pne = md.element('product-name');
3961
+ pne.innerHTML = p.displayName;
3962
+ // Show that product is data by a dashed underline
3963
+ if(p.is_data) {
3964
+ pne.classList.add('is-data');
3965
+ } else {
3966
+ pne.classList.remove('is-data');
3967
+ }
3968
+ // By default, replace only locally
3969
+ this.setBox('replace-local', true);
3970
+ md.show();
3971
+ } else {
3972
+ this.warn('No eligable products to replace ' + p.displayName);
3973
+ }
3974
+ }
3975
+ }
3976
+
3977
+ replaceProduct() {
3978
+ // Replace occurrence(s) of specified product P by product R
3979
+ // NOTE: P is still selected, so clear it
3980
+ MODEL.clearSelection();
3981
+ const
3982
+ md = this.modals.replace,
3983
+ erp = md.element('product-name'),
3984
+ erb = md.element('by-name'),
3985
+ global = !this.boxChecked('replace-local');
3986
+ if(erp && erb) {
3987
+ const
3988
+ p = MODEL.objectByName(erp.innerHTML),
3989
+ rname = erb.options[erb.selectedIndex].text,
3990
+ r = MODEL.objectByName(rname);
3991
+ if(p instanceof Product) {
3992
+ if(r instanceof Product) {
3993
+ MODEL.replaceProduct(p, r, global);
3994
+ md.hide();
3995
+ } else {
3996
+ UI.warn(`No product "${rname}"`);
3997
+ }
3998
+ } else {
3999
+ UI.warn(`No product "${erp.text}"`);
4000
+ }
4001
+ }
4002
+ }
4003
+
4004
+ } // END of class GUIController
4005
+