linny-r 2.0.8 → 2.0.10

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 (36) hide show
  1. package/README.md +3 -40
  2. package/package.json +6 -2
  3. package/server.js +19 -157
  4. package/static/images/solve-not-same-changed.png +0 -0
  5. package/static/images/solve-not-same-not-changed.png +0 -0
  6. package/static/images/solve-same-changed.png +0 -0
  7. package/static/images/solve-same-not-changed.png +0 -0
  8. package/static/index.html +137 -20
  9. package/static/linny-r.css +260 -23
  10. package/static/scripts/iro.min.js +7 -7
  11. package/static/scripts/linny-r-ctrl.js +126 -85
  12. package/static/scripts/linny-r-gui-actor-manager.js +23 -33
  13. package/static/scripts/linny-r-gui-chart-manager.js +56 -53
  14. package/static/scripts/linny-r-gui-constraint-editor.js +10 -14
  15. package/static/scripts/linny-r-gui-controller.js +644 -260
  16. package/static/scripts/linny-r-gui-dataset-manager.js +64 -66
  17. package/static/scripts/linny-r-gui-documentation-manager.js +11 -17
  18. package/static/scripts/linny-r-gui-equation-manager.js +22 -22
  19. package/static/scripts/linny-r-gui-experiment-manager.js +124 -141
  20. package/static/scripts/linny-r-gui-expression-editor.js +26 -12
  21. package/static/scripts/linny-r-gui-file-manager.js +42 -48
  22. package/static/scripts/linny-r-gui-finder.js +294 -55
  23. package/static/scripts/linny-r-gui-model-autosaver.js +2 -4
  24. package/static/scripts/linny-r-gui-monitor.js +35 -41
  25. package/static/scripts/linny-r-gui-paper.js +42 -70
  26. package/static/scripts/linny-r-gui-power-grid-manager.js +31 -34
  27. package/static/scripts/linny-r-gui-receiver.js +1 -2
  28. package/static/scripts/linny-r-gui-repository-browser.js +44 -46
  29. package/static/scripts/linny-r-gui-scale-unit-manager.js +32 -32
  30. package/static/scripts/linny-r-gui-sensitivity-analysis.js +61 -67
  31. package/static/scripts/linny-r-gui-undo-redo.js +94 -95
  32. package/static/scripts/linny-r-milp.js +20 -24
  33. package/static/scripts/linny-r-model.js +1932 -2274
  34. package/static/scripts/linny-r-utils.js +27 -27
  35. package/static/scripts/linny-r-vm.js +900 -998
  36. package/static/show-png.html +0 -113
@@ -75,6 +75,11 @@ class ModalDialog {
75
75
  // Make this modal dialog invisible.
76
76
  this.modal.style.display = 'none';
77
77
  }
78
+
79
+ get showing() {
80
+ // Return TRUE iff this modal dialog is visible.
81
+ return this.modal.style.display === 'block';
82
+ }
78
83
 
79
84
  } // END of class ModalDialog
80
85
 
@@ -85,15 +90,19 @@ class ModalDialog {
85
90
  // such that fields[name] is the entity property name that corresponds
86
91
  // with the DOM input element for that property. For example, for the
87
92
  // process group properties dialog, fields['LB'] = 'lower_bound' to
88
- // indicate that the DOM element having id="process_LB" corresponds to
93
+ // indicate that the DOM element having id="process-LB" corresponds to
89
94
  // the property `p.lower_bound` of process `p`.
90
95
  class GroupPropertiesDialog extends ModalDialog {
91
96
  constructor(id, fields) {
92
97
  super(id);
98
+ // `fields` is the object that relates HTML elements to properties.
93
99
  this.fields = fields;
94
100
  // `group` holds the entities (all of the same type) that should be
95
101
  // updated when the OK-button of the dialog is clicked.
96
102
  this.group = [];
103
+ // `selected_ds` is the dataset that was selected in the Finder when
104
+ // opening this dialog, or the first dataset in the group list.
105
+ this.selected_ds = null;
97
106
  // `initial_values` is a "dictionary" with (field name, value) entries
98
107
  // that hold the initial values of the group-editable properties.
99
108
  this.initial = {};
@@ -123,28 +132,58 @@ class GroupPropertiesDialog extends ModalDialog {
123
132
  e.addEventListener('keydown', fnc);
124
133
  }
125
134
  }
135
+ const spe = this.element('prefix');
136
+ if(spe) {
137
+ spe.addEventListener('keydown', fnc);
138
+ document.getElementById('dsg-add-modif-btn').addEventListener(
139
+ 'click', () => UI.modals.datasetgroup.promptForSelector('Add'));
140
+ document.getElementById('dsg-rename-modif-btn').addEventListener(
141
+ 'click', () => UI.modals.datasetgroup.promptForSelector('Rename'));
142
+ document.getElementById('dsg-edit-modif-btn').addEventListener(
143
+ 'click', () => UI.modals.datasetgroup.editExpression());
144
+ document.getElementById('dsg-delete-modif-btn').addEventListener(
145
+ 'click', () => UI.modals.datasetgroup.deleteModifier());
146
+ this.selector_modal = new ModalDialog('group-selector');
147
+ this.selector_modal.ok.addEventListener(
148
+ 'click', () => UI.modals.datasetgroup.selectorAction());
149
+ this.selector_modal.cancel.addEventListener(
150
+ 'click', () => UI.modals.datasetgroup.selector_modal.hide());
151
+ }
126
152
  }
127
153
 
128
154
  resetFields() {
129
155
  // Remove all class names from fields that relate to their "same"
130
156
  // 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));
157
+ function stripClassList(e) {
158
+ if(e) {
159
+ const cl = e.classList;
160
+ while(cl.length > 0 && cl.item(cl.length - 1).indexOf('same-') >= 0) {
161
+ cl.remove(cl.item(cl.length - 1));
162
+ }
135
163
  }
136
164
  }
165
+ for(let name in this.fields) if(this.initial.hasOwnProperty(name)) {
166
+ stripClassList(this.element(name));
167
+ }
168
+ stripClassList(this.element('prefix'));
137
169
  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;
170
+ for(const id of ['name', 'actor', 'cluster']) {
171
+ const e = this.element(id);
172
+ if(e) e.disabled = false;
173
+ }
174
+ const e = this.element('io');
175
+ if(e) e.style.display = 'block';
144
176
  this.group.length = 0;
145
177
  this.initial = {};
146
178
  this.same = {};
147
179
  this.changed = {};
180
+ this.shared_prefix = '';
181
+ this.selectors = {};
182
+ this.selected_selector = '';
183
+ this.default_selectors = [];
184
+ this.new_defsel = false;
185
+ this.same_defsel = true;
186
+ this.last_time_clicked = 0;
148
187
  }
149
188
 
150
189
  setFields(obj) {
@@ -196,23 +235,212 @@ class GroupPropertiesDialog extends ModalDialog {
196
235
  this.same[name] = same;
197
236
  }
198
237
  }
238
+ // For the dataset group dialog, more fields must be determined.
239
+ if(obj instanceof Dataset) {
240
+ // Determine the longest prefix shared by ALL datasets in the group.
241
+ this.shared_prefix = UI.sharedPrefix(obj.name, obj.name);
242
+ for(const ds of this.group) {
243
+ const sp = UI.sharedPrefix(obj.name, ds.name);
244
+ if(sp && this.shared_prefix.startsWith(sp)) {
245
+ this.shared_prefix = sp;
246
+ } else if(!sp.startsWith(this.shared_prefix)) {
247
+ this.shared_prefix = '';
248
+ break;
249
+ }
250
+ }
251
+ this.element('prefix').value = this.shared_prefix;
252
+ // Determine the set of all dataset modifier selectors while counting
253
+ // the number of occurrences of each selector and checking whether
254
+ // the modifier expressions are identical.
255
+ // NOTE: Here, too, take `obj` as the reference object.
256
+ this.selectors = {};
257
+ this.selected_selector = '';
258
+ this.default_selectors = [];
259
+ this.new_defsel = false;
260
+ this.same_defsel = true;
261
+ if(obj.default_selector) {
262
+ this.default_selectors.push(UI.nameToID(obj.default_selector));
263
+ }
264
+ for(const k of Object.keys(obj.modifiers)) {
265
+ const dsm = obj.modifiers[k];
266
+ this.selectors[k] = {
267
+ count: 1,
268
+ sel: dsm.selector,
269
+ expr: dsm.expression.text,
270
+ same_x: true,
271
+ new_s: false,
272
+ new_x: false,
273
+ deleted: false
274
+ };
275
+ }
276
+ // Then iterate over all datasets, excluding `obj`.
277
+ for(const ds of this.group) if(ds !== obj) {
278
+ const defsel = UI.nameToID(ds.default_selector);
279
+ if(this.default_selectors.indexOf(defsel) < 0) this.same_defsel = false;
280
+ if(defsel) addDistinct(defsel, this.default_selectors);
281
+ for(const k of Object.keys(ds.modifiers)) {
282
+ const
283
+ dsm = ds.modifiers[k],
284
+ s = this.selectors[k];
285
+ if(s) {
286
+ s.count++;
287
+ s.same_x = s.same_x && dsm.expression.text === s.expr;
288
+ } else {
289
+ this.selectors[k] = {
290
+ count: 1,
291
+ sel: dsm.selector,
292
+ expr: dsm.expression.text,
293
+ same_x: true,
294
+ new_s: false,
295
+ new_x: false,
296
+ deleted: false
297
+ };
298
+ }
299
+ }
300
+ }
301
+ // Selectors are not "same" when they do not apply to all datasets
302
+ // in the group.
303
+ const n = this.group.length;
304
+ for(const k of Object.keys(this.selectors)) {
305
+ const s = this.selectors[k];
306
+ s.same_s = s.count === n;
307
+ }
308
+ this.updateModifierList();
309
+ }
310
+ }
311
+
312
+ updateModifierList() {
313
+ // Display the modifier set for a dataset group.
314
+ const
315
+ trl = [],
316
+ not = (x) => { return (x === false ? 'not-' : ''); },
317
+ mdef = (this.new_defsel !== false ? this.new_defsel :
318
+ (this.default_selectors.length ? this.default_selectors[0] : '')),
319
+ sdef = not(this.same_defsel),
320
+ cdef = not(this.new_defsel);
321
+ for(const k of Object.keys(this.selectors)) {
322
+ const
323
+ s = this.selectors[k],
324
+ ms = (s.new_s === false ? s.sel : s.new_s),
325
+ mx = (s.new_x === false ? s.expr : s.new_x),
326
+ wild = (ms.indexOf('*') >= 0 || ms.indexOf('?') >= 0),
327
+ clk = `" onclick="UI.modals.datasetgroup.selectGroupSelector(event, \'${k}\'`;
328
+ // Do not display deleted modifiers.
329
+ if(s.deleted) continue;
330
+ trl.push(['<tr id="dsgs-', k, '" class="dataset-modif',
331
+ (k === this.selected_selector ? ' sel-set' : ''),
332
+ '"><td class="dataset-selector',
333
+ ` ${not(s.same_s)}same-${not(s.new_s)}changed`,
334
+ (wild ? ' wildcard' : ''),
335
+ '" title="Shift-click to ', (s.defsel ? 'clear' : 'set as'),
336
+ ' default modifier', clk, ', false);">',
337
+ (k === mdef ||
338
+ (this.new_defsel === false && this.default_selectors.indexOf(k) >= 0) ?
339
+ `<img src="images/solve-${sdef}same-${cdef}changed.png" ` +
340
+ 'style="height: 14px; width: 14px; margin: 0 1px -3px -1px;">' : ''),
341
+ (wild ? wildcardFormat(ms, true) : ms),
342
+ '</td><td class="dataset-expression',
343
+ ` ${not(s.same_x)}same-${not(s.new_x)}changed`, clk,
344
+ ', true);">', mx, '</td></tr>'
345
+ ].join(''));
346
+ }
347
+ this.element('modif-table').innerHTML = trl.join('');
348
+ if(this.selected_selector) UI.scrollIntoView(
349
+ document.getElementById('dsg-' + this.selected_selector));
350
+ const btns = 'dsg-rename-modif dsg-edit-modif dsg-delete-modif';
351
+ if(this.selected_selector) {
352
+ UI.enableButtons(btns);
353
+ } else {
354
+ UI.disableButtons(btns);
355
+ }
199
356
  }
200
357
 
358
+ selectGroupSelector(event, id, x=true) {
359
+ // Select modifier selector, or when double-clicked, edit its expression when
360
+ // x = TRUE, or the name of the modifier when x = FALSE.
361
+ const edit = event.altKey || this.doubleClicked(id);
362
+ this.selected_selector = id;
363
+ if(edit) {
364
+ this.last_time_clicked = 0;
365
+ if(x) {
366
+ this.editExpression();
367
+ } else {
368
+ this.promptForSelector('Rename');
369
+ }
370
+ return;
371
+ }
372
+ if(event.shiftKey) {
373
+ // Toggle new default selector.
374
+ this.new_defsel = (this.new_defsel === id ? '' : id);
375
+ }
376
+ this.updateModifierList();
377
+ }
378
+
379
+ doubleClicked(s) {
380
+ const
381
+ now = Date.now(),
382
+ dt = now - this.last_time_clicked;
383
+ this.last_time_clicked = now;
384
+ if(s === this.selected_selector) {
385
+ // Consider click to be "double" if it occurred less than 300 ms ago.
386
+ if(dt < 300) {
387
+ this.last_time_clicked = 0;
388
+ return true;
389
+ }
390
+ }
391
+ this.clicked_selector = s;
392
+ return false;
393
+ }
394
+
395
+ enterKey() {
396
+ // Open "edit" dialog for the selected modifier.
397
+ const
398
+ tbl = this.element('modif-table'),
399
+ srl = tbl.getElementsByClassName('sel-set');
400
+ if(srl.length > 0) {
401
+ const r = tbl.rows[srl[0].rowIndex];
402
+ if(r) {
403
+ // Emulate a double-click on the second cell to edit the expression.
404
+ this.last_time_clicked = Date.now();
405
+ r.cells[1].dispatchEvent(new Event('click'));
406
+ }
407
+ }
408
+ }
409
+
410
+ upDownKey(dir) {
411
+ // Select row above or below the selected one (if possible).
412
+ const
413
+ tbl = this.element('modif-table'),
414
+ srl = tbl.getElementsByClassName('sel-set');
415
+ if(srl.length > 0) {
416
+ let r = tbl.rows[srl[0].rowIndex + dir];
417
+ while(r && r.style.display === 'none') {
418
+ r = (dir > 0 ? r.nextSibling : r.previousSibling);
419
+ }
420
+ if(r) {
421
+ UI.scrollIntoView(r);
422
+ // NOTE: Cell, not row, listens for onclick event.
423
+ r.cells[1].dispatchEvent(new Event('click'));
424
+ }
425
+ }
426
+ }
427
+
201
428
  show(attr, obj) {
202
429
  // Make dialog visible with same/changed status and disabled name,
203
430
  // actor and cluster fields.
204
431
  // NOTE: Cluster dialog is also used to *add* a new cluster, and in
205
- // that case no fields should be set
432
+ // that case no fields should be set.
206
433
  if(obj) this.setFields(obj);
207
434
  if(obj && this.group.length > 0) {
208
435
  this.element('group').innerText = `(N=${this.group.length})`;
209
- // Disable name, actor and cluster fields if they exist.
210
- let e = this.element('name');
211
- if(e) e.disabled = true;
212
- e = this.element('actor');
213
- if(e) e.disabled = true;
214
- e = this.element('cluster');
215
- if(e) e.disabled = true;
436
+ // Disable name, actor, and cluster fields if they exist.
437
+ for(const id of ['name', 'actor', 'cluster']) {
438
+ const e = this.element(id);
439
+ if(e) e.disabled = true;
440
+ }
441
+ // Hide io field if it exists.
442
+ const e = this.element('io');
443
+ if(e) e.style.display = 'none';
216
444
  // Set the right colors to reflect same and changed status.
217
445
  this.highlightModifiedFields();
218
446
  }
@@ -231,6 +459,7 @@ class GroupPropertiesDialog extends ModalDialog {
231
459
  // Set the CSS classes of fields so that they reflect their "same"
232
460
  // and "changed" status.
233
461
  if(this.group.length === 0) return;
462
+ const not = {false: 'not-', true: ''};
234
463
  for(let name in this.initial) if(this.initial.hasOwnProperty(name)) {
235
464
  const
236
465
  iv = this.initial[name],
@@ -238,7 +467,6 @@ class GroupPropertiesDialog extends ModalDialog {
238
467
  // for which `same[name]` is TRUE iff all entities had identical
239
468
  // values for the property identified by `name` when the dialog
240
469
  // was opened.
241
- not = {false: 'not-', true: ''},
242
470
  same = `${not[this.same[name]]}same`,
243
471
  el = this.element(name);
244
472
  let changed = false,
@@ -261,10 +489,20 @@ class GroupPropertiesDialog extends ModalDialog {
261
489
  // Compute current value as Boolean.
262
490
  const v = (type === 'box' ? state ==='checked' : state === 'eq');
263
491
  changed = (v !== iv);
492
+ // When array box for dataset group is (un)checked, the time aspects
493
+ // cover div must be hidden (shown).
494
+ if(name === 'array') {
495
+ this.element('no-time-msg').style.display = (v ? 'block' : 'none');
496
+ }
264
497
  }
265
498
  this.changed[name] = changed;
266
499
  el.className = `${type} ${state} ${same}-${not[changed]}changed`.trim();
267
500
  }
501
+ const spe = this.element('prefix');
502
+ if(spe) {
503
+ const changed = spe.value !== this.shared_prefix;
504
+ spe.className = `same-${not[changed]}changed`;
505
+ }
268
506
  }
269
507
 
270
508
  validLinkProperty(link, property, value=0) {
@@ -298,8 +536,7 @@ class GroupPropertiesDialog extends ModalDialog {
298
536
  const
299
537
  propname = this.fields[name],
300
538
  prop = obj[propname];
301
- for(let i = 0; i < this.group.length; i++) {
302
- const ge = this.group[i];
539
+ for(const ge of this.group) {
303
540
  // NOTE: For links, special care must be taken.
304
541
  if(!(ge instanceof Link) ||
305
542
  this.validLinkProperty(ge, propname, prop)) {
@@ -314,7 +551,105 @@ class GroupPropertiesDialog extends ModalDialog {
314
551
  }
315
552
  }
316
553
  }
554
+
555
+ promptForSelector(action) {
556
+ // Open the group selector modal for the specified action.
557
+ let ms = '',
558
+ md = this.selector_modal;
559
+ if(action === 'Rename') {
560
+ ms = this.selectors[this.selected_selector].sel;
561
+ }
562
+ md.element('action').innerText = action;
563
+ md.element('name').value = ms;
564
+ md.show('name');
565
+ }
566
+
567
+ selectorAction() {
568
+ // Perform the specified selector action.
569
+ const
570
+ md = this.selector_modal,
571
+ action = md.element('action').innerText,
572
+ ne = md.element('name'),
573
+ ms = MODEL.validSelector(ne.value);
574
+ if(!ms) {
575
+ ne.focus();
576
+ return;
577
+ }
578
+ const ok = (action === 'Add' ? this.addSelector(ms) : this.renameSelector(ms));
579
+ if(ok) {
580
+ md.hide();
581
+ this.updateModifierList();
582
+ }
583
+ }
584
+
585
+ addSelector(ms) {
586
+ // Create a new selector and adds it to the list.
587
+ const k = UI.nameToID(ms);
588
+ if(!this.selectors.hasOwnProperty(k)) {
589
+ this.selectors[k] = {
590
+ count: 1,
591
+ sel: ms,
592
+ expr: '',
593
+ same_x: true,
594
+ new_s: ms,
595
+ new_x: '',
596
+ deleted: false
597
+ };
598
+ }
599
+ this.selected_selector = k;
600
+ return true;
601
+ }
602
+
603
+ renameSelector(ms) {
604
+ // Record the new name for this selector as property `new_s`.
605
+ if(this.selected_selector) {
606
+ const sel = this.selectors[this.selected_selector];
607
+ // NOTES:
608
+ // (1) When renaming, the old name is be preserved.
609
+ // (2) Name changes do not affect the key of the selector.
610
+ // (3) When the new name is identical to the original, record this
611
+ // by setting `new_s` to FALSE.
612
+ sel.new_s = (ms === sel.sel ? false : ms);
613
+ }
614
+ return true;
615
+ }
317
616
 
617
+ editExpression() {
618
+ // Open the Expression editor for the selected expression.
619
+ const sel = this.selectors[this.selected_selector];
620
+ if(sel) {
621
+ const md = UI.modals.expression;
622
+ md.element('property').innerHTML = '(dataset group)' +
623
+ UI.OA_SEPARATOR + sel.sel;
624
+ md.element('text').value = sel.new_x || sel.expr;
625
+ document.getElementById('variable-obj').value = 0;
626
+ X_EDIT.updateVariableBar();
627
+ X_EDIT.clearStatusBar();
628
+ X_EDIT.showPrefix(this.shared_prefix);
629
+ md.show('text');
630
+ }
631
+ }
632
+
633
+ modifyExpression(x) {
634
+ // Record the new expression for the selected selector.
635
+ // NOTE: Expressions are compiled when changes are saved.
636
+ const sel = this.selectors[this.selected_selector];
637
+ // NOTE: When the new expression is identical to the original,
638
+ // record this by setting `new_x` to FALSE.
639
+ if(sel) sel.new_x = (x === sel.expr ? false : x);
640
+ this.updateModifierList();
641
+ }
642
+
643
+ deleteModifier() {
644
+ // Record that the selected modifier should be deleted.
645
+ const sel = this.selectors[this.selected_selector];
646
+ if(sel) {
647
+ sel.deleted = true;
648
+ this.selected_selector = '';
649
+ this.updateModifierList();
650
+ }
651
+ }
652
+
318
653
  } // END of class GroupPropertiesDialog
319
654
 
320
655
 
@@ -332,12 +667,9 @@ class GUIController extends Controller {
332
667
  ['chrome', 'Chrome'],
333
668
  ['firefox', 'Firefox'],
334
669
  ['safari', 'Safari']];
335
- for(let i = 0; i < browsers.length; i++) {
336
- const b = browsers[i];
337
- if(ua.indexOf(b[0]) >= 0) {
338
- this.browser_name = b[1];
339
- break;
340
- }
670
+ for(const b of browsers) if(ua.indexOf(b[0]) >= 0) {
671
+ this.browser_name = b[1];
672
+ break;
341
673
  }
342
674
  // Display version number as clickable link just below the Linny-R logo.
343
675
  this.version_number = LINNY_R_VERSION;
@@ -363,12 +695,22 @@ class GUIController extends Controller {
363
695
  this.mouse_y = 0;
364
696
  this.mouse_down_x = 0;
365
697
  this.mouse_down_y = 0;
698
+ // When clicking on a node, difference between cursor coordinates
699
+ // and node coordinates is recorded.
366
700
  this.move_dx = 0;
367
701
  this.move_dy = 0;
368
- this.start_sel_x = -1;
369
- this.start_sel_y = -1;
702
+ // When moving the cursor, the cumulative movement since the last
703
+ // mouse DOWN or UP event is recorded.
704
+ this.net_move_x = 0;
705
+ this.net_move_y = 0;
706
+ // When mouse button is pressed while some add button is active,
707
+ // the coordinates of the cursor are recorded.
370
708
  this.add_x = 0;
371
709
  this.add_y = 0;
710
+ // When mouse button is pressed while no node is under the cursor,
711
+ // cursor coordinates are recorded as origin of the drag rectangle.
712
+ this.start_sel_x = -1;
713
+ this.start_sel_y = -1;
372
714
  this.on_node = null;
373
715
  this.on_arrow = null;
374
716
  this.on_link = null;
@@ -393,7 +735,6 @@ class GUIController extends Controller {
393
735
  'D': 'dataset',
394
736
  'E': 'equation',
395
737
  'F': 'finder',
396
- 'G': 'savediagram', // G for "Graph" (as Scalable Vector Graphics image)
397
738
  'H': 'receiver', // activate receiver (H for "Host")
398
739
  'I': 'documentation',
399
740
  'J': 'sensitivity', // J for "Jitter"
@@ -421,7 +762,7 @@ class GUIController extends Controller {
421
762
  this.edit_btns = ['replace', 'clone', 'paste', 'delete', 'undo', 'redo'];
422
763
  this.model_btns = ['settings', 'save', 'repository', 'actors',
423
764
  'dataset', 'equation', 'chart', 'sensitivity', 'experiment',
424
- 'diagram', 'savediagram', 'finder', 'monitor', 'tex', 'solve'];
765
+ 'savediagram', 'finder', 'monitor', 'tex', 'solve'];
425
766
  this.other_btns = ['new', 'load', 'receiver', 'documentation',
426
767
  'parent', 'lift', 'solve', 'stop', 'reset', 'zoomin', 'zoomout',
427
768
  'stepback', 'stepforward', 'autosave', 'recall'];
@@ -429,8 +770,7 @@ class GUIController extends Controller {
429
770
  this.edit_btns, this.model_btns, this.other_btns);
430
771
 
431
772
  // Add all button DOM elements as controller properties.
432
- for(let i = 0; i < this.all_btns.length; i++) {
433
- const b = this.all_btns[i];
773
+ for(const b of this.all_btns) {
434
774
  this.buttons[b] = document.getElementById(b + '-btn');
435
775
  }
436
776
  this.active_button = null;
@@ -450,10 +790,9 @@ class GUIController extends Controller {
450
790
  const main_modals = ['logon', 'model', 'load', 'password', 'settings',
451
791
  'actors', 'add-process', 'add-product', 'move', 'note', 'clone',
452
792
  'replace', 'expression', 'server', 'solver'];
453
- for(let i = 0; i < main_modals.length; i++) {
454
- this.modals[main_modals[i]] = new ModalDialog(main_modals[i]);
455
- }
793
+ for(const m of main_modals) this.modals[m] = new ModalDialog(m);
456
794
 
795
+ // Property dialogs for entities may permit group editing.
457
796
  this.modals.cluster = new GroupPropertiesDialog('cluster', {
458
797
  'collapsed': 'collapsed',
459
798
  'ignore': 'ignore',
@@ -496,6 +835,17 @@ class GUIController extends Controller {
496
835
  'no-links': 'no_links'
497
836
  });
498
837
 
838
+ // The Dataset group modal.
839
+ this.modals.datasetgroup = new GroupPropertiesDialog('datasetgroup', {
840
+ 'default': 'default_value',
841
+ 'unit': 'scale_unit',
842
+ 'periodic': 'periodic',
843
+ 'array': 'array',
844
+ 'time-scale': 'time_scale',
845
+ 'time-unit': 'time_unit',
846
+ 'method': 'method'
847
+ });
848
+
499
849
  // Initially, no dialog being dragged or resized.
500
850
  this.dr_dialog = null;
501
851
 
@@ -534,12 +884,18 @@ class GUIController extends Controller {
534
884
  this.cc.addEventListener('drop', (event) => UI.drop(event));
535
885
 
536
886
  // Disable dragging on all images.
537
- const
538
- imgs = document.getElementsByTagName('img'),
539
- nodrag = (event) => { event.preventDefault(); return false; };
540
- for(let i = 0; i < imgs.length; i++) {
541
- imgs[i].addEventListener('dragstart', nodrag);
887
+ const nodrag = (event) => { event.preventDefault(); return false; };
888
+ for(const img of document.getElementsByTagName('img')) {
889
+ img.addEventListener('dragstart', nodrag);
542
890
  }
891
+
892
+ // Moving cursor over Linny-R logo etc. should display information
893
+ // in Information & Documentation manager.
894
+ const lrf = () => DOCUMENTATION_MANAGER.clearEntity(true);
895
+ document.getElementById('static-icon').addEventListener('mousemove', lrf);
896
+ document.getElementById('linny-r-name').addEventListener('mousemove', lrf);
897
+ document.getElementById('linny-r-version-number')
898
+ .addEventListener('mousemove', lrf);
543
899
 
544
900
  // Make all buttons respond to a mouse click.
545
901
  this.buttons['new'].addEventListener('click',
@@ -552,10 +908,8 @@ class GUIController extends Controller {
552
908
  () => FILE_MANAGER.saveModel(event.shiftKey));
553
909
  this.buttons.actors.addEventListener('click',
554
910
  () => ACTOR_MANAGER.showDialog());
555
- this.buttons.diagram.addEventListener('click',
556
- () => FILE_MANAGER.renderDiagramAsPNG(event.shiftKey));
557
911
  this.buttons.savediagram.addEventListener('click',
558
- () => FILE_MANAGER.saveDiagramAsSVG(event.shiftKey));
912
+ () => FILE_MANAGER.saveDiagramAsSVG(event));
559
913
  this.buttons.receiver.addEventListener('click',
560
914
  () => RECEIVER.toggle());
561
915
  // NOTE: All draggable & resizable dialogs "toggle" show/hide.
@@ -651,11 +1005,9 @@ class GUIController extends Controller {
651
1005
  () => AUTO_SAVE.getAutoSavedModels());
652
1006
 
653
1007
  // Make "stay active" buttons respond to Shift-click.
654
- const
655
- tbs = document.getElementsByClassName('toggle'),
656
- tf = (event) => UI.toggleButton(event);
657
- for(let i = 0; i < tbs.length; i++) {
658
- tbs[i].addEventListener('click', tf);
1008
+ const tf = (event) => UI.toggleButton(event);
1009
+ for(const tb of document.getElementsByClassName('toggle')) {
1010
+ tb.addEventListener('click', tf);
659
1011
  }
660
1012
 
661
1013
  // Add listeners to OK and CANCEL buttons on main modal dialogs.
@@ -805,6 +1157,11 @@ class GUIController extends Controller {
805
1157
  this.modals.product.element('io').addEventListener('click',
806
1158
  () => UI.toggleImportExportBox('product'));
807
1159
 
1160
+ this.modals.datasetgroup.ok.addEventListener('click',
1161
+ () => FINDER.updateDatasetGroupProperties());
1162
+ this.modals.datasetgroup.cancel.addEventListener('click',
1163
+ () => UI.modals.datasetgroup.hide());
1164
+
808
1165
  this.modals.link.ok.addEventListener('click',
809
1166
  () => UI.updateLinkProperties());
810
1167
  this.modals.link.cancel.addEventListener('click',
@@ -857,18 +1214,16 @@ class GUIController extends Controller {
857
1214
 
858
1215
  // Make checkboxes respond to click.
859
1216
  // NOTE: Checkbox-specific events must be bound AFTER this general setting.
860
- const
861
- cbs = document.getElementsByClassName('box'),
862
- cbf = (event) => UI.toggleBox(event);
863
- for(let i = 0; i < cbs.length; i++) {
864
- cbs[i].addEventListener('click', cbf);
1217
+ const cbf = (event) => UI.toggleBox(event);
1218
+ for(const cb of document.getElementsByClassName('box')) {
1219
+ cb.addEventListener('click', cbf);
865
1220
  }
866
- // Make infoline respond to `mouseenter`
1221
+ // Make infoline respond to `mouseenter`.
867
1222
  this.info_line = document.getElementById('info-line');
868
1223
  this.info_line.addEventListener('mouseenter',
869
1224
  (event) => DOCUMENTATION_MANAGER.showInfoMessages(event.shiftKey));
870
1225
  // Ensure that all modal windows respond to ESCape
871
- // (and more in general to other special keys)
1226
+ // (and more in general to other special keys).
872
1227
  document.addEventListener('keydown', (event) => UI.checkModals(event));
873
1228
  }
874
1229
 
@@ -993,8 +1348,7 @@ class GUIController extends Controller {
993
1348
 
994
1349
  drawLinkArrows(cluster, link) {
995
1350
  // Draw all arrows in `cluster` that represent `link`.
996
- for(let i = 0; i < cluster.arrows.length; i++) {
997
- const a = cluster.arrows[i];
1351
+ for(const a of cluster.arrows) {
998
1352
  if(a.links.indexOf(link) >= 0) this.paper.drawArrow(a);
999
1353
  }
1000
1354
  }
@@ -1011,8 +1365,7 @@ class GUIController extends Controller {
1011
1365
  if(VM.server === 'local host') {
1012
1366
  host.title = 'Linny-R directory is ' + VM.working_directory;
1013
1367
  }
1014
- for(let i = 0; i < VM.solver_list.length; i++) {
1015
- const s = VM.solver_list[i];
1368
+ for(const s of VM.solver_list) {
1016
1369
  html.push(['<option value="', s,
1017
1370
  (s === VM.solver_id ? '"selected="selected' : ''),
1018
1371
  '">', VM.solver_names[s], '</option>'].join(''));
@@ -1562,10 +1915,10 @@ class GUIController extends Controller {
1562
1915
 
1563
1916
  reorderDialogs() {
1564
1917
  // Set z-index of draggable dialogs according to their order
1565
- // (most recently shown or clicked on top)
1918
+ // (most recently shown or clicked on top).
1566
1919
  let z = 10;
1567
- for(let i = 0; i < this.dr_dialog_order.length; i++) {
1568
- this.dr_dialog_order[i].style.zIndex = z;
1920
+ for(const dd of this.dr_dialog_order) {
1921
+ dd.style.zIndex = z;
1569
1922
  z += 5;
1570
1923
  }
1571
1924
  }
@@ -1575,18 +1928,16 @@ class GUIController extends Controller {
1575
1928
  //
1576
1929
 
1577
1930
  enableButtons(btns) {
1578
- btns = btns.trim().split(/\s+/);
1579
- for(let i = 0; i < btns.length; i++) {
1580
- const b = document.getElementById(btns[i] + '-btn');
1931
+ for(const btn of btns.trim().split(/\s+/)) {
1932
+ const b = document.getElementById(btn + '-btn');
1581
1933
  b.classList.remove('disab', 'activ');
1582
1934
  b.classList.add('enab');
1583
1935
  }
1584
1936
  }
1585
1937
 
1586
1938
  disableButtons(btns) {
1587
- btns = btns.trim().split(/\s+/);
1588
- for(let i = 0; i < btns.length; i++) {
1589
- const b = document.getElementById(btns[i] + '-btn');
1939
+ for(const btn of btns.trim().split(/\s+/)) {
1940
+ const b = document.getElementById(btn + '-btn');
1590
1941
  b.classList.remove('enab', 'activ', 'stay-activ');
1591
1942
  b.classList.add('disab');
1592
1943
  }
@@ -1598,7 +1949,7 @@ class GUIController extends Controller {
1598
1949
  node_btns = 'process product link constraint cluster note ',
1599
1950
  edit_btns = 'replace clone paste delete undo redo ',
1600
1951
  model_btns = 'settings save actors dataset equation chart ' +
1601
- 'diagram savediagram finder monitor solve';
1952
+ 'savediagram finder monitor solve';
1602
1953
  if(MODEL === null) {
1603
1954
  this.disableButtons(node_btns + edit_btns + model_btns);
1604
1955
  return;
@@ -1680,10 +2031,9 @@ class GUIController extends Controller {
1680
2031
  }
1681
2032
 
1682
2033
  get stayActiveButton() {
1683
- // Return the button that is "stay active", or NULL if none
1684
- const btns = ['process', 'product', 'link', 'constraint', 'cluster', 'note'];
1685
- for(let i = 0; i < btns.length; i++) {
1686
- const b = document.getElementById(btns[i] + '-btn');
2034
+ // Return the button that is "stay active", or NULL if none .
2035
+ for(const btn of ['process', 'product', 'link', 'constraint', 'cluster', 'note']) {
2036
+ const b = document.getElementById(btn + '-btn');
1687
2037
  if(b.classList.contains('stay-activ')) return b;
1688
2038
  }
1689
2039
  return null;
@@ -1707,12 +2057,20 @@ class GUIController extends Controller {
1707
2057
  //
1708
2058
 
1709
2059
  updateCursorPosition(e) {
1710
- // Updates the cursor coordinates and displays them on the status bar
2060
+ // Update the cursor coordinates, and display them on the status bar.
1711
2061
  const cp = this.paper.cursorPosition(e.pageX, e.pageY);
2062
+ // Keep track of the cumulative relative movement since the last
2063
+ // mousedown event.
2064
+ this.net_move_x += cp[0] - this.mouse_x;
2065
+ this.net_move_y += cp[1] - this.mouse_y;
2066
+ // Only now update the mouse coordinates.
1712
2067
  this.mouse_x = cp[0];
1713
2068
  this.mouse_y = cp[1];
2069
+ // Show the coordinates on the status bar.
1714
2070
  document.getElementById('pos-x').innerHTML = 'X = ' + this.mouse_x;
1715
- document.getElementById('pos-y').innerHTML = 'Y = ' + this.mouse_y;
2071
+ document.getElementById('pos-y').innerHTML = 'Y = ' + this.mouse_y;
2072
+ // Reset all "object under cursor detection variables" so that they
2073
+ // will be re-established correctly by mouseMove.
1716
2074
  this.on_note = null;
1717
2075
  this.on_node = null;
1718
2076
  this.on_cluster = null;
@@ -1723,76 +2081,82 @@ class GUIController extends Controller {
1723
2081
  }
1724
2082
 
1725
2083
  mouseMove(e) {
1726
- // Responds to mouse cursor moving over Linny-R diagram area
2084
+ // Respond to mouse cursor moving over Linny-R diagram area.
2085
+ // First translate browser cursor coordinates to diagram coordinates.
1727
2086
  this.updateCursorPosition(e);
1728
2087
 
1729
- // NOTE: check, as MODEL might still be undefined
2088
+ // NOTE: Prevent errors in case MODEL is still undefined.
1730
2089
  if(!MODEL) return;
1731
2090
 
1732
2091
  //console.log(e);
1733
2092
  const fc = MODEL.focal_cluster;
2093
+ // NOTE: Proceed from last added to first added node.
1734
2094
  for(let i = fc.processes.length-1; i >= 0; i--) {
1735
- const obj = fc.processes[i];
1736
- if(obj.containsPoint(this.mouse_x, this.mouse_y)) {
1737
- this.on_node = obj;
2095
+ const p = fc.processes[i];
2096
+ if(p.containsPoint(this.mouse_x, this.mouse_y)) {
2097
+ this.on_node = p;
1738
2098
  break;
1739
2099
  }
1740
2100
  }
1741
2101
  if(!this.on_node) {
1742
2102
  for(let i = fc.product_positions.length-1; i >= 0; i--) {
1743
- const obj = fc.product_positions[i].product.setPositionInFocalCluster();
1744
- if(obj.product.containsPoint(this.mouse_x, this.mouse_y)) {
1745
- this.on_node = obj.product;
2103
+ // NOTE: Set product coordinates to its position in focal cluster.
2104
+ const p = fc.product_positions[i].product.setPositionInFocalCluster();
2105
+ if(p.product.containsPoint(this.mouse_x, this.mouse_y)) {
2106
+ this.on_node = p.product;
1746
2107
  break;
1747
2108
  }
1748
2109
  }
1749
2110
  }
1750
- for(let i = 0; i < fc.arrows.length; i++) {
1751
- const arr = fc.arrows[i];
2111
+ for(const arr of fc.arrows) {
1752
2112
  if(arr) {
1753
2113
  this.on_arrow = arr;
1754
- // NOTE: arrow may represent multiple links, so find out which one
1755
- const obj = arr.containsPoint(this.mouse_x, this.mouse_y);
1756
- if(obj) {
1757
- this.on_link = obj;
2114
+ // NOTE: Arrow may represent multiple links, and `containsPoint`
2115
+ // returns the link if this can be established unambiguously, or
2116
+ // NULL otherwise.
2117
+ const l = arr.containsPoint(this.mouse_x, this.mouse_y);
2118
+ if(l) {
2119
+ this.on_link = l;
1758
2120
  break;
1759
2121
  }
1760
2122
  }
1761
2123
  }
1762
2124
  this.on_constraint = this.constraintStillUnderCursor();
1763
2125
  if(fc.related_constraints != null) {
1764
- for(let i = 0; i < fc.related_constraints.length; i++) {
1765
- const obj = fc.related_constraints[i];
1766
- if(obj.containsPoint(this.mouse_x, this.mouse_y)) {
1767
- this.on_constraint = obj;
2126
+ for(const c of fc.related_constraints) {
2127
+ if(c.containsPoint(this.mouse_x, this.mouse_y)) {
2128
+ this.on_constraint = c;
1768
2129
  break;
1769
2130
  }
1770
2131
  }
1771
2132
  }
1772
2133
  for(let i = fc.sub_clusters.length-1; i >= 0; i--) {
1773
- const obj = fc.sub_clusters[i];
1774
- // NOTE: Ignore cluster that is being dragged, so that a cluster
1775
- // it is being dragged over will be detected instead.
1776
- if(obj != this.dragged_node &&
1777
- obj.containsPoint(this.mouse_x, this.mouse_y)) {
1778
- this.on_cluster = obj;
1779
- this.on_cluster_edge = obj.onEdge(this.mouse_x, this.mouse_y);
1780
- break;
2134
+ const c = fc.sub_clusters[i];
2135
+ if(c.containsPoint(this.mouse_x, this.mouse_y)) {
2136
+ // NOTE: Cluster that is being dragged is superseded by other clusters
2137
+ // so that a cluster it is being dragged over will be detected instead.
2138
+ if(!this.on_cluster || c !== this.dragged_node) {
2139
+ this.on_cluster = c;
2140
+ // NOTE: Cluster edge responds differently to doubble-click.
2141
+ this.on_cluster_edge = c.onEdge(this.mouse_x, this.mouse_y);
2142
+ }
1781
2143
  }
1782
2144
  }
1783
2145
  // Unset and redraw target cluster if cursor no longer over it.
1784
- if(!this.on_cluster && this.target_cluster) {
2146
+ if(this.on_cluster !== this.target_cluster) {
1785
2147
  const c = this.target_cluster;
1786
2148
  this.target_cluster = null;
1787
- UI.paper.drawCluster(c);
1788
- // NOTE: Element is persistent, so semi-transparency must also be
1789
- // undone.
1790
- c.shape.element.setAttribute('opacity', 1);
2149
+ if(c) {
2150
+ UI.paper.drawCluster(c);
2151
+ // NOTE: Element is persistent, so semi-transparency must also be
2152
+ // undone.
2153
+ c.shape.element.setAttribute('opacity', 1);
2154
+ }
1791
2155
  }
1792
2156
  for(let i = fc.notes.length-1; i >= 0; i--) {
1793
- const obj = fc.notes[i];
1794
- if(obj.containsPoint(this.mouse_x, this.mouse_y)) {
1795
- this.on_note = obj;
2157
+ const n = fc.notes[i];
2158
+ if(n.containsPoint(this.mouse_x, this.mouse_y)) {
2159
+ this.on_note = n;
1796
2160
  break;
1797
2161
  }
1798
2162
  }
@@ -1857,10 +2221,12 @@ class GUIController extends Controller {
1857
2221
  this.setMessage('');
1858
2222
  }
1859
2223
  }
1860
- // When dragging selection that contains a process, change cursor to
2224
+ // When dragging a selection over a cluster, change cursor to "cell" to
1861
2225
  // indicate that selected process(es) will be moved into the cluster.
1862
2226
  if(this.dragged_node) {
1863
- if(this.on_cluster) {
2227
+ // NOTE: Cursor will always be over the dragged node, so do not indicate
2228
+ // "drop here?" unless dragged over a different cluster.
2229
+ if(this.on_cluster && this.on_cluster !== this.dragged_node) {
1864
2230
  cr = 'cell';
1865
2231
  this.target_cluster = this.on_cluster;
1866
2232
  // Redraw the target cluster so it will appear on top (and highlighted).
@@ -1874,10 +2240,16 @@ class GUIController extends Controller {
1874
2240
  }
1875
2241
 
1876
2242
  mouseDown(e) {
1877
- // Responds to mousedown event in model diagram area.
1878
- // In case mouseup event occurred outside drawing area,ignore this
1879
- // mousedown event, so that only the mouseup will be processed.
2243
+ // Respond to mousedown event in model diagram area.
2244
+ // NOTE: While dragging the selection rectangle, the mouseup event will
2245
+ // not be observed when it occurred outside the drawing area. In such
2246
+ // cases, the mousedown event must be ignored so that only the mouseup
2247
+ // will be processed.
1880
2248
  if(this.start_sel_x >= 0 && this.start_sel_y >= 0) return;
2249
+ // Reset the cumulative movement since mousedown.
2250
+ this.net_move_x = 0;
2251
+ this.net_move_y = 0;
2252
+ // Get the paper coordinates indicated by the cursor.
1881
2253
  const cp = this.paper.cursorPosition(e.pageX, e.pageY);
1882
2254
  this.mouse_down_x = cp[0];
1883
2255
  this.mouse_down_y = cp[1];
@@ -1891,7 +2263,7 @@ class GUIController extends Controller {
1891
2263
  }
1892
2264
  // NOTE: Only left button is detected (browser catches right menu button).
1893
2265
  if(e.ctrlKey) {
1894
- // Remove clicked item from selection
2266
+ // Remove clicked item from selection.
1895
2267
  if(MODEL.selection) {
1896
2268
  // NOTE: First check constraints -- see mouseMove() for motivation.
1897
2269
  if(this.on_constraint) {
@@ -1943,37 +2315,15 @@ class GUIController extends Controller {
1943
2315
  UI.drawDiagram(MODEL);
1944
2316
  }
1945
2317
 
1946
- // If one of the top six sidebar buttons is active, prompt for new node
1947
- // (not link or constraint).
2318
+ // If one of the top six sidebar buttons is active, prompt for new node.
2319
+ // Note that this does not apply for links or constraints.
1948
2320
  if(this.active_button && this.active_button !== this.buttons.link &&
1949
2321
  this.active_button !== this.buttons.constraint) {
1950
2322
  this.add_x = this.mouse_x;
1951
2323
  this.add_y = this.mouse_y;
1952
- const obj = this.active_button.id.split('-')[0];
2324
+ const ot = this.active_button.id.split('-')[0];
1953
2325
  if(!this.stayActive) this.resetActiveButton();
1954
- if(obj === 'process') {
1955
- setTimeout(() => {
1956
- const md = UI.modals['add-process'];
1957
- md.element('name').value = '';
1958
- md.element('actor').value = '';
1959
- md.show('name');
1960
- });
1961
- } else if(obj === 'product') {
1962
- setTimeout(() => {
1963
- const md = UI.modals['add-product'];
1964
- md.element('name').value = '';
1965
- md.element('unit').value = MODEL.default_unit;
1966
- UI.setBox('add-product-data', false);
1967
- md.show('name');
1968
- });
1969
- } else if(obj === 'cluster') {
1970
- setTimeout(() => {
1971
- const md = UI.modals.cluster;
1972
- md.element('name').value = '';
1973
- md.element('actor').value = '';
1974
- md.show('name');
1975
- });
1976
- } else if(obj === 'note') {
2326
+ if(ot === 'note') {
1977
2327
  setTimeout(() => {
1978
2328
  const md = UI.modals.note;
1979
2329
  md.element('action').innerHTML = 'Add';
@@ -1981,6 +2331,33 @@ class GUIController extends Controller {
1981
2331
  md.element('text').value = '';
1982
2332
  md.show('text');
1983
2333
  });
2334
+ } else {
2335
+ // Align position to the grid.
2336
+ this.add_x = MODEL.aligned(this.add_x);
2337
+ this.add_y = MODEL.aligned(this.add_y);
2338
+ if(ot === 'process') {
2339
+ setTimeout(() => {
2340
+ const md = UI.modals['add-process'];
2341
+ md.element('name').value = '';
2342
+ md.element('actor').value = '';
2343
+ md.show('name');
2344
+ });
2345
+ } else if(ot === 'product') {
2346
+ setTimeout(() => {
2347
+ const md = UI.modals['add-product'];
2348
+ md.element('name').value = '';
2349
+ md.element('unit').value = MODEL.default_unit;
2350
+ UI.setBox('add-product-data', false);
2351
+ md.show('name');
2352
+ });
2353
+ } else if(ot === 'cluster') {
2354
+ setTimeout(() => {
2355
+ const md = UI.modals.cluster;
2356
+ md.element('name').value = '';
2357
+ md.element('actor').value = '';
2358
+ md.show('name');
2359
+ });
2360
+ }
1984
2361
  }
1985
2362
  return;
1986
2363
  }
@@ -2020,7 +2397,7 @@ class GUIController extends Controller {
2020
2397
  } else if(this.on_node) {
2021
2398
  if(this.active_button === this.buttons.link) {
2022
2399
  this.linking_node = this.on_node;
2023
- // NOTE: return without updating buttons
2400
+ // NOTE: Return without updating buttons.
2024
2401
  return;
2025
2402
  } else if(this.active_button === this.buttons.constraint) {
2026
2403
  // Allow constraints only on nodes having upper bounds defined.
@@ -2031,6 +2408,7 @@ class GUIController extends Controller {
2031
2408
  }
2032
2409
  } else {
2033
2410
  this.dragged_node = this.on_node;
2411
+ // NOTE: Keep track of relative movement of the dragged node.
2034
2412
  this.move_dx = this.mouse_x - this.on_node.x;
2035
2413
  this.move_dy = this.mouse_y - this.on_node.y;
2036
2414
  if(MODEL.selection.indexOf(this.on_node) < 0) MODEL.select(this.on_node);
@@ -2055,6 +2433,10 @@ class GUIController extends Controller {
2055
2433
  mouseUp(e) {
2056
2434
  // Responds to mouseup event.
2057
2435
  const cp = this.paper.cursorPosition(e.pageX, e.pageY);
2436
+ // Keep track of the cumulative relative movement since the last
2437
+ // mousedown event.
2438
+ this.net_move_x += cp[0] - this.mouse_x;
2439
+ this.net_move_y += cp[1] - this.mouse_y;
2058
2440
  this.mouse_up_x = cp[0];
2059
2441
  this.mouse_up_y = cp[1];
2060
2442
  // First check whether user is selecting a rectangle.
@@ -2071,44 +2453,32 @@ class GUIController extends Controller {
2071
2453
  // If rectangle has size greater than 2x2 pixels, select all elements
2072
2454
  // having their center inside the selection rectangle.
2073
2455
  if(brx - tlx > 2 && bry - tly > 2) {
2074
- const ol = [], fc = MODEL.focal_cluster;
2075
- for(let i = 0; i < fc.processes.length; i++) {
2076
- const obj = fc.processes[i];
2077
- if(obj.x >= tlx && obj.x <= brx && obj.y >= tly && obj.y < bry) {
2078
- ol.push(obj);
2079
- }
2456
+ const
2457
+ ol = [],
2458
+ fc = MODEL.focal_cluster;
2459
+ for(const p of fc.processes) {
2460
+ if(p.x >= tlx && p.x <= brx && p.y >= tly && p.y < bry) ol.push(p);
2080
2461
  }
2081
- for(let i = 0; i < fc.product_positions.length; i++) {
2082
- const obj = fc.product_positions[i];
2083
- if(obj.x >= tlx && obj.x <= brx && obj.y >= tly && obj.y < bry) {
2084
- ol.push(obj.product);
2462
+ for(const pp of fc.product_positions) {
2463
+ if(pp.x >= tlx && pp.x <= brx && pp.y >= tly && pp.y < bry) {
2464
+ ol.push(pp.product);
2085
2465
  }
2086
2466
  }
2087
- for(let i = 0; i < fc.sub_clusters.length; i++) {
2088
- const obj = fc.sub_clusters[i];
2089
- if(obj.x >= tlx && obj.x <= brx && obj.y >= tly && obj.y < bry) {
2090
- ol.push(obj);
2091
- }
2467
+ for(const c of fc.sub_clusters) {
2468
+ if(c.x >= tlx && c.x <= brx && c.y >= tly && c.y < bry) ol.push(c);
2092
2469
  }
2093
- for(let i = 0; i < fc.notes.length; i++) {
2094
- const obj = fc.notes[i];
2095
- if(obj.x >= tlx && obj.x <= brx && obj.y >= tly && obj.y < bry) {
2096
- ol.push(obj);
2097
- }
2470
+ for(const n of fc.notes) {
2471
+ if(n.x >= tlx && n.x <= brx && n.y >= tly && n.y < bry) ol.push(n);
2098
2472
  }
2099
- for(let i in MODEL.links) if(MODEL.links.hasOwnProperty(i)) {
2100
- const obj = MODEL.links[i];
2473
+ for(let k in MODEL.links) if(MODEL.links.hasOwnProperty(k)) {
2474
+ const l = MODEL.links[k];
2101
2475
  // Only add a link if both its nodes are selected as well.
2102
- if(fc.linkInList(obj, ol)) {
2103
- ol.push(obj);
2104
- }
2476
+ if(fc.linkInList(l, ol)) ol.push(l);
2105
2477
  }
2106
- for(let i in MODEL.constraints) if(MODEL.constraints.hasOwnProperty(i)) {
2107
- const obj = MODEL.constraints[i];
2478
+ for(let k in MODEL.constraints) if(MODEL.constraints.hasOwnProperty(k)) {
2479
+ const c = MODEL.constraints[k];
2108
2480
  // Only add a constraint if both its nodes are selected as well.
2109
- if(fc.linkInList(obj, ol)) {
2110
- ol.push(obj);
2111
- }
2481
+ if(fc.linkInList(c, ol)) ol.push(c);
2112
2482
  }
2113
2483
  // Having compiled the object list, actually select them.
2114
2484
  MODEL.selectList(ol);
@@ -2123,9 +2493,9 @@ class GUIController extends Controller {
2123
2493
  } else if(this.linking_node) {
2124
2494
  // If so, check whether the cursor is over a node of the appropriate type.
2125
2495
  if(this.on_node && MODEL.canLink(this.linking_node, this.on_node)) {
2126
- const obj = MODEL.addLink(this.linking_node, this.on_node);
2127
- UNDO_STACK.push('add', obj);
2128
- MODEL.select(obj);
2496
+ const l = MODEL.addLink(this.linking_node, this.on_node);
2497
+ UNDO_STACK.push('add', l);
2498
+ MODEL.select(l);
2129
2499
  this.paper.drawModel(MODEL);
2130
2500
  }
2131
2501
  this.linking_node = null;
@@ -2136,9 +2506,9 @@ class GUIController extends Controller {
2136
2506
  // dragging its endpoint).
2137
2507
  } else if(this.constraining_node) {
2138
2508
  if(this.on_node && this.constraining_node.canConstrain(this.on_node)) {
2139
- // display constraint editor
2140
- CONSTRAINT_EDITOR.from_name.innerHTML = this.constraining_node.displayName;
2141
- CONSTRAINT_EDITOR.to_name.innerHTML = this.on_node.displayName;
2509
+ // Display constraint editor.
2510
+ CONSTRAINT_EDITOR.from_name.innerText = this.constraining_node.displayName;
2511
+ CONSTRAINT_EDITOR.to_name.innerText = this.on_node.displayName;
2142
2512
  CONSTRAINT_EDITOR.showDialog();
2143
2513
  }
2144
2514
  this.linking_node = null;
@@ -2149,31 +2519,25 @@ class GUIController extends Controller {
2149
2519
  // Then check whether the user is moving a node (possibly part of a
2150
2520
  // larger selection).
2151
2521
  } else if(this.dragged_node) {
2152
- // Always perform the move operation (this will do nothing if the
2153
- // cursor did not move).
2154
- MODEL.moveSelection(
2155
- this.mouse_up_x - this.mouse_x, this.mouse_up_y - this.mouse_y);
2156
- // Set cursor to pointer, as it should be on some node while dragging.
2157
- this.paper.container.style.cursor = 'pointer';
2158
- // @@TO DO: if on top of a cluster, move it there.
2159
- // NOTE: Cursor will always be over the selected cluster (while dragging).
2160
- if(this.on_cluster && !this.on_cluster.selected) {
2161
- UNDO_STACK.push('drop', this.on_cluster);
2162
- MODEL.dropSelectionIntoCluster(this.on_cluster);
2163
- this.on_node = null;
2164
- this.on_note = null;
2165
- this.target_cluster = null;
2166
- // Redraw cluster to erase its orange "target corona".
2167
- UI.paper.drawCluster(this.on_cluster);
2168
- }
2169
-
2170
- // Check wether the cursor has been moved.
2522
+ // NOTE: When double-clicking with a sensitive mouse, the cursor
2523
+ // may move a few pixels, and then this should NOT be considered
2524
+ // as an intentional move. Hence, check wether the cursor has been
2525
+ // moved *significantly* since the mouseDown event.
2171
2526
  const
2172
- absdx = Math.abs(this.mouse_down_x - this.mouse_x),
2173
- absdy = Math.abs(this.mouse_down_y - this.mouse_y);
2174
- // If no *significant* move made, remove the move undo.
2175
- if(absdx + absdy === 0) UNDO_STACK.pop('move');
2176
- if(this.doubleClicked && absdx + absdy < 3) {
2527
+ mdx = this.mouse_down_x - this.mouse_x,
2528
+ mdy = this.mouse_down_y - this.mouse_y,
2529
+ absdx = Math.abs(this.net_move_x),
2530
+ absdy = Math.abs(this.net_move_y),
2531
+ sigmv = (MODEL.align_to_grid ? MODEL.grid_pixels / 4 : 2.5);
2532
+ if(this.doubleClicked) {
2533
+ // Ignore insignificant move.
2534
+ if(absdx < sigmv && absdy < sigmv) {
2535
+ // Undo the move and remove the action from the UNDO-stack.
2536
+ // NOTE: Do not use the regular `undo` routine as this would
2537
+ // make the action redoable.
2538
+ MODEL.moveSelection(mdx, mdy);
2539
+ UNDO_STACK.pop('move');
2540
+ }
2177
2541
  // Double-clicking opens properties dialog, except for clusters;
2178
2542
  // then "drill down", i.e., make the double-clicked cluster focal.
2179
2543
  if(this.dragged_node instanceof Cluster) {
@@ -2197,6 +2561,30 @@ class GUIController extends Controller {
2197
2561
  } else {
2198
2562
  this.showNotePropertiesDialog(this.dragged_node);
2199
2563
  }
2564
+ } else {
2565
+ // Move the selection, even if the movement is very small, because the
2566
+ // final movement since last mouse event may make the *cumulative*
2567
+ // movement since the last mouseDown significant.
2568
+ MODEL.moveSelection(
2569
+ this.mouse_up_x - this.mouse_x, this.mouse_up_y - this.mouse_y);
2570
+ if(this.net_move_x < 0.5 && this.net_move_y < 0.5) {
2571
+ // No effective move of the selection => remove the UNDO.
2572
+ UNDO_STACK.pop('move');
2573
+ }
2574
+ // Set cursor to pointer, as it should be on some node while dragging.
2575
+ this.paper.container.style.cursor = 'pointer';
2576
+ // NOTE: Cursor will always be over the selected cluster (while dragging).
2577
+ if(this.on_cluster && !this.on_cluster.selected) {
2578
+ UNDO_STACK.push('drop', this.on_cluster);
2579
+ MODEL.dropSelectionIntoCluster(this.on_cluster);
2580
+ this.on_node = null;
2581
+ this.on_note = null;
2582
+ this.target_cluster = null;
2583
+ // Redraw cluster to erase its orange "target corona".
2584
+ UI.paper.drawCluster(this.on_cluster);
2585
+ }
2586
+ // Only now align to grid.
2587
+ MODEL.alignToGrid();
2200
2588
  }
2201
2589
  this.dragged_node = null;
2202
2590
 
@@ -2210,6 +2598,8 @@ class GUIController extends Controller {
2210
2598
  this.showConstraintPropertiesDialog(this.on_constraint);
2211
2599
  }
2212
2600
  }
2601
+ // Finally, reset "selecting with rectangle" (just to be sure), and
2602
+ // update the UI button states.
2213
2603
  this.start_sel_x = -1;
2214
2604
  this.start_sel_y = -1;
2215
2605
  this.updateButtons();
@@ -2253,9 +2643,8 @@ class GUIController extends Controller {
2253
2643
  topmod = null,
2254
2644
  code = e.code,
2255
2645
  alt = e.altKey;
2256
- for(let i = 0; i < modals.length; i++) {
2646
+ for(const m of modals) {
2257
2647
  const
2258
- m = modals[i],
2259
2648
  cs = window.getComputedStyle(m),
2260
2649
  z = parseInt(cs.zIndex);
2261
2650
  if(cs.display !== 'none' && z > maxz) {
@@ -2275,15 +2664,16 @@ class GUIController extends Controller {
2275
2664
  while(i < inp.length && inp[i].disabled) i++;
2276
2665
  if(i < inp.length) {
2277
2666
  inp[i].focus();
2278
- } else if(['constraint-modal', 'boundline-data-modal',
2667
+ } else if(['datasetgroup-modal', 'constraint-modal', 'boundline-data-modal',
2279
2668
  'xp-clusters-modal'].indexOf(topmod.id) >= 0) {
2280
- // NOTE: Constraint modal, boundline data modal and "ignore clusters" modal must NOT close
2281
- // when Enter is pressed, but only de-focus the input field.
2669
+ // NOTE: These modals must NOT close when Enter is pressed, but only
2670
+ // de-focus the input field.
2282
2671
  e.target.blur();
2283
2672
  } else {
2284
2673
  const btns = topmod.getElementsByClassName('ok-btn');
2285
2674
  if(btns.length > 0) btns[0].dispatchEvent(new Event('click'));
2286
2675
  }
2676
+ if(topmod.id === 'datasetgroup-modal') UI.modals.datasetgroup.enterKey();
2287
2677
  } else if(this.dr_dialog_order.length > 0) {
2288
2678
  // Send ENTER key event to the top draggable dialog.
2289
2679
  const last = this.dr_dialog_order.length - 1;
@@ -2297,15 +2687,22 @@ class GUIController extends Controller {
2297
2687
  // Prevent backspace to be interpreted (by FireFox) as "go back in browser".
2298
2688
  e.preventDefault();
2299
2689
  } else if(ttag === 'BODY') {
2300
- // Constraint Editor accepts arrow keys.
2301
- if(topmod && topmod.id === 'constraint-modal') {
2302
- if(code.startsWith('Arrow')) {
2690
+ // Dataset group modal and Constraint Editor accept arrow keys.
2691
+ if(topmod) {
2692
+ if(topmod.id === 'constraint-modal' && code.startsWith('Arrow')) {
2303
2693
  e.preventDefault();
2304
2694
  CONSTRAINT_EDITOR.arrowKey(e);
2305
2695
  return;
2306
2696
  }
2697
+ if(topmod.id === 'datasetgroup-modal' &&
2698
+ (code === 'ArrowUp' || code === 'ArrowDown')) {
2699
+ e.preventDefault();
2700
+ // NOTE: Pass key direction as -1 for UP and +1 for DOWN.
2701
+ UI.modals.datasetgroup.upDownKey(e.keyCode - 39);
2702
+ return;
2703
+ }
2307
2704
  }
2308
- // Up and down arrow keys.
2705
+ // Lists in draggable dialogs respond to up and down arrow keys.
2309
2706
  if(code === 'ArrowUp' || code === 'ArrowDown') {
2310
2707
  e.preventDefault();
2311
2708
  // Send event to the top draggable dialog.
@@ -2569,7 +2966,7 @@ class GUIController extends Controller {
2569
2966
  validNames(nn, an='') {
2570
2967
  // Check whether names meet conventions; if not, warn user
2571
2968
  if(!UI.validName(nn) || nn.indexOf(UI.BLACK_BOX) >= 0) {
2572
- UI.warn(`Invalid name "${nn}"`);
2969
+ this.warningInvalidName(nn);
2573
2970
  return false;
2574
2971
  }
2575
2972
  if(an === '' || an === UI.NO_ACTOR) return true;
@@ -2627,12 +3024,12 @@ class GUIController extends Controller {
2627
3024
  }
2628
3025
 
2629
3026
  updateScaleUnitList() {
2630
- // Update the HTML datalist element to reflect all scale units
3027
+ // Update the HTML datalist element to reflect all scale units.
2631
3028
  const
2632
3029
  ul = [],
2633
3030
  keys = Object.keys(MODEL.scale_units).sort(ciCompare);
2634
- for(let i = 0; i < keys.length; i++) {
2635
- ul.push(`<option value="${MODEL.scale_units[keys[i]].name}">`);
3031
+ for(const k of keys) {
3032
+ ul.push(`<option value="${MODEL.scale_units[k].name}">`);
2636
3033
  }
2637
3034
  document.getElementById('units-data').innerHTML = ul.join('');
2638
3035
  }
@@ -3001,6 +3398,7 @@ class GUIController extends Controller {
3001
3398
  const vn = this.validName(nn);
3002
3399
  if(!vn) {
3003
3400
  UNDO_STACK.pop();
3401
+ this.warningInvalidName(nn);
3004
3402
  return false;
3005
3403
  }
3006
3404
  // NOTE: Pre-check if product exists.
@@ -3229,8 +3627,8 @@ class GUIController extends Controller {
3229
3627
  if(elig.length) {
3230
3628
  sl.push('<div class="paste-select"><select id="paste-ft-', i,
3231
3629
  '" style="font-size: 12px">');
3232
- for(let j = 0; j < elig.length; j++) {
3233
- const dn = elig[j].displayName;
3630
+ for(const e of elig) {
3631
+ const dn = e.displayName;
3234
3632
  sl.push('<option value="', dn, '">', dn, '</option>');
3235
3633
  }
3236
3634
  sl.push('</select></div>');
@@ -3404,8 +3802,7 @@ class GUIController extends Controller {
3404
3802
  function nameConflicts(node) {
3405
3803
  // Maps names of entities defined by the child nodes of `node`
3406
3804
  // while detecting name conflicts.
3407
- for(let i = 0; i < node.childNodes.length; i++) {
3408
- const c = node.childNodes[i];
3805
+ for(const c of node.childNodes) {
3409
3806
  if(c.nodeName !== 'link' && c.nodeName !== 'constraint') {
3410
3807
  const
3411
3808
  fn = fullName(c),
@@ -3505,9 +3902,8 @@ class GUIController extends Controller {
3505
3902
  // Prompt for names of selected cluster nodes.
3506
3903
  if(selc_node.childNodes.length && !mapping.prefix) {
3507
3904
  mapping.top_clusters = {};
3508
- for(let i = 0; i < selc_node.childNodes.length; i++) {
3905
+ for(const c of selc_node.childNodes) {
3509
3906
  const
3510
- c = selc_node.childNodes[i],
3511
3907
  fn = fullName(c),
3512
3908
  mn = mappedName(fn);
3513
3909
  mapping.top_clusters[fn] = mn;
@@ -3522,9 +3918,8 @@ class GUIController extends Controller {
3522
3918
  const
3523
3919
  ft_map = {},
3524
3920
  ft_type = {};
3525
- for(let i = 0; i < from_tos_node.childNodes.length; i++) {
3921
+ for(const c of from_tos_node.childNodes) {
3526
3922
  const
3527
- c = from_tos_node.childNodes[i],
3528
3923
  fn = fullName(c),
3529
3924
  mn = mappedName(fn);
3530
3925
  if(MODEL.objectByName(mn)) {
@@ -3554,20 +3949,14 @@ console.log('HERE name conflicts', name_conflicts, mapping);
3554
3949
  }
3555
3950
 
3556
3951
  // No conflicts => add all
3557
- for(let i = 0; i < extras_node.childNodes.length; i++) {
3558
- addEntityFromNode(extras_node.childNodes[i]);
3559
- }
3560
- for(let i = 0; i < from_tos_node.childNodes.length; i++) {
3561
- addEntityFromNode(from_tos_node.childNodes[i]);
3562
- }
3563
- for(let i = 0; i < entities_node.childNodes.length; i++) {
3564
- addEntityFromNode(entities_node.childNodes[i]);
3565
- }
3952
+ for(const c of extras_node.childNodes) addEntityFromNode(c);
3953
+ for(const c of from_tos_node.childNodes) addEntityFromNode(c);
3954
+ for(const c of entities_node.childNodes) addEntityFromNode(c);
3566
3955
  // Update diagram, showing newly added nodes as selection.
3567
3956
  MODEL.clearSelection();
3568
- for(let i = 0; i < selection_node.childNodes.length; i++) {
3957
+ for(const c of selection_node.childNodes) {
3569
3958
  const
3570
- n = xmlDecoded(nodeContent(selection_node.childNodes[i])),
3959
+ n = xmlDecoded(nodeContent(c)),
3571
3960
  obj = MODEL.objectByName(mappedName(n));
3572
3961
  if(obj) {
3573
3962
  // NOTE: Selected products must be positioned.
@@ -3681,6 +4070,12 @@ console.log('HERE name conflicts', name_conflicts, mapping);
3681
4070
  cb = UI.boxChecked('settings-power');
3682
4071
  redraw = redraw || cb !== model.with_power_flow;
3683
4072
  model.with_power_flow = cb;
4073
+ // NOTE: Clear the "ignore" options if no power flow constraints.
4074
+ if(!model.with_power_flow) {
4075
+ model.ignore_grid_capacity = false;
4076
+ model.ignore_KVL = false;
4077
+ model.ignore_power_losses = false;
4078
+ }
3684
4079
  cb = UI.boxChecked('settings-cost-prices');
3685
4080
  redraw = redraw || cb !== model.infer_cost_prices;
3686
4081
  model.infer_cost_prices = cb;
@@ -3749,8 +4144,7 @@ console.log('HERE name conflicts', name_conflicts, mapping);
3749
4144
  const
3750
4145
  md = this.modals.solver,
3751
4146
  html = ['<option value="">(default)</option>'];
3752
- for(let i = 0; i < VM.solver_list.length; i++) {
3753
- const s = VM.solver_list[i];
4147
+ for(const s of VM.solver_list) {
3754
4148
  html.push(['<option value="', s,
3755
4149
  (s === MODEL.preferred_solver ? '"selected="selected' : ''),
3756
4150
  '">', VM.solver_names[s], '</option>'].join(''));
@@ -3885,17 +4279,13 @@ console.log('HERE name conflicts', name_conflicts, mapping);
3885
4279
  plate.innerHTML = pg.voltage;
3886
4280
  overlay.style.display = 'block';
3887
4281
  // Disable tab stop for the properties that are now not shown.
3888
- for(let i = 0; i < notab.length; i++) {
3889
- md.element(notab[i]).tabIndex = -1;
3890
- }
4282
+ for(const nt of notab) md.element(nt).tabIndex = -1;
3891
4283
  } else {
3892
4284
  plate.innerHTML = '(&#x21AF;)';
3893
4285
  plate.className = 'no-grid-plate';
3894
4286
  overlay.style.display = 'none';
3895
4287
  // Enable tab stop for the properties that are now not shown.
3896
- for(let i = 0; i < notab.length; i++) {
3897
- md.element(notab[i]).tabIndex = 0;
3898
- }
4288
+ for(const nt of notab) md.element(nt).tabIndex = 0;
3899
4289
  }
3900
4290
  this.hideGridPlateMenu('process');
3901
4291
  // Show plate "button" only when power grids option is set for model.
@@ -4131,9 +4521,7 @@ console.log('HERE name conflicts', name_conflicts, mapping);
4131
4521
 
4132
4522
  showClusterPropertiesDialog(c, group=[]) {
4133
4523
  let bb = false;
4134
- for(let i = 0; !bb && i < group.length; i++) {
4135
- bb = group[i].is_black_boxed;
4136
- }
4524
+ for(const g of group) bb = bb || g.is_black_boxed;
4137
4525
  if(bb || c.is_black_boxed) {
4138
4526
  this.notify('Black-boxed clusters cannot be edited');
4139
4527
  return;
@@ -4375,31 +4763,27 @@ console.log('HERE name conflicts', name_conflicts, mapping);
4375
4763
  this.drawObject(p);
4376
4764
  // Make list of nodes related to P by links
4377
4765
  const rel_nodes = [];
4378
- for(let i = 0; i < p.inputs.length; i++) {
4379
- rel_nodes.push(p.inputs[i].from_node);
4380
- }
4381
- for(let i = 0; i < p.outputs.length; i++) {
4382
- rel_nodes.push(p.outputs[i].to_node);
4383
- }
4766
+ for(const l of p.inputs) rel_nodes.push(l.from_node);
4767
+ for(const l of p.outputs) rel_nodes.push(l.to_node);
4384
4768
  const options = [];
4385
- for(let i in MODEL.products) if(MODEL.products.hasOwnProperty(i) &&
4769
+ for(let k in MODEL.products) if(MODEL.products.hasOwnProperty(k) &&
4386
4770
  // NOTE: do not show "black-boxed" products
4387
- !i.startsWith(UI.BLACK_BOX)) {
4388
- const po = MODEL.products[i];
4771
+ !k.startsWith(UI.BLACK_BOX)) {
4772
+ const po = MODEL.products[k];
4389
4773
  // Skip the product that is to be replaced, an also products having a
4390
4774
  // different type (regular product or data product)
4391
4775
  if(po !== p && po.is_data === p.is_data) {
4392
4776
  // NOTE: also skip products PO that are linked to a node Q that is
4393
4777
  // already linked to P (as replacing would then create a two-way link)
4394
4778
  let no_rel = true;
4395
- for(let j = 0; j < po.inputs.length; j++) {
4396
- if(rel_nodes.indexOf(po.inputs[j].from_node) >= 0) {
4779
+ for(const l of po.inputs) {
4780
+ if(rel_nodes.indexOf(l.from_node) >= 0) {
4397
4781
  no_rel = false;
4398
4782
  break;
4399
4783
  }
4400
4784
  }
4401
- for(let j = 0; j < po.outputs.length; j++) {
4402
- if(rel_nodes.indexOf(po.outputs[j].to_node) >= 0) {
4785
+ for(const l of po.outputs) {
4786
+ if(rel_nodes.indexOf(l.to_node) >= 0) {
4403
4787
  no_rel = false;
4404
4788
  break;
4405
4789
  }