linny-r 1.4.3 → 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 (49) hide show
  1. package/README.md +102 -48
  2. package/package.json +1 -1
  3. package/server.js +31 -6
  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/sort-asc-lead.png +0 -0
  21. package/static/images/sort-asc.png +0 -0
  22. package/static/images/sort-desc-lead.png +0 -0
  23. package/static/images/sort-desc.png +0 -0
  24. package/static/images/sort-not.png +0 -0
  25. package/static/index.html +51 -35
  26. package/static/linny-r.css +167 -53
  27. package/static/scripts/linny-r-gui-actor-manager.js +340 -0
  28. package/static/scripts/linny-r-gui-chart-manager.js +944 -0
  29. package/static/scripts/linny-r-gui-constraint-editor.js +681 -0
  30. package/static/scripts/linny-r-gui-controller.js +4005 -0
  31. package/static/scripts/linny-r-gui-dataset-manager.js +1176 -0
  32. package/static/scripts/linny-r-gui-documentation-manager.js +739 -0
  33. package/static/scripts/linny-r-gui-equation-manager.js +307 -0
  34. package/static/scripts/linny-r-gui-experiment-manager.js +1944 -0
  35. package/static/scripts/linny-r-gui-expression-editor.js +449 -0
  36. package/static/scripts/linny-r-gui-file-manager.js +392 -0
  37. package/static/scripts/linny-r-gui-finder.js +727 -0
  38. package/static/scripts/linny-r-gui-model-autosaver.js +230 -0
  39. package/static/scripts/linny-r-gui-monitor.js +448 -0
  40. package/static/scripts/linny-r-gui-paper.js +2789 -0
  41. package/static/scripts/linny-r-gui-receiver.js +323 -0
  42. package/static/scripts/linny-r-gui-repository-browser.js +819 -0
  43. package/static/scripts/linny-r-gui-scale-unit-manager.js +244 -0
  44. package/static/scripts/linny-r-gui-sensitivity-analysis.js +778 -0
  45. package/static/scripts/linny-r-gui-undo-redo.js +560 -0
  46. package/static/scripts/linny-r-model.js +24 -11
  47. package/static/scripts/linny-r-utils.js +10 -0
  48. package/static/scripts/linny-r-vm.js +21 -12
  49. package/static/scripts/linny-r-gui.js +0 -16908
@@ -0,0 +1,560 @@
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-undo.js) provides the GUI undo/redo
9
+ functionality for the Linny-R model editor.
10
+
11
+ */
12
+
13
+ /*
14
+ Copyright (c) 2017-2023 Delft University of Technology
15
+
16
+ Permission is hereby granted, free of charge, to any person obtaining a copy
17
+ of this software and associated documentation files (the "Software"), to deal
18
+ in the Software without restriction, including without limitation the rights to
19
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
20
+ of the Software, and to permit persons to whom the Software is furnished to do
21
+ so, subject to the following conditions:
22
+
23
+ The above copyright notice and this permission notice shall be included in
24
+ all copies or substantial portions of the Software.
25
+
26
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
27
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
28
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
29
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
30
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
31
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
32
+ SOFTWARE.
33
+ */
34
+
35
+ // CLASS UndoEdit
36
+ class UndoEdit {
37
+ constructor(action) {
38
+ this.action = action;
39
+ // NOTE: store present focal cluster, as modeler may move to other clusters
40
+ // after an edit
41
+ this.cluster = MODEL.focal_cluster;
42
+ this.object_id = null;
43
+ // NOTE: the properties stored for an edit may differ, depending on the action
44
+ this.properties = [];
45
+ // Undo may involve restoring the `selected` property of selected items
46
+ this.selection = [];
47
+ this.xml = '';
48
+ }
49
+
50
+ get fullAction() {
51
+ // Returns a string that reflects this edit action
52
+ // If the identifier is set, return the action followed by the class name
53
+ // of the object. NOTE: `obj` should then not be NULL, but check anyway
54
+ if(this.action === 'drop' || this.action == 'lift') {
55
+ return `Move ${pluralS(this.properties.length, 'node')} to cluster ` +
56
+ MODEL.objectByID(this.object_id).displayName;
57
+ } else if(this.action === 'replace') {
58
+ return `Replace ${this.properties.g ? '' : '(locally) '}product \u2018` +
59
+ this.properties.p + '\u2019 by product \u2018' +
60
+ this.properties.r + '\u2019';
61
+ } else if(this.object_id) {
62
+ const
63
+ obj = MODEL.objectByID(this.object_id),
64
+ obt = (obj ? obj.type.toLowerCase() : 'UNKOWN ' + this.object_id);
65
+ return this.action + ' ' + obt;
66
+ // A REDO of "add" has as properties [class name, identifier] of the added object
67
+ } else if(this.action === 'add' && this.properties.length === 2) {
68
+ return 'add ' + this.properties[0].toLowerCase();
69
+ }
70
+ // By default, return the action without further specification
71
+ return this.action;
72
+ }
73
+
74
+ setSelection() {
75
+ // Compile the list of IDs of selected entities
76
+ this.selection.length = 0;
77
+ for(let i = 0; i < MODEL.selection.length; i++) {
78
+ this.selection.push(MODEL.selection[i].identifier);
79
+ }
80
+ }
81
+
82
+ get getSelection() {
83
+ // Return the list of entities that were selected at the time of the action
84
+ const ol = [];
85
+ for(let i = 0; i < this.selection.length; i++) {
86
+ const obj = MODEL.objectByID(this.selection[i]);
87
+ // Guard against pushing NULL pointers in case object is not found
88
+ if(obj) ol.push(obj);
89
+ }
90
+ return ol;
91
+ }
92
+ } // END of class UndoEdit
93
+
94
+
95
+ // CLASS UndoStack
96
+ // NOTE: this object actually comprises TWO stacks -- one with undoable actions
97
+ // and one with redoable actions
98
+ class UndoStack {
99
+ constructor() {
100
+ this.undoables = [];
101
+ this.redoables = [];
102
+ this.clear();
103
+ }
104
+
105
+ clear() {
106
+ this.undoables.length = 0;
107
+ this.redoables.length = 0;
108
+ }
109
+
110
+ get topUndo() {
111
+ // Return the short name of the top undoable action (if any)
112
+ const i = this.undoables.length;
113
+ if(i > 0) return this.undoables[i - 1].action;
114
+ return false;
115
+ }
116
+
117
+ get canUndo() {
118
+ // Return the "display name" of the top undoable action (if any)
119
+ const i = this.undoables.length;
120
+ if(i > 0) return `Undo "${this.undoables[i - 1].fullAction}"`;
121
+ return false;
122
+ }
123
+
124
+ get topRedo() {
125
+ // Return the short name of the top undoable action (if any)
126
+ const i = this.redoables.length;
127
+ if(i > 0) return this.redoables[i - 1].action;
128
+ return false;
129
+ }
130
+
131
+ get canRedo() {
132
+ // Return the "display name" of the top redoable action (if any)
133
+ const i = this.redoables.length;
134
+ if(i > 0) return `Redo "${this.redoables[i - 1].fullAction}"`;
135
+ return false;
136
+ }
137
+
138
+ addXML(xml) {
139
+ // Insert xml at the start (!) of any XML added previously to the UndoEdit
140
+ // at the top of the UNDO stack
141
+ const i = this.undoables.length;
142
+ if(i === 0) return false;
143
+ this.undoables[i-1].xml = xml + this.undoables[i-1].xml;
144
+ }
145
+
146
+ addOffset(dx, dy) {
147
+ // Add (dx, dy) to the offset of the "move" UndoEdit that should be at the
148
+ // top of the UNDO stack
149
+ let i = this.undoables.length;
150
+ if(i === 0) return false;
151
+ this.undoables[i-1].properties[3] += dx;
152
+ this.undoables[i-1].properties[4] += dy;
153
+ }
154
+
155
+ push(action, args=null, tentative=false) {
156
+ // Add an UndoEdit to the undo stack, labeled with edit action that is
157
+ // about to be performed. NOTE: the IDs of objects are stored, rather than
158
+ // the objects themselves, because deleted objects will have different
159
+ // memory addresses when restored by an UNDO
160
+
161
+ // Any action except "move" is likely to invalidate the solver result
162
+ if(action !== 'move' && !(
163
+ // Exceptions:
164
+ // (1) adding/modifying notes
165
+ (args instanceof Note)
166
+ )) VM.reset();
167
+
168
+ // If this edit is new (i.e., not a redo) then remove all "redoable" edits
169
+ if(!tentative) this.redoables.length = 0;
170
+ // If the undo stack is full then discard its bottom edit
171
+ if(this.undoables.length == CONFIGURATION.undo_stack_size) this.undoables.splice(0, 1);
172
+ const ue = new UndoEdit(action);
173
+ // For specific actions, store the IDs of the selected entities
174
+ if(['move', 'delete', 'drop', 'lift'].indexOf(action) >= 0) {
175
+ ue.setSelection();
176
+ }
177
+ // Set the properties of this undoable, depending on the type of action
178
+ if(action === 'move') {
179
+ // `args` holds the dragged node => store its ID and position
180
+ // NOTE: for products, use their ProductPosition in the focal cluster
181
+ const obj = (args instanceof Product ?
182
+ args.positionInFocalCluster : args);
183
+ ue.properties = [args.identifier, obj.x, obj.y, 0, 0];
184
+ // NOTE: object_id is NOT set, as dragged selection may contain
185
+ // multiple entities
186
+ } else if(action === 'add') {
187
+ // `args` holds the added entity => store its ID
188
+ ue.object_id = args.identifier;
189
+ } else if(action === 'drop' || action === 'lift') {
190
+ // Store ID of target cluster
191
+ ue.object_id = args.identifier;
192
+ ue.properties = MODEL.getSelectionPositions;
193
+ } else if(action === 'replace') {
194
+ // Replace passes its undo information as an object
195
+ ue.properties = args;
196
+ }
197
+
198
+ // NOTE: for a DELETE action, no properties are stored; the XML needed to
199
+ // restore deleted entities will be added by the respective delete methods
200
+
201
+ // Push the new edit onto the UNDO stack
202
+ this.undoables.push(ue);
203
+ // Update the GUI buttons
204
+ UI.updateButtons();
205
+ // NOTE: update the Finder only if needed, and with a delay because
206
+ // the "prepare for undo" is performed before the actual change
207
+ if(action !== 'move') setTimeout(() => { FINDER.updateDialog(); }, 5);
208
+ //console.log('push ' + action);
209
+ //console.log(UNDO_STACK);
210
+ }
211
+
212
+ pop(action='') {
213
+ // Remove the top edit (if any) from the stack if it has the specified action
214
+ // NOTE: pop does NOT undo the action (the model is not modified)
215
+ let i = this.undoables.length - 1;
216
+ if(i >= 0 && (action === '' || this.undoables[i].action === action)) {
217
+ this.undoables.pop();
218
+ UI.updateButtons();
219
+ }
220
+ //console.log('pop ' + action);
221
+ //console.log(UNDO_STACK);
222
+ }
223
+
224
+ doMove(ue) {
225
+ // This method implements shared code for UNDO and REDO of "move" actions
226
+ // First get the dragged node
227
+ let obj = MODEL.objectByID(ue.properties[0]);
228
+ if(obj) {
229
+ // For products, use the x and y of the ProductPosition
230
+ if(obj instanceof Product) obj = obj.positionInFocalCluster;
231
+ // Calculate the relative move (dx, dy)
232
+ const
233
+ dx = ue.properties[1] - obj.x,
234
+ dy = ue.properties[2] - obj.y,
235
+ tdx = -ue.properties[3],
236
+ tdy = -ue.properties[4];
237
+ // Update the undo edit's x and y properties so that it can be pushed onto
238
+ // the other stack (as the dragged node ID and the selection stays the same)
239
+ ue.properties[1] = obj.x;
240
+ ue.properties[2] = obj.y;
241
+ // Prepare to translate back (NOTE: this will also prepare for REDO)
242
+ ue.properties[3] = tdx;
243
+ ue.properties[4] = tdy;
244
+ // Translate the entire graph (NOTE: this does nothing if dx and dy both equal 0)
245
+ MODEL.translateGraph(tdx, tdy);
246
+ // Restore the selection as it was at the time of the "move" action
247
+ MODEL.selectList(ue.getSelection);
248
+ // Move the selection back to its original position
249
+ MODEL.moveSelection(dx - tdx, dy - tdy);
250
+ }
251
+ }
252
+
253
+ restoreFromXML(xml) {
254
+ // Restore deleted objects from XML and add them to the UndoEdit's selection
255
+ // (so that they can be RE-deleted)
256
+ // NOTES:
257
+ // (1) Store focal cluster, because this may change while initializing a
258
+ // cluster from XML
259
+ // (2) Set "selected" attribute of objects to FALSE, as the selection will
260
+ // be restored from UndoEdit
261
+ const n = parseXML(MODEL.xml_header + `<edits>${xml}</edits>`);
262
+ if(n && n.childNodes) {
263
+ let c, li = [], ppi = [], ci = [];
264
+ for(let i = 0; i < n.childNodes.length; i++) {
265
+ c = n.childNodes[i];
266
+ // Immediately restore "independent" entities ...
267
+ if(c.nodeName === 'dataset') {
268
+ MODEL.addDataset(xmlDecoded(nodeContentByTag(c, 'name')), c);
269
+ } else if(c.nodeName === 'actor') {
270
+ MODEL.addActor(xmlDecoded(nodeContentByTag(c, 'name')), c);
271
+ } else if(c.nodeName === 'note') {
272
+ const obj = MODEL.addNote(c);
273
+ obj.selected = false;
274
+ } else if(c.nodeName === 'process') {
275
+ const obj = MODEL.addProcess(xmlDecoded(nodeContentByTag(c, 'name')),
276
+ xmlDecoded(nodeContentByTag(c, 'owner')), c);
277
+ obj.selected = false;
278
+ } else if(c.nodeName === 'product') {
279
+ const obj = MODEL.addProduct(
280
+ xmlDecoded(nodeContentByTag(c, 'name')), c);
281
+ obj.selected = false;
282
+ } else if(c.nodeName === 'chart') {
283
+ MODEL.addChart(xmlDecoded(nodeContentByTag(c, 'title')), c);
284
+ // ... but merely collect indices of other entities
285
+ } else if(c.nodeName === 'link' || c.nodeName === 'constraint') {
286
+ li.push(i);
287
+ } else if(c.nodeName === 'product-position') {
288
+ ppi.push(i);
289
+ } else if(c.nodeName === 'cluster') {
290
+ ci.push(i);
291
+ }
292
+ }
293
+ // NOTE: collecting the indices of links, product positions and clusters
294
+ // saves the effort to iterate over ALL childnodes again
295
+ // First restore links and constraints
296
+ for(let i = 0; i < li.length; i++) {
297
+ c = n.childNodes[li[i]];
298
+ // Double-check that this node defines a link or a constraint
299
+ if(c.nodeName === 'link' || c.nodeName === 'constraint') {
300
+ let name = xmlDecoded(nodeContentByTag(c, 'from-name'));
301
+ let actor = xmlDecoded(nodeContentByTag(c, 'from-owner'));
302
+ if(actor != UI.NO_ACTOR) name += ` (${actor})`;
303
+ let fn = MODEL.nodeBoxByID(UI.nameToID(name));
304
+ if(fn) {
305
+ name = xmlDecoded(nodeContentByTag(c, 'to-name'));
306
+ actor = xmlDecoded(nodeContentByTag(c, 'to-owner'));
307
+ if(actor != UI.NO_ACTOR) name += ` (${actor})`;
308
+ let tn = MODEL.nodeBoxByID(UI.nameToID(name));
309
+ if(tn) {
310
+ if(c.nodeName === 'link') {
311
+ MODEL.addLink(fn, tn, c).selected = false;
312
+ } else {
313
+ MODEL.addConstraint(fn, tn, c).selected = false;
314
+ }
315
+ }
316
+ }
317
+ }
318
+ }
319
+ // Then restore product positions.
320
+ // NOTE: These correspond to the products that were part of the
321
+ // selection; all other product positions are restored as part of their
322
+ // containing clusters
323
+ for(let i = 0; i < ppi.length; i++) {
324
+ c = n.childNodes[ppi[i]];
325
+ // Double-check that this node defines a product position
326
+ if(c.nodeName === 'product-position') {
327
+ const obj = MODEL.nodeBoxByID(UI.nameToID(
328
+ xmlDecoded(nodeContentByTag(c, 'product-name'))));
329
+ if(obj) {
330
+ obj.selected = false;
331
+ MODEL.focal_cluster.addProductPosition(obj).initFromXML(c);
332
+ }
333
+ }
334
+ }
335
+ // Lastly, restore clusters.
336
+ // NOTE: Store focal cluster, because this may change while initializing
337
+ // a cluster from XML
338
+ const fc = MODEL.focal_cluster;
339
+ for(let i = 0; i < ci.length; i++) {
340
+ c = n.childNodes[ci[i]];
341
+ if(c.nodeName === 'cluster') {
342
+ const obj = MODEL.addCluster(xmlDecoded(nodeContentByTag(c, 'name')),
343
+ xmlDecoded(nodeContentByTag(c, 'owner')), c);
344
+ obj.selected = false;
345
+
346
+ // TEMPORARY trace (remove when done testing)
347
+ if (MODEL.focal_cluster === fc) {
348
+ console.log('NO refocus needed');
349
+ } else {
350
+ console.log('Refocusing from ... to ... : ', MODEL.focal_cluster, fc);
351
+ }
352
+ // Restore original focal cluster because addCluster may shift focus
353
+ // to a sub-cluster
354
+ MODEL.focal_cluster = fc;
355
+ }
356
+ }
357
+ }
358
+ MODEL.clearSelection();
359
+ }
360
+
361
+ undo() {
362
+ // Undo the most recent "undoable" action
363
+ let ue;
364
+ if(this.undoables.length > 0) {
365
+ UI.reset();
366
+ // Get the action to be undone
367
+ ue = this.undoables.pop();
368
+ // Focus on the cluster that was focal at the time of action
369
+ // NOTE: do this WITHOUT calling UI.makeFocalCluster because this
370
+ // clears the selection and redraws the graph
371
+ MODEL.focal_cluster = ue.cluster;
372
+ //console.log('undo' + ue.fullAction);
373
+ //console.log(ue);
374
+ if(ue.action === 'move') {
375
+ this.doMove(ue);
376
+ // NOTE: doMove modifies the undo edit so that it can be used as redo edit
377
+ this.redoables.push(ue);
378
+ } else if(ue.action === 'add') {
379
+ // UNDO add means deleting the lastly added entity
380
+ let obj = MODEL.objectByID(ue.object_id);
381
+ if(obj) {
382
+ // Prepare UndoEdit for redo
383
+ const ot = obj.type;
384
+ // Set properties to [class name, identifier] (for tooltip display and redo)
385
+ ue.properties = [ot, ue.object_id];
386
+ // NOTE: `action` remains "add", but ID is set to null because otherwise
387
+ // the fullAction method would fail
388
+ ue.object_id = null;
389
+ // Push the "delete" UndoEdit back onto the undo stack so that XML will
390
+ // be added to it
391
+ this.undoables.push(ue);
392
+ // Mimic the exact selection state immediately after adding the entity
393
+ MODEL.clearSelection();
394
+ MODEL.select(obj);
395
+ // Execute the proper delete, depending on the type of entity
396
+ if(ot === 'Link') {
397
+ MODEL.deleteLink(obj);
398
+ } else if(ot === 'Note') {
399
+ MODEL.focal_cluster.deleteNote(obj);
400
+ } else if(ot === 'Cluster') {
401
+ MODEL.deleteCluster(obj);
402
+ } else if(ot === 'Product') {
403
+ // NOTE: `deleteProduct` deletes the ProductPosition, and the product
404
+ // itself only if needed
405
+ MODEL.focal_cluster.deleteProduct(obj);
406
+ } else if(ot === 'Process') {
407
+ MODEL.deleteNode(obj);
408
+ }
409
+ // Clear the model's selection, since we've bypassed the regular
410
+ // `deleteSelection` routine
411
+ MODEL.selection.length = 0;
412
+ // Move the UndoEdit to the redo stack
413
+ this.redoables.push(this.undoables.pop());
414
+ }
415
+ } else if(ue.action === 'delete') {
416
+ this.restoreFromXML(ue.xml);
417
+ // Restore the selection as it was at the time of the "delete" action
418
+ MODEL.selectList(ue.getSelection);
419
+ // Clear the XML (not useful for REDO delete)
420
+ ue.xml = null;
421
+ this.redoables.push(ue);
422
+ } else if(ue.action === 'drop' || ue.action === 'lift') {
423
+ // Restore the selection as it was at the time of the action
424
+ MODEL.selectList(ue.getSelection);
425
+ // NOTE: first focus on the original target cluster
426
+ MODEL.focal_cluster = MODEL.objectByID(ue.object_id);
427
+ // Drop the selection "back" to the focal cluster
428
+ MODEL.dropSelectionIntoCluster(ue.cluster);
429
+ // Refocus on the original focal cluster
430
+ MODEL.focal_cluster = ue.cluster;
431
+ // NOTE: now restore the selection in THIS cluster!
432
+ MODEL.selectList(ue.getSelection);
433
+ // Now restore the position of the nodes
434
+ MODEL.setSelectionPositions(ue.properties);
435
+ this.redoables.push(ue);
436
+ // NOTE: a drop action will always be preceded by a move action
437
+ if(ue.action === 'drop') {
438
+ // Double-check, and if so, undo this move as well
439
+ if(this.topUndo === 'move') this.undo();
440
+ }
441
+ } else if(ue.action === 'replace') {
442
+ let uep = ue.properties,
443
+ p = MODEL.objectByName(uep.p);
444
+ // First check whether product P needs to be restored
445
+ if(!p && ue.xml) {
446
+ const n = parseXML(MODEL.xml_header + `<edits>${ue.xml}</edits>`);
447
+ if(n && n.childNodes) {
448
+ let c = n.childNodes[0];
449
+ if(c.nodeName === 'product') {
450
+ p = MODEL.addProduct(
451
+ xmlDecoded(nodeContentByTag(c, 'name')), c);
452
+ p.selected = false;
453
+ }
454
+ }
455
+ }
456
+ if(p) {
457
+ // Restore product position of P in focal cluster
458
+ MODEL.focal_cluster.addProductPosition(p, uep.x, uep.y);
459
+ // Restore links in/out of P
460
+ for(let i = 0; i < uep.lt.length; i++) {
461
+ const l = MODEL.linkByID(uep.lt[i]);
462
+ if(l) {
463
+ const ml = MODEL.addLink(l.from_node, p);
464
+ ml.copyPropertiesFrom(l);
465
+ MODEL.deleteLink(l);
466
+ }
467
+ }
468
+ for(let i = 0; i < uep.lf.length; i++) {
469
+ const l = MODEL.linkByID(uep.lf[i]);
470
+ if(l) {
471
+ const ml = MODEL.addLink(p, l.to_node);
472
+ ml.copyPropertiesFrom(l);
473
+ MODEL.deleteLink(l);
474
+ }
475
+ }
476
+ // Restore constraints on/by P
477
+ for(let i = 0; i < uep.ct.length; i++) {
478
+ const c = MODEL.constraintByID(uep.ct[i]);
479
+ if(c) {
480
+ const mc = MODEL.addConstraint(c.from_node, p);
481
+ mc.copyPropertiesFrom(c);
482
+ MODEL.deleteConstraint(c);
483
+ }
484
+ }
485
+ for(let i = 0; i < uep.cf.length; i++) {
486
+ const c = MODEL.constraintByID(uep.cf[i]);
487
+ if(c) c.fromNode = p;
488
+ if(c) {
489
+ const mc = MODEL.addConstraint(p, c.to_node);
490
+ mc.copyPropertiesFrom(c);
491
+ MODEL.deleteConstraint(c);
492
+ }
493
+ }
494
+ // NOTE: same UndoEdit object can be used for REDO
495
+ this.redoables.push(ue);
496
+ } else {
497
+ throw 'Failed to UNDO replace action';
498
+ }
499
+ }
500
+ // Update the main window
501
+ MODEL.focal_cluster.clearAllProcesses();
502
+ UI.drawDiagram(MODEL);
503
+ UI.updateButtons();
504
+ // Update the Finder if needed
505
+ if(ue.action !== 'move') FINDER.updateDialog();
506
+ }
507
+ //console.log('undo');
508
+ //console.log(UNDO_STACK);
509
+ }
510
+
511
+ redo() {
512
+ // Restore the model to its state prior to the last undo
513
+ if(this.redoables.length > 0) {
514
+ UI.reset();
515
+ let re = this.redoables.pop();
516
+ //console.log('redo ' + re.fullAction);
517
+ //console.log(UNDO_STACK);
518
+ // Focus on the cluster that was focal at the time of action
519
+ // NOTE: no call to UI.makeFocalCluster because this clears the selection
520
+ // and redraws the graph
521
+ MODEL.focal_cluster = re.cluster;
522
+ if(re.action === 'move') {
523
+ // NOTE: this is a mirror operation of the UNDO
524
+ this.doMove(re);
525
+ // NOTE: doMove modifies the RedoEdit so that it can be used as UndoEdit
526
+ this.undoables.push(re);
527
+ // NOTE: when next redoable action is "drop", redo this as well
528
+ if(this.topRedo === 'drop') this.redo();
529
+ } else if(re.action === 'add') {
530
+ //console.log('ADD redo properties:', re.properties);
531
+ // NOTE: redo an undone "add" => mimick undoing a "delete"
532
+ this.restoreFromXML(re.xml);
533
+ // Clear the XML and restore the object identifier
534
+ re.xml = null;
535
+ re.object_id = re.properties[1];
536
+ this.undoables.push(re);
537
+ } else if(re.action === 'delete') {
538
+ // Restore the selection as it was at the time of the "delete" action
539
+ MODEL.selectList(re.getSelection);
540
+ this.undoables.push(re);
541
+ // Then perform a delete action
542
+ MODEL.deleteSelection();
543
+ } else if(re.action === 'drop' || re.action === 'lift') {
544
+ const c = MODEL.objectByID(re.object_id);
545
+ if(c instanceof Cluster) MODEL.dropSelectionIntoCluster(c);
546
+ } else if(re.action === 'replace') {
547
+ const
548
+ p = MODEL.objectByName(re.properties.p),
549
+ r = MODEL.objectByName(re.properties.r);
550
+ if(p instanceof Product && r instanceof Product) {
551
+ MODEL.doReplace(p, r, re.properties.g);
552
+ }
553
+ }
554
+ MODEL.focal_cluster.clearAllProcesses();
555
+ UI.drawDiagram(MODEL);
556
+ UI.updateButtons();
557
+ if(re.action !== 'move') FINDER.updateDialog();
558
+ }
559
+ }
560
+ } // END of class UndoStack
@@ -5389,11 +5389,17 @@ class NodeBox extends ObjectWithXYWH {
5389
5389
  }
5390
5390
 
5391
5391
  drawWithLinks() {
5392
- // TO DO: Also draw relevant arrows when this is a cluster.
5392
+ const fc = this.cluster;
5393
+ // Do not redraw if this node is not visible in the focal cluster.
5394
+ if(this instanceof Product) {
5395
+ if(!this.positionInFocalCluster) return;
5396
+ } else {
5397
+ if(fc !== MODEL.focal_cluster) return;
5398
+ }
5393
5399
  UI.drawObject(this);
5400
+ // @@TO DO: Also draw relevant arrows when this is a cluster.
5394
5401
  if(this instanceof Cluster) return;
5395
- // Draw ALL arrows associated with this node.
5396
- const fc = this.cluster;
5402
+ // Draw all *visible* arrows associated with this node.
5397
5403
  fc.categorizeEntities();
5398
5404
  // Make list of arrows that represent a link related to this node.
5399
5405
  let a,
@@ -5413,6 +5419,11 @@ class NodeBox extends ObjectWithXYWH {
5413
5419
  }
5414
5420
  // Draw all arrows in this list.
5415
5421
  for(let i = 0; i < alist.length; i++) UI.drawObject(alist[i]);
5422
+ // Also draw related constraint arrows.
5423
+ for(let k in MODEL.constraints) if(MODEL.constraints.hasOwnProperty(k)) {
5424
+ const c = MODEL.constraints[k];
5425
+ if(c.from_node === this || c.to_node === this) UI.drawObject(c);
5426
+ }
5416
5427
  }
5417
5428
 
5418
5429
  clearHiddenIO() {
@@ -6936,7 +6947,7 @@ class Node extends NodeBox {
6936
6947
 
6937
6948
  setConstraintOffsets() {
6938
6949
  // Sets the offset properties of the constraints that relate to this
6939
- // node; these properties are used when drawing these constraints
6950
+ // node; these properties are used when drawing these constraints.
6940
6951
  const tbc = {top: [], bottom: [], thumb: []};
6941
6952
  for(let k in MODEL.constraints) if(MODEL.constraints.hasOwnProperty(k)) {
6942
6953
  const
@@ -6952,21 +6963,21 @@ class Node extends NodeBox {
6952
6963
  continue;
6953
6964
  }
6954
6965
  if(vn[0] && vn[1]) {
6955
- // Both nodes visible => arrow
6966
+ // Both nodes visible => arrow.
6956
6967
  if(this.y < q.y - (q.height/2 + 3) - this.height) {
6957
- // arrow from bottom of THIS to top of q
6968
+ // Arrow from bottom of THIS to top of q.
6958
6969
  c.bottom_x = q.x;
6959
6970
  c.bottom_y = q.y;
6960
6971
  tbc.bottom.push(c);
6961
6972
  } else {
6962
- // arrow from bottom of THIS to top of q
6973
+ // Arrow from bottom of THIS to top of q.
6963
6974
  c.top_x = q.x;
6964
6975
  c.top_y = q.y;
6965
6976
  tbc.top.push(c);
6966
6977
  }
6967
6978
  } else {
6968
- // One node visible => thumbnail at top of this process;
6969
- // NOTE: X coordinate not needed for sorting
6979
+ // One node visible => thumbnail at top of this process.
6980
+ // NOTE: X coordinate not needed for sorting.
6970
6981
  tbc.thumb.push(c);
6971
6982
  }
6972
6983
  }
@@ -7012,7 +7023,8 @@ class Node extends NodeBox {
7012
7023
  // Keep 10px between connection points, and for processes
7013
7024
  // also keep this distance from the rectangle corners
7014
7025
  margin = 10,
7015
- aw = (this instanceof Process ? this.width / 2 :
7026
+ aw = (this instanceof Process ?
7027
+ (this.collapsed ? 8.5 : this.width / 2) :
7016
7028
  (this.width - this.height) / 2 + margin) - 9 * thl;
7017
7029
  if(tl > 0) {
7018
7030
  tbc.top.sort(tcmp);
@@ -7485,11 +7497,12 @@ class Process extends Node {
7485
7497
  }
7486
7498
 
7487
7499
  attributeExpression(a) {
7488
- // Processes have three expression attributes
7500
+ // Processes have four expression attributes
7489
7501
  if(a === 'LB') return this.lower_bound;
7490
7502
  if(a === 'UB') {
7491
7503
  return (this.equal_bounds ? this.lower_bound : this.upper_bound);
7492
7504
  }
7505
+ if(a === 'LCF') return this.pace_expression;
7493
7506
  if(a === 'IL') return this.initial_level;
7494
7507
  return null;
7495
7508
  }
@@ -483,6 +483,16 @@ function matchingNumber(m, s) {
483
483
  return (n == m ? n : false);
484
484
  }
485
485
 
486
+ function matchingNumberInList(ml, s) {
487
+ // Traverses list `ml` and returns the first matching number, or FALSE
488
+ // if no match is found.
489
+ for(let i = 0; i < ml.length; i++) {
490
+ const n = matchingNumber(ml[i], s);
491
+ if(n !== false) return n;
492
+ }
493
+ return false;
494
+ }
495
+
486
496
  function compareWithTailNumbers(s1, s2) {
487
497
  // Returns 0 on equal, an integer < 0 if `s1` comes before `s2`, and
488
498
  // an integer > 0 if `s2` comes before `s1`.