linny-r 1.4.3 → 1.4.5

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 +450 -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 +34 -15
  47. package/static/scripts/linny-r-utils.js +11 -1
  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
@@ -2988,7 +2988,8 @@ class LinnyRModel {
2988
2988
  if(names.indexOf(n) < 0) {
2989
2989
  // Here, too, NULL can be used as "owner chart".
2990
2990
  const cv = new ChartVariable(null);
2991
- cv.setProperties(ds, dm.selector, false, '#000000');
2991
+ // NOTE: For equations, the object is the dataset modifier.
2992
+ cv.setProperties(eq ? dm : ds, dm.selector, false, '#000000');
2992
2993
  vbls.push(cv);
2993
2994
  }
2994
2995
  }
@@ -5389,11 +5390,17 @@ class NodeBox extends ObjectWithXYWH {
5389
5390
  }
5390
5391
 
5391
5392
  drawWithLinks() {
5392
- // TO DO: Also draw relevant arrows when this is a cluster.
5393
+ const fc = this.cluster;
5394
+ // Do not redraw if this node is not visible in the focal cluster.
5395
+ if(this instanceof Product) {
5396
+ if(!this.positionInFocalCluster) return;
5397
+ } else {
5398
+ if(fc !== MODEL.focal_cluster) return;
5399
+ }
5393
5400
  UI.drawObject(this);
5401
+ // @@TO DO: Also draw relevant arrows when this is a cluster.
5394
5402
  if(this instanceof Cluster) return;
5395
- // Draw ALL arrows associated with this node.
5396
- const fc = this.cluster;
5403
+ // Draw all *visible* arrows associated with this node.
5397
5404
  fc.categorizeEntities();
5398
5405
  // Make list of arrows that represent a link related to this node.
5399
5406
  let a,
@@ -5413,6 +5420,11 @@ class NodeBox extends ObjectWithXYWH {
5413
5420
  }
5414
5421
  // Draw all arrows in this list.
5415
5422
  for(let i = 0; i < alist.length; i++) UI.drawObject(alist[i]);
5423
+ // Also draw related constraint arrows.
5424
+ for(let k in MODEL.constraints) if(MODEL.constraints.hasOwnProperty(k)) {
5425
+ const c = MODEL.constraints[k];
5426
+ if(c.from_node === this || c.to_node === this) UI.drawObject(c);
5427
+ }
5416
5428
  }
5417
5429
 
5418
5430
  clearHiddenIO() {
@@ -6936,7 +6948,7 @@ class Node extends NodeBox {
6936
6948
 
6937
6949
  setConstraintOffsets() {
6938
6950
  // Sets the offset properties of the constraints that relate to this
6939
- // node; these properties are used when drawing these constraints
6951
+ // node; these properties are used when drawing these constraints.
6940
6952
  const tbc = {top: [], bottom: [], thumb: []};
6941
6953
  for(let k in MODEL.constraints) if(MODEL.constraints.hasOwnProperty(k)) {
6942
6954
  const
@@ -6952,21 +6964,21 @@ class Node extends NodeBox {
6952
6964
  continue;
6953
6965
  }
6954
6966
  if(vn[0] && vn[1]) {
6955
- // Both nodes visible => arrow
6967
+ // Both nodes visible => arrow.
6956
6968
  if(this.y < q.y - (q.height/2 + 3) - this.height) {
6957
- // arrow from bottom of THIS to top of q
6969
+ // Arrow from bottom of THIS to top of q.
6958
6970
  c.bottom_x = q.x;
6959
6971
  c.bottom_y = q.y;
6960
6972
  tbc.bottom.push(c);
6961
6973
  } else {
6962
- // arrow from bottom of THIS to top of q
6974
+ // Arrow from bottom of THIS to top of q.
6963
6975
  c.top_x = q.x;
6964
6976
  c.top_y = q.y;
6965
6977
  tbc.top.push(c);
6966
6978
  }
6967
6979
  } else {
6968
- // One node visible => thumbnail at top of this process;
6969
- // NOTE: X coordinate not needed for sorting
6980
+ // One node visible => thumbnail at top of this process.
6981
+ // NOTE: X coordinate not needed for sorting.
6970
6982
  tbc.thumb.push(c);
6971
6983
  }
6972
6984
  }
@@ -7012,7 +7024,8 @@ class Node extends NodeBox {
7012
7024
  // Keep 10px between connection points, and for processes
7013
7025
  // also keep this distance from the rectangle corners
7014
7026
  margin = 10,
7015
- aw = (this instanceof Process ? this.width / 2 :
7027
+ aw = (this instanceof Process ?
7028
+ (this.collapsed ? 8.5 : this.width / 2) :
7016
7029
  (this.width - this.height) / 2 + margin) - 9 * thl;
7017
7030
  if(tl > 0) {
7018
7031
  tbc.top.sort(tcmp);
@@ -7485,11 +7498,12 @@ class Process extends Node {
7485
7498
  }
7486
7499
 
7487
7500
  attributeExpression(a) {
7488
- // Processes have three expression attributes
7501
+ // Processes have four expression attributes
7489
7502
  if(a === 'LB') return this.lower_bound;
7490
7503
  if(a === 'UB') {
7491
7504
  return (this.equal_bounds ? this.lower_bound : this.upper_bound);
7492
7505
  }
7506
+ if(a === 'LCF') return this.pace_expression;
7493
7507
  if(a === 'IL') return this.initial_level;
7494
7508
  return null;
7495
7509
  }
@@ -8900,9 +8914,12 @@ class ChartVariable {
8900
8914
  // by its scale factor unless it equals 1 (no scaling).
8901
8915
  const sf = (this.scale_factor === 1 ? '' :
8902
8916
  ` (x${VM.sig4Dig(this.scale_factor)})`);
8903
- // NOTE: Display name of equation is just the equations dataset modifier.
8904
- if(this.object === MODEL.equations_dataset) {
8905
- let eqn = this.attribute;
8917
+ //Display name of equation is just the equations dataset selector.
8918
+ if(this.object instanceof DatasetModifier ||
8919
+ // NOTE: Same holds for "dummy variables" added for wildcard
8920
+ // dataset selectors.
8921
+ this.object === MODEL.equations_dataset) {
8922
+ let eqn = this.object.selector;
8906
8923
  if(this.wildcard_index !== false) {
8907
8924
  eqn = eqn.replace('??', this.wildcard_index);
8908
8925
  }
@@ -9286,6 +9303,8 @@ class Chart {
9286
9303
  vlist.push(v);
9287
9304
  }
9288
9305
  return vlist;
9306
+ // FALL-THROUGH: Equation name has no wildcard => use the equation
9307
+ // object (= dataset modifier) as `obj`.
9289
9308
  }
9290
9309
  const v = new ChartVariable(this);
9291
9310
  v.setProperties(obj, a, false, this.nextAvailableDefaultColor, 1, 1);