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