linny-r 1.4.6 → 1.5.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linny-r",
3
- "version": "1.4.6",
3
+ "version": "1.5.0",
4
4
  "description": "Executable graphical language with WYSIWYG editor for MILP models",
5
5
  "main": "server.js",
6
6
  "scripts": {
package/static/index.html CHANGED
@@ -1854,16 +1854,19 @@ NOTE: * and ? will be interpreted as wildcards"
1854
1854
  src="images/add.png"
1855
1855
  title="Add variable (Shift-click to add a new equation)">
1856
1856
  <img id="chart-variable-up-btn" class="btn disab"
1857
- src="images/up.png" style="margin-left: 4px"
1857
+ src="images/up.png"
1858
1858
  title="Move selected variable up in list">
1859
1859
  <img id="chart-variable-down-btn" class="btn disab"
1860
1860
  src="images/down.png"
1861
1861
  title="Move selected variable down in list">
1862
1862
  <img id="chart-edit-variable-btn" class="btn disab"
1863
- src="images/edit.png" style="margin-left: 4px"
1863
+ src="images/edit.png"
1864
+ title="Edit selected variable">
1865
+ <img id="chart-sort-variable-btn" class="btn disab"
1866
+ src="images/sort-not.png"
1864
1867
  title="Edit selected variable">
1865
1868
  <img id="chart-delete-variable-btn" class="btn disab"
1866
- src="images/remove.png" style="margin-left: 4px"
1869
+ src="images/remove.png"
1867
1870
  title="Delete selected variable">
1868
1871
  </div>
1869
1872
  </div>
@@ -1871,6 +1874,23 @@ NOTE: * and ? will be interpreted as wildcards"
1871
1874
  <table id="chart-variables-table">
1872
1875
  </table>
1873
1876
  </div>
1877
+ <div id="chart-sorting-menu">
1878
+ <img id="chart-sort-not-btn" class="btn enab"
1879
+ src="images/sort-not.png"
1880
+ title="Do not sort series data for selected variable">
1881
+ <img id="chart-sort-asc-btn" class="btn enab"
1882
+ src="images/sort-asc.png"
1883
+ title="Sort series data for selected variable in ascending order">
1884
+ <img id="chart-sort-asc-lead-btn" class="btn enab"
1885
+ src="images/sort-asc-lead.png"
1886
+ title="Sort chart data in ascending order for selected variable">
1887
+ <img id="chart-sort-desc-btn" class="btn enab"
1888
+ src="images/sort-desc.png"
1889
+ title="Sort series data for selected variable in descending order">
1890
+ <img id="chart-sort-desc-lead-btn" class="btn enab"
1891
+ src="images/sort-desc-lead.png"
1892
+ title="Sort chart data in descending order for selected variable">
1893
+ </div>
1874
1894
  </div>
1875
1895
  <div id="chart-display-panel">
1876
1896
  <!-- NOTE: the scroller scrolls when the container is "stretched" -->
@@ -2750,15 +2750,59 @@ td.equation-expression {
2750
2750
  width: 146px;
2751
2751
  display: inline-block;
2752
2752
  position: absolute;
2753
- left: 60px;
2753
+ left: 55px;
2754
2754
  top: -2px;
2755
2755
  }
2756
2756
 
2757
2757
  #chart-variable-buttons > img.btn {
2758
2758
  height: 18px;
2759
2759
  width: 18px;
2760
+ margin-left: 2px;
2761
+ }
2762
+
2763
+ #chart-sorting-menu {
2764
+ position: absolute;
2765
+ top: 89.5px;
2766
+ left: 153px;
2767
+ width: 19px;
2768
+ display: none;
2769
+ background-color: #f4f0f2;
2770
+ border: solid silver 1px;
2771
+ border-radius: 3px;
2772
+ padding: 1px;
2773
+ }
2774
+
2775
+ #chart-sorting-menu > img.btn {
2776
+ height: 18px;
2777
+ width: 18px;
2778
+ margin: 0px;
2779
+ }
2780
+
2781
+ td.vbl-asc,
2782
+ td.vbl-desc,
2783
+ td.vbl-asc-lead,
2784
+ td.vbl-desc-lead {
2785
+ color: #8000b0;
2760
2786
  }
2761
2787
 
2788
+ td.vbl-asc-lead,
2789
+ td.vbl-desc-lead {
2790
+ font-weight: bold;
2791
+ }
2792
+
2793
+ td.vbl-asc::after,
2794
+ td.vbl-asc-lead::after {
2795
+ content: ' \2B67';
2796
+ color: #b00080;
2797
+ }
2798
+
2799
+ td.vbl-desc::after,
2800
+ td.vbl-desc-lead::after {
2801
+ content: ' \2B68';
2802
+ color: #b00080;
2803
+ }
2804
+
2805
+
2762
2806
  #chart-variables {
2763
2807
  position: absolute;
2764
2808
  top: 113px;
@@ -298,6 +298,14 @@ class Controller {
298
298
  // NOTE: normalize to also accept letters with accents
299
299
  if(name === this.TOP_CLUSTER_NAME) return true;
300
300
  name = name.normalize('NFKD').trim();
301
+ if(name.startsWith('$')) {
302
+ const
303
+ parts = name.substring(1).split(' '),
304
+ flow = parts.shift(),
305
+ aid = this.nameToID(parts.join(' ')),
306
+ a = MODEL.actorByID(aid);
307
+ return a && ['IN', 'OUT', 'FLOW'].indexOf(flow) >= 0;
308
+ }
301
309
  return name && !name.match(/\[\\\|\]/) && !name.endsWith(':') &&
302
310
  (name.startsWith(this.BLACK_BOX) || name[0].match(/[\w]/));
303
311
  }
@@ -714,34 +722,44 @@ class DatasetManager {
714
722
  } // END of class DatasetManager
715
723
 
716
724
 
717
- // CLASS ChartManager controls the collection of charts of a model
725
+ // CLASS ChartManager controls the collection of charts of a model.
718
726
  class ChartManager {
719
727
  constructor() {
720
728
  this.new_chart_title = '(new chart)';
721
- // NOTE: The SVG height is fixed at 500 units, as this gives good results
722
- // for the SVG units for line width = 1; fill patterns definitions are
723
- // defined to work for images of this height (see further down)
729
+ // NOTE: The SVG height is fixed at 500 units, as this gives good
730
+ // results for the SVG units for line width = 1.
731
+ // Fill patterns definitions are defined to work for images of this
732
+ // height (see further down).
724
733
  this.svg_height = 500;
725
734
  this.container_height = this.svg_height;
726
- // Default aspect ratio W:H is 1.75 -- stretch factor will make it more oblong
735
+ // Default aspect ratio W:H is 1.75. The stretch factor of the chart
736
+ // manager will make the chart more oblong.
727
737
  this.container_width = this.svg_height * 1.75;
728
738
  this.legend_options = ['None', 'Top', 'Right', 'Bottom'];
729
- // Basic properties -- also needed for console application
739
+ // Basic properties -- also needed for console application.
730
740
  this.visible = false;
731
741
  this.chart_index = -1;
732
742
  this.variable_index = -1;
733
743
  this.stretch_factor = 1;
734
744
  this.drawing_graph = false;
735
745
  this.runs_chart = false;
736
- // Fill styles used to differentiate between experiments in histograms
746
+ // Arrows indicating sort direction.
747
+ this.sort_arrows = {
748
+ 'not' : '',
749
+ 'asc': ' \u2B67',
750
+ 'desc': ' \u2B68',
751
+ 'asc-lead': ' \u21D7',
752
+ 'desc-lead': ' \u21D8'
753
+ };
754
+ // Fill styles used to differentiate between experiments in histograms.
737
755
  this.fill_styles = [
738
756
  'diagonal-cross-hatch', 'dots',
739
757
  'diagonal-hatch', 'checkers', 'horizontal-hatch',
740
758
  'cross-hatch', 'circles', 'vertical-hatch'
741
759
  ];
742
760
 
743
- // SVG for chart fill patterns
744
- // NOTE: mask width and height are based on SVG height = 500
761
+ // SVG for chart fill patterns.
762
+ // NOTE: Mask width and height are based on SVG height = 500.
745
763
  this.fill_patterns = `
746
764
  <pattern id="vertical-hatch" width="4" height="4"
747
765
  patternUnits="userSpaceOnUse">
@@ -79,8 +79,25 @@ class GUIChartManager extends ChartManager {
79
79
  'click', () => CHART_MANAGER.moveVariable(1));
80
80
  document.getElementById('chart-edit-variable-btn').addEventListener(
81
81
  'click', () => CHART_MANAGER.editVariable());
82
+ document.getElementById('chart-sort-variable-btn').addEventListener(
83
+ 'mouseenter', () => CHART_MANAGER.showSortingMenu());
82
84
  document.getElementById('chart-delete-variable-btn').addEventListener(
83
85
  'click', () => CHART_MANAGER.deleteVariable());
86
+ // Make the sorting menu responsive.
87
+ this.sorting_menu = document.getElementById('chart-sorting-menu');
88
+ this.sorting_menu.addEventListener(
89
+ 'mouseleave', () => CHART_MANAGER.hideSortingMenu());
90
+ document.getElementById('chart-sort-not-btn').addEventListener(
91
+ 'click', (e) => CHART_MANAGER.setSortType(e.target));
92
+ document.getElementById('chart-sort-asc-btn').addEventListener(
93
+ 'click', (e) => CHART_MANAGER.setSortType(e.target));
94
+ document.getElementById('chart-sort-asc-lead-btn').addEventListener(
95
+ 'click', (e) => CHART_MANAGER.setSortType(e.target));
96
+ document.getElementById('chart-sort-desc-btn').addEventListener(
97
+ 'click', (e) => CHART_MANAGER.setSortType(e.target));
98
+ document.getElementById('chart-sort-desc-lead-btn').addEventListener(
99
+ 'click', (e) => CHART_MANAGER.setSortType(e.target));
100
+ // Add properties for access to other chart manager dialog elements.
84
101
  this.variables_table = document.getElementById('chart-variables-table');
85
102
  this.display_panel = document.getElementById('chart-display-panel');
86
103
  this.toggle_chevron = document.getElementById('chart-toggle-chevron');
@@ -188,6 +205,7 @@ class GUIChartManager extends ChartManager {
188
205
  this.setRunsChart(false);
189
206
  this.last_time_selected = 0;
190
207
  this.paste_color = '';
208
+ this.hideSortingMenu();
191
209
  }
192
210
 
193
211
  enterKey() {
@@ -336,17 +354,27 @@ class GUIChartManager extends ChartManager {
336
354
  '<td class="v-box"><div id="v-box-', i, '" class="vbox',
337
355
  (cv.visible ? ' checked' : ' clear'),
338
356
  '" onclick="CHART_MANAGER.toggleVariable(', i,
339
- ');"></div></td><td class="v-name">', cv.displayName,
357
+ ');"></div></td><td class="v-name vbl-', cv.sorted,
358
+ '">', cv.displayName,
340
359
  '</td></tr>'].join(''));
341
360
  }
342
361
  this.variables_table.innerHTML = ol.join('');
343
362
  } else {
344
363
  this.variable_index = -1;
345
364
  }
365
+ // Set the image of the sort type button.
366
+ if(this.variable_index >= 0) {
367
+ const
368
+ cv = c.variables[this.variable_index],
369
+ sb = document.getElementById('chart-sort-variable-btn'),
370
+ mb = document.getElementById(`chart-sort-${cv.sorted}-btn`);
371
+ sb.src = `images/sort-${cv.sorted}.png`;
372
+ sb.title = mb.title;
373
+ }
346
374
  const
347
375
  u_btn = 'chart-variable-up ',
348
376
  d_btn = 'chart-variable-down ',
349
- ed_btns = 'chart-edit-variable chart-delete-variable ';
377
+ ed_btns = 'chart-edit-variable chart-sort-variable chart-delete-variable ';
350
378
  // Just in case variable index has not been adjusted after some
351
379
  // variables have been deleted
352
380
  if(this.variable_index >= c.variables.length) {
@@ -378,8 +406,35 @@ class GUIChartManager extends ChartManager {
378
406
  this.stretchChart(0);
379
407
  }
380
408
 
409
+ showSortingMenu() {
410
+ // Show the pane with sort type buttons only if variable is selected.
411
+ this.sorting_menu.style.display =
412
+ (this.variable_index >= 0 ? 'block' : 'none');
413
+ }
414
+
415
+ hideSortingMenu() {
416
+ // Hide the pane with sort type buttons.
417
+ this.sorting_menu.style.display = 'none';
418
+ }
419
+
420
+ setSortType(btn) {
421
+ // Set the sort type for the selected chart variable.
422
+ if(this.chart_index < 0 || this.variable_index < 0) return;
423
+ const
424
+ c = MODEL.charts[this.chart_index],
425
+ cv = c.variables[this.variable_index],
426
+ parts = btn.id.split('-');
427
+ parts.shift();
428
+ parts.shift();
429
+ parts.pop();
430
+ cv.sorted = parts.join('-');
431
+ this.hideSortingMenu();
432
+ this.updateDialog();
433
+ }
434
+
381
435
  updateExperimentInfo() {
382
- // Display selected experiment title in dialog header if run data are used
436
+ // Display selected experiment title in dialog header if run data
437
+ // are used.
383
438
  const
384
439
  selx = EXPERIMENT_MANAGER.selected_experiment,
385
440
  el = document.getElementById('chart-experiment-info');
@@ -498,7 +553,7 @@ class GUIChartManager extends ChartManager {
498
553
  cv = c.variables[i],
499
554
  nv = new ChartVariable(nc);
500
555
  nv.setProperties(cv.object, cv.attribute, cv.stacked,
501
- cv.color, cv.scale_factor, cv.line_width);
556
+ cv.color, cv.scale_factor, cv.line_width, cv.sorted);
502
557
  nc.variables.push(nv);
503
558
  }
504
559
  this.chart_index = MODEL.indexOfChart(nc.title);
@@ -649,7 +704,7 @@ class GUIChartManager extends ChartManager {
649
704
  this.variable_modal.element('color').style.backgroundColor = cv.color;
650
705
  this.setColorPicker(cv.color);
651
706
  // Show change equation buttons only for equation variables
652
- if(cv.object === MODEL.equations_dataset) {
707
+ if(cv.object === MODEL.equations_dataset || cv.object instanceof DatasetModifier) {
653
708
  this.change_equation_btns.style.display = 'block';
654
709
  } else {
655
710
  this.change_equation_btns.style.display = 'none';
@@ -741,7 +796,7 @@ class GUIChartManager extends ChartManager {
741
796
  // Renames the selected variable (if it is an equation)
742
797
  if(this.chart_index >= 0 && this.variable_index >= 0) {
743
798
  const v = MODEL.charts[this.chart_index].variables[this.variable_index];
744
- if(v.object === MODEL.equations_dataset) {
799
+ if(v.object === MODEL.equations_dataset || v.object instanceof DatasetModifier) {
745
800
  const m = MODEL.equations_dataset.modifiers[UI.nameToID(v.attribute)];
746
801
  if(m instanceof DatasetModifier) {
747
802
  EQUATION_MANAGER.selected_modifier = m;
@@ -755,7 +810,7 @@ class GUIChartManager extends ChartManager {
755
810
  // Opens the expression editor for the selected variable (if equation)
756
811
  if(this.chart_index >= 0 && this.variable_index >= 0) {
757
812
  const v = MODEL.charts[this.chart_index].variables[this.variable_index];
758
- if(v.object === MODEL.equations_dataset) {
813
+ if(v.object === MODEL.equations_dataset || v.object instanceof DatasetModifier) {
759
814
  const m = MODEL.equations_dataset.modifiers[UI.nameToID(v.attribute)];
760
815
  if(m instanceof DatasetModifier) {
761
816
  EQUATION_MANAGER.selected_modifier = m;
@@ -168,6 +168,8 @@ class GroupPropertiesDialog extends ModalDialog {
168
168
  this.initial[name] = prop;
169
169
  if(token === 'bbtn') {
170
170
  el.className = (prop ? 'bbtn eq' : 'bbtn ne');
171
+ // NOTE: Update required to enable or disable UB field.
172
+ UI.updateEqualBounds(obj.type.toLowerCase());
171
173
  } else if(token === 'box') {
172
174
  el.className = (prop ? 'box checked' : 'box clear');
173
175
  } else if(propname === 'share_of_cost') {
@@ -1811,7 +1813,7 @@ class GUIController extends Controller {
1811
1813
  setTimeout(() => {
1812
1814
  const md = UI.modals['add-process'];
1813
1815
  md.element('name').value = '';
1814
- md.element('actor-name').value = '';
1816
+ md.element('actor').value = '';
1815
1817
  md.show('name');
1816
1818
  });
1817
1819
  } else if(obj === 'product') {
@@ -2761,7 +2763,44 @@ class GUIController extends Controller {
2761
2763
  } else {
2762
2764
  md = this.modals['add-product'];
2763
2765
  nn = md.element('name').value;
2764
- if(!this.validNames(nn)) {
2766
+ // NOTE: As of version 1.5, actor cash IN, chash OUT and cash FLOW
2767
+ // can be added as special data products. These products are indicated
2768
+ // by a leading dollar sign or euro sign, followed by an acceptable
2769
+ // flow indicator (I, O, F, CI, CO, CF, IN, OUT, FLOW), or none to
2770
+ // indicate the default "cash flow", followed by the name of an
2771
+ // actor already defined in the model.
2772
+ if(nn.startsWith('$') || nn.startsWith('\u20AC')) {
2773
+ const
2774
+ valid = {
2775
+ 'i': 'IN',
2776
+ 'ci': 'IN',
2777
+ 'in': 'IN',
2778
+ 'o': 'OUT',
2779
+ 'co': 'OUT',
2780
+ 'out': 'OUT',
2781
+ 'f': 'FLOW',
2782
+ 'cf': 'FLOW',
2783
+ 'flow': 'FLOW'
2784
+ },
2785
+ parts = nn.substring(1).trim().split(' ');
2786
+ let flow = valid[parts[0].toLowerCase()];
2787
+ if(flow === undefined) flow = '';
2788
+ // If first part indicates flow type, trim it from the name parts.
2789
+ if(flow) parts.shift();
2790
+ // Now the parts should identify an actor; this may be (no actor).
2791
+ const
2792
+ aid = this.nameToID(parts.join(' ').trim() || this.NO_ACTOR),
2793
+ a = MODEL.actorByID(aid);
2794
+ if(a) {
2795
+ // If so, and no flow type, assume the default (cash FLOW).
2796
+ if(!flow) flow = 'FLOW';
2797
+ // Change name to canonical.form, i.e., like "$FLOW actor name"
2798
+ nn = `$${flow} ${a.name}`;
2799
+ }
2800
+ }
2801
+ // Test if name is valid.
2802
+ const vn = this.validName(nn);
2803
+ if(!vn) {
2765
2804
  UNDO_STACK.pop();
2766
2805
  return false;
2767
2806
  }
@@ -2770,8 +2809,13 @@ class GUIController extends Controller {
2770
2809
  n = MODEL.addProduct(nn);
2771
2810
  if(n) {
2772
2811
  if(pp) {
2773
- // Do not change unit or data type of existing product
2812
+ // Do not change unit or data type of existing product.
2774
2813
  this.notify(`Added existing product <em>${pp.displayName}</em>`);
2814
+ } else if(nn.startsWith('$')) {
2815
+ // Actor cash flow products must be data products, and
2816
+ // must have the model's currency unit as scale unit.
2817
+ n.scale_unit = MODEL.currency_unit;
2818
+ n.is_data = true;
2775
2819
  } else {
2776
2820
  n.scale_unit = MODEL.addScaleUnit(md.element('unit').value);
2777
2821
  n.is_data = this.boxChecked('add-product-data');
@@ -3658,6 +3702,12 @@ console.log('HERE name conflicts', name_conflicts, mapping);
3658
3702
  p.lower_bound)) return false;
3659
3703
  if(!this.updateExpressionInput('product-UB', 'upper bound',
3660
3704
  p.upper_bound)) return false;
3705
+ if(p.name.startsWith('$')) {
3706
+ // NOTE: For actor cash flow data products, price and initial
3707
+ // level must remain blank.
3708
+ md.element('P').value = '';
3709
+ md.element('IL').value = '';
3710
+ }
3661
3711
  if(!this.updateExpressionInput('product-IL', 'initial level',
3662
3712
  p.initial_level)) return false;
3663
3713
  if(!this.updateExpressionInput('product-P', 'market price',
@@ -3673,15 +3723,23 @@ console.log('HERE name conflicts', name_conflicts, mapping);
3673
3723
  }
3674
3724
  // At this point, all input has been validated, so entity properties
3675
3725
  // can be modified.
3676
- p.changeScaleUnit(md.element('unit').value);
3677
- p.equal_bounds = this.getEqualBounds('product-UB-equal');
3678
- p.is_source = this.boxChecked('product-source');
3679
- p.is_sink = this.boxChecked('product-sink');
3680
- // NOTE: Do not unset is_data if product has ingoing data arrows.
3681
- p.is_data = p.hasDataInputs || this.boxChecked('product-data');
3682
- p.is_buffer = this.boxChecked('product-stock');
3683
- p.integer_level = this.boxChecked('product-integer');
3726
+ if(!p.name.startsWith('$')) {
3727
+ // NOTE: For actor cash flow data products, these properties must
3728
+ // also retain their initial value.
3729
+ p.changeScaleUnit(md.element('unit').value);
3730
+ p.is_source = this.boxChecked('product-source');
3731
+ p.is_sink = this.boxChecked('product-sink');
3732
+ // NOTE: Do not unset `is_data` if product has ingoing data arrows.
3733
+ p.is_data = p.hasDataInputs || this.boxChecked('product-data');
3734
+ p.is_buffer = this.boxChecked('product-stock');
3735
+ // NOTE: Integer constraint will typically not work because cash
3736
+ // flows are scaled when setting up the Simplex tableau, and hence
3737
+ // the values of their decision variable will differ from their
3738
+ // level in the model.
3739
+ p.integer_level = this.boxChecked('product-integer');
3740
+ }
3684
3741
  p.no_slack = this.boxChecked('product-no-slack');
3742
+ p.equal_bounds = this.getEqualBounds('product-UB-equal');
3685
3743
  const pnl = p.no_links;
3686
3744
  p.no_links = this.boxChecked('product-no-links');
3687
3745
  let must_redraw = (pnl !== p.no_links);
@@ -226,6 +226,9 @@ NOTE: Grouping groups results in a single group, e.g., (1;2);(3;4;5) evaluates a
226
226
  // Clear the "shortcut flag" that may be set by Shift-clicking the
227
227
  // "add chart variable" button in the chart dialog
228
228
  EQUATION_MANAGER.add_to_chart = false;
229
+ // CLear other properties that relate to the edited expression.
230
+ this.edited_input_id = '';
231
+ this.edited_expression = null;
229
232
  }
230
233
 
231
234
  parseExpression() {
@@ -294,7 +294,10 @@ class Finder {
294
294
  for(let i = 0; i < n; i++) {
295
295
  const e = this.entities[i];
296
296
  // Exclude "no actor" and top cluster.
297
- if(e.name !== '(no_actor)' && e.name !== '(top_cluster)') {
297
+ if(e.name !== '(no_actor)' && e.name !== '(top_cluster)' &&
298
+ // Also exclude actor cash flow data products because
299
+ // many of their properties should not be changed.
300
+ !e.name.startsWith('$')) {
298
301
  eg.push(e);
299
302
  }
300
303
  }
@@ -2262,8 +2262,11 @@ class Paper {
2262
2262
  rim_color,
2263
2263
  stroke_color,
2264
2264
  stroke_width,
2265
- // Draw border as dashed line if product is data product
2266
- sda = (prod.is_data ? UI.sda.dash : 'none'),
2265
+ // Draw border as dashed line if product is data product, and
2266
+ // for actor cash flow data as dotted line.
2267
+ sda = (prod.is_data ?
2268
+ (prod.name.startsWith('$') ? UI.sda.dot : UI.sda.dash) :
2269
+ 'none'),
2267
2270
  first_commit_option = prod.needsFirstCommitData,
2268
2271
  x = prod.x + dx,
2269
2272
  y = prod.y + dy,
@@ -2736,15 +2739,22 @@ class Paper {
2736
2739
  clstr.shape.addBlockArrow(x, y - hh, UI.BLOCK_IO,
2737
2740
  clstr.hidden_io.length);
2738
2741
  }
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);
2742
+ if(clstr === UI.target_cluster) {
2743
+ // Highlight cluster if it is the drop target for the selection.
2744
+ clstr.shape.element.childNodes[0].setAttribute('style',
2745
+ this.target_filter);
2746
+ clstr.shape.element.childNodes[1].setAttribute('style',
2747
+ this.target_filter);
2748
+ } else if(DOCUMENTATION_MANAGER.visible && clstr.comments) {
2749
+ // Highlight shape if it has comments.
2750
+ clstr.shape.element.childNodes[0].setAttribute('style',
2751
+ this.documented_filter);
2752
+ clstr.shape.element.childNodes[1].setAttribute('style',
2753
+ this.documented_filter);
2746
2754
  } else {
2747
- clstr.shape.element.setAttribute('style', '');
2755
+ // No highlighting.
2756
+ clstr.shape.element.childNodes[0].setAttribute('style', '');
2757
+ clstr.shape.element.childNodes[1].setAttribute('style', '');
2748
2758
  }
2749
2759
  clstr.shape.element.setAttribute('opacity', 0.9);
2750
2760
  clstr.shape.appendToDOM();
@@ -728,17 +728,21 @@ class LinnyRModel {
728
728
 
729
729
  canLink(from, to) {
730
730
  // Return TRUE iff FROM-node can feature a "straight" link (i.e., a
731
- // product flow) to TO-node
731
+ // product flow) to TO-node.
732
732
  if(from.type === to.type) {
733
733
  // No "straight" link between nodes of same type (see canConstrain
734
- // for "curved" links) UNLESS TO-node is a data product
734
+ // for "curved" links) UNLESS TO-node is a data product.
735
735
  if(!to.is_data) return false;
736
736
  }
737
+ // No links to actor cash flow data products.
738
+ if(to.name.startsWith('$')) return false;
739
+ // No links from actor cash flow data to processes.
740
+ if(from.name.startsWith('$') && to instanceof Process) return false;
737
741
  // At most ONE link A --> B.
738
742
  for(let i = 0; i < from.outputs.length; i++) {
739
743
  if(from.outputs[i].to_node === to) return false;
740
744
  }
741
- // No link A --> B if there already exists a link B --> A
745
+ // No link A --> B if there already exists a link B --> A.
742
746
  for(let i = 0; i < to.outputs.length; i++) {
743
747
  if(to.outputs[i].to_node === from) return false;
744
748
  }
@@ -1188,6 +1192,7 @@ class LinnyRModel {
1188
1192
  // Product nodes have no actor
1189
1193
  let actor = this.addActor('');
1190
1194
  name = UI.cleanName(name);
1195
+ // Leading dollar sign indicates an actor cash flow data product.
1191
1196
  if(!UI.validName(name)) {
1192
1197
  UI.warningInvalidName(name);
1193
1198
  return null;
@@ -1211,6 +1216,12 @@ class LinnyRModel {
1211
1216
  if(node) p.initFromXML(node);
1212
1217
  p.setCode();
1213
1218
  this.products[p.identifier] = p;
1219
+ // NOTE: Cash flow products must be data, and must have the model's
1220
+ // currency unit as unit.
1221
+ if(p.name.startsWith('$')) {
1222
+ p.is_data = true;
1223
+ p.unit = MODEL.currency_unit;
1224
+ }
1214
1225
  p.resize();
1215
1226
  // New product => prepare for redraw
1216
1227
  p.cluster.clearAllProcesses();
@@ -2051,11 +2062,23 @@ class LinnyRModel {
2051
2062
  // that actually delete model elements append their XML to the XML attribute
2052
2063
  // of this UndoEdit
2053
2064
  let obj,
2054
- dc = false,
2055
2065
  fc = this.focal_cluster;
2056
2066
  // Update the documentation manager (GUI only) if selection contains the
2057
2067
  // current entity
2058
2068
  if(DOCUMENTATION_MANAGER) DOCUMENTATION_MANAGER.clearEntity(this.selection);
2069
+ // First delete links and constraints.
2070
+ for(let i = this.selection.length - 1; i >= 0; i--) {
2071
+ if(this.selection[i] instanceof Link ||
2072
+ this.selection[i] instanceof Constraint) {
2073
+ obj = this.selection.splice(i, 1)[0];
2074
+ if(obj instanceof Link) {
2075
+ this.deleteLink(obj);
2076
+ } else {
2077
+ this.deleteConstraint(obj);
2078
+ }
2079
+ }
2080
+ }
2081
+ // Then delete selected nodes.
2059
2082
  for(let i = this.selection.length - 1; i >= 0; i--) {
2060
2083
  obj = this.selection.splice(i, 1)[0];
2061
2084
  // NOTE: when deleting a selection, this selection has been made in the
@@ -2064,13 +2087,8 @@ class LinnyRModel {
2064
2087
  fc.deleteNote(obj);
2065
2088
  } else if(obj instanceof Product) {
2066
2089
  fc.deleteProduct(obj);
2067
- } else if(obj instanceof Link) {
2068
- this.deleteLink(obj);
2069
- } else if(obj instanceof Constraint) {
2070
- this.deleteConstraint(obj);
2071
2090
  } else if(obj instanceof Cluster) {
2072
2091
  this.deleteCluster(obj);
2073
- dc = true;
2074
2092
  } else {
2075
2093
  this.deleteNode(obj);
2076
2094
  }
@@ -7695,9 +7713,10 @@ class Product extends Node {
7695
7713
  }
7696
7714
 
7697
7715
  get isConstant() {
7698
- // Returns TRUE if this product is data, has no links to processes,
7699
- // and has set LB = UB
7700
- if(!this.is_data || !this.allOutputsAreData) return false;
7716
+ // Return TRUE if this product is data, has no links to processes,
7717
+ // is not an actor cash flow, and has set LB = UB
7718
+ if(!this.is_data || this.name.startsWith('$') ||
7719
+ !this.allOutputsAreData) return false;
7701
7720
  for(let i = 0; i < this.inputs.length; i++) {
7702
7721
  if(this.inputs[i].from_node instanceof Process) return false;
7703
7722
  }
@@ -8909,7 +8928,7 @@ class ChartVariable {
8909
8928
  this.wildcard_index = false;
8910
8929
  }
8911
8930
 
8912
- setProperties(obj, attr, stck, clr, sf=1, lw=1, vis=true) {
8931
+ setProperties(obj, attr, stck, clr, sf=1, lw=1, vis=true, sort='not') {
8913
8932
  // Sets the defining properties for this chart variable.
8914
8933
  this.object = obj;
8915
8934
  this.attribute = attr;
@@ -8918,6 +8937,7 @@ class ChartVariable {
8918
8937
  this.scale_factor = sf;
8919
8938
  this.line_width = lw;
8920
8939
  this.visible = vis;
8940
+ this.sorted = sort;
8921
8941
  }
8922
8942
 
8923
8943
  get displayName() {
@@ -8956,6 +8976,7 @@ class ChartVariable {
8956
8976
  }
8957
8977
  const xml = ['<chart-variable', (this.stacked ? ' stacked="1"' : ''),
8958
8978
  (this.visible ? ' visible="1"' : ''),
8979
+ ` sorted="${this.sorted}"`,
8959
8980
  '><object-id>', xmlEncoded(id),
8960
8981
  '</object-id><attribute>', this.attribute,
8961
8982
  '</attribute><color>', this.color,
@@ -9007,7 +9028,8 @@ class ChartVariable {
9007
9028
  nodeContentByTag(node, 'color'),
9008
9029
  safeStrToFloat(nodeContentByTag(node, 'scale-factor')),
9009
9030
  safeStrToFloat(nodeContentByTag(node, 'line-width')),
9010
- nodeParameterValue(node, 'visible') === '1');
9031
+ nodeParameterValue(node, 'visible') === '1',
9032
+ nodeParameterValue(node, 'sorted') || 'not');
9011
9033
  return true;
9012
9034
  }
9013
9035
 
@@ -9150,15 +9172,31 @@ class ChartVariable {
9150
9172
  }
9151
9173
 
9152
9174
  setLinePath(x0, y0, dx, dy) {
9153
- // Set SVG path unless already set or line not visible
9175
+ // Set SVG path unless already set or line not visible.
9154
9176
  if(this.line_path.length === 0 && this.visible) {
9177
+ // Vector may have to be sorted in some way.
9178
+ const vect = this.vector.slice();
9179
+ if(this.sorted === 'asc') {
9180
+ // Sort values in ascending order.
9181
+ vect.sort();
9182
+ } else if(this.sorted === 'desc') {
9183
+ // Sort values in descending order.
9184
+ vect.sort((a, b) => { return b - a; });
9185
+ } else if(this.chart.time_step_numbers) {
9186
+ // Fill vector with its values sorted by time step.
9187
+ const tsn = this.chart.time_step_numbers;
9188
+ for(let i = 0; i < this.vector.length; i++) {
9189
+ vect[i] = this.vector[tsn[i]];
9190
+ }
9191
+ }
9192
+ //
9155
9193
  let y = y0 - this.vector[0] * dy;
9156
9194
  const
9157
9195
  path = ['M', x0, ',', y],
9158
- l = this.vector.length;
9196
+ l = vect.length;
9159
9197
  // NOTE: Now we can use relative line coordinates
9160
9198
  for(let t = 1; t < l; t++) {
9161
- const new_y = y0 - this.vector[t] * dy;
9199
+ const new_y = y0 - vect[t] * dy;
9162
9200
  path.push(`v${new_y - y}h${dx}`);
9163
9201
  y = new_y;
9164
9202
  }
@@ -9369,6 +9407,37 @@ class Chart {
9369
9407
  }
9370
9408
  }
9371
9409
 
9410
+ sortLeadTimeVector() {
9411
+ // Set the time vector to NULL if no "lead-sort" varianles are
9412
+ // visible, or to a list of N+1 time steps (with N the run length)
9413
+ // sorted on the values of the lead-sorted variables in their order
9414
+ // of appearance in the chart.
9415
+ this.time_step_numbers = null;
9416
+ const
9417
+ lsv = [],
9418
+ lss = [];
9419
+ for(let i = 0; i < this.variables.length; i++) {
9420
+ const cv = this.variables[i];
9421
+ if(cv.visible && cv.sorted.endsWith('-lead')) {
9422
+ lsv.push(cv.vector);
9423
+ lss.push(cv.sorted.startsWith('asc') ? 1 : -1);
9424
+ }
9425
+ }
9426
+ // If no "lead sort" variables, then no need to set the time steps.
9427
+ if(lsv.length === 0) return;
9428
+ this.time_step_numbers = Array.apply(null,
9429
+ {length: this.total_time_steps + 1}).map(Number.call, Number);
9430
+ this.time_step_numbers.sort((a, b) => {
9431
+ let c = 0;
9432
+ for(let i = 0; c === 0 && i < lsv.length; i++) {
9433
+ // Multiply by `lss` (lead sort sign), which will be + 1 for
9434
+ // ascending order and -1 for descending order.
9435
+ c = (lsv[i][a] - lsv[i][b]) * lss[i];
9436
+ }
9437
+ return c;
9438
+ });
9439
+ }
9440
+
9372
9441
  resetVectors() {
9373
9442
  // Empties the vector arrays of all variables
9374
9443
  for(let i = 0; i < this.variables.length; i++) {
@@ -9506,14 +9575,15 @@ class Chart {
9506
9575
  }
9507
9576
  }
9508
9577
  if(this.legend_position != 'none') {
9509
- // Draw the legend items
9578
+ // Draw the legend items.
9510
9579
  let x = leg_left,
9511
- // NOTE: vertical text align is middle, so add half a font height
9580
+ // Vertical text align is middle, so add half a font height.
9512
9581
  y = leg_top + font_height;
9513
9582
  for(let i = 0; i < this.variables.length; i++) {
9514
9583
  let v = this.variables[i];
9515
9584
  if(v.visible) {
9516
- const vn = v.displayName;
9585
+ // Add arrow indicating sort direction to name if applicable.
9586
+ const vn = v.displayName + CHART_MANAGER.sort_arrows[v.sorted];
9517
9587
  if(v.stacked || this.histogram) {
9518
9588
  this.addSVG(['<rect x="', x, '" y="', y - sym_size + 2,
9519
9589
  '" width="', sym_size, '" height="', sym_size,
@@ -9662,6 +9732,9 @@ class Chart {
9662
9732
  }
9663
9733
  }
9664
9734
 
9735
+ // The time step vector is not used when making a histogram.
9736
+ if(!this.histogram) this.sortLeadTimeVector();
9737
+
9665
9738
  // Draw the grid rectangle
9666
9739
  this.addSVG(['<rect id="c_h_a_r_t__a_r_e_a__ID*" x="', rl, '" y="', rt,
9667
9740
  '" width="', rw, '" height="', rh,
@@ -9690,9 +9763,9 @@ class Chart {
9690
9763
  this.addText(x + 5, y, VM.sig2Dig(b), 'black', font_height,
9691
9764
  'text-anchor="end"');
9692
9765
  } else {
9693
- // Draw the time labels along the horizontal axis
9766
+ // Draw the time labels along the horizontal axis.
9694
9767
  // TO DO: convert to time units if modeler checks this additional option
9695
- // Draw the time step duration in bottom-left corner
9768
+ // Draw the time step duration in bottom-left corner.
9696
9769
  this.addText(1, y, 'dt = ' + this.timeScaleAsString(this.time_scale),
9697
9770
  'black', font_height, 'text-anchor="start"');
9698
9771
  // Calculate width corresponding to one half time step
@@ -9873,14 +9946,28 @@ class Chart {
9873
9946
  this.run_index = runs[0];
9874
9947
  v.computeVector();
9875
9948
  }
9949
+ // Vector may have to be sorted in some way.
9950
+ const vect = v.vector.slice();
9951
+ if(v.sorted === 'asc') {
9952
+ // Sort values in ascending order.
9953
+ vect.sort();
9954
+ } else if(v.sorted === 'desc') {
9955
+ // Sort values in descending order.
9956
+ vect.sort((a, b) => { return b - a; });
9957
+ } else if(this.time_step_numbers) {
9958
+ // Fill vector with its values sorted by time step.
9959
+ for(let i = 0; i < v.vector.length; i++) {
9960
+ vect[i] = v.vector[this.time_step_numbers[i]];
9961
+ }
9962
+ }
9876
9963
  // NOTE: add x-value to x0, but SUBTRACT y-value from y0!
9877
9964
  x = x0;
9878
- y = y0 - v.vector[0]*dy - offset[0];
9965
+ y = y0 - vect[0]*dy - offset[0];
9879
9966
  // Begin with the top contour
9880
9967
  const path = ['M', x, ',', y];
9881
- for(let t = 1; t < v.vector.length; t++) {
9968
+ for(let t = 1; t < vect.length; t++) {
9882
9969
  // First draw line to the Y for time step t
9883
- y = y0 - (v.vector[t] + offset[t])*dy;
9970
+ y = y0 - (vect[t] + offset[t])*dy;
9884
9971
  path.push(`L${x},${y}`);
9885
9972
  // Then move right for the duration of time step t
9886
9973
  x += dx;
@@ -9890,13 +9977,13 @@ class Chart {
9890
9977
  // chart variable
9891
9978
  v.line_path = path.join('');
9892
9979
  // Now add the path for the bottom contour (= offset) ...
9893
- for(let t = v.vector.length - 1; t > 0; t--) {
9980
+ for(let t = vect.length - 1; t > 0; t--) {
9894
9981
  y = y0 - offset[t]*dy;
9895
9982
  path.push(`L${x},${y}`);
9896
9983
  x -= dx;
9897
9984
  path.push(`L${x},${y}`);
9898
9985
  // ... while computing the new offset
9899
- offset[t] += v.vector[t];
9986
+ offset[t] += vect[t];
9900
9987
  }
9901
9988
  // Draw the filled area with semi-transparent color
9902
9989
  this.addSVG(['<path d="', path.join(''),
@@ -780,13 +780,15 @@ function escapedSingleQuotes(s) {
780
780
  }
781
781
 
782
782
  function nameToLines(name, actor_name = '') {
783
- // Returns the name of a Linny-R entity as a string-with-line-breaks that
784
- // fits nicely in an oblong box. For efficiency reasons, a fixed width/height
785
- // ratio is assumed, as this produces quite acceptable results
783
+ // Return the name of a Linny-R entity as a string-with-line-breaks
784
+ // that fits nicely in an oblong box. For efficiency reasons, a fixed
785
+ // width/height ratio is assumed, as this produces quite acceptable
786
+ // results. Actor names are not split, so their length may stretch
787
+ // the node box.
786
788
  let m = actor_name.length;
787
789
  const
788
790
  d = Math.floor(Math.sqrt(0.3 * name.length)),
789
- // Do not wrap strings shorter than 13 characters (about 50 pixels)
791
+ // Do not wrap strings shorter than 13 characters (about 50 pixels).
790
792
  limit = Math.max(Math.ceil(name.length / d), m, 13),
791
793
  a = name.split(' ');
792
794
  // Split words at '-' when wider than limit
@@ -810,7 +812,12 @@ function nameToLines(name, actor_name = '') {
810
812
  let n = 0,
811
813
  l = ww[n],
812
814
  space;
813
- for(let i = 1; i < a.length; i++) {
815
+ // Actor cash flow indicators like $FLOW have their own line.
816
+ if(a[0].startsWith('$')) {
817
+ n++;
818
+ lines[n] = a[1];
819
+ }
820
+ for(let i = 1 + n; i < a.length; i++) {
814
821
  if(l + ww[i] < limit) {
815
822
  space = (lines[n].endsWith('-') ? '' : ' ');
816
823
  lines[n] += space + a[i];
@@ -3351,8 +3351,42 @@ class VirtualMachine {
3351
3351
  k = product_keys[pi];
3352
3352
  if(!MODEL.ignored_entities[k]) {
3353
3353
  p = MODEL.products[k];
3354
+ // NOTE: Actor cash flow data products are a special case.
3355
+ if(p.name.startsWith('$')) {
3356
+ // Get the associated actor entity.
3357
+ const parts = p.name.substring(1).split(' ');
3358
+ parts.shift();
3359
+ const
3360
+ aid = UI.nameToID(parts.join(' ')),
3361
+ a = MODEL.actorByID(aid);
3362
+ if(a) {
3363
+ this.code.push([VMI_clear_coefficients, null]);
3364
+ // Use actor's cash variable indices w/o weight.
3365
+ if(p.name.startsWith('$IN ')) {
3366
+ // Add coefficient +1 for cash IN index.
3367
+ this.code.push([VMI_add_const_to_coefficient,
3368
+ [a.cash_in_var_index, 1, 0]]);
3369
+ } else if(p.name.startsWith('$OUT ')) {
3370
+ // Add coefficient +1 for cash OUT index.
3371
+ this.code.push([VMI_add_const_to_coefficient,
3372
+ [a.cash_out_var_index, 1, 0]]);
3373
+ } else if(p.name.startsWith('$FLOW ')) {
3374
+ // Add coefficient +1 for cash IN index.
3375
+ this.code.push([VMI_add_const_to_coefficient,
3376
+ [a.cash_in_var_index, 1, 0]]);
3377
+ // Add coefficient -1 for cash OUT index.
3378
+ this.code.push([VMI_add_const_to_coefficient,
3379
+ [a.cash_out_var_index, -1, 0]]);
3380
+ }
3381
+ // Add coefficient -1 for level index variable of `p`.
3382
+ this.code.push([VMI_add_const_to_coefficient,
3383
+ [p.level_var_index, -1, 0]]);
3384
+ this.code.push([VMI_add_constraint, VM.EQ]);
3385
+ } else {
3386
+ console.log('ANOMALY: no actor for cash flow product', p.displayName);
3387
+ }
3354
3388
  // NOTE: constants are not affected by their outgoing data (!) links
3355
- if(!p.isConstant) {
3389
+ } else if(!p.isConstant) {
3356
3390
 
3357
3391
  // FIRST: add a constraint that "computes" the product stock level
3358
3392
  // set coefficients vector to 0 (NOTE: this also sets RHS to 0)