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.
- package/README.md +102 -48
- package/package.json +1 -1
- package/server.js +31 -6
- package/static/images/check-off-not-same-changed.png +0 -0
- package/static/images/check-off-not-same-not-changed.png +0 -0
- package/static/images/check-off-same-changed.png +0 -0
- package/static/images/check-off-same-not-changed.png +0 -0
- package/static/images/check-on-not-same-changed.png +0 -0
- package/static/images/check-on-not-same-not-changed.png +0 -0
- package/static/images/check-on-same-changed.png +0 -0
- package/static/images/check-on-same-not-changed.png +0 -0
- package/static/images/eq-not-same-changed.png +0 -0
- package/static/images/eq-not-same-not-changed.png +0 -0
- package/static/images/eq-same-changed.png +0 -0
- package/static/images/eq-same-not-changed.png +0 -0
- package/static/images/ne-not-same-changed.png +0 -0
- package/static/images/ne-not-same-not-changed.png +0 -0
- package/static/images/ne-same-changed.png +0 -0
- package/static/images/ne-same-not-changed.png +0 -0
- package/static/images/sort-asc-lead.png +0 -0
- package/static/images/sort-asc.png +0 -0
- package/static/images/sort-desc-lead.png +0 -0
- package/static/images/sort-desc.png +0 -0
- package/static/images/sort-not.png +0 -0
- package/static/index.html +51 -35
- package/static/linny-r.css +167 -53
- package/static/scripts/linny-r-gui-actor-manager.js +340 -0
- package/static/scripts/linny-r-gui-chart-manager.js +944 -0
- package/static/scripts/linny-r-gui-constraint-editor.js +681 -0
- package/static/scripts/linny-r-gui-controller.js +4005 -0
- package/static/scripts/linny-r-gui-dataset-manager.js +1176 -0
- package/static/scripts/linny-r-gui-documentation-manager.js +739 -0
- package/static/scripts/linny-r-gui-equation-manager.js +307 -0
- package/static/scripts/linny-r-gui-experiment-manager.js +1944 -0
- package/static/scripts/linny-r-gui-expression-editor.js +450 -0
- package/static/scripts/linny-r-gui-file-manager.js +392 -0
- package/static/scripts/linny-r-gui-finder.js +727 -0
- package/static/scripts/linny-r-gui-model-autosaver.js +230 -0
- package/static/scripts/linny-r-gui-monitor.js +448 -0
- package/static/scripts/linny-r-gui-paper.js +2789 -0
- package/static/scripts/linny-r-gui-receiver.js +323 -0
- package/static/scripts/linny-r-gui-repository-browser.js +819 -0
- package/static/scripts/linny-r-gui-scale-unit-manager.js +244 -0
- package/static/scripts/linny-r-gui-sensitivity-analysis.js +778 -0
- package/static/scripts/linny-r-gui-undo-redo.js +560 -0
- package/static/scripts/linny-r-model.js +34 -15
- package/static/scripts/linny-r-utils.js +11 -1
- package/static/scripts/linny-r-vm.js +21 -12
- 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
|
+
|