linny-r 1.4.3 → 1.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +102 -48
  2. package/package.json +1 -1
  3. package/server.js +31 -6
  4. package/static/images/check-off-not-same-changed.png +0 -0
  5. package/static/images/check-off-not-same-not-changed.png +0 -0
  6. package/static/images/check-off-same-changed.png +0 -0
  7. package/static/images/check-off-same-not-changed.png +0 -0
  8. package/static/images/check-on-not-same-changed.png +0 -0
  9. package/static/images/check-on-not-same-not-changed.png +0 -0
  10. package/static/images/check-on-same-changed.png +0 -0
  11. package/static/images/check-on-same-not-changed.png +0 -0
  12. package/static/images/eq-not-same-changed.png +0 -0
  13. package/static/images/eq-not-same-not-changed.png +0 -0
  14. package/static/images/eq-same-changed.png +0 -0
  15. package/static/images/eq-same-not-changed.png +0 -0
  16. package/static/images/ne-not-same-changed.png +0 -0
  17. package/static/images/ne-not-same-not-changed.png +0 -0
  18. package/static/images/ne-same-changed.png +0 -0
  19. package/static/images/ne-same-not-changed.png +0 -0
  20. package/static/images/sort-asc-lead.png +0 -0
  21. package/static/images/sort-asc.png +0 -0
  22. package/static/images/sort-desc-lead.png +0 -0
  23. package/static/images/sort-desc.png +0 -0
  24. package/static/images/sort-not.png +0 -0
  25. package/static/index.html +51 -35
  26. package/static/linny-r.css +167 -53
  27. package/static/scripts/linny-r-gui-actor-manager.js +340 -0
  28. package/static/scripts/linny-r-gui-chart-manager.js +944 -0
  29. package/static/scripts/linny-r-gui-constraint-editor.js +681 -0
  30. package/static/scripts/linny-r-gui-controller.js +4005 -0
  31. package/static/scripts/linny-r-gui-dataset-manager.js +1176 -0
  32. package/static/scripts/linny-r-gui-documentation-manager.js +739 -0
  33. package/static/scripts/linny-r-gui-equation-manager.js +307 -0
  34. package/static/scripts/linny-r-gui-experiment-manager.js +1944 -0
  35. package/static/scripts/linny-r-gui-expression-editor.js +450 -0
  36. package/static/scripts/linny-r-gui-file-manager.js +392 -0
  37. package/static/scripts/linny-r-gui-finder.js +727 -0
  38. package/static/scripts/linny-r-gui-model-autosaver.js +230 -0
  39. package/static/scripts/linny-r-gui-monitor.js +448 -0
  40. package/static/scripts/linny-r-gui-paper.js +2789 -0
  41. package/static/scripts/linny-r-gui-receiver.js +323 -0
  42. package/static/scripts/linny-r-gui-repository-browser.js +819 -0
  43. package/static/scripts/linny-r-gui-scale-unit-manager.js +244 -0
  44. package/static/scripts/linny-r-gui-sensitivity-analysis.js +778 -0
  45. package/static/scripts/linny-r-gui-undo-redo.js +560 -0
  46. package/static/scripts/linny-r-model.js +34 -15
  47. package/static/scripts/linny-r-utils.js +11 -1
  48. package/static/scripts/linny-r-vm.js +21 -12
  49. package/static/scripts/linny-r-gui.js +0 -16908
@@ -0,0 +1,2789 @@
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-paper.js) provides the SVG diagram-drawing
9
+ functionality for the Linny-R model editor.
10
+
11
+ */
12
+
13
+ /*
14
+ Copyright (c) 2017-2023 Delft University of Technology
15
+
16
+ Permission is hereby granted, free of charge, to any person obtaining a copy
17
+ of this software and associated documentation files (the "Software"), to deal
18
+ in the Software without restriction, including without limitation the rights to
19
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
20
+ of the Software, and to permit persons to whom the Software is furnished to do
21
+ so, subject to the following conditions:
22
+
23
+ The above copyright notice and this permission notice shall be included in
24
+ all copies or substantial portions of the Software.
25
+
26
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
27
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
28
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
29
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
30
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
31
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
32
+ SOFTWARE.
33
+ */
34
+
35
+ // CLASS Shape
36
+ // A shape is a group of one or more SVG elements with a time-based ID number,
37
+ // and typically represents an entity in a Linny-R model diagram
38
+ class Shape {
39
+ constructor(owner) {
40
+ this.owner = owner;
41
+ this.id = randomID();
42
+ if(UI.paper) {
43
+ // Create a new SVG element, but do not add it to the main SVG object
44
+ // NOTE: by default, only constraints respond to pointer events
45
+ const ow = (owner instanceof Constraint ? owner : null);
46
+ this.element = UI.paper.newSVGElement('svg', ow);
47
+ this.element.id = this.id;
48
+ }
49
+ }
50
+
51
+ clear() {
52
+ // Removes all composing elements from this shape's SVG object
53
+ UI.paper.clearSVGElement(this.element);
54
+ }
55
+
56
+ appendToDOM() {
57
+ // Appends this shape's SVG element to the main SVG object
58
+ const el = document.getElementById(this.id);
59
+ // Replace existing element, if it exists
60
+ if(el) UI.paper.svg.removeChild(el);
61
+ // Add the new version
62
+ UI.paper.svg.appendChild(this.element);
63
+ }
64
+
65
+ removeFromDOM() {
66
+ // Removes this shape's SVG element from the main SVG object
67
+ const el = document.getElementById(this.id);
68
+ if(el) UI.paper.svg.removeChild(el);
69
+ this.element = null;
70
+ }
71
+
72
+ addPath(path, attrs) {
73
+ // Appends a path to the SVG element for this shape
74
+ const
75
+ ow = (this.owner instanceof Constraint ? this.owner : null),
76
+ el = UI.paper.newSVGElement('path', ow);
77
+ el.setAttribute('d', path.join(''));
78
+ UI.paper.addSVGAttributes(el, attrs);
79
+ this.element.appendChild(el);
80
+ return el;
81
+ }
82
+
83
+ addNumber(x, y, number, attrs) {
84
+ // Appends SVG for a numeric string centered at (x, y)
85
+ // NOTES:
86
+ // (1) A numeric string is scaled to a fixed width per character
87
+ // (0.65*font size)
88
+ // (2) If anchor is not "middle", x is taken as the border to align against
89
+ // (3) Calling routines may pass a number instead of a string, so "lines"
90
+ // is forced to a string
91
+ number = '' + number;
92
+ // Assume default font size and weight unless specified
93
+ const
94
+ size = (attrs.hasOwnProperty('font-size') ?
95
+ attrs['font-size'] : 8),
96
+ weight = (attrs.hasOwnProperty('font-weight') ?
97
+ attrs['font-weight'] : 400),
98
+ fh = UI.paper.font_heights[size],
99
+ el = UI.paper.newSVGElement('text');
100
+ el.setAttribute('x', x);
101
+ el.setAttribute('y', y + 0.35*fh);
102
+ el.setAttribute('textLength',
103
+ UI.paper.numberSize(number, size, weight).width);
104
+ el.textContent = number;
105
+ UI.paper.addSVGAttributes(el, attrs);
106
+ this.element.appendChild(el);
107
+ return el;
108
+ }
109
+
110
+ addText(x, y, lines, attrs) {
111
+ // Appends SVG for a (multi)string centered at (x, y)
112
+ // NOTES:
113
+ // (1) If anchor is not "middle", x is taken as the border to align against
114
+ // (2) Calling routines may pass a number, a string or an array
115
+ if(!Array.isArray(lines)) {
116
+ // Force `lines` into a string, and then split it at newlines
117
+ lines = ('' + lines).split('\n');
118
+ }
119
+ // Assume default font size unless specified
120
+ const size = (attrs.hasOwnProperty('font-size') ? attrs['font-size'] : 8);
121
+ // Vertically align text such that y is at its center
122
+ // NOTE: subtract 30% of 1 line height more, or the text is consistently
123
+ // too low
124
+ const
125
+ fh = UI.paper.font_heights[size],
126
+ cy = y - (lines.length + 0.3) * fh/2,
127
+ el = UI.paper.newSVGElement('text');
128
+ el.setAttribute('x', x);
129
+ el.setAttribute('y', cy);
130
+ UI.paper.addSVGAttributes(el, attrs);
131
+ for(let i = 0; i < lines.length; i++) {
132
+ const ts = UI.paper.newSVGElement('tspan');
133
+ ts.setAttribute('x', x);
134
+ ts.setAttribute('dy', fh);
135
+ ts.textContent = lines[i];
136
+ el.appendChild(ts);
137
+ }
138
+ this.element.appendChild(el);
139
+ return el;
140
+ }
141
+
142
+ addRect(x, y, w, h, attrs) {
143
+ // Adds a rectangle with center point (x, y), width w, and height h
144
+ // NOTE: for a "roundbox", pass the corner radii rx and ry
145
+ const
146
+ ow = (this.owner instanceof Constraint ? this.owner : null),
147
+ el = UI.paper.newSVGElement('rect', ow);
148
+ el.setAttribute('x', x - w/2);
149
+ el.setAttribute('y', y - h/2);
150
+ el.setAttribute('width', Math.max(0, w));
151
+ el.setAttribute('height', Math.max(0, h));
152
+ UI.paper.addSVGAttributes(el, attrs);
153
+ this.element.appendChild(el);
154
+ return el;
155
+ }
156
+
157
+ addCircle(x, y, r, attrs) {
158
+ // Adds a circle with center point (x, y) and radius r
159
+ const el = UI.paper.newSVGElement('circle');
160
+ el.setAttribute('cx', x);
161
+ el.setAttribute('cy', y);
162
+ el.setAttribute('r', r);
163
+ UI.paper.addSVGAttributes(el, attrs);
164
+ this.element.appendChild(el);
165
+ return el;
166
+ }
167
+
168
+ addEllipse(x, y, rx, ry, attrs) {
169
+ // Adds an ellipse with center point (x, y), and specified radii and
170
+ // attributes
171
+ const el = UI.paper.newSVGElement('ellipse');
172
+ el.setAttribute('cx', x);
173
+ el.setAttribute('cy', y);
174
+ el.setAttribute('rx', rx);
175
+ el.setAttribute('ry', ry);
176
+ UI.paper.addSVGAttributes(el, attrs);
177
+ this.element.appendChild(el);
178
+ return el;
179
+ }
180
+
181
+ addSVG(x, y, attrs) {
182
+ // Adds an SVG subelement with top-left (x, y) and specified attributes
183
+ const
184
+ ow = (this.owner instanceof Constraint ? this.owner : null),
185
+ el = UI.paper.newSVGElement('svg', ow);
186
+ el.setAttribute('x', x);
187
+ el.setAttribute('y', y);
188
+ UI.paper.addSVGAttributes(el, attrs);
189
+ this.element.appendChild(el);
190
+ return el;
191
+ }
192
+
193
+ addBlockArrow(x, y, io, n) {
194
+ // Adds a colored block arrow with the number `n` in white IF n > 0
195
+ // NOTE: the ID of the owner of this shape (cluster, process or product)
196
+ // is passed as data attribute so that the SVG element "knows" for which
197
+ // entity the hidden flows must be displayed. The `io` data attribute
198
+ // indicates whether it concerns IN, OUT or IO flows
199
+ if(n <= 0) return;
200
+ const
201
+ p = (io === UI.BLOCK_IO ?
202
+ ['M', x-4, ',', y-5, 'h8v-2l6,7l-6,7v-2h-8v2l-6,-7l6,-7z'] :
203
+ ['M', x-6, ',', y-5, 'h10v-2l6,7l-6,7v-2h-10z']),
204
+ a = this.addPath(p,
205
+ {'fill': UI.color.block_arrow, 'stroke': 'black',
206
+ 'stroke-width': 0.4, 'stroke-linejoin': 'round',
207
+ 'data-id': this.owner.identifier, 'data-io': io});
208
+ this.addText(x, y, n, {'fill': 'white'});
209
+ // Make SVG element responsive to cursor event
210
+ a.setAttribute('pointer-events', 'auto');
211
+ a.addEventListener('mouseover',
212
+ (event) => {
213
+ const
214
+ el = event.target,
215
+ nb = MODEL.nodeBoxByID(el.dataset.id);
216
+ if(nb) {
217
+ DOCUMENTATION_MANAGER.showHiddenIO(nb,
218
+ parseInt(el.dataset.io));
219
+ }
220
+ });
221
+ a.addEventListener('mouseout', () => { UI.on_block_arrow = false; });
222
+ return this.element;
223
+ }
224
+
225
+ moveTo(x, y) {
226
+ const el = document.getElementById(this.id);
227
+ if(el) {
228
+ el.setAttribute('x', x);
229
+ el.setAttribute('y', y);
230
+ }
231
+ }
232
+
233
+ } // END of class Shape
234
+
235
+
236
+ // CLASS Paper (the SVG diagram)
237
+ class Paper {
238
+ constructor() {
239
+ this.svg = document.getElementById('svg-root');
240
+ this.container = document.getElementById('cc');
241
+ this.height = 100;
242
+ this.width = 200;
243
+ this.zoom_factor = 1;
244
+ this.zoom_label = document.getElementById('zoom');
245
+ // Initialize colors used when drawing the model diagram
246
+ this.palette = {
247
+ // Selected model elements are bright red
248
+ select: '#ff0000',
249
+ // Nodes (clusters, products and processes) have dark gray rim...
250
+ node_rim: '#707070',
251
+ // ... and state-dependent fill colors
252
+ node_fill: '#ffffff',
253
+ src_snk: '#e0e0e0',
254
+ has_bounds: '#f4f4f4',
255
+ // Products are red if stock is below target, blue if above target...
256
+ below_lower_bound: '#ffb0b0',
257
+ above_upper_bound: '#b0b0ff',
258
+ // Clusters are mixed if they involve GE as well as LE slack
259
+ beyond_both_bounds: '#ffb0ff',
260
+ // ... and different shades of green if within or on their bounds
261
+ neg_within_bounds: '#e0ffb0',
262
+ pos_within_bounds: '#b0ffe0',
263
+ zero_within_bounds: '#c8ffc8',
264
+ // Product are filled in darker green shades ...
265
+ below_zero_fill: '#c0f070',
266
+ above_zero_fill: '#70f0c0',
267
+ at_zero_fill: '#98f098',
268
+ // ... and still a bit darker when at LB and more so at UB
269
+ at_pos_lb_fill: '#60f0b0',
270
+ at_pos_ub_fill: '#50e0a8',
271
+ at_neg_lb_fill: '#b0f060',
272
+ at_neg_ub_fill: '#a0e050',
273
+ at_zero_lb_fill: '#88f088',
274
+ at_zero_ub_fill: '#78e078',
275
+ // Constants are data products having non-zero LB = UB,
276
+ // and NO in/out flows
277
+ neg_constant: '#e0e8b0',
278
+ pos_constant: '#b0e8e0',
279
+ // If no bounds but non-zero: light orange if < 0, light cyan if > 0
280
+ positive_stock: '#b0f8ff',
281
+ negative_stock: '#fff0b0',
282
+ // Font colors for products
283
+ actor_font: '#600060', // deep purple
284
+ unit: '#006000', // dark green
285
+ consumed: '#b00000', // deep red
286
+ produced: '#0000b0', // navy blue
287
+ within_bounds_font: '#007000', // dark green
288
+ // Process with level > 0 has a dark blue rim and production level font
289
+ active_process: '#000080',
290
+ // The % (level / process upper bound) is drawn as a vertical bar
291
+ process_level_bar: '#dcdcff',
292
+ // Processes with level = upper bound are displayed in purple shades
293
+ at_process_ub: '#500080',
294
+ at_process_ub_fill: '#f8f0ff',
295
+ at_process_ub_bar: '#e8d8f8',
296
+ at_process_ub_arrow: '#f0b0e8',
297
+ // NOTE: special color when level at negative lower bound
298
+ at_process_neg_lb: '#800050',
299
+ // Process with unbound level = +INF is displayed in maroon-red
300
+ infinite_level: '#a00001',
301
+ infinite_level_fill: '#ff90a0',
302
+ // Process state change symbols are displayed in red
303
+ switch_on_off: '#b00000',
304
+ // Compound arrows with non-zero actual flow are displayed in red-purple
305
+ compound_flow: '#800060',
306
+ // Market prices are displayed in gold(!)yellow rectangles
307
+ price: '#ffe000',
308
+ price_rim: '#a09000',
309
+ // Cost prices are displayed in light yellow rectangles...
310
+ cost_price: '#ffff80',
311
+ // ... and even lighter if computed for a process having level 0
312
+ virtual_cost_price: '#ffffc0',
313
+ // Cash flows of clusters likewise a light yellow shade
314
+ cash_flow: '#ffffb0',
315
+ // Share of cost percentages are displayed in orange to signal that
316
+ // they total to more than 100% for a process
317
+ soc_too_high: '#f08020',
318
+ // Ignored clusters are crossed out, and all ignored entities are outlined
319
+ // in a pastel fuchsia
320
+ ignore: '#cc88b0',
321
+ // Block arrows are filled in grayish purple
322
+ block_arrow: '#9070a0',
323
+ // All notes have thin gray rim, similar to other model diagram elements,
324
+ // that turns red when a note is selected
325
+ note_rim: '#909090', // medium gray
326
+ note_font: '#2060a0', // medium dark gray-blue
327
+ // Notes are semi-transparent (will have opacity 0.5) and have a fixed
328
+ // range of color numbers (0 - 5) that correspond to lighter and darker
329
+ // shades of yellow, green, cyan, fuchsia, light gray, and bright red.
330
+ note_fill:
331
+ ['#ffff80', '#80ff80', '#80ffff', '#ff80ff', '#f8f8f8', '#ff2000'],
332
+ note_band:
333
+ ['#ffd860', '#60d860', '#60d8ff', '#d860ff', '#d0d0d0', '#101010'],
334
+ // Computation errors in expressions are signalled by displaying
335
+ // the result in bright red, typically the general error symbol (X)
336
+ VM_error: '#e80000',
337
+ // Background color of GUI dialogs
338
+ dialog_background: '#f4f0f2'
339
+ };
340
+ this.io_formats = [
341
+ {'font-size': 10},
342
+ {'font-size': 10, 'font-style': 'oblique',
343
+ 'text-decoration': 'underline dotted 1.5px'},
344
+ {'font-size': 10, 'font-weight': 'bold',
345
+ 'text-decoration': 'underline dotted 1.5px'}];
346
+ // Standard SVG URL
347
+ this.svg_url = 'http://www.w3.org/2000/svg';
348
+ this.clear();
349
+ }
350
+
351
+ clear() {
352
+ // First, clear the entire SVG
353
+ this.clearSVGElement(this.svg);
354
+ // Set default style properties
355
+ this.svg.setAttribute('font-family', this.font_name);
356
+ this.svg.setAttribute('font-size', 8);
357
+ this.svg.setAttribute('text-anchor', 'middle');
358
+ this.svg.setAttribute('alignment-baseline', 'middle');
359
+ // Add marker definitions
360
+ const
361
+ defs = this.newSVGElement('defs'),
362
+ // Standard arrow tips: solid triangle
363
+ tri = 'M0,0 L10,5 L0,10 z',
364
+ // Wedge arrow tips have no baseline
365
+ wedge = 'M0,0 L10,5 L0,10 L0,8.5 L8.5,5 L0,1.5 z',
366
+ // Constraint arrows have a flat, "chevron-style" tip
367
+ chev = 'M0,0 L10,5 L0,10 L4,5 z',
368
+ // Feedback arrows are hollow and have hole in their baseline
369
+ fbt = 'M0,3L0,0L10,5L0,10L0,7L1.5,7L1.5,8.5L8.5,5L1.5,1.5L1.5,3z';
370
+
371
+ // NOTE: standard SVG elements are defined as properties of this paper
372
+ this.size_box = '__c_o_m_p_u_t_e__b_b_o_x__ID*';
373
+ this.drag_line = '__d_r_a_g__l_i_n_e__ID*';
374
+ this.drag_rect = '__d_r_a_g__r_e_c_t__ID*';
375
+ let id = 't_r_i_a_n_g_l_e__t_i_p__ID*';
376
+ this.triangle = `url(#${id})`;
377
+ this.addMarker(defs, id, tri, 8, this.palette.node_rim);
378
+ id = 'a_c_t_i_v_e__t_r_i_a_n_g_l_e__t_i_p__ID*';
379
+ this.active_triangle = `url(#${id})`;
380
+ this.addMarker(defs, id, tri, 8, this.palette.active_process);
381
+ id = 'a_c_t_i_v_e__r_e_v__t_r_i__t_i_p__ID*';
382
+ this.active_reversed_triangle = `url(#${id})`;
383
+ this.addMarker(defs, id, tri, 8, this.palette.compound_flow);
384
+ id = 'i_n_a_c_t_i_v_e__t_r_i_a_n_g_l_e__t_i_p__ID';
385
+ this.inactive_triangle = `url(#${id})`;
386
+ this.addMarker(defs, id, tri, 8, 'silver');
387
+ id = 'o_p_e_n__t_r_i_a_n_g_l_e__t_i_p__ID*';
388
+ this.open_triangle = `url(#${id})`;
389
+ this.addMarker(defs, id, tri, 7.5, 'white');
390
+ id = 's_e_l_e_c_t_e_d__t_r_i_a_n_g_l_e__t_i_p__ID*';
391
+ this.selected_triangle = `url(#${id})`;
392
+ this.addMarker(defs, id, tri, 7.5, this.palette.select);
393
+ id = 'w_h_i_t_e__t_r_i_a_n_g_l_e__t_i_p__ID*';
394
+ this.white_triangle = `url(#${id})`;
395
+ this.addMarker(defs, id, tri, 9.5, 'white');
396
+ id = 'c_o_n_g_e_s_t_e_d__t_r_i_a_n_g_l_e__t_i_p__ID*';
397
+ this.congested_triangle = `url(#${id})`;
398
+ this.addMarker(defs, id, tri, 7.5, this.palette.at_process_ub_arrow);
399
+ id = 'd_o_u_b_l_e__t_r_i_a_n_g_l_e__t_i_p__ID*';
400
+ this.double_triangle = `url(#${id})`;
401
+ this.addMarker(defs, id, tri, 12, this.palette.node_rim);
402
+ id = 'a_c_t_i_v_e__d_b_l__t_r_i__t_i_p__ID*';
403
+ this.active_double_triangle = `url(#${id})`;
404
+ this.addMarker(defs, id, tri, 12, this.palette.active_process);
405
+ id = 'i_n_a_c_t_i_v_e__d_b_l__t_r_i__t_i_p__ID*';
406
+ this.inactive_double_triangle = `url(#${id})`;
407
+ this.addMarker(defs, id, tri, 12, 'silver');
408
+ id = 'f_e_e_d_b_a_c_k__t_r_i_a_n_g_l_e__t_i_p__ID*';
409
+ this.feedback_triangle = `url(#${id})`;
410
+ this.addMarker(defs, id, fbt, 10, this.palette.node_rim);
411
+ id = 'c_h_e_v_r_o_n__t_i_p__ID*';
412
+ this.chevron = `url(#${id})`;
413
+ this.addMarker(defs, id, chev, 8, this.palette.node_rim);
414
+ id = 's_e_l_e_c_t_e_d__c_h_e_v_r_o_n__t_i_p__ID*';
415
+ this.selected_chevron = `url(#${id})`;
416
+ this.addMarker(defs, id, chev, 10, this.palette.select);
417
+ id = 'a_c_t_i_v_e__c_h_e_v_r_o_n__t_i_p__ID*';
418
+ this.active_chevron = `url(#${id})`;
419
+ this.addMarker(defs, id, chev, 7, this.palette.at_process_ub);
420
+ id = 'b_l_a_c_k__c_h_e_v_r_o_n__t_i_p__ID*';
421
+ this.black_chevron = `url(#${id})`;
422
+ this.addMarker(defs, id, chev, 6, 'black');
423
+ id = 'o_p_e_n__w_e_d_g_e__t_i_p__ID*';
424
+ this.open_wedge = `url(#${id})`;
425
+ this.addMarker(defs, id, wedge, 9, this.palette.node_rim);
426
+ id = 's_e_l_e_c_t_e_d__o_p_e_n__w_e_d_g_e__t_i_p__ID*';
427
+ this.selected_open_wedge = `url(#${id})`;
428
+ this.addMarker(defs, id, wedge, 11, this.palette.select);
429
+ id = 's_m_a_l_l__o_v_a_l__t_i_p__ID*';
430
+ this.small_oval = `url(#${id})`;
431
+ this.addMarker(defs, id, 'ellipse', 6, this.palette.node_rim);
432
+ id = 's_e_l_e_c_t_e_d__s_m_a_l_l__o_v_a_l__t_i_p__ID*';
433
+ this.selected_small_oval = `url(#${id})`;
434
+ this.addMarker(defs, id, 'ellipse', 8, this.palette.select);
435
+ id = 'a_c_t_i_v_e__s_m_a_l_l__o_v_a_l__t_i_p__ID*';
436
+ this.active_small_oval = `url(#${id})`;
437
+ this.addMarker(defs, id, 'ellipse', 7, this.palette.at_process_ub);
438
+ id = 'b_l_a_c_k__s_m_a_l_l__o_v_a_l__t_i_p__ID*';
439
+ this.black_small_oval = `url(#${id})`;
440
+ this.addMarker(defs, id, 'ellipse', 6, 'black');
441
+ id = 'r__b__g_r_a_d_i_e_n_t__ID*';
442
+ this.red_blue_gradient = `url(#${id})`;
443
+ this.addGradient(defs, id, 'rgb(255,176,176)', 'rgb(176,176,255)');
444
+ id = 'd_o_c_u_m_e_n_t_e_d__ID*';
445
+ this.documented_filter = `filter: url(#${id})`;
446
+ this.addShadowFilter(defs, id, 'rgb(50,120,255)', 2);
447
+ id = 't_a_r_g_e_t__ID*';
448
+ this.target_filter = `filter: url(#${id})`;
449
+ this.addShadowFilter(defs, id, 'rgb(250,125,0)', 8);
450
+ this.svg.appendChild(defs);
451
+ this.changeFont(CONFIGURATION.default_font_name);
452
+ }
453
+
454
+ newSVGElement(type, owner=null) {
455
+ // Creates and returns a new SVG element of the specified type
456
+ const el = document.createElementNS(this.svg_url, type);
457
+ if(!el) throw UI.ERROR.CREATE_FAILED;
458
+ // NOTE: by default, SVG elements should not respond to any mouse events!
459
+ if(owner) {
460
+ el.setAttribute('pointer-events', 'auto');
461
+ if(owner instanceof Constraint) {
462
+ el.addEventListener('mouseover',
463
+ () => { UI.setConstraintUnderCursor(owner); });
464
+ el.addEventListener('mouseout',
465
+ () => { UI.setConstraintUnderCursor(null); });
466
+ }
467
+ } else {
468
+ el.setAttribute('pointer-events', 'none');
469
+ }
470
+ return el;
471
+ }
472
+
473
+ clearSVGElement(el) {
474
+ // Clears all sub-nodes of the specified SVG node
475
+ if(el) while(el.lastChild) el.removeChild(el.lastChild);
476
+ }
477
+
478
+ addSVGAttributes(el, obj) {
479
+ // Adds attributes specified by `obj` to (SVG) element `el`
480
+ for(let prop in obj) {
481
+ if(obj.hasOwnProperty(prop)) el.setAttribute(prop, obj[prop]);
482
+ }
483
+ }
484
+
485
+ addMarker(defs, mid, mpath, msize, mcolor) {
486
+ // Defines SVG for markers used to draw arrows and bound lines
487
+ const marker = this.newSVGElement('marker');
488
+ let shape = null;
489
+ this.addSVGAttributes(marker,
490
+ {id: mid, viewBox: '0,0 10,10', markerWidth: msize, markerHeight: msize,
491
+ refX: 5, refY: 5, orient: 'auto-start-reverse',
492
+ markerUnits: 'userSpaceOnUse', fill: mcolor});
493
+ if(mpath == 'ellipse') {
494
+ shape = this.newSVGElement('ellipse');
495
+ this.addSVGAttributes(shape,
496
+ {cx: 5, cy: 5, rx: 4, ry: 4, stroke: 'none'});
497
+ } else {
498
+ shape = this.newSVGElement('path');
499
+ shape.setAttribute('d', mpath);
500
+ }
501
+ shape.setAttribute('stroke-linecap', 'round');
502
+ marker.appendChild(shape);
503
+ defs.appendChild(marker);
504
+ }
505
+
506
+ addGradient(defs, gid, color1, color2) {
507
+ const gradient = this.newSVGElement('linearGradient');
508
+ this.addSVGAttributes(gradient,
509
+ {id: gid, x1: '0%', y1: '0%', x2: '100%', y2: '0%'});
510
+ let stop = this.newSVGElement('stop');
511
+ this.addSVGAttributes(stop,
512
+ {offset: '0%', style: 'stop-color:' + color1 + ';stop-opacity:1'});
513
+ gradient.appendChild(stop);
514
+ stop = this.newSVGElement('stop');
515
+ this.addSVGAttributes(stop,
516
+ {offset: '100%', style:'stop-color:' + color2 + ';stop-opacity:1'});
517
+ gradient.appendChild(stop);
518
+ defs.appendChild(gradient);
519
+ }
520
+
521
+ addShadowFilter(defs, fid, color, radius) {
522
+ // Defines SVG for filters used to highlight elements
523
+ const filter = this.newSVGElement('filter');
524
+ this.addSVGAttributes(filter, {id: fid, filterUnits: 'userSpaceOnUse'});
525
+ const sub = this.newSVGElement('feDropShadow');
526
+ this.addSVGAttributes(sub,
527
+ {dx:0, dy:0, 'flood-color': color, 'stdDeviation': radius});
528
+ filter.appendChild(sub);
529
+ defs.appendChild(filter);
530
+ }
531
+
532
+ addShadowFilter2(defs, fid, color, radius) {
533
+ // Defines SVG for more InkScape compatible filters used to highlight elements
534
+ const filter = this.newSVGElement('filter');
535
+ this.addSVGAttributes(filter, {id: fid, filterUnits: 'userSpaceOnUse'});
536
+ let sub = this.newSVGElement('feGaussianBlur');
537
+ this.addSVGAttributes(sub, {'in': 'SourceAlpha', 'stdDeviation': radius});
538
+ filter.appendChild(sub);
539
+ sub = this.newSVGElement('feOffset');
540
+ this.addSVGAttributes(sub, {dx: 0, dy: 0, result: 'offsetblur'});
541
+ filter.appendChild(sub);
542
+ sub = this.newSVGElement('feFlood');
543
+ this.addSVGAttributes(sub, {'flood-color': color, 'flood-opacity': 1});
544
+ filter.appendChild(sub);
545
+ sub = this.newSVGElement('feComposite');
546
+ this.addSVGAttributes(sub, {in2: 'offsetblur', operator: 'in'});
547
+ filter.appendChild(sub);
548
+ const merge = this.newSVGElement('feMerge');
549
+ sub = this.newSVGElement('feMergeNode');
550
+ merge.appendChild(sub);
551
+ sub = this.newSVGElement('feMergeNode');
552
+ this.addSVGAttributes(sub, {'in': 'SourceGraphic'});
553
+ merge.appendChild(sub);
554
+ filter.appendChild(merge);
555
+ defs.appendChild(filter);
556
+ }
557
+
558
+ changeFont(fn) {
559
+ // For efficiency, this computes for all integer font sizes up to 16 the
560
+ // height (in pixels) of a string, and also the relative font weight factors
561
+ // (relative to the normal font weight 400)
562
+ this.font_name = fn;
563
+ this.font_heights = [0];
564
+ this.weight_factors = [0];
565
+ // Get the SVG element used for text size computation
566
+ const el = this.getSizingElement();
567
+ // Set the (new) font name
568
+ el.style.fontFamily = this.font_name;
569
+ el.style.fontWeight = 400;
570
+ // Calculate height and average widths for font sizes 1, 2, ... 16 px
571
+ for(let i = 1; i <= 16; i++) {
572
+ el.style.fontSize = i + 'px';
573
+ // Use characters that probably affect height the most
574
+ el.textContent = '[hq_|';
575
+ this.font_heights.push(el.getBBox().height);
576
+ }
577
+ // Approximate how the font weight will impact string length relative
578
+ // to normal. NOTE: only for 8px font, as this is the default size
579
+ el.style.fontSize = '8px';
580
+ // NOTE: Use a sample of most frequently used characters (digits!)
581
+ // to estimate width change
582
+ el.textContent = '0123456789%+-=<>.';
583
+ const w400 = el.getBBox().width;
584
+ for(let i = 1; i < 10; i++) {
585
+ el.style.fontWeight = 100*i;
586
+ this.weight_factors.push(el.getBBox().width / w400);
587
+ }
588
+ }
589
+
590
+ numberSize(number, fsize=8, fweight=400) {
591
+ // Returns the boundingbox {width: ..., height: ...} of a numeric
592
+ // string (in pixels)
593
+ // NOTE: this routine is about 500x faster than textSize because it
594
+ // does not use the DOM tree
595
+ // NOTE: using parseInt makes this function robust to font sizes passed
596
+ // as strings (e.g., "10px")
597
+ fsize = parseInt(fsize);
598
+ // NOTE: 'number' may indeed be a number, so concatenate with '' to force
599
+ // it to become a string
600
+ const
601
+ ns = '' + number,
602
+ fh = this.font_heights[fsize],
603
+ fw = fh / 2;
604
+ let w = 0, m = 0;
605
+ // Approximate the width of the Unicode characters representing
606
+ // special values
607
+ if(ns === '\u2047') {
608
+ w = 8; // undefined (??)
609
+ } else if(ns === '\u25A6' || ns === '\u2BBF' || ns === '\u26A0') {
610
+ w = 6; // computing, not computed, warning sign
611
+ } else {
612
+ // Assume that number has been rendered with fixed spacing
613
+ // (cf. addNumber method of class Shape)
614
+ w = ns.length * fw;
615
+ // Decimal point and minus sign are narrower
616
+ if(ns.indexOf('.') >= 0) w -= 0.6 * fw;
617
+ if(ns.startsWith('-')) w -= 0.55 * fw;
618
+ // Add approximate extra length for =, % and special Unicode characters
619
+ if(ns.indexOf('=') >= 0) {
620
+ w += 0.2 * fw;
621
+ } else {
622
+ // LE, GE, undefined (??), or INF are a bit wider
623
+ m = ns.match(/%|\u2264|\u2265|\u2047|\u221E/g);
624
+ if(m) {
625
+ w += m.length * 0.25 * fw;
626
+ }
627
+ // Ellipsis (may occur between process bounds) is much wider
628
+ m = ns.match(/\u2026/g);
629
+ if(m) w += m.length * 0.6 * fw;
630
+ }
631
+ }
632
+ // adjust for font weight
633
+ return {width: w * this.weight_factors[Math.round(fweight / 100)],
634
+ height: fh};
635
+ }
636
+
637
+ textSize(string, fsize=8, fweight=400) {
638
+ // Returns the boundingbox {width: ..., height: ...} of a string (in pixels)
639
+ // NOTE: uses the invisible SVG element that is defined specifically
640
+ // for text size computation
641
+ // NOTE: text size calculation tends to slightly underestimate the
642
+ // length of the string as it is actually rendered, as font sizes
643
+ // appear to be rounded to the nearest available size.
644
+ const el = this.getSizingElement();
645
+ // Accept numbers and strings as font sizes -- NOTE: fractions are ignored!
646
+ el.style.fontSize = parseInt(fsize) + 'px';
647
+ el.style.fontWeight = fweight;
648
+ el.style.fontFamily = this.font_name;
649
+ let w = 0,
650
+ h = 0;
651
+ // Consider the separate lines of the string
652
+ const
653
+ lines = ('' + string).split('\n'), // Add '' in case string is a number
654
+ ll = lines.length;
655
+ for(let i = 0; i < ll; i++) {
656
+ el.textContent = lines[i];
657
+ const bb = el.getBBox();
658
+ w = Math.max(w, bb.width);
659
+ h += bb.height;
660
+ }
661
+ return {width: w, height: h};
662
+ }
663
+
664
+ removeInvisibleSVG() {
665
+ // Removes SVG elements used by the user interface (not part of the model)
666
+ let el = document.getElementById(this.size_box);
667
+ if(el) this.svg.removeChild(el);
668
+ el = document.getElementById(this.drag_line);
669
+ if(el) this.svg.removeChild(el);
670
+ el = document.getElementById(this.drag_rect);
671
+ if(el) this.svg.removeChild(el);
672
+ }
673
+
674
+ getSizingElement() {
675
+ // Returns the SVG sizing element, or creates it if not found
676
+ let el = document.getElementById(this.size_box);
677
+ // Create it if not found
678
+ if(!el) {
679
+ // Append an invisible text element to the SVG
680
+ el = document.createElementNS(this.svg_url, 'text');
681
+ if(!el) throw UI.ERROR.CREATE_FAILED;
682
+ el.id = this.size_box;
683
+ el.style.opacity = 0;
684
+ this.svg.appendChild(el);
685
+ }
686
+ return el;
687
+ }
688
+
689
+ fitToSize() {
690
+ // Adjust the dimensions of the main SVG to fit the graph plus 15px margin
691
+ // all around
692
+ this.removeInvisibleSVG();
693
+ const
694
+ bb = this.svg.getBBox(),
695
+ w = bb.width + 30,
696
+ h = bb.height + 30;
697
+ if(w !== this.width || h !== this.height) {
698
+ MODEL.translateGraph(-bb.x + 15, -bb.y + 25);
699
+ this.width = w;
700
+ this.height = h;
701
+ this.svg.setAttribute('width', this.width);
702
+ this.svg.setAttribute('height', this.height);
703
+ this.zoom_factor = 1;
704
+ this.zoom_label.innerHTML = Math.round(100 / this.zoom_factor) + '%';
705
+ this.extend();
706
+ }
707
+ }
708
+
709
+ extend() {
710
+ // Adjust the paper size to fit all objects WITHOUT changing the origin (0, 0)
711
+ // NOTE: keep a minimum page size to keep the scrolling more "natural"
712
+ this.removeInvisibleSVG();
713
+ const
714
+ bb = this.svg.getBBox(),
715
+ // Let `w` and `h` be the actual width and height in pixels
716
+ w = bb.x + bb.width + 30,
717
+ h = bb.y + bb.height + 30,
718
+ // Let `ccw` and `cch` be the size of the scrollable area
719
+ ccw = w / this.zoom_factor,
720
+ cch = h / this.zoom_factor;
721
+ if(this.zoom_factor >= 1) {
722
+ this.width = w;
723
+ this.height = h;
724
+ this.svg.setAttribute('width', this.width);
725
+ this.svg.setAttribute('height', this.height);
726
+ // Reduce the image by making the view box larger than the paper
727
+ const
728
+ zw = w * this.zoom_factor,
729
+ zh = h * this.zoom_factor;
730
+ this.svg.setAttribute('viewBox', ['0 0', zw, zh].join(' '));
731
+ } else {
732
+ // Enlarge the image by making paper larger than the viewbox...
733
+ this.svg.setAttribute('width', ccw / this.zoom_factor);
734
+ this.svg.setAttribute('height', cch / this.zoom_factor);
735
+ this.svg.setAttribute('viewBox', ['0 0', ccw, cch].join(' '));
736
+ }
737
+ // ... while making the scrollable area smaller (if ZF > 1)
738
+ // c.q. larger (if ZF < 1)
739
+ this.container.style.width = (this.width / this.zoom_factor) + 'px';
740
+ this.container.style.height = (this.height / this.zoom_factor) + 'px';
741
+ }
742
+
743
+ //
744
+ // ZOOM functionality
745
+ //
746
+
747
+ doZoom(z) {
748
+ this.zoom_factor *= Math.sqrt(z);
749
+ document.getElementById('zoom').innerHTML =
750
+ Math.round(100 / this.zoom_factor) + '%';
751
+ this.extend();
752
+ }
753
+
754
+ zoomIn() {
755
+ if(UI.buttons.zoomin && !UI.buttons.zoomin.classList.contains('disab')) {
756
+ // Enlarging graph by more than 200% would seem not functional
757
+ if(this.zoom_factor > 0.55) this.doZoom(0.5);
758
+ }
759
+ }
760
+
761
+ zoomOut() {
762
+ if(UI.buttons.zoomout && !UI.buttons.zoomout.classList.contains('disab')) {
763
+ // Reducing graph by to less than 25% would seem not functional
764
+ if(this.zoom_factor <= 4) this.doZoom(2);
765
+ }
766
+ }
767
+
768
+ cursorPosition(x, y) {
769
+ // Returns [x, y] in diagram coordinates
770
+ const
771
+ rect = this.container.getBoundingClientRect(),
772
+ top = rect.top + window.scrollY + document.body.scrollTop,
773
+ left = rect.left + window.scrollX + document.body.scrollLeft;
774
+ x = Math.max(0, Math.floor((x - left) * this.zoom_factor));
775
+ y = Math.max(0, Math.floor((y - top) * this.zoom_factor));
776
+ return [x, y];
777
+ }
778
+
779
+ //
780
+ // Metods for visual feedback while linking or selecting
781
+ //
782
+
783
+ dragLineToCursor(node, x, y) {
784
+ // NOTE: does not remove element; only updates path and opacity
785
+ let el = document.getElementById(this.drag_line);
786
+ // Create it if not found
787
+ if(!el) {
788
+ el = this.newSVGElement('path');
789
+ el.id = this.drag_line;
790
+ el.style.opacity = 0;
791
+ el.style.fill = 'none';
792
+ el.style.stroke = 'red';
793
+ el.style.strokeWidth = 1.5;
794
+ el.style.strokeDasharray = UI.sda.dash;
795
+ this.svg.appendChild(el);
796
+ }
797
+ el.setAttribute('d', `M${node.x},${node.y}l${x - node.x},${y - node.y}`);
798
+ el.style.opacity = 1;
799
+ this.adjustPaperSize(x, y);
800
+ }
801
+
802
+ adjustPaperSize(x, y) {
803
+ if(this.zoom_factor < 1) return;
804
+ const
805
+ w = parseFloat(this.svg.getAttribute('width')),
806
+ h = parseFloat(this.svg.getAttribute('height'));
807
+ if(x <= w && y <= h) return;
808
+ if(x > w) {
809
+ this.svg.setAttribute('width', x);
810
+ this.width = x;
811
+ this.container.style.width = (x / this.zoom_factor) + 'px';
812
+ }
813
+ if(y > h) {
814
+ this.svg.setAttribute('height', y);
815
+ this.height = y;
816
+ this.container.style.height = (y / this.zoom_factor) + 'px';
817
+ }
818
+ this.svg.setAttribute('viewBox',
819
+ ['0 0', this.width * this.zoom_factor,
820
+ this.height * this.zoom_factor].join(' '));
821
+ }
822
+
823
+ hideDragLine() {
824
+ const el = document.getElementById(this.drag_line);
825
+ if(el) el.style.opacity = 0;
826
+ }
827
+
828
+ dragRectToCursor(ox, oy, dx, dy) {
829
+ // NOTE: does not remove element; only updates path and opacity
830
+ let el = document.getElementById(this.drag_rect);
831
+ // Create it if not found
832
+ if(!el) {
833
+ el = this.newSVGElement('rect');
834
+ el.id = this.drag_rect;
835
+ el.style.opacity = 0;
836
+ el.style.fill = 'none';
837
+ el.style.stroke = 'red';
838
+ el.style.strokeWidth = 1.5;
839
+ el.style.strokeDasharray = UI.sda.dash;
840
+ el.setAttribute('rx', 0);
841
+ el.setAttribute('ry', 0);
842
+ this.svg.appendChild(el);
843
+ }
844
+ let lx = Math.min(ox, dx),
845
+ ty = Math.min(oy, dy),
846
+ rx = Math.max(ox, dx),
847
+ by = Math.max(oy, dy);
848
+ el.setAttribute('x', lx);
849
+ el.setAttribute('y', ty);
850
+ el.setAttribute('width', rx - lx);
851
+ el.setAttribute('height', by - ty);
852
+ el.style.opacity = 1;
853
+ this.adjustPaperSize(rx, by);
854
+ }
855
+
856
+ hideDragRect() {
857
+ const el = document.getElementById(this.drag_rect);
858
+ if(el) { el.style.opacity = 0; }
859
+ }
860
+
861
+ //
862
+ // Auxiliary methods used while drawing shapes
863
+ //
864
+
865
+ arc(r, srad, erad) {
866
+ // Returns SVG path code for an arc having radius `r`, start angle `srad`,
867
+ // and end angle `erad`
868
+ return 'a' + [r, r, 0, 0, 1, r * Math.cos(erad) - r * Math.cos(srad),
869
+ r * Math.sin(erad) - r * Math.sin(srad)].join(',');
870
+ }
871
+
872
+ bezierPoint(a, b, c, d, t) {
873
+ // Returns the point on a cubic Bezier curve from `a` to `b` with control
874
+ // points `c` and `d`, and `t` indicating the relative distance from `a`
875
+ // as a fraction between 0 and 1. NOTE: the four points must be represented
876
+ // as lists [x, y]
877
+ function interPoint(a, b, t) {
878
+ // Local function that performs linear interpolation between two points
879
+ // `a` = [x1, y1] and `b` = [x2, y2] when parameter `t` indicates
880
+ // the relative distance from `a` as afraction between 0 and 1
881
+ return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
882
+ }
883
+ // Calculate the Bezier points
884
+ const ab = interPoint(a, b, t),
885
+ bc = interPoint(b, c, t),
886
+ cd = interPoint(c, d, t);
887
+ return interPoint(interPoint(ab, bc, t), interPoint(bc, cd, t), t);
888
+ }
889
+
890
+ relDif(n1, n2) {
891
+ // Returns the relative difference (n1 - n2) / |n2| unless n2 is
892
+ // near-zero; then it returns the absolute difference n1 - n2
893
+ const div = Math.abs(n2);
894
+ if(div < VM.NEAR_ZERO) {
895
+ return n1 - n2;
896
+ }
897
+ return (n1 - n2) / div;
898
+ }
899
+
900
+ //
901
+ // Diagram-drawing method draws the diagram for the focal cluster
902
+ //
903
+
904
+ drawModel(mdl) {
905
+ // Draw the diagram for the focal cluster
906
+ this.clear();
907
+ // Prepare to draw all elements in the focal cluster
908
+ const fc = mdl.focal_cluster;
909
+ fc.categorizeEntities();
910
+ // NOTE: product positions must be updated before links are drawn
911
+ fc.positionProducts();
912
+ for(let i = 0; i < fc.processes.length; i++) {
913
+ fc.processes[i].clearHiddenIO();
914
+ }
915
+ for(let i = 0; i < fc.sub_clusters.length; i++) {
916
+ fc.sub_clusters[i].clearHiddenIO();
917
+ }
918
+ // NOTE: also ensure that notes will update their fields
919
+ fc.resetNoteFields();
920
+ // Draw link arrows and constraints first, as all other entities are
921
+ // slightly transparent so they cannot completely hide these lines
922
+ for(let i = 0; i < fc.arrows.length; i++) {
923
+ this.drawArrow(fc.arrows[i]);
924
+ }
925
+ for(let i = 0; i < fc.related_constraints.length; i++) {
926
+ this.drawConstraint(fc.related_constraints[i]);
927
+ }
928
+ for(let i = 0; i < fc.processes.length; i++) {
929
+ this.drawProcess(fc.processes[i]);
930
+ }
931
+ for(let i = 0; i < fc.product_positions.length; i++) {
932
+ this.drawProduct(fc.product_positions[i].product);
933
+ }
934
+ for(let i = 0; i < fc.sub_clusters.length; i++) {
935
+ this.drawCluster(fc.sub_clusters[i]);
936
+ }
937
+ // Draw notes last, as they are semi-transparent (and can be quite small)
938
+ for(let i = 0; i < fc.notes.length; i++) {
939
+ this.drawNote(fc.notes[i]);
940
+ }
941
+ // Resize paper if necessary
942
+ this.extend();
943
+ // Display model name in browser
944
+ document.title = mdl.name || 'Linny-R';
945
+ }
946
+
947
+ drawSelection(mdl, dx=0, dy=0) {
948
+ // NOTE: Clear this global, as Bezier curves move from under the cursor
949
+ // without a mouseout event
950
+ this.constraint_under_cursor = null;
951
+ // Draw the selected entities and associated links, and also constraints
952
+ for(let i = 0; i < mdl.selection.length; i++) {
953
+ const obj = mdl.selection[i];
954
+ // Links and constraints are drawn separately, so do not draw those
955
+ // contained in the selection
956
+ if(!(obj instanceof Link || obj instanceof Constraint)) {
957
+ if(obj instanceof Note) obj.parsed = false;
958
+ UI.drawObject(obj, dx, dy);
959
+ }
960
+ }
961
+ if(mdl.selection_related_arrows.length === 0) {
962
+ mdl.selection_related_arrows = mdl.focal_cluster.selectedArrows();
963
+ }
964
+ // Only draw the arrows that relate to the selection
965
+ for(let i = 0; i < mdl.selection_related_arrows.length; i++) {
966
+ this.drawArrow(mdl.selection_related_arrows[i]);
967
+ }
968
+ // As they typically are few, simply redraw all constraints that relate to
969
+ // the focal cluster
970
+ for(let i = 0; i < mdl.focal_cluster.related_constraints.length; i++) {
971
+ this.drawConstraint(mdl.focal_cluster.related_constraints[i]);
972
+ }
973
+ this.extend();
974
+ }
975
+
976
+
977
+ //
978
+ // Shape-drawing methods for model entities
979
+ //
980
+
981
+ drawArrow(arrw, dx=0, dy=0) {
982
+ // Draws an arrow from FROM nodebox to TO nodebox
983
+ // NOTE: first erase previously drawn arrow
984
+ arrw.shape.clear();
985
+ arrw.hidden_nodes.length = 0;
986
+ // Use local variables so as not to change any "real" attribute values
987
+ let cnb, proc, prod, fnx, fny, fnw, fnh, tnx, tny, tnw, tnh,
988
+ cp, rr, aa, bb, dd, nn, af, l, s, w, tw, th, bpx, bpy, epx, epy,
989
+ sda, stroke_color, stroke_width, arrow_start, arrow_end,
990
+ font_color, font_weight, luc = null;
991
+ // Get the main arrow attributes
992
+ const
993
+ from_nb = arrw.from_node,
994
+ to_nb = arrw.to_node;
995
+ // Use "let" because `ignored` may also be set later on (for single link)
996
+ let ignored = (from_nb && MODEL.ignored_entities[from_nb.identifier]) ||
997
+ (to_nb && MODEL.ignored_entities[to_nb.identifier]);
998
+ // First check if this is a block arrow (ONE node being null)
999
+ if(!from_nb) {
1000
+ cnb = to_nb;
1001
+ } else if(!to_nb) {
1002
+ cnb = from_nb;
1003
+ } else {
1004
+ cnb = null;
1005
+ }
1006
+ // If not NULL `cnb` is the cluster or node box (product or process) having
1007
+ // links to entities outside the focal cluster. Such links are summarized
1008
+ // by "block arrows": on the left edge of the box to indicate inflows,
1009
+ // on the right edge to indicate outflows, and two-headed on the top edge
1010
+ // to indicate two-way flows. When the cursor is moved over a block arrow,
1011
+ // the Documentation dialog will display the list of associated nodes
1012
+ // (with their actual flows if non-zero)
1013
+ if(cnb) {
1014
+ // Distinguish between input, output and io products
1015
+ let ip = [], op = [], iop = [];
1016
+ if(cnb instanceof Cluster) {
1017
+ for(let i = 0; i < arrw.links.length; i++) {
1018
+ const lnk = arrw.links[i];
1019
+ // determine which product is involved
1020
+ prod = (lnk.from_node instanceof Product ? lnk.from_node : lnk.to_node);
1021
+ // NOTE: clusters "know" their input/output products
1022
+ if(cnb.io_products.indexOf(prod) >= 0) {
1023
+ addDistinct(prod, iop);
1024
+ } else if(cnb.consumed_products.indexOf(prod) >= 0) {
1025
+ addDistinct(prod, ip);
1026
+ } else if(cnb.produced_products.indexOf(prod) >= 0) {
1027
+ addDistinct(prod, op);
1028
+ }
1029
+ }
1030
+ } else {
1031
+ // cnb is process or product => knows its inputs and outputs
1032
+ for(let i = 0; i < arrw.links.length; i++) {
1033
+ const lnk = arrw.links[i];
1034
+ if(lnk.from_node === cnb) {
1035
+ addDistinct(lnk.to_node, op);
1036
+ } else {
1037
+ addDistinct(lnk.from_node, ip);
1038
+ }
1039
+ // NOTE: for processes, products cannot be BOTH input and output
1040
+ }
1041
+ }
1042
+ cnb.hidden_inputs = ip;
1043
+ cnb.hidden_outputs = op;
1044
+ cnb.hidden_io = iop;
1045
+ return true;
1046
+ } // end of IF "block arrow"
1047
+
1048
+ // Arrows having both "from" and "to" are displayed as "real" arrows
1049
+ // The hidden nodes list must contain the nodes that have no position
1050
+ // in the cluster being drawn
1051
+ // NOTE: products are "hidden" typically when this arrow represents multiple
1052
+ // links, but also if it is a single link from a cluster to a process
1053
+ const
1054
+ from_c = from_nb instanceof Cluster,
1055
+ to_c = to_nb instanceof Cluster,
1056
+ from_p = from_nb instanceof Process,
1057
+ to_p = to_nb instanceof Process;
1058
+ let data_flows = 0;
1059
+ if(arrw.links.length > 1 || (from_c && to_p) || (from_p && to_c)) {
1060
+ for(let i = 0; i < arrw.links.length; i++) {
1061
+ const
1062
+ lnk = arrw.links[i],
1063
+ fn = lnk.from_node,
1064
+ tn = lnk.to_node;
1065
+ if(fn instanceof Product && fn != from_nb && fn != to_nb) {
1066
+ // Add node only if they not already shown at EITHER end of the arrow
1067
+ addDistinct(fn, arrw.hidden_nodes);
1068
+ // Count number of data flows represented by arrow
1069
+ if(tn.is_data) data_flows++;
1070
+ }
1071
+ // NOTE: no ELSE IF, because BOTH link nodes can be products
1072
+ if(tn instanceof Product && tn != from_nb && tn != to_nb) {
1073
+ addDistinct(tn, arrw.hidden_nodes);
1074
+ // Count number of data flows represented by arrow
1075
+ if(fn.is_data) data_flows++;
1076
+ }
1077
+ }
1078
+ }
1079
+
1080
+ // NEXT: some more local variables
1081
+ fnx = from_nb.x + dx;
1082
+ fny = from_nb.y + dy;
1083
+ fnw = from_nb.width;
1084
+ fnh = from_nb.height;
1085
+ tnx = to_nb.x + dx;
1086
+ tny = to_nb.y + dy;
1087
+ tnw = to_nb.width;
1088
+ tnh = to_nb.height;
1089
+ // Processes and clusters may have been collapsed to small rectangles
1090
+ if(from_p && from_nb.collapsed) {
1091
+ fnw = 17;
1092
+ fnh = 12;
1093
+ } else if(from_c && from_nb.collapsed) {
1094
+ fnw = 24;
1095
+ fnh = 24;
1096
+ }
1097
+ if(to_p && to_nb.collapsed) {
1098
+ tnw = 17;
1099
+ tnh = 12;
1100
+ } else if(to_c && to_nb.collapsed) {
1101
+ tnw = 24;
1102
+ tnh = 24;
1103
+ }
1104
+
1105
+ // Do not draw arrow if so short that it is hidden by its FROM and TO nodes
1106
+ if((Math.abs(fnx - tnx) < (fnw + tnw)/2) &&
1107
+ (Math.abs(fny - tny) <= (fnh + tnh)/2)) {
1108
+ return false;
1109
+ }
1110
+
1111
+ // Adjust node heights if nodes are thick-rimmed
1112
+ if((from_nb instanceof Product) && from_nb.is_buffer) fnh += 2;
1113
+ if((to_nb instanceof Product) && to_nb.is_buffer) tnh += 2;
1114
+ // Get horizontal distance dx and vertical distance dy of the node centers
1115
+ dx = tnx - fnx;
1116
+ dy = tny - fny;
1117
+ // If dx is less than half a pixel, draw a vertical line
1118
+ if(Math.abs(dx) < 0.5) {
1119
+ arrw.from_x = fnx;
1120
+ arrw.to_x = fnx;
1121
+ if(dy > 0) {
1122
+ arrw.from_y = fny + fnh/2;
1123
+ arrw.to_y = tny - tnh/2;
1124
+ } else {
1125
+ arrw.from_y = fny - fnh/2;
1126
+ arrw.to_y = tny + tnh/2;
1127
+ }
1128
+ } else {
1129
+ // Now dx > 0, so no division by zero can occur when calculating dy/dx
1130
+ // First compute X and Y of tail (FROM node)
1131
+ w = (from_nb instanceof Product ? from_nb.frame_width : fnw);
1132
+ if(Math.abs(dy / dx) >= Math.abs(fnh / w)) {
1133
+ // Arrow connects to horizontal edge
1134
+ arrw.from_y = (dy > 0 ? fny + fnh/2 : fny - fnh/2);
1135
+ arrw.from_x = fnx + fnh/2 * dx / Math.abs(dy);
1136
+ } else if(from_nb instanceof Product) {
1137
+ // Box with semicircular sides
1138
+ fnw = from_nb.frame_width;
1139
+ rr = (fnh/2) * (fnh/2); // R square
1140
+ aa = (dy / dx) * (dy / dx); // A square
1141
+ dd = fnw/2;
1142
+ nn = (-dd - Math.sqrt(rr - aa * dd * dd + aa * rr)) / (1 + aa);
1143
+ if(dx > 0) {
1144
+ // link points towards the right
1145
+ arrw.from_x = fnx - nn;
1146
+ arrw.from_y = fny - nn * dy / dx;
1147
+ } else {
1148
+ arrw.from_x = fnx + nn;
1149
+ arrw.from_y = fny + nn * dy / dx;
1150
+ }
1151
+ } else {
1152
+ // Rectangular box
1153
+ arrw.from_x = (dx > 0 ? fnx + w/2 : fnx - w/2);
1154
+ arrw.from_y = fny + w/2 * dy / Math.abs(dx);
1155
+ }
1156
+ // Then compute X and Y of head (TO node)
1157
+ w = (to_nb instanceof Product ? to_nb.frame_width : tnw);
1158
+ dx = arrw.from_x - tnx;
1159
+ dy = arrw.from_y - tny;
1160
+ if(Math.abs(dx) > 0) {
1161
+ if(Math.abs(dy / dx) >= Math.abs(tnh / w)) {
1162
+ // Connects to horizontal edge
1163
+ arrw.to_y = (dy > 0 ? tny + tnh/2 : tny - tnh/2);
1164
+ arrw.to_x = tnx + tnh/2 * dx / Math.abs(dy);
1165
+ } else if(to_nb instanceof Product) {
1166
+ // Node with semicircular sides}
1167
+ tnw = to_nb.frame_width;
1168
+ rr = (tnh/2) * (tnh/2); // R square
1169
+ aa = (dy / dx) * (dy / dx); // A square
1170
+ dd = tnw/2;
1171
+ nn = (-dd - Math.sqrt(rr - aa*(dd*dd - rr))) / (1 + aa);
1172
+ if(dx > 0) {
1173
+ // Link points towards the right
1174
+ arrw.to_x = tnx - nn;
1175
+ arrw.to_y = tny - nn * dy / dx;
1176
+ } else {
1177
+ arrw.to_x = tnx + nn;
1178
+ arrw.to_y = tny + nn * dy / dx;
1179
+ }
1180
+ } else {
1181
+ // Rectangular node
1182
+ arrw.to_x = (dx > 0 ? tnx + w/2 : tnx - w/2);
1183
+ arrw.to_y = tny + w/2 * dy / Math.abs(dx);
1184
+ }
1185
+ }
1186
+ }
1187
+
1188
+ // Assume default arrow properties
1189
+ sda = 'none';
1190
+ stroke_color = (ignored ? this.palette.ignore : this.palette.node_rim);
1191
+ stroke_width = 1.5;
1192
+ arrow_start = 'none';
1193
+ arrow_end = this.triangle;
1194
+ // Default multi-flow values are: NO multiflow, NOT congested or reversed
1195
+ let mf = [0, 0, 0, false, false],
1196
+ reversed = false;
1197
+ // These may need to be modified due to actual flow, etc.
1198
+ if(arrw.links.length === 1) {
1199
+ // Display link properties of a specific link if arrow is plain
1200
+ luc = arrw.links[0];
1201
+ ignored = MODEL.ignored_entities[luc.identifier];
1202
+ if(MODEL.solved && !ignored) {
1203
+ // Draw arrow in dark blue if a flow occurs, or in a lighter gray
1204
+ // if NO flow occurs
1205
+ af = luc.actualFlow(MODEL.t);
1206
+ if(Math.abs(af) > VM.SIG_DIF_FROM_ZERO) {
1207
+ // NOTE: negative flow should affect arrow heads only when link has
1208
+ // default multiplier AND connects to a process
1209
+ if(af < 0 && luc.multiplier === VM.LM_LEVEL &&
1210
+ (luc.from_node instanceof Process ||
1211
+ luc.to_node instanceof Process)) {
1212
+ reversed = true;
1213
+ stroke_color = this.palette.compound_flow;
1214
+ arrow_end = this.active_reversed_triangle;
1215
+ } else {
1216
+ stroke_color = this.palette.active_process;
1217
+ arrow_end = this.active_triangle;
1218
+ }
1219
+ } else {
1220
+ stroke_color = (MODEL.ignored_entities[luc.identifier] ?
1221
+ this.palette.ignore : 'silver');
1222
+ arrow_end = this.inactive_triangle;
1223
+ }
1224
+ } else {
1225
+ af = VM.UNDEFINED;
1226
+ }
1227
+ if(luc.from_node instanceof Process) {
1228
+ proc = luc.from_node;
1229
+ prod = luc.to_node;
1230
+ } else {
1231
+ proc = luc.to_node;
1232
+ prod = luc.from_node;
1233
+ }
1234
+ // NOTE: `luc` may also be a constraint!
1235
+ if(luc instanceof Link && luc.is_feedback) {
1236
+ sda = UI.sda.long_dash_dot;
1237
+ arrow_end = this.feedback_triangle;
1238
+ }
1239
+ // Data link => dotted line
1240
+ if(luc.dataOnly) {
1241
+ sda = UI.sda.dot;
1242
+ }
1243
+ if(luc.selected) {
1244
+ // Draw arrow line thick and in red
1245
+ stroke_color = this.palette.select;
1246
+ stroke_width = 2;
1247
+ if(arrow_end == this.open_wedge) {
1248
+ arrow_end = this.selected_open_wedge;
1249
+ } else {
1250
+ arrow_end = this.selected_triangle;
1251
+ }
1252
+ }
1253
+ if(ignored) stroke_color = this.palette.ignore;
1254
+ } else {
1255
+ // A composite arrow is visualized differently, depending on the number
1256
+ // of related products and the direction of the underlying links:
1257
+ // - if only ONE product, the arrow is plain UNLESS both end nodes are
1258
+ // processes; then the arrow is dashed to highlight that it is special
1259
+ // - if multiple data flows (product-to-product) the arrow is dashed
1260
+ // - if multiple products, the arrow is drawn with a double line ===>
1261
+ // - if the links do not all flow in the same direction, the arrow has
1262
+ // two heads
1263
+ // NOTE: the hidden nodes have already been computed because
1264
+ // they are also used outside this ELSE clause
1265
+ if(arrw.hidden_nodes.length > 1) {
1266
+ stroke_width = 3;
1267
+ arrow_end = this.double_triangle;
1268
+ }
1269
+ // Draw arrows between two processes or two products using dashed lines
1270
+ if((from_nb instanceof Process && to_nb instanceof Process) ||
1271
+ (from_nb instanceof Product && to_nb instanceof Product)) {
1272
+ sda = UI.sda.dash;
1273
+ }
1274
+ // Bidirectional => also an arrow head at start point
1275
+ if(arrw.bidirectional) arrow_start = arrow_end;
1276
+ }
1277
+ // Correct the start and end points of the shaft for the stroke width
1278
+ // and size and number of the arrow heads
1279
+ // NOTE: re-use of dx and dy for different purpose!
1280
+ dx = arrw.to_x - arrw.from_x;
1281
+ dy = arrw.to_y - arrw.from_y;
1282
+ l = Math.sqrt(dx * dx + dy * dy);
1283
+ let cdx = 0, cdy = 0;
1284
+ if(l > 0) {
1285
+ // Amount to shorten the line to accommodate arrow head
1286
+ // NOTE: for thicker arrows, subtract a bit more
1287
+ cdx = (4 + 1.7 * (stroke_width - 1.5)) * dx / l;
1288
+ cdy = (4 + 1.7 * (stroke_width - 1.5)) * dy / l;
1289
+ }
1290
+ if(reversed) {
1291
+ // Adjust end points by 1/2 px for rounded stroke end
1292
+ bpx = arrw.to_x - 0.5*dx / l;
1293
+ bpy = arrw.to_y - 0.5*dy / l;
1294
+ // Adjust start points for arrow head(s)
1295
+ epx = arrw.from_x + cdx;
1296
+ epy = arrw.from_y + cdy;
1297
+ if(arrw.bidirectional) {
1298
+ bpx -= cdx;
1299
+ bpy -= cdy;
1300
+ }
1301
+ } else {
1302
+ // Adjust start points by 1/2 px for rounded stroke end
1303
+ bpx = arrw.from_x + 0.5*dx / l;
1304
+ bpy = arrw.from_y + 0.5*dy / l;
1305
+ // Adjust end points for arrow head(s)
1306
+ epx = arrw.to_x - cdx;
1307
+ epy = arrw.to_y - cdy;
1308
+ if(arrw.bidirectional) {
1309
+ bpx += cdx;
1310
+ bpy += cdy;
1311
+ }
1312
+ }
1313
+ // Calculate actual (multi)flow, as this co-determines the color of the arrow
1314
+ if(MODEL.solved) {
1315
+ if(!luc) {
1316
+ mf = arrw.multiFlows;
1317
+ af = mf[1] + mf[2];
1318
+ }
1319
+ if(Math.abs(af) > VM.SIG_DIF_FROM_ZERO && stroke_color != this.palette.select) {
1320
+ stroke_color = this.palette.active_process;
1321
+ if(arrow_end === this.double_triangle) {
1322
+ arrow_end = this.active_double_triangle;
1323
+ } else if(reversed) {
1324
+ stroke_color = this.palette.compound_flow;
1325
+ arrow_end = this.active_reversed_triangle;
1326
+ } else {
1327
+ arrow_end = this.active_triangle;
1328
+ }
1329
+ if(arrw.bidirectional) {
1330
+ arrow_start = arrow_end;
1331
+ }
1332
+ } else {
1333
+ if(stroke_color != this.palette.select) stroke_color = 'silver';
1334
+ if(arrow_end === this.double_triangle) {
1335
+ arrow_end = this.inactive_double_triangle;
1336
+ if(arrw.bidirectional) {
1337
+ arrow_start = this.inactive_double_triangle;
1338
+ }
1339
+ }
1340
+ }
1341
+ } else {
1342
+ af = VM.UNDEFINED;
1343
+ }
1344
+
1345
+ // Draw arrow shaft
1346
+ if(stroke_width === 3 && data_flows) {
1347
+ // Hollow shaft arrow: dotted when *all* represented links are
1348
+ // data links, dashed when some links are regular links
1349
+ sda = (data_flows === arrw.hidden_nodes.length ? '3,2' : '8,3');
1350
+ }
1351
+ arrw.shape.addPath(['M', bpx, ',', bpy, 'L', epx, ',', epy],
1352
+ {fill: 'none', stroke: stroke_color,
1353
+ 'stroke-width': stroke_width, 'stroke-dasharray': sda,
1354
+ 'stroke-linecap': (stroke_width === 3 ? 'butt' : 'round'),
1355
+ 'marker-end': arrow_end, 'marker-start': arrow_start,
1356
+ 'marker-fill': stroke_color,
1357
+ 'style': (DOCUMENTATION_MANAGER.visible && arrw.hasComments ?
1358
+ this.documented_filter : '')});
1359
+ // For compound arrow, add a thin white stripe in the middle to
1360
+ // suggest a double line
1361
+ if(stroke_width === 3) {
1362
+ const fclr = (mf[3] || mf[4] ? this.palette.at_process_ub : 'white');
1363
+ arrow_end = (mf[4] ? this.congested_triangle :
1364
+ this.white_triangle);
1365
+ // NOTE: adjust end points to place white arrowheads nicely
1366
+ // in the larger ones
1367
+ epx -= 0.1*cdx;
1368
+ epy -= 0.1*cdy;
1369
+ if(arrow_start !== 'none') {
1370
+ arrow_start = (mf[3] ? this.congested_triangle :
1371
+ this.white_triangle);
1372
+ bpx += 0.1*cdx;
1373
+ bpy += 0.1*cdy;
1374
+ }
1375
+ const format = {stroke: (data_flows ? 'white' : fclr),
1376
+ 'stroke-width': 1.5, 'stroke-linecap': 'butt',
1377
+ 'marker-end': arrow_end, 'marker-start': arrow_start
1378
+ };
1379
+ arrw.shape.addPath(['M', bpx, ',', bpy, 'L', epx, ',', epy],
1380
+ format);
1381
+ }
1382
+
1383
+ // NEXT: draw data fields (if single link) only if arrow has a length
1384
+ // l > 0 (this check also protects against division by 0)
1385
+ if(luc && l > 0) {
1386
+ // NOTE: "shift" is the distance in pixels from the arrow tip to the
1387
+ // first "empty" spot on the shaft
1388
+ // The arrow head takes about 7 pixels, or 9 when link is selected
1389
+ let head = (luc.selected || luc.is_feedback ? 9 : 7),
1390
+ headshift = head,
1391
+ // Add 2px margin
1392
+ shift = 2;
1393
+ const lfd = (luc.actualDelay(MODEL.t));
1394
+ if(lfd > 0) {
1395
+ // If delay, draw it in a circle behind arrow head
1396
+ s = lfd;
1397
+ bb = this.numberSize(s, 7);
1398
+ // The circle radius should accomodate the text both in width and height
1399
+ tw = Math.max(bb.width, bb.height) / 2 + 0.5;
1400
+ // shift this amount further down the shaft
1401
+ headshift += tw;
1402
+ // Draw delay in circle (solid fill with stroke color)
1403
+ epx = arrw.to_x - (shift + headshift) * dx / l;
1404
+ epy = arrw.to_y - (shift + headshift) * dy / l;
1405
+ arrw.shape.addCircle(epx, epy, tw, {'fill':stroke_color});
1406
+ // Draw the delay (integer number of time steps) in white
1407
+ arrw.shape.addNumber(epx, epy, s, {'font-size': 7, fill: 'white'});
1408
+ // Shift another radius down plus 2px margin
1409
+ shift += tw + 2;
1410
+ }
1411
+
1412
+ // Draw the special multiplier symbol if necessary
1413
+ if(luc.multiplier) {
1414
+ // Shift circle radius (5px) down the shaft
1415
+ headshift += 5;
1416
+ epx = arrw.to_x - (shift + headshift) * dx / l;
1417
+ epy = arrw.to_y - (shift + headshift) * dy / l;
1418
+ arrw.shape.addCircle(epx, epy, 5,
1419
+ {stroke:stroke_color, 'stroke-width': 0.5, fill: 'white'});
1420
+ // MU symbol does not center prettily => raise by 1 px
1421
+ const raise = (luc.multiplier === VM.LM_MEAN ||
1422
+ luc.multiplier === VM.LM_THROUGHPUT ? 1 :
1423
+ (luc.multiplier === VM.LM_PEAK_INC ? 1.5 : 0));
1424
+ arrw.shape.addText(epx, epy - raise, VM.LM_SYMBOLS[luc.multiplier],
1425
+ {fill: 'black'});
1426
+ // Shift another radius plus 2px margin
1427
+ headshift += 7;
1428
+ }
1429
+
1430
+ // Draw link rate near head or tail, depending on link type
1431
+ // NOTE: take into account the delay (a process outputs at rate[t - delta])
1432
+ s = VM.sig4Dig(luc.relative_rate.result(MODEL.t - lfd));
1433
+ const rrfs = (luc.relative_rate.isStatic ? 'normal' : 'italic');
1434
+ bb = this.numberSize(s);
1435
+ th = bb.height;
1436
+ // For small rates (typically 1), the text height will exceed its width
1437
+ tw = Math.max(th, bb.width);
1438
+ // NOTE: The extra distance ("gap") to keep from the start point varies
1439
+ // with abs(dy/l). At most (horizontal, dy = 0) half the width of the
1440
+ // number, at least (vertical) half the height. Add 3px margin (partly
1441
+ // used inside the text box).
1442
+ shift += 3 + th + (tw - th)/2 * (1 - Math.abs(dy/l));
1443
+ if(luc.to_node instanceof Process || luc.dataOnly) {
1444
+ // Show rate near arrow head, leaving extra room for delay and
1445
+ // multiplier circles
1446
+ epx = arrw.to_x - (shift + headshift) * dx / l;
1447
+ epy = arrw.to_y - (shift + headshift) * dy / l;
1448
+ if(luc.dataOnly) {
1449
+ // Show non-negative data multipliers in black,
1450
+ // and negative data multipliers in bright red
1451
+ font_color = (s < 0 ? 'red' : 'black');
1452
+ } else {
1453
+ // "regular" product flows are consumed by the TO-node
1454
+ // (being a process)
1455
+ font_color = this.palette.consumed;
1456
+ }
1457
+ } else {
1458
+ // Show the rate near the arrow tail (ignore space for arrowhead
1459
+ // unless bidirectional)
1460
+ const bi = (arrw.bidirectional ? head : 0);
1461
+ epx = arrw.from_x + (shift + bi) * dx / l;
1462
+ epy = arrw.from_y + (shift + bi) * dy / l;
1463
+ font_color = this.palette.produced;
1464
+ }
1465
+ // Draw the rate in a semi-transparent white ellipse
1466
+ arrw.shape.addEllipse(epx, epy, tw/2, th/2, {fill: 'white', opacity: 0.8});
1467
+ arrw.shape.addNumber(epx, epy, s, {fill: font_color, 'font-style': rrfs});
1468
+
1469
+ // Draw the share of cost (only if relevant and > 0) behind the rate
1470
+ // in a pale yellow filled box
1471
+ if(MODEL.infer_cost_prices && luc.share_of_cost > 0) {
1472
+ // Keep the right distance from the rate: the midpoint should
1473
+ // increase by a varying length: number lengths / 2 when arrow is
1474
+ // horizontal, while number heights when arrow is vertical. This is
1475
+ // achieved by multiplying the "gap" being (lengths - heights)/2 by
1476
+ // (1 - |dy/l|). NOTE: we re-use the values of `th` and `tw`
1477
+ // computed in the previous block!
1478
+ shift += th / 2;
1479
+ s = VM.sig4Dig(luc.share_of_cost * 100) + '%';
1480
+ bb = this.numberSize(s, 7);
1481
+ const sgap = (tw + bb.width + 3 - th - bb.height) / 2;
1482
+ tw = bb.width + 3;
1483
+ th = bb.height + 1;
1484
+ shift += 3 + th/2 + sgap * (1 - Math.abs(dy/l));
1485
+ // NOTE: if rate is shown near head, just accommodate the SoC box
1486
+ if(luc.dataOnly) {
1487
+ shift = 5 + th + (tw - th)/2 * (1 - Math.abs(dy/l));
1488
+ }
1489
+ // Do not draw SoC if arrow is very short
1490
+ if(shift < l) {
1491
+ epx = arrw.from_x + shift * dx / l;
1492
+ epy = arrw.from_y + shift * dy / l;
1493
+ arrw.shape.addRect(epx, epy, tw, th,
1494
+ {stroke: 'black', 'stroke-width': 0.3,
1495
+ fill: (luc.from_node.totalAttributedCost <= 1 ?
1496
+ this.palette.cost_price : this.palette.soc_too_high),
1497
+ rx: 2, ry: 2});
1498
+ arrw.shape.addNumber(epx, epy, s, {fill: 'black'});
1499
+ }
1500
+ }
1501
+ }
1502
+
1503
+ // Draw the actual flow
1504
+ if(l > 0 && af < VM.UNDEFINED && Math.abs(af) > VM.SIG_DIF_FROM_ZERO) {
1505
+ const ffill = {fill:'white', opacity:0.8};
1506
+ if(luc || mf[0] == 1) {
1507
+ // Draw flow data halfway the arrow only if calculated and non-zero
1508
+ s = VM.sig4Dig(af);
1509
+ bb = this.numberSize(s, 10, 700);
1510
+ tw = bb.width/2;
1511
+ th = bb.height/2;
1512
+ // NOTE: for short arrows (less than 100 pixels long) that have data
1513
+ // near the head, move the actual flow label further down the shaft
1514
+ const pfr = (l < 100 &&
1515
+ (luc && (luc.to_node instanceof Process || luc.dataOnly)) ? 0.65 : 0.5);
1516
+ epx = arrw.to_x - dx*pfr;
1517
+ epy = arrw.to_y - dy*pfr;
1518
+ arrw.shape.addEllipse(epx, epy, tw + 2, th, ffill);
1519
+ arrw.shape.addNumber(epx, epy, s,
1520
+ {fill:stroke_color, 'font-size':10, 'font-weight':700});
1521
+ } else if(mf[0] > 1) {
1522
+ // Multi-flow arrow with flow data computed
1523
+ let clr = this.palette.active_process;
1524
+ // Highlight if related process(es) are at upper bound
1525
+ if(mf[3]) ffill.fill = this.palette.at_process_ub_bar;
1526
+ s = VM.sig4Dig(mf[1]);
1527
+ bb = this.numberSize(s, 10, 700);
1528
+ tw = bb.width/2;
1529
+ th = bb.height/2;
1530
+ if(mf[0] == 2) {
1531
+ // Single aggregated flow (for monodirectional arrow) in middle
1532
+ epx = arrw.to_x - dx*0.5;
1533
+ epy = arrw.to_y - dy*0.5;
1534
+ } else {
1535
+ clr = this.palette.compound_flow;
1536
+ // Two aggregated flows: first the tail flow ...
1537
+ epx = arrw.to_x - dx*0.75;
1538
+ epy = arrw.to_y - dy*0.75;
1539
+ // Only display if non-zero
1540
+ if(s !== 0) {
1541
+ if(ffill.fill !== 'white') {
1542
+ arrw.shape.addRect(epx, epy, tw*2, th*2, ffill);
1543
+ } else {
1544
+ arrw.shape.addEllipse(epx, epy, tw, th, ffill);
1545
+ }
1546
+ arrw.shape.addNumber(epx, epy, s,
1547
+ {fill:clr, 'font-size':10, 'font-weight':700});
1548
+ }
1549
+ // ... then also the head flow
1550
+ s = VM.sig4Dig(mf[2]);
1551
+ bb = this.numberSize(s, 10, 700);
1552
+ tw = bb.width/2;
1553
+ th = bb.height/2;
1554
+ ffill.fill = (mf[4] ? this.palette.at_process_ub_bar : 'white');
1555
+ epx += dx*0.5;
1556
+ epy += dy*0.5;
1557
+ }
1558
+ // Only display if non-zero
1559
+ if(s !== 0) {
1560
+ if(ffill.fill !== 'white') {
1561
+ arrw.shape.addRect(epx, epy, tw*2, th*2, ffill);
1562
+ } else {
1563
+ arrw.shape.addEllipse(epx, epy, tw, th, ffill);
1564
+ }
1565
+ arrw.shape.addNumber(epx, epy, s,
1566
+ {fill:clr, 'font-size':10, 'font-weight':700});
1567
+ }
1568
+ }
1569
+ // For single links, show cost prices of non-zero flows only for
1570
+ // non-error, non-infinite actual flows
1571
+ if(luc && MODEL.infer_cost_prices &&
1572
+ af > VM.MINUS_INFINITY && af < VM.PLUS_INFINITY
1573
+ ) {
1574
+ // Assume no cost price to be displayed
1575
+ s = '';
1576
+ let soc = 0;
1577
+ // NOTE: flows INTO processes always carry cost
1578
+ if(luc.to_node instanceof Process) {
1579
+ soc = 1;
1580
+ prod = luc.from_node;
1581
+ proc = luc.to_node;
1582
+ } else {
1583
+ if(luc.from_node instanceof Process) {
1584
+ soc = luc.share_of_cost;
1585
+ }
1586
+ prod = luc.to_node;
1587
+ proc = luc.from_node;
1588
+ }
1589
+ // If a link FROM a process carries no cost, the flow has no
1590
+ // cost price...
1591
+ if(soc === 0) {
1592
+ if(luc.to_node.price.defined) {
1593
+ cp = luc.to_node.price.result(MODEL.t);
1594
+ // ... unless it is a flow of a by-product having a market
1595
+ // value (+ or -)
1596
+ if(cp !== 0) {
1597
+ //Just in case, check for error codes (if so, display them)
1598
+ if(cp < VM.MINUS_INFINITY) {
1599
+ s = VM.sig4Dig(cp);
1600
+ } else if(cp < 0) {
1601
+ s = `(${VM.sig4Dig(af * cp)})`;
1602
+ }
1603
+ }
1604
+ }
1605
+ } else {
1606
+ if(af > 0) {
1607
+ // Positive flow => use cost price of FROM node
1608
+ if(luc.from_node instanceof Process) {
1609
+ // For processes, this is their cost price per level
1610
+ // DIVIDED BY the relative rate of the link
1611
+ const rr = luc.relative_rate.result(MODEL.t);
1612
+ if(Math.abs(rr) < VM.NEAR_ZERO) {
1613
+ cp = (rr < 0 && cp < 0 || rr > 0 && cp > 0 ?
1614
+ VM.PLUS_INFINITY : VM.MINUS_INFINITY);
1615
+ } else {
1616
+ cp = proc.costPrice(MODEL.t) / rr;
1617
+ }
1618
+ } else if(prod.price.defined) {
1619
+ // For products their market price if defined...
1620
+ cp = prod.price.result(MODEL.t);
1621
+ } else {
1622
+ // ... otherwise their cost price
1623
+ cp = prod.costPrice(MODEL.t);
1624
+ }
1625
+ } else {
1626
+ // Negative flow => use cost price of TO node
1627
+ if(luc.to_node instanceof Process) {
1628
+ cp = proc.costPrice(MODEL.t);
1629
+ } else if(prod.price.defined) {
1630
+ cp = prod.price.result(MODEL.t);
1631
+ } else {
1632
+ cp = prod.costPrice(MODEL.t);
1633
+ }
1634
+ }
1635
+ // NOTE: the first condition ensures that error codes will be displayed
1636
+ if(cp <= VM.MINUS_INFINITY || cp >= VM.PLUS_INFINITY) {
1637
+ s = VM.sig4Dig(cp);
1638
+ } else if(Math.abs(cp) <= VM.SIG_DIF_FROM_ZERO) {
1639
+ // DO not display CP when it is "propagated" NO_COST
1640
+ s = (cp === VM.NO_COST ? '' : '0');
1641
+ } else {
1642
+ // NOTE: use the absolute value of the flow, as cost is not affected by direction
1643
+ s = VM.sig4Dig(Math.abs(af) * soc * cp);
1644
+ }
1645
+ }
1646
+ // Only display cost price if it is meaningful
1647
+ if(s) {
1648
+ font_color = 'gray';
1649
+ bb = this.numberSize(s, 8, font_weight);
1650
+ tw = bb.width;
1651
+ th = bb.height;
1652
+ // NOTE: offset cost price label relative to actual flow label
1653
+ epy += th + 1;
1654
+ arrw.shape.addRect(epx, epy, tw, th, {'fill': this.palette.cost_price});
1655
+ arrw.shape.addNumber(epx, epy, s, {'fill': font_color});
1656
+ }
1657
+ } // end IF luc and cost prices shown and actual flow not infinite
1658
+ } // end IF l > 0 and actual flow is defined and non-zero
1659
+
1660
+ if(l > 0) {
1661
+ // NOTE: make the arrow shape nearly transparant when it connects to a
1662
+ // product that has the "hide links" option selected
1663
+ if(arrw.from_node.no_links || arrw.to_node.no_links) {
1664
+ arrw.shape.element.setAttribute('opacity', 0.08);
1665
+ }
1666
+ arrw.shape.appendToDOM();
1667
+ return true;
1668
+ }
1669
+ // If nothing is drawn, return FALSE although this does NOT imply an error
1670
+ return false;
1671
+ }
1672
+
1673
+ drawConstraint(c) {
1674
+ // Draws constraint `c` on the paper.
1675
+ let from_ctrl,
1676
+ to_ctrl,
1677
+ ignored = MODEL.ignored_entities[c.identifier],
1678
+ dy,
1679
+ stroke_color,
1680
+ stroke_width,
1681
+ slack_color = '',
1682
+ active = false,
1683
+ oval,
1684
+ chev,
1685
+ ady;
1686
+ if(!ignored && MODEL.solved) {
1687
+ // Check whether slack is used in this time step
1688
+ if(!c.no_slack && c.slack_info.hasOwnProperty(MODEL.t)) {
1689
+ // If so, draw constraint in red if UB slack is used, or in
1690
+ // blue if LB slack is used
1691
+ slack_color = (c.slack_info[MODEL.t] === 'UB' ? '#c00000' : '#0000d0');
1692
+ } else {
1693
+ // Check if constraint is "active" ("on" a bound line)
1694
+ active = c.active(MODEL.t);
1695
+ }
1696
+ }
1697
+ // Clear previous drawing
1698
+ c.shape.clear();
1699
+ const vn = c.visibleNodes;
1700
+
1701
+ // Double-check: do not draw unless either node is visible
1702
+ if(!vn[0] && !vn[1]) return;
1703
+
1704
+ // NOTE: `ady` ("arrow dy") compensates for the length of the
1705
+ // (always vertical) arrow heads
1706
+ if(c.selected) {
1707
+ // Draw arrow line thick and in red
1708
+ stroke_color = this.palette.select;
1709
+ stroke_width = 1.5;
1710
+ oval = this.selected_small_oval;
1711
+ chev = this.selected_chevron;
1712
+ ady = 4;
1713
+ } else if(!ignored && active) {
1714
+ // Draw arrow line a bit thicker and in purple
1715
+ stroke_color = this.palette.at_process_ub;
1716
+ stroke_width = 1.4;
1717
+ oval = this.active_small_oval;
1718
+ chev = this.active_chevron;
1719
+ ady = 3.5;
1720
+ } else if(!ignored && c.no_slack) {
1721
+ // Draw arrow in black
1722
+ stroke_color = 'black';
1723
+ stroke_width = 1.3;
1724
+ oval = this.black_small_oval;
1725
+ chev = this.black_chevron;
1726
+ ady = 3;
1727
+ } else {
1728
+ stroke_color = ignored ? this.palette.ignore :
1729
+ (slack_color ? slack_color : this.palette.node_rim);
1730
+ stroke_width = 1.25;
1731
+ oval = this.small_oval;
1732
+ chev = this.chevron;
1733
+ ady = 3;
1734
+ }
1735
+
1736
+ if(vn[0] && vn[1]) {
1737
+ // Both nodes are visible => calculate start, end and control
1738
+ // points for the curved arrow.
1739
+ // NOTE: Nodes are assumed to have been positioned, so the X and Y
1740
+ // of products have been updated to correspond with those of their
1741
+ // placeholders in the focal cluster.
1742
+ const
1743
+ p = c.from_node,
1744
+ q = c.to_node;
1745
+ // First calculate the constraint offsets
1746
+ p.setConstraintOffsets();
1747
+ q.setConstraintOffsets();
1748
+ const
1749
+ from = [p.x + c.from_offset, p.y],
1750
+ to = [q.x + c.to_offset, q.y],
1751
+ hph = (p.collapsed ? 6: p.height/2) + ady,
1752
+ hqh = (q.collapsed ? 6: q.height/2) + ady,
1753
+ // Control point modifier: less vertical "pull" on points
1754
+ // that have their X further from the node center
1755
+ from_cpm = (1 - Math.abs(c.from_offset) / p.width) * 1.3,
1756
+ to_cpm = (1 - Math.abs(c.to_offset) / p.width) * 1.3;
1757
+ // Now establish the correct y-coordinates
1758
+ dy = to[1] - from[1];
1759
+ if(p.y < q.y - hqh - p.height) {
1760
+ // If q lies amply below p, then bottom p --> top q
1761
+ from[1] += hph;
1762
+ to[1] -= hqh;
1763
+ // Control point below start point and above end point
1764
+ from_ctrl = [from[0], from[1] + from_cpm * dy / 3];
1765
+ to_ctrl = [to[0], to[1] - to_cpm * dy / 3];
1766
+ } else if(q.y < p.y - hph - q.height) {
1767
+ // If p lies amply below q, then top p --> bottom q
1768
+ from[1] -= hph;
1769
+ to[1] += hqh;
1770
+ // Control point above start point and below end point
1771
+ from_ctrl = [from[0], from[1] + from_cpm * dy / 3];
1772
+ to_ctrl = [to[0], to[1] - to_cpm * dy / 3];
1773
+ } else {
1774
+ // If top --> top (never bottom --> bottom)
1775
+ from[1] -= hph;
1776
+ to[1] -= hqh;
1777
+ // Control point above start point and end point
1778
+ from_ctrl = [from[0], from[1] - from_cpm * hph];
1779
+ to_ctrl = [to[0], to[1] - to_cpm * hqh];
1780
+ }
1781
+ c.midpoint = this.bezierPoint(from, from_ctrl, to_ctrl, to, 0.5);
1782
+ // NOTE: SoC is displayed near the node that *incurs* the cost
1783
+ c.socpoint = this.bezierPoint(from, from_ctrl, to_ctrl, to,
1784
+ (c.soc_direction === VM.SOC_X_Y ? 0.75 : 0.25));
1785
+ // Arrow head markers depend on constraint
1786
+ const path = ['M', from[0], ',', from[1], 'C', from_ctrl[0], ',',
1787
+ from_ctrl[1], ',', to_ctrl[0], ',', to_ctrl[1], ',',
1788
+ to[0], ',', to[1]];
1789
+ // Draw Bezier path of curved arrow first thickly and nearly transparent
1790
+ // to be easier to "hit" by the cursor
1791
+ c.shape.addPath(path,
1792
+ {fill: 'none', stroke: 'rgba(255,255,255,0.1)', 'stroke-width': 5});
1793
+ // Over this thick band, draw the dashed-line arrow
1794
+ c.shape.addPath(path,
1795
+ {fill: 'none', stroke: stroke_color, 'stroke-width': stroke_width,
1796
+ 'stroke-dasharray': UI.sda.short_dash, 'stroke-linecap': 'round',
1797
+ // NOTE: to indicate "no constraint", omit the oval, but keep
1798
+ // the arrow point or the direction X->Y would become ambiguous
1799
+ 'marker-start': oval, 'marker-end': chev});
1800
+ } else if(vn[0]) {
1801
+ // If only the FROM node is visible, set the thumbnail midpoint at
1802
+ // the top center of this node, taking into account other constraints
1803
+ // that relate to this node
1804
+ c.from_node.setConstraintOffsets();
1805
+ c.midpoint = [c.from_node.x + c.from_offset,
1806
+ c.from_node.y - c.from_node.height/2 - 7];
1807
+ } else if(vn[1]) {
1808
+ // Do likewise if only the TO node is visible
1809
+ c.to_node.setConstraintOffsets();
1810
+ c.midpoint = [c.to_node.x + c.to_offset,
1811
+ c.to_node.y - c.to_node.height/2 - 7];
1812
+ }
1813
+ // Draw the 12x12 px size thumbnail chart showing the infeasible areas
1814
+ // NOTE: if no arrow, the hasArrow method will have set c.midpoint
1815
+ // Add the SVG sub-element that will contain the paths
1816
+ // NOTE: define same scale for viewbox as used by the ConstraintEditor
1817
+ const
1818
+ scale = CONSTRAINT_EDITOR.scale,
1819
+ s = 100 * scale,
1820
+ ox = CONSTRAINT_EDITOR.oX,
1821
+ oy = CONSTRAINT_EDITOR.oY,
1822
+ svg = c.shape.addSVG(c.midpoint[0] - 6, c.midpoint[1] - 6,
1823
+ {width: 12, height: 12, viewBox: `${ox},${oy - s},${s},${s}`});
1824
+ // Draw a white square with gray border as base for the two contours
1825
+ let el = this.newSVGElement('rect');
1826
+ // Adjust rim thickness and color if slack is used in this time step
1827
+ // NOTE: use extra thick border, as this image will be scaled down
1828
+ // by a factor 25
1829
+ if(slack_color) {
1830
+ stroke_width = 100;
1831
+ stroke_color = slack_color;
1832
+ } else {
1833
+ stroke_width = 25;
1834
+ }
1835
+ this.addSVGAttributes(el,
1836
+ {x: ox, y: (oy - s), width: s, height: s,
1837
+ // NOTE: EQ boundline => whole area is infeasible => silver
1838
+ fill: (c.setsEquality ? UI.color.src_snk : 'white'),
1839
+ stroke: stroke_color, 'stroke-width': stroke_width});
1840
+ svg.appendChild(el);
1841
+ // Add the bound line contours
1842
+ for(let i = 0; i < c.bound_lines.length; i++) {
1843
+ const
1844
+ bl = c.bound_lines[i],
1845
+ // Draw thumbnail in shades of the arrow color, but use black
1846
+ // for regular color or the filled areas turn out too light
1847
+ clr = (stroke_color === this.palette.node_rim ? 'black' : stroke_color);
1848
+ el = this.newSVGElement('path');
1849
+ if(bl.type === VM.EQ) {
1850
+ // For EQ bound lines, draw crisp line on silver background
1851
+ this.addSVGAttributes(el,
1852
+ {d: bl.contour_path, fill: 'none', stroke: clr, 'stroke-width': 30});
1853
+ } else {
1854
+ // Draw infeasible area in gray
1855
+ this.addSVGAttributes(el, {d: bl.contour_path, fill: clr, opacity: 0.3});
1856
+ }
1857
+ svg.appendChild(el);
1858
+ }
1859
+ // Draw the share of cost (only if relevant and non-zero) near tail
1860
+ // (or head if Y->X) of arrow in a pale yellow filled box
1861
+ if(MODEL.infer_cost_prices && c.share_of_cost) {
1862
+ let s = VM.sig4Dig(c.share_of_cost * 100) + '%',
1863
+ bb = this.numberSize(s, 7),
1864
+ tw = bb.width + 3,
1865
+ th = bb.height + 1,
1866
+ // NOTE: when only one node is visible, display the SoC in
1867
+ // gray for the node that is *contributing* the cost, and
1868
+ // then do not display the total amount
1869
+ soc = ((vn[0] && c.soc_direction === VM.SOC_Y_X) ||
1870
+ (vn[1] && c.soc_direction === VM.SOC_X_Y)),
1871
+ clr = (soc ? 'black' : 'gray');
1872
+ if(!(vn[0] && vn[1])) {
1873
+ // No arrow => draw SoC above the thumbnail
1874
+ c.socpoint = [c.midpoint[0], c.midpoint[1] - 11];
1875
+ }
1876
+ c.shape.addRect(c.socpoint[0], c.socpoint[1], tw, th,
1877
+ {stroke: clr, 'stroke-width': 0.3, fill: this.palette.cost_price,
1878
+ rx: 2, ry: 2});
1879
+ c.shape.addNumber(c.socpoint[0], c.socpoint[1], s, {fill: clr});
1880
+ if(MODEL.solved && soc) {
1881
+ // Assume no cost price to be displayed
1882
+ s = '';
1883
+ // For X->Y transfer, display SoC * (unit cost price * level) of
1884
+ // FROM node, of TO node
1885
+ const
1886
+ ucp = (c.soc_direction === VM.SOC_X_Y ?
1887
+ c.from_node.costPrice(MODEL.t) :
1888
+ c.to_node.costPrice(MODEL.t)),
1889
+ fl = c.from_node.actualLevel(MODEL.t),
1890
+ tl = c.to_node.actualLevel(MODEL.t);
1891
+ // If either node level indicates an exception
1892
+ if(fl <= VM.MINUS_INFINITY || tl <= VM.MINUS_INFINITY) {
1893
+ s = '\u26A0'; // Warning sign
1894
+ } else if(ucp <= VM.MINUS_INFINITY || ucp >= VM.PLUS_INFINITY) {
1895
+ // NOTE: the first condition ensures that error codes will be displayed
1896
+ s = VM.sig4Dig(ucp);
1897
+ } else if(Math.abs(ucp) <= VM.SIG_DIF_FROM_ZERO ||
1898
+ Math.abs(fl) <= VM.SIG_DIF_FROM_ZERO ||
1899
+ Math.abs(tl) <= VM.SIG_DIF_FROM_ZERO) {
1900
+ s = '0';
1901
+ } else {
1902
+ // NOTE: display the total cost price (so not "per unit")
1903
+ s = VM.sig4Dig((c.soc_direction === VM.SOC_X_Y ? fl : tl) *
1904
+ ucp * c.share_of_cost);
1905
+ }
1906
+ // Only display cost price if it is meaningful
1907
+ if(s) {
1908
+ bb = this.numberSize(s, 8);
1909
+ tw = bb.width;
1910
+ th = bb.height;
1911
+ const
1912
+ cpx = c.midpoint[0],
1913
+ cpy = c.midpoint[1] + 12;
1914
+ c.shape.addRect(cpx, cpy, tw, th, {'fill': this.palette.cost_price});
1915
+ c.shape.addNumber(cpx, cpy, s, {'fill': 'gray'});
1916
+ }
1917
+ }
1918
+ }
1919
+ // Highlight shape if it has comments
1920
+ c.shape.element.setAttribute('style',
1921
+ (DOCUMENTATION_MANAGER.visible && c.comments ?
1922
+ this.documented_filter : ''));
1923
+ c.shape.appendToDOM();
1924
+ }
1925
+
1926
+ drawProcess(proc, dx=0, dy=0) {
1927
+ // Clear previous drawing
1928
+ proc.shape.clear();
1929
+ // Do not draw process unless in focal cluster
1930
+ if(MODEL.focal_cluster.processes.indexOf(proc) < 0) return;
1931
+ // Set local constants and variables
1932
+ const
1933
+ ignored = MODEL.ignored_entities[proc.identifier],
1934
+ x = proc.x + dx,
1935
+ y = proc.y + dy,
1936
+ // NOTE: display bounds in italics if either is not static
1937
+ bfs = (proc.lower_bound.isStatic && proc.upper_bound.isStatic ?
1938
+ 'normal' : 'italic'),
1939
+ il = proc.initial_level.result(1);
1940
+ let l = (MODEL.solved ? proc.actualLevel(MODEL.t) : VM.NOT_COMPUTED),
1941
+ lb = proc.lower_bound.result(MODEL.t),
1942
+ ub = (proc.equal_bounds ? lb : proc.upper_bound.result(MODEL.t));
1943
+ // NOTE: by default, lower bound = 0 (but do show exceptional values)
1944
+ if(lb === VM.UNDEFINED && !proc.lower_bound.defined) lb = 0;
1945
+ let hw,
1946
+ hh,
1947
+ s,
1948
+ font_color = 'white',
1949
+ lrect_color = 'none',
1950
+ stroke_width = 1,
1951
+ stroke_color = (ignored ? this.palette.ignore : this.palette.node_rim),
1952
+ is_fc_option = proc.needsFirstCommitData,
1953
+ fc_option_node = proc.linksToFirstCommitDataProduct,
1954
+ // First-commit options have a shorter-dashed rim
1955
+ sda = (is_fc_option || fc_option_node ?
1956
+ UI.sda.shorter_dash : 'none'),
1957
+ bar_ratio = 0,
1958
+ fill_color = this.palette.node_fill,
1959
+ bar_color = this.palette.process_level_bar;
1960
+ // Colors co-depend on production level (if computed)
1961
+ if(MODEL.solved && !ignored) {
1962
+ if(l === VM.PLUS_INFINITY) {
1963
+ // Infinite level => unbounded solution
1964
+ stroke_color = this.palette.infinite_level;
1965
+ fill_color = this.palette.infinite_level_fill;
1966
+ lrect_color = this.palette.infinite_level;
1967
+ font_color = 'white';
1968
+ stroke_width = 2;
1969
+ } else if(l > ub - VM.SIG_DIF_FROM_ZERO ||
1970
+ (lb < -VM.SIG_DIF_FROM_ZERO && l < lb + VM.SIG_DIF_FROM_ZERO)) {
1971
+ // At full capacity => active constraint
1972
+ if(Math.abs(l) < VM.SIG_DIF_FROM_ZERO) {
1973
+ // Differentiate: if bound = 0, use neutral colors to reflect that
1974
+ // the process is not actually "running"
1975
+ stroke_color = this.palette.node_rim;
1976
+ fill_color = 'white';
1977
+ lrect_color = 'black';
1978
+ bar_color = this.palette.src_snk;
1979
+ } else {
1980
+ stroke_color = (l < 0 ? this.palette.at_process_neg_lb :
1981
+ this.palette.at_process_ub);
1982
+ fill_color = this.palette.at_process_ub_fill;
1983
+ lrect_color = stroke_color;
1984
+ bar_color = this.palette.at_process_ub_bar;
1985
+ }
1986
+ bar_ratio = 1;
1987
+ font_color = 'white';
1988
+ stroke_width = 2;
1989
+ } else if(Math.abs(l) < VM.SIG_DIF_FROM_ZERO) {
1990
+ font_color = this.palette.node_rim;
1991
+ } else if(l < 0) {
1992
+ // Negative level => more reddish stroke and font
1993
+ font_color = this.palette.compound_flow;
1994
+ stroke_color = font_color;
1995
+ if(lb < -VM.NEAR_ZERO) bar_ratio = l / lb;
1996
+ stroke_width = 1.25;
1997
+ } else {
1998
+ font_color = this.palette.active_process;
1999
+ stroke_color = font_color;
2000
+ if(ub > VM.NEAR_ZERO) bar_ratio = l / ub;
2001
+ stroke_width = 1.25;
2002
+ }
2003
+ // For options, set longer-dashed rim if committed at time <= t
2004
+ const fcn = (is_fc_option ? proc : fc_option_node);
2005
+ if(fcn && fcn.start_ups.length > 0 && MODEL.t >= fcn.start_ups[0]) {
2006
+ sda = UI.sda.longer_dash;
2007
+ }
2008
+ } else if(il) {
2009
+ // Display non-zero initial level black-on-white, and then also
2010
+ // display the level bar
2011
+ if(il < 0 && lb < -VM.NEAR_ZERO) {
2012
+ bar_ratio = il / lb;
2013
+ } else if(il > 0 && ub > VM.NEAR_ZERO) {
2014
+ bar_ratio = il / ub;
2015
+ }
2016
+ bar_color = this.palette.src_snk;
2017
+ }
2018
+ // Being selected overrules special border properties except SDA
2019
+ if(proc.selected) {
2020
+ stroke_color = this.palette.select;
2021
+ stroke_width = 2;
2022
+ }
2023
+ if(proc.collapsed) {
2024
+ hw = 8.5;
2025
+ hh = 6;
2026
+ } else {
2027
+ hw = proc.width / 2;
2028
+ hh = proc.height / 2;
2029
+ }
2030
+ // Draw frame using colors as defined above
2031
+ proc.shape.addRect(x, y, 2 * hw, 2 * hh,
2032
+ {fill: fill_color, stroke: stroke_color, 'stroke-width': stroke_width,
2033
+ 'stroke-dasharray': sda, 'stroke-linecap': 'round'});
2034
+ // Draw level indicator: 8-pixel wide vertical bar on the right
2035
+ if(bar_ratio > VM.NEAR_ZERO) {
2036
+ // Calculate half the bar's height (bar rectangle is centered)
2037
+ const
2038
+ hsw = stroke_width / 2,
2039
+ hbl = hh * bar_ratio - hsw;
2040
+ // NOTE: when level < 0, bar drops down from top
2041
+ proc.shape.addRect(x + hw - 4 - hsw,
2042
+ (l < 0 ? y - hh + hbl + hsw : y + hh - hbl - hsw),
2043
+ 8, 2 * hbl, {fill: bar_color, stroke: 'none'});
2044
+ }
2045
+ // If semi-continuous, add a double rim 2 px above the bottom line
2046
+ if(proc.level_to_zero) {
2047
+ const bly = y + hh - 2;
2048
+ proc.shape.addPath(['M', x - hw, ',', bly, 'L', x + hw, ',', bly],
2049
+ {'fill': 'none', stroke: stroke_color, 'stroke-width': 0.6});
2050
+ }
2051
+ if(!proc.collapsed) {
2052
+ // If model has been computed or initial level is non-zero, draw
2053
+ // production level in upper right corner
2054
+ const il = proc.initial_level.result(1);
2055
+ if(MODEL.solved || il) {
2056
+ if(!MODEL.solved) {
2057
+ l = il;
2058
+ font_color = 'black';
2059
+ }
2060
+ s = VM.sig4Dig(Math.abs(l));
2061
+ // Oversize level box width by 4px and height by 1px
2062
+ const
2063
+ bb = this.numberSize(s, 9),
2064
+ bw = bb.width + 2,
2065
+ bh = bb.height;
2066
+ // Upper right corner =>
2067
+ // (x + width/2 - number width/2, y - height/2 + number height/2)
2068
+ // NOTE: add 0.5 margin to stay clear from the edges
2069
+ const
2070
+ cx = x + hw - bw / 2 - 0.5,
2071
+ cy = y - hh + bh / 2 + 0.5;
2072
+ proc.shape.addRect(cx, cy, bw, bh, {fill: lrect_color});
2073
+ if(Math.abs(l) >= -VM.ERROR) {
2074
+ proc.shape.addNumber(cx, cy, s,
2075
+ {'font-size': 9, 'fill': this.palette.VM_error});
2076
+ } else {
2077
+ proc.shape.addNumber(cx, cy, s,
2078
+ {'font-size': 9, 'fill': font_color, 'font-weight': 700});
2079
+ }
2080
+ }
2081
+ // Draw boundaries in upper left corner
2082
+ // NOTE: their expressions should have been computed
2083
+ s = VM.sig4Dig(lb);
2084
+ // Calculate width of lower bound because it may have to be underlined
2085
+ let lbw = this.numberSize(s).width;
2086
+ // Default offset for lower bound undercore (if drawn)
2087
+ let lbo = 1.5;
2088
+ if(ub === lb) {
2089
+ // If bounds are equal, show bound preceded by equal sign
2090
+ s = '=' + s;
2091
+ // Add text width of equal sign to offset
2092
+ lbo += 5;
2093
+ } else {
2094
+ const ubs = (ub >= VM.PLUS_INFINITY && !proc.upper_bound.defined ?
2095
+ '\u221E' : VM.sig4Dig(ub));
2096
+ if(Math.abs(lb) > VM.NEAR_ZERO) {
2097
+ // If lb <> 0 then lb...ub (with ellipsis)
2098
+ s += '\u2026' + ubs;
2099
+ } else {
2100
+ // If lb = 0 show only the upper bound
2101
+ s = ubs;
2102
+ lbw = 0;
2103
+ }
2104
+ }
2105
+ // Keep track of the width of the boundary text, as later it may be
2106
+ // followed by more text
2107
+ const
2108
+ bb = this.numberSize(s),
2109
+ btw = bb.width + 2,
2110
+ sh = bb.height,
2111
+ tx = x - hw + 1,
2112
+ ty = y - hh + sh/2 + 1;
2113
+ proc.shape.addNumber(tx + btw/2, ty, s,
2114
+ {fill: 'black', 'font-style': bfs});
2115
+ // Show start/stop-related status right of the process boundaries
2116
+ // NOTE: lb must be > 0 for start/stop to work
2117
+ if(proc.level_to_zero && lbw) {
2118
+ font_color = 'black';
2119
+ // Underline the lower bound to indicate semi-continuity
2120
+ proc.shape.addPath(
2121
+ ['M', tx + lbo, ',', ty + sh/2, 'L', tx + lbo + lbw, ',', ty + sh/2],
2122
+ {'fill': 'none', stroke: font_color, 'stroke-width': 0.4});
2123
+ // By default, no ON/OFF indicator
2124
+ s = '';
2125
+ if(MODEL.solved && l !== VM.UNDEFINED) {
2126
+ // Solver has been active
2127
+ const
2128
+ pl = proc.actualLevel(MODEL.t - 1),
2129
+ su = proc.start_ups.indexOf(MODEL.t),
2130
+ sd = proc.shut_downs.indexOf(MODEL.t);
2131
+ if(Math.abs(l) > VM.NEAR_ZERO) {
2132
+ // Process is ON
2133
+ if(Math.abs(pl) < VM.NEAR_ZERO && su >= 0) {
2134
+ font_color = this.palette.switch_on_off;
2135
+ // Start-up arrow or first-commit asterisk
2136
+ s = VM.LM_SYMBOLS[su ? VM.LM_STARTUP : VM.LM_FIRST_COMMIT];
2137
+ } else if(su >= 0) {
2138
+ font_color = 'black';
2139
+ s = '\u25B3'; // Outline triangle up to indicate anomaly
2140
+ }
2141
+ if(sd >= 0) {
2142
+ // Should not occur, as for shut-down, level should be 0
2143
+ font_color = 'black';
2144
+ s += '\u25BD'; // Add outline triangle down to indicate anomaly
2145
+ }
2146
+ } else {
2147
+ // Process is OFF => check previous level
2148
+ if(Math.abs(pl) > VM.NEAR_ZERO && sd >= 0) {
2149
+ // Process was on, and is now switched OFF
2150
+ font_color = this.palette.switch_on_off;
2151
+ s = VM.LM_SYMBOLS[VM.LM_SHUTDOWN];
2152
+ } else if(sd >= 0) {
2153
+ font_color = 'black';
2154
+ s = '\u25BD'; // Outline triangle down to indicate anomaly
2155
+ }
2156
+ if(su >= 0) {
2157
+ // Should not occur, as for start-up, level should be > 0
2158
+ font_color = 'black';
2159
+ s += '\u25B3'; // Add outline triangle up to indicate anomaly
2160
+ }
2161
+ }
2162
+ }
2163
+ if(s) {
2164
+ // Special symbols are 5 pixels wide and 9 high
2165
+ proc.shape.addText(x - hw + btw + 5, y - hh + 4.5, s,
2166
+ {fill: font_color});
2167
+ }
2168
+ }
2169
+ if(MODEL.infer_cost_prices && MODEL.solved) {
2170
+ // Draw costprice data in lower left corner
2171
+ const cp = proc.costPrice(MODEL.t);
2172
+ s = VM.sig4Dig(cp);
2173
+ if(l === 0) {
2174
+ // No "real" cost price when process level = 0
2175
+ font_color = 'silver';
2176
+ fill_color = this.palette.virtual_cost_price;
2177
+ } else {
2178
+ font_color = 'black';
2179
+ fill_color = this.palette.cost_price;
2180
+ }
2181
+ const
2182
+ cpbb = this.numberSize(s),
2183
+ cpbw = cpbb.width + 2,
2184
+ cpbh = cpbb.height + 1;
2185
+ proc.shape.addRect(x - hw + cpbw/2 + 0.5, y + hh - cpbh/2 - 0.5,
2186
+ cpbw, cpbh, {fill: fill_color});
2187
+ proc.shape.addNumber(x - hw + cpbw/2 + 0.5, y + hh - cpbh/2 - 0.5, s,
2188
+ {fill: font_color});
2189
+ }
2190
+ // Draw pace in lower right corner if it is not equal to 1
2191
+ if(proc.pace !== 1) {
2192
+ const
2193
+ pbb = this.numberSize(proc.pace, 7),
2194
+ pbw = pbb.width,
2195
+ hpbh = pbb.height/2;
2196
+ proc.shape.addText(x + hw - pbw - 5.75, y + hh - hpbh - 3.5,
2197
+ '1', {'font-size': 7,fill: '#202060'});
2198
+ proc.shape.addText(x + hw - pbw - 3, y + hh - hpbh - 2.5, '/',
2199
+ {'font-size': 10, fill: '#202060'});
2200
+ proc.shape.addText(x + hw - pbw/2 - 2, y + hh - hpbh - 1.25,
2201
+ proc.pace, {'font-size': 7, fill: '#603060'});
2202
+ }
2203
+ // Always draw process name plus actor name (if any)
2204
+ const
2205
+ th = proc.name_lines.split('\n').length * this.font_heights[10] / 2,
2206
+ cy = (proc.hasActor ? y - 8 : y - 2);
2207
+ proc.shape.addText(x, cy, proc.name_lines, {'font-size': 10});
2208
+ if(proc.hasActor) {
2209
+ const format = Object.assign({},
2210
+ this.io_formats[MODEL.ioType(proc.actor)],
2211
+ {'font-size': 10, fill: this.palette.actor_font,
2212
+ 'font-style': 'italic'});
2213
+ proc.shape.addText(x, cy + th + 6, proc.actor.name, format);
2214
+ }
2215
+ // Integer level is denoted by enclosing name in large [ and ]
2216
+ // to denote "floor" as well as "ceiling"
2217
+ if(proc.integer_level) {
2218
+ const
2219
+ htw = 0.5 * proc.bbox.width + 5,
2220
+ brh = proc.bbox.height + 4,
2221
+ brw = 3.5;
2222
+ proc.shape.addPath(['m', x - htw, ',', cy - 0.5*brh,
2223
+ 'l-', brw, ',0l0,', brh, 'l', brw, ',0'],
2224
+ {fill: 'none', stroke: 'gray', 'stroke-width': 1});
2225
+ proc.shape.addPath(['m', x + htw, ',', cy - 0.5*brh,
2226
+ 'l', brw, ',0l0,', brh, 'l-', brw, ',0'],
2227
+ {fill: 'none', stroke: 'gray', 'stroke-width': 1});
2228
+ }
2229
+ } // end IF not collapsed
2230
+ if(MODEL.show_block_arrows && !ignored) {
2231
+ // Add block arrows for hidden input and output links (no IO for processes)
2232
+ proc.shape.addBlockArrow(x - hw + 3, y - hh + 17, UI.BLOCK_IN,
2233
+ proc.hidden_inputs.length);
2234
+ proc.shape.addBlockArrow(x + hw - 4, y - hh + 17, UI.BLOCK_OUT,
2235
+ proc.hidden_outputs.length);
2236
+ }
2237
+ // Highlight shape if it has comments
2238
+ proc.shape.element.firstChild.setAttribute('style',
2239
+ (DOCUMENTATION_MANAGER.visible && proc.comments.length > 0 ?
2240
+ this.documented_filter : ''));
2241
+ proc.shape.element.setAttribute('opacity', 0.9);
2242
+ proc.shape.appendToDOM();
2243
+ }
2244
+
2245
+ drawProduct(prod, dx=0, dy=0) {
2246
+ // Clear previous drawing
2247
+ prod.shape.clear();
2248
+ // Do not draw product unless it has a position in the focal cluster
2249
+ let pp = prod.positionInFocalCluster;
2250
+
2251
+ if(!pp) return;
2252
+ // Set X and Y to correct value for this diagram
2253
+ prod.x = pp.x;
2254
+ prod.y = pp.y;
2255
+ let s,
2256
+ bb,
2257
+ pf = false,
2258
+ at_bound = false,
2259
+ ignored = MODEL.ignored_entities[prod.identifier],
2260
+ font_color = 'black',
2261
+ fill_color = 'white',
2262
+ rim_color,
2263
+ stroke_color,
2264
+ stroke_width,
2265
+ // Draw border as dashed line if product is data product
2266
+ sda = (prod.is_data ? UI.sda.dash : 'none'),
2267
+ first_commit_option = prod.needsFirstCommitData,
2268
+ x = prod.x + dx,
2269
+ y = prod.y + dy,
2270
+ hw = prod.width / 2,
2271
+ hh = prod.height / 2,
2272
+ cx,
2273
+ cy,
2274
+ lb = VM.MINUS_INFINITY,
2275
+ ub = VM.PLUS_INFINITY;
2276
+ if(prod.hasBounds) {
2277
+ if(prod.lower_bound.defined) {
2278
+ lb = prod.lower_bound.result(MODEL.t);
2279
+ }
2280
+ if(prod.equal_bounds) {
2281
+ ub = lb;
2282
+ } else if(prod.upper_bound.defined) {
2283
+ ub = prod.upper_bound.result(MODEL.t);
2284
+ }
2285
+ }
2286
+ // When model not solved, use initial level
2287
+ let l = prod.actualLevel(MODEL.t);
2288
+ if(!MODEL.solved && prod.initial_level.defined) {
2289
+ l = prod.initial_level.result(1);
2290
+ }
2291
+ if(first_commit_option) {
2292
+ // Set short-dashed rim if not committed yet at time t
2293
+ if(!MODEL.solved || prod.start_ups.length === 0 ||
2294
+ MODEL.t < prod.start_ups[0]) {
2295
+ sda = UI.sda.shorter_dash;
2296
+ } else {
2297
+ // Otherwise, set longer-dashed rim to denote "has been committed"
2298
+ sda = UI.sda.longer_dash;
2299
+ }
2300
+ }
2301
+ if(prod.selected) {
2302
+ stroke_color = this.palette.select;
2303
+ stroke_width = 2;
2304
+ } else {
2305
+ stroke_color = ignored ? this.palette.ignore :
2306
+ (prod.no_slack ? 'black' : this.palette.node_rim);
2307
+ // Thick rim if deleting this product only occurs in the focal cluster
2308
+ stroke_width = (prod.allLinksInCluster(MODEL.focal_cluster) ? 1.5 : 0.6);
2309
+ }
2310
+ if(prod.hasBounds) {
2311
+ font_color = 'black';
2312
+ // By default, "plain" factors having bounds are filled in silver
2313
+ fill_color = this.palette.has_bounds;
2314
+ // Use relative distance to bounds so that 100000.1 is not shown
2315
+ // as overflow, but 100.1 is
2316
+ let udif = this.relDif(l, ub),
2317
+ ldif = this.relDif(lb, l);
2318
+ // Special case: for LB = 0, use the ON/OFF threshold
2319
+ if(Math.abs(lb) <= VM.SIG_DIF_LIMIT &&
2320
+ Math.abs(l) <= VM.ON_OFF_THRESHOLD) ldif = 0;
2321
+ if(MODEL.solved) {
2322
+ // NOTE: use bright red and blue colors in case of "stock level out of bounds"
2323
+ if(ub < VM.PLUS_INFINITY && l < VM.UNDEFINED && udif > VM.SIG_DIF_LIMIT) {
2324
+ fill_color = this.palette.above_upper_bound;
2325
+ font_color = 'blue';
2326
+ } else if(lb > VM.MINUS_INFINITY && ldif > VM.SIG_DIF_LIMIT) {
2327
+ fill_color = this.palette.below_lower_bound;
2328
+ font_color = 'red';
2329
+ } else if(l < VM.ERROR || l > VM.EXCEPTION) {
2330
+ font_color = this.palette.VM_error;
2331
+ } else if(l < VM.UNDEFINED) {
2332
+ // Shades of green reflect whether level within bounds, where
2333
+ // "sources" (negative level) and "sinks" (positive level) are
2334
+ // shown as more reddish / bluish shades of green
2335
+ if(l < -VM.ON_OFF_THRESHOLD) {
2336
+ fill_color = this.palette.neg_within_bounds;
2337
+ } else if(l > VM.ON_OFF_THRESHOLD) {
2338
+ fill_color = this.palette.pos_within_bounds;
2339
+ } else {
2340
+ fill_color = this.palette.zero_within_bounds;
2341
+ }
2342
+ if(ub - lb < VM.NEAR_ZERO) {
2343
+ if(prod.isConstant && Math.abs(l) > VM.NEAR_ZERO) {
2344
+ // Non-zero constants have less saturated shades
2345
+ fill_color = (l < 0 ? this.palette.neg_constant :
2346
+ this.palette.pos_constant);
2347
+ }
2348
+ } else if(ub - l < VM.SIG_DIF_LIMIT) {
2349
+ // Black font and darker fill color indicate "at upper bound"
2350
+ font_color = 'black';
2351
+ fill_color = (ub > 0 ? this.palette.at_pos_ub_fill :
2352
+ (ub < 0 ? this.palette.at_neg_ub_fill :
2353
+ this.palette.at_zero_ub_fill));
2354
+ at_bound = true;
2355
+ } else if (l - lb < VM.SIG_DIF_LIMIT) {
2356
+ // Font and rim color indicate "at upper bound"
2357
+ font_color = 'black';
2358
+ fill_color = (lb > 0 ? this.palette.at_pos_lb_fill :
2359
+ (lb < 0 ? this.palette.at_neg_lb_fill :
2360
+ this.palette.at_zero_lb_fill));
2361
+ at_bound = true;
2362
+ } else {
2363
+ // set "partial fill" flag if not at lower bound and UB < INF
2364
+ pf = ub < VM.PLUS_INFINITY;
2365
+ font_color = this.palette.within_bounds_font;
2366
+ }
2367
+ }
2368
+ } else if(ub - lb < VM.NEAR_ZERO) {
2369
+ // Not solved but equal bounds => probably constants
2370
+ if(prod.isConstant && Math.abs(ub) > VM.NEAR_ZERO) {
2371
+ // Non-zero constants have less saturated shades
2372
+ fill_color = (ub < 0 ? this.palette.neg_constant :
2373
+ this.palette.pos_constant);
2374
+ }
2375
+ } else if(l < VM.UNDEFINED) {
2376
+ // Different bounds and initial level set => partial fill
2377
+ fill_color = this.palette.src_snk;
2378
+ pf = true;
2379
+ if(ub - l < VM.SIG_DIF_LIMIT || l - lb < VM.SIG_DIF_LIMIT) {
2380
+ at_bound = true;
2381
+ }
2382
+ }
2383
+ } else if(l < VM.UNDEFINED) {
2384
+ if(l > VM.SIG_DIF_FROM_ZERO) {
2385
+ if(l >= VM.PLUS_INFINITY) {
2386
+ fill_color = this.palette.above_upper_bound;
2387
+ font_color = 'blue';
2388
+ } else if(prod.isSinkNode) {
2389
+ fill_color = this.palette.positive_stock;
2390
+ font_color = this.palette.produced;
2391
+ } else {
2392
+ fill_color = this.palette.above_upper_bound;
2393
+ font_color = 'blue';
2394
+ }
2395
+ } else if(l < -VM.SIG_DIF_FROM_ZERO) {
2396
+ if(l <= VM.MINUS_INFINITY) {
2397
+ fill_color = this.palette.below_lower_bound;
2398
+ font_color = 'red';
2399
+ } else if(prod.isSourceNode) {
2400
+ fill_color = this.palette.negative_stock;
2401
+ font_color = this.palette.consumed;
2402
+ } else {
2403
+ fill_color = this.palette.below_lower_bound;
2404
+ font_color = 'red';
2405
+ }
2406
+ } else if(prod.is_buffer) {
2407
+ fill_color = 'silver';
2408
+ font_color = 'black';
2409
+ }
2410
+ }
2411
+ if(prod.is_buffer) {
2412
+ // Products with storage capacity show their partial fill
2413
+ pf = true;
2414
+ // Background fill color of buffers is white unless exceptional
2415
+ let npfbg = 'white';
2416
+ if(fill_color === this.palette.above_upper_bound ||
2417
+ fill_color === this.palette.below_lower_bound ||
2418
+ // NOTE: empty buffers (at level 0) should be entirely white
2419
+ (at_bound && l > VM.ON_OFF_THRESHOLD)) {
2420
+ npfbg = fill_color;
2421
+ pf = false;
2422
+ }
2423
+ // Products are displayed as "roundboxes" with sides that are full hemicircles
2424
+ prod.shape.addRect(x, y, 2*hw, 2*hh,
2425
+ {fill: npfbg, stroke: stroke_color, 'stroke-width': 3, rx: hh, ry: hh});
2426
+ // Draw thin white line insize thick border to suggest a double rim
2427
+ prod.shape.addRect(x, y, 2*hw, 2*hh, {fill: 'none', stroke: 'white',
2428
+ 'stroke-dasharray': sda, 'stroke-linecap': 'round', rx: hh, ry: hh});
2429
+ } else {
2430
+ prod.shape.addRect(x, y, 2*hw, 2*hh,
2431
+ {fill: fill_color, stroke: stroke_color, 'stroke-width': stroke_width,
2432
+ 'stroke-dasharray': sda, 'stroke-linecap': 'round',
2433
+ 'rx': hh, 'ry': hh});
2434
+ // NOTE: set fill color to darker shade for partial fill
2435
+ fill_color = (!MODEL.solved ? this.palette.src_snk :
2436
+ (l > VM.NEAR_ZERO ? this.palette.above_zero_fill :
2437
+ (l < -VM.NEAR_ZERO ? this.palette.below_zero_fill :
2438
+ this.palette.at_zero_fill)));
2439
+ }
2440
+ // Add partial fill if appropriate
2441
+ if(pf && l > lb && l < VM.UNDEFINED) {
2442
+ // Calculate used part of range (1 = 100%)
2443
+ let part,
2444
+ range = ub - lb;
2445
+ if(l >= VM.PLUS_INFINITY) {
2446
+ // Show exceptions and +INF as "overflow"
2447
+ part = 1;
2448
+ fill_color = this.palette.above_upper_bound;
2449
+ } else {
2450
+ part = (range > 0 ? (l - lb) / range : 1);
2451
+ }
2452
+ if(part > 0 && l >= lb) {
2453
+ // Only fill the portion of used range with the fill color
2454
+ const rad = Math.asin(1 - 2*part);
2455
+ prod.shape.addPath(['m', x + hw - hh + (hh - 1.5) * Math.cos(rad),
2456
+ ',', y + (hh - 1.5) * Math.sin(rad),
2457
+ this.arc(hh - 1.5, rad, Math.PI/2),
2458
+ 'l', 2*(hh - hw), ',0',
2459
+ this.arc(hh - 1.5, Math.PI/2, Math.PI-rad), 'z'],
2460
+ {fill: fill_color});
2461
+ }
2462
+ }
2463
+ fill_color = this.palette.src_snk;
2464
+ stroke_color = 'none';
2465
+ stroke_width = 0;
2466
+ // Sources have a triangle pointing up from the bottom
2467
+ // (in outline if *implicit* source)
2468
+ if(prod.isSourceNode) {
2469
+ if(!prod.is_source) {
2470
+ fill_color = 'none';
2471
+ stroke_color = this.palette.src_snk;
2472
+ stroke_width = 0.75;
2473
+ }
2474
+ prod.shape.addPath(['m', x, ',', y, 'l', 0.44*hw, ',', hh-1.5,
2475
+ 'l-', 0.88*hw, ',0z'], {fill: fill_color, stroke: stroke_color,
2476
+ 'stroke-width': stroke_width});
2477
+ }
2478
+ // Sinks have a triangle pointing down from the top
2479
+ // (in outline if implicit sink)
2480
+ if(prod.isSinkNode) {
2481
+ if(!prod.is_sink) {
2482
+ fill_color = 'none';
2483
+ stroke_color = this.palette.src_snk;
2484
+ stroke_width = 0.75;
2485
+ }
2486
+ prod.shape.addPath(['m', x, ',', y, 'l', 0.44*hw, ',-', hh-1.5,
2487
+ 'l-', 0.88*hw, ',0z'], {fill: fill_color, stroke: stroke_color,
2488
+ 'stroke-width': stroke_width});
2489
+ }
2490
+ // Integer level is denoted by enclosing name in large [ and ]
2491
+ // to denote "floor" as well as "ceiling"
2492
+ if(prod.integer_level) {
2493
+ const
2494
+ brh = prod.name_lines.split('\n').length * this.font_heights[8] + 4,
2495
+ brw = 3.5;
2496
+ prod.shape.addPath(['m', x - 0.5*(hw + brw), ',', y - 0.5*brh - 2,
2497
+ 'l-', brw, ',0l0,', brh, 'l', brw, ',0'],
2498
+ {fill: 'none', stroke: 'gray', 'stroke-width': 1});
2499
+ prod.shape.addPath(['m', x + 0.5*(hw + brw), ',', y - 0.5*brh - 2,
2500
+ 'l', brw, ',0l0,', brh, 'l-', brw, ',0'],
2501
+ {fill: 'none', stroke: 'gray', 'stroke-width': 1});
2502
+ }
2503
+
2504
+ let hlh = 0,
2505
+ lw = 0,
2506
+ lx = x + hw - 3;
2507
+ if(!ignored && (MODEL.solved || (l > 0 && l < VM.EXCEPTION))) {
2508
+ // Write the stock level in the right semicircle
2509
+ s = VM.sig4Dig(l);
2510
+ bb = this.numberSize(s, 9, 700);
2511
+ lw = bb.width;
2512
+ hlh = bb.height/2 + 1;
2513
+ const attr = {'font-size': 9, 'text-anchor': 'end'};
2514
+ // NOTE: use anchor to align the stock level text to the right side
2515
+ if(l <= VM.ERROR) {
2516
+ attr.fill = this.palette.VM_error;
2517
+ } else {
2518
+ attr.fill = font_color;
2519
+ attr['font-weight'] = 700;
2520
+ if(at_bound) attr['text-decoration'] = 'solid black underline';
2521
+ }
2522
+ prod.shape.addNumber(lx, y - hlh, s, attr);
2523
+ }
2524
+ if(MODEL.solved && !ignored) {
2525
+ if(MODEL.infer_cost_prices) {
2526
+ // Write the cost price at bottom-right in a light-yellow, slightly
2527
+ // rounded box. NOTE: for products with storage, display the STOCK price
2528
+ // rather than the cost price
2529
+ const cp = (prod.is_buffer ? prod.stockPrice(MODEL.t) :
2530
+ prod.costPrice(MODEL.t));
2531
+ s = VM.sig4Dig(cp);
2532
+ if(prod.noInflows(MODEL.t) && !(prod.is_buffer && (l > 0))) {
2533
+ // Display cost price less prominently if the product is not produced
2534
+ font_color = 'silver';
2535
+ fill_color = this.palette.virtual_cost_price;
2536
+ } else {
2537
+ font_color = 'black';
2538
+ fill_color = this.palette.cost_price;
2539
+ }
2540
+ bb = this.numberSize(s);
2541
+ prod.shape.addRect(x - hw + hh + 7, y + hh - bb.height/2 - 1,
2542
+ bb.width+1, bb.height, {fill: fill_color, stroke: this.palette.node_rim,
2543
+ 'stroke-width': 0.25, rx: 1.5, ry: 1.5});
2544
+ prod.shape.addNumber(x - hw + hh + 7, y + hh - bb.height/2 - 1, s,
2545
+ {fill: font_color});
2546
+ }
2547
+ }
2548
+
2549
+ // Write the product scale unit in the right semicircle UNDER the stock level
2550
+ const us = prod.scale_unit;
2551
+ // do not show 1 as it denotes "no unit"
2552
+ if(us != '1') {
2553
+ const uw = this.textSize(us).width;
2554
+ // Add a right margin to the unit if it is narrower than the stock level
2555
+ const ux = lx - Math.max((lw - uw)/2, 0);
2556
+ prod.shape.addText(ux, y + hlh, us,
2557
+ {fill: this.palette.unit, 'text-anchor': 'end'});
2558
+ }
2559
+
2560
+ // If market price is non-zero, write it at bottom-right in a gold box...
2561
+ const
2562
+ mp = prod.price.result(MODEL.t),
2563
+ // Italics denote "price is dynamic"
2564
+ pfs = (prod.price.isStatic ? 'normal' : 'italic');
2565
+ if((Math.abs(mp) - VM.NEAR_ZERO > 0) && (mp < VM.UNDEFINED)) {
2566
+ s = VM.sig4Dig(mp);
2567
+ if(mp > 0) {
2568
+ font_color = 'black';
2569
+ fill_color = this.palette.price;
2570
+ rim_color = this.palette.price_rim;
2571
+ } else {
2572
+ // ... or in a black box if the price is negative
2573
+ font_color = 'white';
2574
+ fill_color = 'black';
2575
+ rim_color = this.palette.node_rim;
2576
+ }
2577
+ bb = this.numberSize(s);
2578
+ prod.shape.addRect(x + hw - hh - 7, y + hh - bb.height/2 - 1,
2579
+ bb.width+1, bb.height, {fill: fill_color, stroke: rim_color,
2580
+ 'stroke-width': 0.25, rx: 1.5, ry: 1.5});
2581
+ prod.shape.addNumber(x + hw - hh - 7, y + hh - bb.height/2 - 1,
2582
+ s, {fill: font_color, 'font-style': pfs});
2583
+ }
2584
+
2585
+ // Bounds are displayed on the left
2586
+ // NOTE: their expressions should have been computed
2587
+ if(prod.hasBounds) {
2588
+ // Display bounds in bold face if no slack, and italic if not static
2589
+ const
2590
+ fw = (prod.no_slack ? 700 : 400),
2591
+ lbfs = (prod.lower_bound.isStatic ? 'normal' : 'italic'),
2592
+ ubfs = (prod.upper_bound.isStatic ? 'normal' : 'italic');
2593
+ cx = x - hw + 2;
2594
+ cy = y;
2595
+ if((ub < VM.PLUS_INFINITY || prod.upper_bound.defined) &&
2596
+ (lb > VM.MINUS_INFINITY)) {
2597
+ const dif = (ub - lb) / (ub > VM.NEAR_ZERO ? ub : 1);
2598
+ if(Math.abs(dif) < VM.SIG_DIF_LIMIT) {
2599
+ s = '=' + VM.sig4Dig(ub);
2600
+ } else {
2601
+ cy -= 5;
2602
+ s = '\u2264' + VM.sig4Dig(ub); // Unicode for LE
2603
+ // NOTE: use anchor to align text to the left side
2604
+ prod.shape.addNumber(cx, cy, s, {fill:'black', 'text-anchor':'start',
2605
+ 'font-weight':fw, 'font-style':ubfs});
2606
+ cy += 10;
2607
+ s = '\u2265' + VM.sig4Dig(lb); // Unicode for GE
2608
+ }
2609
+ } else {
2610
+ // NOTE: also display special values (>> -1e+40) when bound expression
2611
+ // is not empty
2612
+ if(ub < VM.PLUS_INFINITY || prod.upper_bound.defined) {
2613
+ s = '\u2264' + VM.sig4Dig(ub); // Unicode for LE
2614
+ } else if(lb > VM.MINUS_INFINITY) {
2615
+ s = '\u2265' + VM.sig4Dig(lb); // Unicode for GE
2616
+ }
2617
+ }
2618
+ // NOTE: use anchor to align text to the left side
2619
+ prod.shape.addNumber(cx, cy, s, {fill: 'black', 'text-anchor': 'start',
2620
+ 'font-weight': fw, 'font-style': lbfs});
2621
+ }
2622
+
2623
+ // ALWAYS draw product name
2624
+ // NOTE: import/export products have a dotted underscore; export in bold,
2625
+ // import in oblique
2626
+ prod.shape.addText(x, y - 3, prod.name_lines,
2627
+ this.io_formats[MODEL.ioType(prod)]);
2628
+ if(MODEL.show_block_arrows && !ignored) {
2629
+ // Add block arrows for hidden input and output links (no IO for products)
2630
+ prod.shape.addBlockArrow(x - hw + 7, y - hh/2 - 3,
2631
+ UI.BLOCK_IN, prod.hidden_inputs.length);
2632
+ prod.shape.addBlockArrow(x + hw - 10, y - hh/2 - 3,
2633
+ UI.BLOCK_OUT, prod.hidden_outputs.length);
2634
+ }
2635
+ // Highlight shape if it has comments
2636
+ prod.shape.element.firstChild.setAttribute('style',
2637
+ (DOCUMENTATION_MANAGER.visible && prod.comments ?
2638
+ this.documented_filter : ''));
2639
+ prod.shape.element.setAttribute('opacity', 0.9);
2640
+ prod.shape.appendToDOM();
2641
+ }
2642
+
2643
+ drawCluster(clstr, dx=0, dy=0) {
2644
+ // Clear previous drawing
2645
+ clstr.shape.clear();
2646
+ // NOTE: do not draw cluster unless it is a node in the focal cluster
2647
+ if(MODEL.focal_cluster.sub_clusters.indexOf(clstr) < 0) return;
2648
+ const ignored = MODEL.ignored_entities[clstr.identifier];
2649
+ let stroke_color = (ignored ? this.palette.ignore : this.palette.node_rim),
2650
+ stroke_width = 1,
2651
+ shadow_width = 3,
2652
+ fill_color = 'white',
2653
+ font_color = 'black';
2654
+ if(clstr.selected) {
2655
+ stroke_color = this.palette.select;
2656
+ stroke_width = 2;
2657
+ }
2658
+ let w = clstr.width,
2659
+ h = clstr.height;
2660
+ if(clstr.collapsed) {
2661
+ w = 24;
2662
+ h = 24;
2663
+ }
2664
+ if(clstr.is_black_boxed) {
2665
+ fill_color = '#201828';
2666
+ font_color = 'white';
2667
+ } else if(clstr.black_box) {
2668
+ fill_color = '#504858';
2669
+ font_color = 'white';
2670
+ }
2671
+ // Clusters are displayed as squares having a shadow (having width sw = 3 pixels)
2672
+ const
2673
+ hw = w / 2,
2674
+ hh = h / 2,
2675
+ x = clstr.x + dx,
2676
+ y = clstr.y + dy;
2677
+ // Draw "shadows"
2678
+ clstr.shape.addPath(['m', x + hw - shadow_width, ',', y - hh + shadow_width,
2679
+ 'h', shadow_width, 'v', h - shadow_width,
2680
+ 'h-', w - shadow_width, 'v-', shadow_width,
2681
+ 'h', w - 2*shadow_width, 'z'],
2682
+ {fill:stroke_color, stroke:stroke_color, 'stroke-width':stroke_width});
2683
+ // Set fill color if slack used by some product contained by this cluster
2684
+ if(MODEL.t in clstr.slack_info) {
2685
+ const s = clstr.slack_info[MODEL.t];
2686
+ if(s.GE.length > 0) {
2687
+ fill_color = (s.LE.length > 0 ?
2688
+ // Show to-color gradient if both types of slack are used
2689
+ this.red_blue_gradient : this.palette.below_lower_bound);
2690
+ } else if(s.LE.length > 0) {
2691
+ fill_color = this.palette.above_upper_bound;
2692
+ }
2693
+ }
2694
+ // Draw frame
2695
+ clstr.shape.addPath(['m', x - hw, ',', y - hh,
2696
+ 'h', w - shadow_width,
2697
+ 'v', h - shadow_width, 'h-', w - shadow_width, 'z'],
2698
+ {fill: fill_color, stroke: stroke_color, 'stroke-width': stroke_width});
2699
+ if(clstr.ignore) {
2700
+ // Draw diagonal cross
2701
+ clstr.shape.addPath(['m', x - hw + 6, ',', y - hh + 6,
2702
+ 'l', w - 12 - shadow_width, ',', h - 12 - shadow_width,
2703
+ 'm', 12 - w + shadow_width, ',0',
2704
+ 'l', w - 12 - shadow_width, ',', 12 - h + shadow_width],
2705
+ {stroke: this.palette.ignore, 'stroke-width': 6,
2706
+ 'stroke-linecap': 'round'});
2707
+ }
2708
+ if(!clstr.collapsed) {
2709
+ // Draw text
2710
+ const
2711
+ lcnt = clstr.name_lines.split('\n').length,
2712
+ cy = (clstr.hasActor ? y - 12 / (lcnt + 1) : y);
2713
+ clstr.shape.addText(x, cy, clstr.name_lines,
2714
+ {fill:font_color, 'font-size':12});
2715
+ if(clstr.hasActor) {
2716
+ const
2717
+ th = lcnt * this.font_heights[12],
2718
+ anl = UI.stringToLineArray(clstr.actor.name, hw * 1.7, 12),
2719
+ format = Object.assign({},
2720
+ this.io_formats[MODEL.ioType(clstr.actor)],
2721
+ {'font-size': 12, fill: this.palette.actor_font,
2722
+ 'font-style': 'italic'});
2723
+ let any = cy + th/2 + 7;
2724
+ for(let i = 0; i < anl.length; i++) {
2725
+ clstr.shape.addText(x, any, anl[i], format);
2726
+ any += 12;
2727
+ }
2728
+ }
2729
+ }
2730
+ if(MODEL.show_block_arrows && !ignored) {
2731
+ // Add block arrows for hidden IO links
2732
+ clstr.shape.addBlockArrow(x - hw + 3, y - hh + 15, UI.BLOCK_IN,
2733
+ clstr.hidden_inputs.length);
2734
+ clstr.shape.addBlockArrow(x + hw - 4, y - hh + 15, UI.BLOCK_OUT,
2735
+ clstr.hidden_outputs.length);
2736
+ clstr.shape.addBlockArrow(x, y - hh, UI.BLOCK_IO,
2737
+ clstr.hidden_io.length);
2738
+ }
2739
+ // Highlight shape if it has comments
2740
+ clstr.shape.element.firstChild.setAttribute('style',
2741
+ (DOCUMENTATION_MANAGER.visible && clstr.comments ?
2742
+ this.documented_filter : ''));
2743
+ // Highlight cluster if it is the drop target for the selection
2744
+ if(clstr === this.target_cluster) {
2745
+ clstr.shape.element.setAttribute('style', this.target_filter);
2746
+ } else {
2747
+ clstr.shape.element.setAttribute('style', '');
2748
+ }
2749
+ clstr.shape.element.setAttribute('opacity', 0.9);
2750
+ clstr.shape.appendToDOM();
2751
+ }
2752
+
2753
+ drawNote(note, dx=0, dy=0) {
2754
+ // NOTE: call resize if text contains fields, as text determines size
2755
+ if(!note.parsed) note.parseFields();
2756
+ note.resize();
2757
+ const
2758
+ x = note.x + dx,
2759
+ y = note.y + dy,
2760
+ w = note.width,
2761
+ h = note.height;
2762
+ let stroke_color, stroke_width;
2763
+ if(note.selected) {
2764
+ stroke_color = this.palette.select;
2765
+ stroke_width = 1.6;
2766
+ } else {
2767
+ stroke_color = this.palette.note_rim;
2768
+ stroke_width = 0.6;
2769
+ }
2770
+ let clr = note.color.result(MODEL.t);
2771
+ if(clr < 0 || clr >= this.palette.note_fill.length) {
2772
+ clr = 0;
2773
+ } else {
2774
+ clr = Math.round(clr);
2775
+ }
2776
+ note.shape.clear();
2777
+ note.shape.addRect(x, y, w, h,
2778
+ {fill: this.palette.note_fill[clr], opacity: 0.75, stroke: stroke_color,
2779
+ 'stroke-width': stroke_width, rx: 4, ry: 4});
2780
+ note.shape.addRect(x, y, w-2, h-2,
2781
+ {fill: 'none', stroke: this.palette.note_band[clr], 'stroke-width': 1.5,
2782
+ rx: 3, ry: 3});
2783
+ note.shape.addText(x - w/2 + 4, y, note.lines,
2784
+ {fill: (clr === 5 ? 'black' : this.palette.note_font), 'text-anchor': 'start'});
2785
+ note.shape.appendToDOM();
2786
+ }
2787
+
2788
+ } // END of class Paper
2789
+