linny-r 1.4.2 → 1.4.3

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.
@@ -2789,13 +2789,15 @@ class Paper {
2789
2789
  } // END of class Paper
2790
2790
 
2791
2791
 
2792
- // CLASS ModalDialog provides basic modal dialog functionality
2792
+ // CLASS ModalDialog provides basic modal dialog functionality.
2793
2793
  class ModalDialog {
2794
2794
  constructor(id) {
2795
2795
  this.id = id;
2796
2796
  this.modal = document.getElementById(id + '-modal');
2797
2797
  this.dialog = document.getElementById(id + '-dlg');
2798
- // NOTE: dialog button properties will be `undefined` if not in the header
2798
+ // NOTE: Dialog title and button properties will be `undefined` if
2799
+ // not in the header DIV child of the dialog DIV element.
2800
+ this.title = this.dialog.getElementsByClassName('dlg-title')[0];
2799
2801
  this.ok = this.dialog.getElementsByClassName('ok-btn')[0];
2800
2802
  this.cancel = this.dialog.getElementsByClassName('cancel-btn')[0];
2801
2803
  this.info = this.dialog.getElementsByClassName('info-btn')[0];
@@ -3266,9 +3268,13 @@ class GUIController extends Controller {
3266
3268
  // The CHECK UPDATE dialog appears when a new version is detected
3267
3269
  this.check_update_modal = new ModalDialog('check-update');
3268
3270
  this.check_update_modal.ok.addEventListener('click',
3269
- () => UI.shutDownServer());
3271
+ () => UI.shutDownToUpdate());
3270
3272
  this.check_update_modal.cancel.addEventListener('click',
3271
- () => UI.check_update_modal.hide());
3273
+ () => UI.preventUpdate());
3274
+
3275
+ // The UPDATING modal appears when updating has started.
3276
+ // NOTE: This modal has no OK or CANCEL buttons.
3277
+ this.updating_modal = new ModalDialog('updating');
3272
3278
 
3273
3279
  // Add all draggable stay-on-top dialogs as controller properties
3274
3280
 
@@ -3399,7 +3405,7 @@ class GUIController extends Controller {
3399
3405
  }
3400
3406
 
3401
3407
  drawLinkArrows(cluster, link) {
3402
- // Draw all arrows in `cluster` that represent `link`
3408
+ // Draw all arrows in `cluster` that represent `link`.
3403
3409
  for(let i = 0; i < cluster.arrows.length; i++) {
3404
3410
  const a = cluster.arrows[i];
3405
3411
  if(a.links.indexOf(link) >= 0) this.paper.drawArrow(a);
@@ -3407,19 +3413,124 @@ class GUIController extends Controller {
3407
3413
  }
3408
3414
 
3409
3415
  shutDownServer() {
3410
- // Shut down -- this terminates the local host server script
3416
+ // This terminates the local host server script and display a plain
3417
+ // HTML message in the browser with a restart button.
3411
3418
  if(!SOLVER.user_id) window.open('./shutdown', '_self');
3412
3419
  }
3413
3420
 
3421
+ shutDownToUpdate() {
3422
+ // Sisgnal server that an update is required. This will close the
3423
+ // local host Linny-R server. If this server was started by the
3424
+ // standard OS batch script, this script will proceed to update
3425
+ // Linny-R via npm and then restart the server again. If not, the
3426
+ // fetch request will time out, anf the user will be warned.
3427
+ if(SOLVER.user_id) return;
3428
+ fetch('update/')
3429
+ .then((response) => {
3430
+ if(!response.ok) {
3431
+ UI.alert(`ERROR ${response.status}: ${response.statusText}`);
3432
+ }
3433
+ return response.text();
3434
+ })
3435
+ .then((data) => {
3436
+ if(UI.postResponseOK(data, true)) {
3437
+ UI.check_update_modal.hide();
3438
+ if(data.startsWith('Installing')) UI.waitToRestart();
3439
+ }
3440
+ })
3441
+ .catch((err) => {
3442
+ UI.warn(UI.WARNING.NO_CONNECTION, err);
3443
+ });
3444
+ }
3445
+
3446
+ waitToRestart() {
3447
+ // Shows the "update in progress" dialog and then fetches the current
3448
+ // version page from the server. Always wait for 5 seconds to permit
3449
+ // reading the text, and ensure that the server has been stopped.
3450
+ // Only then try to restart.
3451
+ if(SOLVER.user_id) return;
3452
+ UI.updating_modal.show();
3453
+ setTimeout(() => UI.tryToRestart(0), 5000);
3454
+ }
3455
+
3456
+ tryToRestart(trials) {
3457
+ // Fetch the current version number from the server. This may take
3458
+ // a wile, as the server was shut down and restarts only after npm
3459
+ // has updated the Linny-R software. Typically, this takes only a few
3460
+ // seconds, but the connection with the npm server may be slow.
3461
+ // Default timeout on Firefox (90 seconds) and Chrome (300 seconds)
3462
+ // should amply suffice, though, hence no provision for a second attempt.
3463
+ fetch('version/')
3464
+ .then((response) => {
3465
+ if(!response.ok) {
3466
+ UI.alert(`ERROR ${response.status}: ${response.statusText}`);
3467
+ }
3468
+ return response.text();
3469
+ })
3470
+ .then((data) => {
3471
+ if(UI.postResponseOK(data)) {
3472
+ // Change the dialog text in case the user does not confirm
3473
+ // when prompted by the browser to leave the page.
3474
+ const
3475
+ m = data.match(/(\d+\.\d+\.\d+)/),
3476
+ md = UI.updating_modal;
3477
+ md.title.innerText = 'Update terminated';
3478
+ let msg = [];
3479
+ if(m) {
3480
+ msg.push(
3481
+ `Linny-R version ${m[1]} has been installed.`,
3482
+ 'To continue, you must reload this page, and',
3483
+ 'confirm when prompted by your browser.');
3484
+ } else {
3485
+ // Inform user that install appears to have failed.
3486
+ msg.push(
3487
+ 'Installation of new version may <strong>not</strong> have',
3488
+ 'been successful. Please check the CLI for',
3489
+ 'error messages or warnings.');
3490
+ }
3491
+ md.element('msg').innerHTML = msg.join('<br>');
3492
+ // Reload `index.html`. This will start Linny-R anew.
3493
+ window.open('./', '_self');
3494
+ }
3495
+ })
3496
+ .catch((err) => {
3497
+ if(trials < 10) {
3498
+ setTimeout(() => UI.tryToRestart(trials + 1), 5000);
3499
+ } else {
3500
+ UI.warn(UI.WARNING.NO_CONNECTION, err);
3501
+ }
3502
+ });
3503
+ }
3504
+
3505
+ preventUpdate() {
3506
+ // Signal server that no update is required.
3507
+ if(SOLVER.user_id) return;
3508
+ fetch('no-update/')
3509
+ .then((response) => {
3510
+ if(!response.ok) {
3511
+ UI.alert(`ERROR ${response.status}: ${response.statusText}`);
3512
+ }
3513
+ return response.text();
3514
+ })
3515
+ .then((data) => {
3516
+ if(UI.postResponseOK(data, true)) UI.check_update_modal.hide();
3517
+ })
3518
+ .catch((err) => {
3519
+ UI.warn(UI.WARNING.NO_CONNECTION, err);
3520
+ UI.check_update_modal.hide();
3521
+ });
3522
+ }
3523
+
3414
3524
  loginPrompt() {
3415
- // Show the server logon modal
3525
+ // Show the server logon modal.
3416
3526
  this.modals.logon.element('name').value = SOLVER.user_id;
3417
3527
  this.modals.logon.element('password').value = '';
3418
3528
  this.modals.logon.show('password');
3419
3529
  }
3420
3530
 
3421
3531
  rotatingIcon(rotate=false) {
3422
- // Controls the appearance of the Linny-R icon (top-left in browser window)
3532
+ // Controls the appearance of the Linny-R icon in the top-left
3533
+ // corner of the browser window.
3423
3534
  const
3424
3535
  si = document.getElementById('static-icon'),
3425
3536
  ri = document.getElementById('rotating-icon');
@@ -3433,47 +3544,47 @@ class GUIController extends Controller {
3433
3544
  }
3434
3545
 
3435
3546
  updateTimeStep(t=MODEL.simulationTimeStep) {
3436
- // Displays `t` as the current time step
3437
- // NOTE: the Virtual Machine passes its relative time VM.t
3547
+ // Display `t` as the current time step.
3548
+ // NOTE: The Virtual Machine passes its relative time `VM.t`.
3438
3549
  document.getElementById('step').innerHTML = t;
3439
3550
  }
3440
3551
 
3441
3552
  stopSolving() {
3442
- // Reset solver-related GUI elements and notify modeler
3553
+ // Reset solver-related GUI elements and notify modeler.
3443
3554
  super.stopSolving();
3444
3555
  this.buttons.solve.classList.remove('off');
3445
3556
  this.buttons.stop.classList.remove('blink');
3446
3557
  this.buttons.stop.classList.add('off');
3447
3558
  this.rotatingIcon(false);
3448
- // Update the time step on the status bar
3559
+ // Update the time step on the status bar.
3449
3560
  this.updateTimeStep();
3450
3561
  }
3451
3562
 
3452
3563
  readyToSolve() {
3453
- // Set Stop and Reset buttons to their initial state
3564
+ // Set Stop and Reset buttons to their initial state.
3454
3565
  UI.buttons.stop.classList.remove('blink');
3455
3566
  // Hide the reset button
3456
3567
  UI.buttons.reset.classList.add('off');
3457
3568
  }
3458
3569
 
3459
3570
  startSolving() {
3460
- // Hide Start button and show Stop button
3571
+ // Hide Start button and show Stop button.
3461
3572
  UI.buttons.solve.classList.add('off');
3462
3573
  UI.buttons.stop.classList.remove('off');
3463
3574
  }
3464
3575
 
3465
3576
  waitToStop() {
3466
- // Make Stop button blink to indicate "halting -- please wait"
3577
+ // Make Stop button blink to indicate "halting -- please wait".
3467
3578
  UI.buttons.stop.classList.add('blink');
3468
3579
  }
3469
3580
 
3470
3581
  readyToReset() {
3471
- // Show the Reset button
3582
+ // Show the Reset button.
3472
3583
  UI.buttons.reset.classList.remove('off');
3473
3584
  }
3474
3585
 
3475
3586
  reset() {
3476
- // Reset properties related to cursor position on diagram
3587
+ // Reset properties related to cursor position on diagram.
3477
3588
  this.on_node = null;
3478
3589
  this.on_arrow = null;
3479
3590
  this.on_cluster = null;
@@ -3527,7 +3638,7 @@ class GUIController extends Controller {
3527
3638
  jumpToIssue() {
3528
3639
  // Set time step to the one of the warning message for the issue
3529
3640
  // index, redraw the diagram if needed, and display the message
3530
- // on the infoline
3641
+ // on the infoline.
3531
3642
  if(VM.issue_index >= 0) {
3532
3643
  const
3533
3644
  issue = VM.issue_list[VM.issue_index],
@@ -15331,9 +15442,11 @@ class Finder {
15331
15442
  this.close_btn = document.getElementById('finder-close-btn');
15332
15443
  // Make toolbar buttons responsive
15333
15444
  this.close_btn.addEventListener('click', (e) => UI.toggleDialog(e));
15334
- this.entities = [];
15335
15445
  this.filter_input = document.getElementById('finder-filter-text');
15336
15446
  this.filter_input.addEventListener('input', () => FINDER.changeFilter());
15447
+ this.edit_btn = document.getElementById('finder-edit-btn');
15448
+ this.edit_btn.addEventListener(
15449
+ 'click', (event) => FINDER.editAttributes(event.shiftKey));
15337
15450
  this.copy_btn = document.getElementById('finder-copy-btn');
15338
15451
  this.copy_btn.addEventListener(
15339
15452
  'click', (event) => FINDER.copyAttributesToClipboard(event.shiftKey));
@@ -15341,7 +15454,7 @@ class Finder {
15341
15454
  this.item_table = document.getElementById('finder-item-table');
15342
15455
  this.expression_table = document.getElementById('finder-expression-table');
15343
15456
 
15344
- // Attribute headers are used by Finder to output entity attribute values
15457
+ // Attribute headers are used by Finder to output entity attribute values.
15345
15458
  this.attribute_headers = {
15346
15459
  A: 'ACTORS:\tWeight\tCash IN\tCash OUT\tCash FLOW',
15347
15460
  B: 'CONSTRAINTS (no attributes)',
@@ -15354,12 +15467,15 @@ class Finder {
15354
15467
  Q: 'PRODUCTS:\tLower bound\tUpper bound\tInitial level\tPrice' +
15355
15468
  '\tLevel\tCost price\tHighest cost price'
15356
15469
  };
15357
- // Set own properties
15470
+ // Set own properties.
15471
+ this.entities = [];
15472
+ this.filtered_types = [];
15358
15473
  this.reset();
15359
15474
  }
15360
15475
 
15361
15476
  reset() {
15362
15477
  this.entities.length = 0;
15478
+ this.filtered_types.length = 0;
15363
15479
  this.selected_entity = null;
15364
15480
  this.filter_input.value = '';
15365
15481
  this.filter_pattern = null;
@@ -15368,7 +15484,7 @@ class Finder {
15368
15484
  this.last_time_clicked = 0;
15369
15485
  this.clicked_object = null;
15370
15486
  // Product cluster index "remembers" for which cluster a product was
15371
- // last revealed, so it can reveal the next cluster when clicked again
15487
+ // last revealed, so it can reveal the next cluster when clicked again.
15372
15488
  this.product_cluster_index = 0;
15373
15489
  }
15374
15490
 
@@ -15378,7 +15494,7 @@ class Finder {
15378
15494
  dt = now - this.last_time_clicked;
15379
15495
  this.last_time_clicked = now;
15380
15496
  if(obj === this.clicked_object) {
15381
- // Consider click to be "double" if it occurred less than 300 ms ago
15497
+ // Consider click to be "double" if it occurred less than 300 ms ago.
15382
15498
  if(dt < 300) {
15383
15499
  this.last_time_clicked = 0;
15384
15500
  return true;
@@ -15422,6 +15538,7 @@ class Finder {
15422
15538
  fp = this.filter_pattern && this.filter_pattern.length > 0;
15423
15539
  let imgs = '';
15424
15540
  this.entities.length = 0;
15541
+ this.filtered_types.length = 0;
15425
15542
  // No list unless a pattern OR a specified SUB-set of entity types
15426
15543
  if(fp || et && et !== VM.entity_letters) {
15427
15544
  if(et.indexOf('A') >= 0) {
@@ -15430,6 +15547,7 @@ class Finder {
15430
15547
  if(!fp || patternMatch(MODEL.actors[k].name, this.filter_pattern)) {
15431
15548
  enl.push(k);
15432
15549
  this.entities.push(MODEL.actors[k]);
15550
+ addDistinct('A', this.filtered_types);
15433
15551
  }
15434
15552
  }
15435
15553
  }
@@ -15441,6 +15559,7 @@ class Finder {
15441
15559
  MODEL.processes[k].displayName, this.filter_pattern))) {
15442
15560
  enl.push(k);
15443
15561
  this.entities.push(MODEL.processes[k]);
15562
+ addDistinct('P', this.filtered_types);
15444
15563
  }
15445
15564
  }
15446
15565
  }
@@ -15451,6 +15570,7 @@ class Finder {
15451
15570
  MODEL.products[k].displayName, this.filter_pattern))) {
15452
15571
  enl.push(k);
15453
15572
  this.entities.push(MODEL.products[k]);
15573
+ addDistinct('Q', this.filtered_types);
15454
15574
  }
15455
15575
  }
15456
15576
  }
@@ -15461,6 +15581,7 @@ class Finder {
15461
15581
  MODEL.clusters[k].displayName, this.filter_pattern))) {
15462
15582
  enl.push(k);
15463
15583
  this.entities.push(MODEL.clusters[k]);
15584
+ addDistinct('C', this.filtered_types);
15464
15585
  }
15465
15586
  }
15466
15587
  }
@@ -15474,6 +15595,7 @@ class Finder {
15474
15595
  if(ds !== MODEL.equations_dataset) {
15475
15596
  enl.push(k);
15476
15597
  this.entities.push(MODEL.datasets[k]);
15598
+ addDistinct('D', this.filtered_types);
15477
15599
  }
15478
15600
  }
15479
15601
  }
@@ -15487,6 +15609,7 @@ class Finder {
15487
15609
  this.filter_pattern)) {
15488
15610
  enl.push(k);
15489
15611
  this.entities.push(MODEL.equations_dataset.modifiers[k]);
15612
+ addDistinct('E', this.filtered_types);
15490
15613
  }
15491
15614
  }
15492
15615
  }
@@ -15503,6 +15626,7 @@ class Finder {
15503
15626
  if(!bb && (!fp || patternMatch(ldn, this.filter_pattern))) {
15504
15627
  enl.push(k);
15505
15628
  this.entities.push(l);
15629
+ addDistinct('L', this.filtered_types);
15506
15630
  }
15507
15631
  }
15508
15632
  }
@@ -15515,6 +15639,7 @@ class Finder {
15515
15639
  MODEL.constraints[k].displayName, this.filter_pattern))) {
15516
15640
  enl.push(k);
15517
15641
  this.entities.push(MODEL.constraints[k]);
15642
+ addDistinct('B', this.filtered_types);
15518
15643
  }
15519
15644
  }
15520
15645
  }
@@ -15540,10 +15665,24 @@ class Finder {
15540
15665
  UI.scrollIntoView(document.getElementById(seid));
15541
15666
  document.getElementById('finder-count').innerHTML = pluralS(
15542
15667
  el.length, 'entity', 'entities');
15543
- if(el.length > 0) {
15668
+ // Only show the edit button if all filtered entities are of the
15669
+ // same type.
15670
+ let n = el.length;
15671
+ this.edit_btn.style.display = 'none';
15672
+ this.copy_btn.style.display = 'none';
15673
+ if(n > 0) {
15544
15674
  this.copy_btn.style.display = 'block';
15545
- } else {
15546
- this.copy_btn.style.display = 'none';
15675
+ const ft = this.filtered_types[0];
15676
+ if(this.filtered_types.length === 1 && 'DE'.indexOf(ft) < 0) {
15677
+ // NOTE: Attributes of "no actor" and top cluster cannot be edited.
15678
+ if((ft === 'A' && enl.indexOf('(no_actor)') >= 0) ||
15679
+ (ft === 'C' && enl.indexOf('(top_cluster)') >= 0)) n--;
15680
+ if(n > 0) {
15681
+ this.edit_btn.title = 'Edit attributes of ' +
15682
+ pluralS(n, VM.entity_names[ft]);
15683
+ this.edit_btn.style.display = 'block';
15684
+ }
15685
+ }
15547
15686
  }
15548
15687
  this.updateRightPane();
15549
15688
  }
@@ -15769,6 +15908,10 @@ class Finder {
15769
15908
  UI.showProductPropertiesDialog(obj);
15770
15909
  } else if(obj instanceof Link) {
15771
15910
  UI.showLinkPropertiesDialog(obj);
15911
+ } else if(obj instanceof Cluster && obj !== MODEL.top_cluster) {
15912
+ UI.showClusterPropertiesDialog(obj);
15913
+ } else if(obj instanceof Actor) {
15914
+ ACTOR_MANAGER.showEditActorDialog(obj.name, obj.weight.text);
15772
15915
  } else if(obj instanceof Note) {
15773
15916
  obj.showNotePropertiesDialog();
15774
15917
  } else if(obj instanceof Dataset) {
@@ -15884,12 +16027,19 @@ class Finder {
15884
16027
  }
15885
16028
  }
15886
16029
 
16030
+ editAttributes(shift) {
16031
+ // Show the Edit properties dialog for the filtered-out entities.
16032
+ // These must all be of the same type, or the edit button will not
16033
+ // show. Just in case, check anyway.
16034
+
16035
+ }
16036
+
15887
16037
  copyAttributesToClipboard(shift) {
15888
- // Copy relevant entity attributes as tab-separated text to clipboard
15889
- // NOTE: all entity types have "get" `attributes` that returns an object
15890
- // that for each defined attribute (and if model has been solved also each
15891
- // inferred attribute) has a property with its value. For dynamic
15892
- // expressions, the expression text is used
16038
+ // Copy relevant entity attributes as tab-separated text to clipboard.
16039
+ // NOTE: All entity types have "get" `attributes` that returns an
16040
+ // object that for each defined attribute (and if model has been
16041
+ // solved also each inferred attribute) has a property with its value.
16042
+ // For dynamic expressions, the expression text is used
15893
16043
  const ea_dict = {A: [], B: [], C: [], D: [], E: [], L: [], P: [], Q: []};
15894
16044
  let e = this.selected_entity;
15895
16045
  if(shift && e) {
@@ -15909,18 +16059,15 @@ class Finder {
15909
16059
  etl = seq[i],
15910
16060
  ead = ea_dict[etl];
15911
16061
  if(ead && ead.length > 0) {
15912
- // No blank line before first entity type
16062
+ // No blank line before first entity type.
15913
16063
  if(text.length > 0) text.push('');
15914
- let ah = this.attribute_letters[etl];
16064
+ const en = capitalized(VM.entity_names[etl]);
16065
+ let ah = en + '\t' + VM.entity_attribute_names[etl].join('\t');
16066
+ if(etl === 'L' || etl === 'B') ah = ah.replace(en, `${en} FROM\tTO`);
15915
16067
  if(!MODEL.infer_cost_prices) {
15916
- // No cost price calculation => trim associated attributes from header
15917
- let p = ah.indexOf('\tCost price');
15918
- if(p > 0) {
15919
- ah = ah.substring(0, p);
15920
- } else {
15921
- // SOC is exogenous, and hence comes before F in header => replace
15922
- ah = ah.replace('\tShare of cost', '');
15923
- }
16068
+ // If no cost price calculation, trim associated attributes
16069
+ // from the header.
16070
+ ah = ah.replace('\tCost price', '').replace('\tShare of cost', '');
15924
16071
  }
15925
16072
  text.push(ah);
15926
16073
  attr.length = 0;
@@ -7339,6 +7339,7 @@ class Process extends Node {
7339
7339
  a.LB = this.lower_bound.asAttribute;
7340
7340
  a.UB = (this.equal_bounds ? a.LB : this.upper_bound.asAttribute);
7341
7341
  a.IL = this.initial_level.asAttribute;
7342
+ a.LCF = this.pace_expression.asAttribute;
7342
7343
  if(MODEL.solved) {
7343
7344
  const t = MODEL.t;
7344
7345
  a.L = this.level[t];
@@ -7600,6 +7601,8 @@ class Product extends Node {
7600
7601
  if(MODEL.infer_cost_prices) {
7601
7602
  a.CP = this.cost_price[t];
7602
7603
  a.HCP = this.highest_cost_price[t];
7604
+ // Highest cost price may be undefined if product has no inflows.
7605
+ if(a.HCP === VM.MINUS_INFINITY) a.HCP = '';
7603
7606
  }
7604
7607
  }
7605
7608
  return a;
@@ -186,9 +186,14 @@ function uniformDecimals(data) {
186
186
  }
187
187
  }
188
188
 
189
+ function capitalized(s) {
190
+ // Returns string `s` with its first letter capitalized.
191
+ return s.charAt(0).toUpperCase() + s.slice(1);
192
+ }
193
+
189
194
  function ellipsedText(text, n=50, m=10) {
190
- // Returns `text` with ellipsis " ... " between its first `n` and last `m`
191
- // characters
195
+ // Returns `text` with ellipsis " ... " between its first `n` and
196
+ // last `m` characters.
192
197
  if(text.length <= n + m + 3) return text;
193
198
  return text.slice(0, n) + ' \u2026 ' + text.slice(text.length - m);
194
199
  }
@@ -2070,6 +2070,16 @@ class VirtualMachine {
2070
2070
  P: this.process_attr,
2071
2071
  Q: this.product_attr
2072
2072
  };
2073
+ this.entity_attribute_names = {};
2074
+ for(let i = 0; i < this.entity_letters.length; i++) {
2075
+ const
2076
+ el = this.entity_letters.charAt(i),
2077
+ ac = this.attribute_codes[el];
2078
+ this.entity_attribute_names[el] = [];
2079
+ for(let j = 0; j < ac.length; j++) {
2080
+ this.entity_attribute_names[el].push(ac[j]);
2081
+ }
2082
+ }
2073
2083
  // Level-based attributes are computed only AFTER optimization
2074
2084
  this.level_based_attr = ['L', 'CP', 'HCP', 'CF', 'CI', 'CO', 'F', 'A'];
2075
2085
  this.object_types = ['Process', 'Product', 'Cluster', 'Link', 'Constraint',