linny-r 2.0.10 → 2.0.11

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": "2.0.10",
3
+ "version": "2.0.11",
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
@@ -1942,17 +1942,7 @@ NOTE: * and ? will be interpreted as wildcards"
1942
1942
  title="Delete selected dataset"
1943
1943
  style="position: absolute; right: 2px">
1944
1944
  </div>
1945
- <div id="dataset-header">
1946
- Datasets
1947
- <img id="ds-filter-btn" class="btn enab" src="images/filter.png"
1948
- title="Filter on name">
1949
- </div>
1950
- <div id="ds-filter-bar">
1951
- <input id="ds-filter-text" type="text"
1952
- placeholder="(name filtering pattern)"
1953
- title="Pattern may contain logical & (AND), | (OR) and ^ (NOT)
1954
- Start with = to find exact match, with ~ to match first characters">
1955
- </div>
1945
+ <div id="dataset-header">Datasets</div>
1956
1946
  <div id="dataset-scroll-area">
1957
1947
  <table id="dataset-table">
1958
1948
  </table>
@@ -1984,6 +1974,7 @@ Start with = to find exact match, with ~ to match first characters">
1984
1974
  <div id="dataset-export">&uarr;</div>
1985
1975
  </div>
1986
1976
  </div>
1977
+ <div id="dataset-prefixed-count" class="blink"></div>
1987
1978
  <div id="dataset-separator"></div>
1988
1979
  <div id="dataset-modif-header">(no dataset selected)</div>
1989
1980
  <div id="dataset-modif-ds-name"></div>
@@ -284,6 +284,7 @@ img.off {
284
284
  }
285
285
  }
286
286
 
287
+ div.blink,
287
288
  img.blink {
288
289
  animation: blink 1s step-start 0s infinite;
289
290
  }
@@ -2665,6 +2666,15 @@ div.io-box {
2665
2666
  height: 59px;
2666
2667
  }
2667
2668
 
2669
+ #dataset-prefixed-count {
2670
+ position: absolute;
2671
+ bottom: 2px;
2672
+ left: 2px;
2673
+ max-width: 40%;
2674
+ color: #700090;
2675
+ font-weight: bold;
2676
+ }
2677
+
2668
2678
  #dataset-outcome {
2669
2679
  position: absolute;
2670
2680
  bottom: 0px;
@@ -845,7 +845,7 @@ class GUIController extends Controller {
845
845
  'time-unit': 'time_unit',
846
846
  'method': 'method'
847
847
  });
848
-
848
+
849
849
  // Initially, no dialog being dragged or resized.
850
850
  this.dr_dialog = null;
851
851
 
@@ -1225,6 +1225,12 @@ class GUIController extends Controller {
1225
1225
  // Ensure that all modal windows respond to ESCape
1226
1226
  // (and more in general to other special keys).
1227
1227
  document.addEventListener('keydown', (event) => UI.checkModals(event));
1228
+ // Ensure that all modal dialogs "swallow" mousedown events, as otherwise
1229
+ // these may alo be processed by the main window drawing canvas.
1230
+ for(const modal of document.getElementsByClassName('modal')) {
1231
+ modal.addEventListener('mousedown', (event) => event.stopPropagation());
1232
+ }
1233
+
1228
1234
  }
1229
1235
 
1230
1236
  setConstraintUnderCursor(c) {
@@ -1927,18 +1933,19 @@ class GUIController extends Controller {
1927
1933
  // Button functionality
1928
1934
  //
1929
1935
 
1930
- enableButtons(btns) {
1936
+ enableButtons(btns, blink=false) {
1931
1937
  for(const btn of btns.trim().split(/\s+/)) {
1932
1938
  const b = document.getElementById(btn + '-btn');
1933
- b.classList.remove('disab', 'activ');
1939
+ b.classList.remove('disab', 'activ', 'blink');
1934
1940
  b.classList.add('enab');
1941
+ if(blink) b.classList.add('blink');
1935
1942
  }
1936
1943
  }
1937
1944
 
1938
1945
  disableButtons(btns) {
1939
1946
  for(const btn of btns.trim().split(/\s+/)) {
1940
1947
  const b = document.getElementById(btn + '-btn');
1941
- b.classList.remove('enab', 'activ', 'stay-activ');
1948
+ b.classList.remove('enab', 'activ', 'stay-activ', 'blink');
1942
1949
  b.classList.add('disab');
1943
1950
  }
1944
1951
  }
@@ -2632,17 +2639,11 @@ class GUIController extends Controller {
2632
2639
  // Handler for keyboard events
2633
2640
  //
2634
2641
 
2635
- checkModals(e) {
2636
- // Respond to Escape, Enter and shortcut keys.
2637
- const
2638
- ttype = e.target.type,
2639
- ttag = e.target.tagName,
2640
- modals = document.getElementsByClassName('modal');
2641
- // Modal dialogs: hide on ESC and move to next input on ENTER.
2642
+ get topModal() {
2643
+ // Return the topmost visible modal dialog, or NULL if none are showing.
2644
+ const modals = document.getElementsByClassName('modal');
2642
2645
  let maxz = 0,
2643
- topmod = null,
2644
- code = e.code,
2645
- alt = e.altKey;
2646
+ topmod = null;
2646
2647
  for(const m of modals) {
2647
2648
  const
2648
2649
  cs = window.getComputedStyle(m),
@@ -2652,6 +2653,18 @@ class GUIController extends Controller {
2652
2653
  maxz = z;
2653
2654
  }
2654
2655
  }
2656
+ return topmod;
2657
+ }
2658
+
2659
+ checkModals(e) {
2660
+ // Respond to Escape, Enter and shortcut keys.
2661
+ const
2662
+ ttype = e.target.type,
2663
+ ttag = e.target.tagName,
2664
+ code = e.code,
2665
+ alt = e.altKey,
2666
+ topmod = this.topModal;
2667
+ // Modal dialogs: hide on ESC and move to next input on ENTER.
2655
2668
  // NOTE: Consider only the top modal (if any is showing).
2656
2669
  if(code === 'Escape') {
2657
2670
  e.stopImmediatePropagation();
@@ -11,7 +11,7 @@ for the Linny-R Dataset Manager dialog.
11
11
  */
12
12
 
13
13
  /*
14
- Copyright (c) 2017-2024 Delft University of Technology
14
+ Copyright (c) 2017-2025 Delft University of Technology
15
15
 
16
16
  Permission is hereby granted, free of charge, to any person obtaining a copy
17
17
  of this software and associated documentation files (the "Software"), to deal
@@ -56,15 +56,11 @@ class GUIDatasetManager extends DatasetManager {
56
56
  'click', () => DATASET_MANAGER.load_csv_modal.show());
57
57
  document.getElementById('ds-delete-btn').addEventListener(
58
58
  'click', () => DATASET_MANAGER.deleteDataset());
59
- document.getElementById('ds-filter-btn').addEventListener(
60
- 'click', () => DATASET_MANAGER.toggleFilter());
61
- // Update when filter input text changes.
62
- this.filter_text = document.getElementById('ds-filter-text');
63
- this.filter_text.addEventListener(
64
- 'input', () => DATASET_MANAGER.changeFilter());
65
59
  this.dataset_table = document.getElementById('dataset-table');
66
- // Data properties pane.
60
+ // Data properties pane below the dataset scroll area.
67
61
  this.properties = document.getElementById('dataset-properties');
62
+ // Number of prefixed datasets is displayed at bottom of left pane.
63
+ this.prefixed_count = document.getElementById('dataset-prefixed-count');
68
64
  // Toggle buttons at bottom of dialog.
69
65
  this.blackbox = document.getElementById('dataset-blackbox');
70
66
  this.blackbox.addEventListener(
@@ -144,12 +140,13 @@ class GUIDatasetManager extends DatasetManager {
144
140
  reset() {
145
141
  super.reset();
146
142
  this.selected_prefix_row = null;
143
+ this.selected_dataset = null;
147
144
  this.selected_modifier = null;
148
145
  this.edited_expression = null;
149
- this.filter_pattern = null;
150
146
  this.clicked_object = null;
151
147
  this.last_time_clicked = 0;
152
148
  this.focal_table = null;
149
+ this.prefixed_datasets = [];
153
150
  this.expanded_rows = [];
154
151
  }
155
152
 
@@ -291,6 +288,17 @@ class GUIDatasetManager extends DatasetManager {
291
288
  for(const r of this.dataset_table.rows) if(r.dataset.prefix === lcp) return r;
292
289
  return null;
293
290
  }
291
+
292
+ datasetsByPrefix(prefix) {
293
+ // Return the list of datasets having the specified prefix.
294
+ const
295
+ pid = UI.nameToID(prefix + UI.PREFIXER),
296
+ dsl = [];
297
+ for(const k of Object.keys(MODEL.datasets)) {
298
+ if(k.startsWith(pid)) dsl.push(k);
299
+ }
300
+ return dsl;
301
+ }
294
302
 
295
303
  selectPrefixRow(e) {
296
304
  // Select expand/collapse prefix row.
@@ -302,6 +310,7 @@ class GUIDatasetManager extends DatasetManager {
302
310
  const toggle = r.classList.contains('tree-btn');
303
311
  while(r.tagName !== 'TR') r = r.parentNode;
304
312
  this.selected_prefix_row = r;
313
+ this.prefixed_datasets = this.datasetsByPrefix(r.dataset.prefix);
305
314
  const sel = this.dataset_table.getElementsByClassName('sel-set');
306
315
  this.selected_dataset = null;
307
316
  if(sel.length > 0) {
@@ -311,7 +320,7 @@ class GUIDatasetManager extends DatasetManager {
311
320
  r.classList.add('sel-set');
312
321
  if(!e.target) r.scrollIntoView({block: 'center'});
313
322
  if(toggle || e.altKey || this.doubleClicked(r)) this.togglePrefixRow(e);
314
- UI.enableButtons('ds-rename');
323
+ this.updatePanes();
315
324
  }
316
325
 
317
326
  updateDialog() {
@@ -321,18 +330,13 @@ class GUIDatasetManager extends DatasetManager {
321
330
  dnl = [],
322
331
  sd = this.selected_dataset,
323
332
  ioclass = ['', 'import', 'export'];
324
- for(let d in MODEL.datasets) if(MODEL.datasets.hasOwnProperty(d) &&
325
- // NOTE: do not list "black-boxed" entities
326
- !d.startsWith(UI.BLACK_BOX) &&
327
- // NOTE: do not list the equations dataset
328
- MODEL.datasets[d] !== MODEL.equations_dataset) {
329
- if(!this.filter_pattern || this.filter_pattern.length === 0 ||
330
- patternMatch(MODEL.datasets[d].displayName, this.filter_pattern)) {
331
- dnl.push(d);
332
- }
333
+ for(let d in MODEL.datasets) if(MODEL.datasets.hasOwnProperty(d)) {
334
+ // NOTE: Do not list "black-boxed" entities or the equations dataset.
335
+ if(!d.startsWith(UI.BLACK_BOX) &&
336
+ MODEL.datasets[d] !== MODEL.equations_dataset) dnl.push(d);
333
337
  }
334
338
  dnl.sort((a, b) => UI.compareFullNames(a, b, true));
335
- // First determine indentation levels, prefixes and names
339
+ // First determine indentation levels, prefixes and names.
336
340
  const
337
341
  indent = [],
338
342
  pref_ids = [],
@@ -341,11 +345,11 @@ class GUIDatasetManager extends DatasetManager {
341
345
  xids = [];
342
346
  for(const dn of dnl) {
343
347
  const pref = UI.prefixesAndName(MODEL.datasets[dn].name);
344
- // NOTE: only the name part (so no prefixes at all) will be shown
348
+ // NOTE: Only the name part (so no prefixes at all) will be shown.
345
349
  names.push(pref.pop());
346
350
  indent.push(pref.length);
347
- // NOTE: ignore case but join again with ": " because prefixes
348
- // can contain any character; only the prefixer is "reserved"
351
+ // NOTE: Ignore case but join again with ": " because prefixes
352
+ // can contain any character; only the prefixer is "reserved".
349
353
  const pref_id = pref.join(UI.PREFIXER).toLowerCase();
350
354
  pref_ids.push(pref_id);
351
355
  pref_names[pref_id] = pref;
@@ -363,11 +367,11 @@ class GUIDatasetManager extends DatasetManager {
363
367
  } else {
364
368
  ind_div = '';
365
369
  }
366
- // NOTE: empty string should not add a collapse/expand row
370
+ // NOTE: Empty string should not add a collapse/expand row.
367
371
  if(pid && pid != prev_id && xids.indexOf(pid) < 0) {
368
372
  // NOTE: XX: aa may be followed by XX: YY: ZZ: bb, which requires
369
373
  // *two* collapsable lines: XX: YY and XX: YY: ZZ: before adding
370
- // XX: YY: ZZ: bb
374
+ // XX: YY: ZZ: bb.
371
375
  const
372
376
  ps = pid.split(UI.PREFIXER),
373
377
  pps = prev_id.split(UI.PREFIXER),
@@ -375,20 +379,20 @@ class GUIDatasetManager extends DatasetManager {
375
379
  pns = pn.join(UI.PREFIXER),
376
380
  lpl = [];
377
381
  let lindent = 0;
378
- // Ignore identical leading prefixes
382
+ // Ignore identical leading prefixes.
379
383
  while(ps.length > 0 && pps.length > 0 && ps[0] === pps[0]) {
380
384
  lpl.push(ps.shift());
381
385
  pps.shift();
382
386
  pn.shift();
383
387
  lindent++;
384
388
  }
385
- // Add a "collapse" row for each new prefix
389
+ // Add a "collapse" row for each new prefix.
386
390
  while(ps.length > 0) {
387
391
  lpl.push(ps.shift());
388
392
  lindent++;
389
393
  const lpid = lpl.join(UI.PREFIXER);
390
394
  dl.push(['<tr data-prefix="', lpid,
391
- '" data-prefix-name="', pns, '" class="dataset"',
395
+ '" data-prefix-name="', pns.slice(0, lpid.length), '" class="dataset"',
392
396
  'onclick="DATASET_MANAGER.selectPrefixRow(event);"><td>',
393
397
  // NOTE: data-prefix="x" signals that this is an extra row
394
398
  (lindent > 0 ?
@@ -398,7 +402,7 @@ class GUIDatasetManager extends DatasetManager {
398
402
  '<div data-prefix="x" class="tree-btn">',
399
403
  (this.expanded_rows.indexOf(lpid) >= 0 ? '\u25BC' : '\u25BA'),
400
404
  '</div>', pn.shift(), '</td></tr>'].join(''));
401
- // Add to the list to prevent multiple c/x-rows for the same prefix
405
+ // Add to the list to prevent multiple c/x-rows for the same prefix.
402
406
  xids.push(lpid);
403
407
  }
404
408
  }
@@ -437,6 +441,7 @@ class GUIDatasetManager extends DatasetManager {
437
441
  sd = this.selected_dataset,
438
442
  btns = 'ds-data ds-clone ds-delete ds-rename';
439
443
  if(sd) {
444
+ this.prefixed_count.style.display = 'none';
440
445
  this.properties.style.display = 'block';
441
446
  document.getElementById('dataset-default').innerHTML =
442
447
  VM.sig4Dig(sd.default_value) +
@@ -469,8 +474,22 @@ class GUIDatasetManager extends DatasetManager {
469
474
  UI.enableButtons(btns);
470
475
  } else {
471
476
  this.properties.style.display = 'none';
477
+ const
478
+ pdsl = this.prefixed_datasets.length,
479
+ npds = pluralS(pdsl, 'dataset');
480
+ this.prefixed_count.innerText = npds;
481
+ this.prefixed_count.style.display = (pdsl ? 'block' : 'none');
472
482
  UI.disableButtons(btns);
473
- if(this.selected_prefix_row) UI.enableButtons('ds-rename');
483
+ if(this.selected_prefix_row) {
484
+ UI.enableButtons('ds-rename ds-delete', true);
485
+ document.getElementById('ds-rename-btn')
486
+ .title = `Rename ${npds} by changing prefix "${this.selectedPrefix}"`;
487
+ document.getElementById('ds-delete-btn')
488
+ .title = `Delete ${npds} having prefix "${this.selectedPrefix}"`;
489
+ } else {
490
+ document.getElementById('ds-rename-btn').title = 'Rename selected dataset';
491
+ document.getElementById('ds-delete-btn').title = 'Delete selected dataset';
492
+ }
474
493
  }
475
494
  this.updateModifiers();
476
495
  }
@@ -555,37 +574,14 @@ class GUIDatasetManager extends DatasetManager {
555
574
  if(d) DOCUMENTATION_MANAGER.update(d, shift);
556
575
  }
557
576
 
558
- toggleFilter() {
559
- const
560
- btn = document.getElementById('ds-filter-btn'),
561
- bar = document.getElementById('ds-filter-bar'),
562
- dsa = document.getElementById('dataset-scroll-area');
563
- if(btn.classList.toggle('stay-activ')) {
564
- bar.style.display = 'block';
565
- dsa.style.top = '81px';
566
- dsa.style.height = 'calc(100% - 141px)';
567
- this.changeFilter();
568
- } else {
569
- bar.style.display = 'none';
570
- dsa.style.top = '62px';
571
- dsa.style.height = 'calc(100% - 122px)';
572
- this.filter_pattern = null;
573
- this.updateDialog();
574
- }
575
- }
576
-
577
- changeFilter() {
578
- this.filter_pattern = patternList(this.filter_text.value);
579
- this.updateDialog();
580
- }
581
-
582
577
  selectDataset(event, id) {
583
- // Select dataset, or edit it when Alt- or double-clicked
578
+ // Select dataset, or edit it when Alt- or double-clicked.
584
579
  this.focal_table = this.dataset_table;
585
580
  const
586
581
  d = MODEL.datasets[id] || null,
587
582
  edit = event.altKey || this.doubleClicked(d);
588
583
  this.selected_dataset = d;
584
+ this.prefixed_datasets.length = 0;
589
585
  if(d && edit) {
590
586
  this.last_time_clicked = 0;
591
587
  this.editData();
@@ -748,18 +744,34 @@ class GUIDatasetManager extends DatasetManager {
748
744
  this.updateDialog();
749
745
  }
750
746
  }
747
+
748
+ get selectedAsList() {
749
+ // Return list of datasets selected directly or by prefix.
750
+ const dsl = [];
751
+ // Prevent including the equations dataset (just in case).
752
+ if(this.selected_dataset && this.selected_dataset !== MODEL.equations_dataset) {
753
+ dsl.push(this.selected_dataset);
754
+ } else {
755
+ // NOTE: List of prefixed datasets contains keys, not objects.
756
+ for(const k of this.prefixed_datasets) {
757
+ const ds = MODEL.datasets[k];
758
+ if(ds !== MODEL.equations_dataset) dsl.push();
759
+ }
760
+ }
761
+ return dsl;
762
+ }
751
763
 
752
764
  deleteDataset() {
753
- const d = this.selected_dataset;
754
- // Double-check, just in case...
755
- if(d && d !== MODEL.equations_dataset) {
756
- MODEL.removeImport(d);
757
- MODEL.removeExport(d);
758
- delete MODEL.datasets[d.identifier];
759
- this.selected_dataset = null;
760
- this.updateDialog();
761
- MODEL.updateDimensions();
765
+ // Delete selected dataset(s).
766
+ for(const ds of this.selectedAsList) {
767
+ MODEL.removeImport(ds);
768
+ MODEL.removeExport(ds);
769
+ delete MODEL.datasets[ds.identifier];
762
770
  }
771
+ this.selected_dataset = null;
772
+ this.prefixed_datasets.length = 0;
773
+ this.updateDialog();
774
+ MODEL.updateDimensions();
763
775
  }
764
776
 
765
777
  toggleBlackBox() {
@@ -289,6 +289,8 @@ class GUIRepositoryBrowser extends RepositoryBrowser {
289
289
  'click', () => REPOSITORY_BROWSER.performInclusion());
290
290
  this.include_modal.cancel.addEventListener(
291
291
  'click', () => REPOSITORY_BROWSER.cancelInclusion());
292
+ this.include_modal.element('prefix').addEventListener(
293
+ 'blur', () => REPOSITORY_BROWSER.suggestBindings());
292
294
  this.include_modal.element('actor').addEventListener(
293
295
  'blur', () => REPOSITORY_BROWSER.updateActors());
294
296
 
@@ -614,6 +616,22 @@ class GUIRepositoryBrowser extends RepositoryBrowser {
614
616
  md.show('prefix');
615
617
  }
616
618
 
619
+ suggestBindings() {
620
+ // Select for each "Cluster: XXX" drop-down the one that matches the
621
+ // value of the prefix input field.
622
+ const
623
+ md = this.include_modal,
624
+ prefix = md.element('prefix').value.trim(),
625
+ sa = md.element('scroll-area'),
626
+ sels = sa.querySelectorAll('select');
627
+ for(const sel of sels) {
628
+ const
629
+ oid = UI.nameToID(prefix) + ':_' + sel.id,
630
+ ids = [...sel.options].map(o => o.value);
631
+ if(ids.indexOf(oid) >= 0) sel.value = oid;
632
+ }
633
+ }
634
+
617
635
  updateActors() {
618
636
  // Add actor (if specified) to model, and then updates the selector options
619
637
  // for each actor binding selector.
@@ -501,6 +501,30 @@ class LinnyRModel {
501
501
  return this.namedObjectByID(UI.nameToID(name));
502
502
  }
503
503
 
504
+ validVariable(name) {
505
+ // Return TRUE if `name` references an entity plus valid attribute.
506
+ const
507
+ ea = name.split('|'),
508
+ en = ea[0].trim(),
509
+ e = this.objectByName(en);
510
+ if(!e) return `Unknown model entity "${en}"`;
511
+ const
512
+ ao = ea[1].split('@'),
513
+ a = ao[0].trim();
514
+ // Valid if no attribute, as all entity types have a default attribute.
515
+ if(!a) return true;
516
+ // Attribute should be valid for the entity type.
517
+ const
518
+ et = e.type.toLowerCase(),
519
+ ac = VM.attribute_codes[VM.entity_letter_codes[et]];
520
+ if(ac.indexOf(a) >= 0 || (e instanceof Cluster && a.startsWith('=')) ||
521
+ (e instanceof Dataset && e.modifiers.hasOwnProperty(a.toLowerCase()))) {
522
+ return true;
523
+ }
524
+ if(e instanceof Dataset) return `Dataset ${e.displayName} has no modifier "${a}"`;
525
+ return `Invalid attribute "${a}"`;
526
+ }
527
+
504
528
  setByType(type) {
505
529
  // Return a "dictionary" object with entities of the specified types
506
530
  if(type === 'Process') return this.processes;
@@ -4370,22 +4394,24 @@ class IOBinding {
4370
4394
  datastyle = (this.is_data ?
4371
4395
  '; text-decoration: 1.5px dashed underline' : '');
4372
4396
  let html = ['<tr class="', ioc[this.io_type], '-param">',
4373
- '<td style="padding-bottom:2px">',
4374
- '<span style="font-style:normal; font-weight:normal', datastyle, '">',
4397
+ '<td style="padding-bottom: 2px">',
4398
+ '<span style="font-style: normal; font-weight: normal', datastyle, '">',
4375
4399
  this.entity_type, ':</span> ', this.name_in_module].join('');
4376
4400
  if(this.io_type === 1) {
4377
4401
  // An IMPORT binding generates two rows: the formal name (in the module)
4378
- // and the actual name (in the current model) as dropdown box
4379
- // NOTE: the first (default) option is the *prefixed* formal name, which
4380
- // means that the parameter is not bound to an entity in the current model
4402
+ // and the actual name (in the current model) as dropdown box.
4403
+ // NOTE: The first (default) option is the *prefixed* formal name, which
4404
+ // means that the parameter is not bound to an entity in the current model.
4381
4405
  html += ['<br>&rdca;<select id="', this.id, '" name="', this.id,
4382
- '" class="i-param"><option value="_CLUSTER">Cluster: ',
4406
+ '" class="i-param"><option value="_CLUSTER" style="color: purple">Cluster: ',
4383
4407
  this.name_in_module, '</option>'].join('');
4384
4408
  const
4385
4409
  s = MODEL.setByType(this.entity_type),
4386
- index = Object.keys(s).sort();
4410
+ tail = ':_' + this.name_in_module.toLowerCase(),
4411
+ index = Object.keys(s).sort(
4412
+ (a, b) => compareTailFirst(a, b, tail));
4387
4413
  if(s === MODEL.datasets) {
4388
- // NOTE: do not list the model equations as dataset
4414
+ // NOTE: Do not list the model equations as dataset.
4389
4415
  const i = index.indexOf(UI.EQUATIONS_DATASET_ID);
4390
4416
  if(i >= 0) index.splice(i, 1);
4391
4417
  }
@@ -4467,12 +4493,14 @@ class IOContext {
4467
4493
  }
4468
4494
 
4469
4495
  actualName(n, an='') {
4470
- // Returns the actual name for a parameter with formal name `n`
4496
+ // Return the actual name for a parameter with formal name `n`
4471
4497
  // (and for processes and clusters: with actor name `an` if specified and
4472
- // not "(no actor)")
4473
- // NOTE: do not modify (no actor), nor the "dataset dot"
4498
+ // not "(no actor)").
4499
+ // NOTE: Do not modify (no actor), nor the "dataset dot".
4474
4500
  if(n === UI.NO_ACTOR || n === '.') return n;
4475
- // NOTE: the top cluster of the included model has the prefix as its name
4501
+ // NOTE: Do not modify "prefix-relative" variables.
4502
+ if(n.startsWith(':')) return n;
4503
+ // NOTE: The top cluster of the included model has the prefix as its name.
4476
4504
  if(n === UI.TOP_CLUSTER_NAME || n === UI.FORMER_TOP_CLUSTER_NAME) {
4477
4505
  return this.prefix;
4478
4506
  }
@@ -4489,7 +4517,7 @@ class IOContext {
4489
4517
  return n;
4490
4518
  }
4491
4519
  // All other entities are prefixed
4492
- return (this.prefix ? this.prefix + ': ' : '') + n;
4520
+ return (this.prefix ? this.prefix + UI.PREFIXER : '') + n;
4493
4521
  }
4494
4522
 
4495
4523
  get clusterName() {
@@ -4581,19 +4609,19 @@ class IOContext {
4581
4609
  }
4582
4610
 
4583
4611
  supersede(obj) {
4584
- // Logs that entity `obj` is superseded, i.e., that this entity already
4612
+ // Log that entity `obj` is superseded, i.e., that this entity already
4585
4613
  // exists in the current model, and is initialized anew from the XML of
4586
4614
  // the model that is being included. The log is shown to modeler afterwards.
4587
4615
  addDistinct(obj.type + UI.PREFIXER + obj.displayName, this.superseded);
4588
4616
  }
4589
4617
 
4590
4618
  rewrite(x, n1='', n2='') {
4591
- // Replaces entity names of variables used in expression `x` by their
4592
- // actual name after inclusion
4593
- // NOTE: when strings `n1` and `n2` are passed, replace entity name `n1`
4619
+ // Replace entity names of variables used in expression `x` by their
4620
+ // actual name after inclusion.
4621
+ // NOTE: When strings `n1` and `n2` are passed, replace entity name `n1`
4594
4622
  // by `n2` in all variables (this is not IO-related, but used when the
4595
- // modeler renames an entity)
4596
- // NOTE: nothing to do if expression contains no variables
4623
+ // modeler renames an entity).
4624
+ // NOTE: Nothing to do if expression contains no variables.
4597
4625
  if(x.text.indexOf('[') < 0) return;
4598
4626
  const rcnt = this.replace_count;
4599
4627
  let s = '',
@@ -4607,28 +4635,28 @@ class IOContext {
4607
4635
  while(true) {
4608
4636
  p = x.text.indexOf('[', q + 1);
4609
4637
  if(p < 0) {
4610
- // No more '[' => add remaining part of text, and quit
4638
+ // No more '[' => add remaining part of text, and quit.
4611
4639
  s += x.text.slice(q + 1);
4612
4640
  break;
4613
4641
  }
4614
- // Add part from last ']' up to new '['
4642
+ // Add part from last ']' up to new '['.
4615
4643
  s += x.text.slice(q + 1, p);
4616
4644
  // Find next ']'
4617
4645
  q = indexOfMatchingBracket(x.text, p);
4618
- // Get the bracketed text (without brackets)
4646
+ // Get the bracketed text (without brackets).
4619
4647
  ss = x.text.slice(p + 1, q);
4620
- // Separate into variable and attribute + offset string (if any)
4648
+ // Separate into variable and attribute + offset string (if any).
4621
4649
  vb = ss.lastIndexOf('|');
4622
4650
  if(vb >= 0) {
4623
4651
  v = ss.slice(0, vb);
4624
- // NOTE: attribute string includes the vertical bar '|'
4652
+ // NOTE: Attribute string includes the vertical bar '|'.
4625
4653
  a = ss.slice(vb);
4626
4654
  } else {
4627
- // Separate into variable and offset string (if any)
4655
+ // Separate into variable and offset string (if any).
4628
4656
  vb = ss.lastIndexOf('@');
4629
4657
  if(vb >= 0) {
4630
4658
  v = ss.slice(0, vb);
4631
- // NOTE: attribute string includes the "at" sign '@'
4659
+ // NOTE: Attribute string includes the "at" sign '@'.
4632
4660
  a = ss.slice(vb);
4633
4661
  } else {
4634
4662
  v = ss;
@@ -4650,12 +4678,13 @@ class IOContext {
4650
4678
  brace = '';
4651
4679
  }
4652
4680
  }
4653
- // NOTE: patterns used to compute statistics must not be rewritten
4681
+ // NOTE: Patterns used to compute statistics must not be rewritten.
4654
4682
  let doit = true;
4655
4683
  stat = v.split('$');
4656
4684
  if(stat.length > 1 && VM.statistic_operators.indexOf(stat[0]) >= 0) {
4657
4685
  if(brace) {
4658
- // NOTE: this does not hold for statistics for experiment outcomes
4686
+ // NOTE: This does not hold for statistics for experiment outcomes,
4687
+ // because there no patterns but actual names are used.
4659
4688
  brace += stat[0] + '$';
4660
4689
  v = stat.slice(1).join('$');
4661
4690
  } else {
@@ -4663,21 +4692,21 @@ class IOContext {
4663
4692
  }
4664
4693
  }
4665
4694
  if(doit) {
4666
- // NOTE: when `n1` and `n2` have been specified, compare `v` with `n1`,
4667
- // and if matching, replace it by `n2`
4695
+ // NOTE: When `n1` and `n2` have been specified, compare `v` with `n1`,
4696
+ // and if matching, replace it by `n2`.
4668
4697
  if(n1 && n2) {
4669
4698
  // NOTE: UI.replaceEntity handles link names by replacing either the
4670
- // FROM or TO node name if it matches with `n1`
4699
+ // FROM or TO node name if it matches with `n1`.
4671
4700
  const r = UI.replaceEntity(v, n1, n2);
4672
- // Only replace `v` by `r` in case of a match
4701
+ // Only replace `v` by `r` in case of a match.
4673
4702
  if(r) {
4674
4703
  this.replace_count++;
4675
4704
  v = r;
4676
4705
  }
4677
4706
  } else {
4678
4707
  // When `n1` and `n2` are NOT specified, rewrite the variable
4679
- // using the parameter bindings
4680
- // NOTE: link variables contain TWO entity names
4708
+ // using the parameter bindings.
4709
+ // NOTE: Link variables contain TWO entity names.
4681
4710
  if(v.indexOf(UI.LINK_ARROW) >= 0) {
4682
4711
  const ln = v.split(UI.LINK_ARROW);
4683
4712
  v = this.actualName(ln[0]) + UI.LINK_ARROW + this.actualName(ln[1]);
@@ -4686,24 +4715,24 @@ class IOContext {
4686
4715
  }
4687
4716
  }
4688
4717
  }
4689
- // Add [actual name|attribute string] while preserving "by reference"
4718
+ // Add [actual name|attribute string] while preserving "by reference".
4690
4719
  s += `[${brace}${by_ref}${v}${a}]`;
4691
4720
  }
4692
- // Increase expression count when 1 or more variables were replaced
4721
+ // Increase expression count when 1 or more variables were replaced.
4693
4722
  if(this.replace_count > rcnt) this.expression_count++;
4694
- // Replace the original expression by the new one
4723
+ // Replace the original expression by the new one.
4695
4724
  x.text = s;
4696
- // Force expression to recompile
4725
+ // Force expression to recompile.
4697
4726
  x.code = null;
4698
4727
  }
4699
4728
 
4700
4729
  addedNode(node) {
4701
- // Record that node was added
4730
+ // Record that node was added.
4702
4731
  this.added_nodes.push(node);
4703
4732
  }
4704
4733
 
4705
4734
  addedLink(link) {
4706
- // Record that link was added
4735
+ // Record that link was added.
4707
4736
  this.added_links.push(link);
4708
4737
  }
4709
4738
 
@@ -9196,12 +9225,17 @@ class Dataset {
9196
9225
  // Data is stored simply as semicolon-separated floating point numbers,
9197
9226
  // with N-digit precision to keep model files compact (default: N = 8).
9198
9227
  let d = [];
9199
- for(const v of this.data) {
9200
- // Convert number to string with the desired precision.
9201
- const f = v.toPrecision(CONFIGURATION.dataset_precision);
9202
- // Then parse it again, so that the number will be represented
9203
- // (by JavaScript) in the most compact representation.
9204
- d.push(parseFloat(f));
9228
+ // NOTE: Guard against empty strings and other invalid data.
9229
+ for(const v of this.data) if(v) {
9230
+ try {
9231
+ // Convert number to string with the desired precision.
9232
+ const f = v.toPrecision(CONFIGURATION.dataset_precision);
9233
+ // Then parse it again, so that the number will be represented
9234
+ // (by JavaScript) in the most compact representation.
9235
+ d.push(parseFloat(f));
9236
+ } catch(err) {
9237
+ console.log('-- Notice: dataset', this.displayName, 'has invalid data', v);
9238
+ }
9205
9239
  }
9206
9240
  return d.join(';');
9207
9241
  }
@@ -303,11 +303,21 @@ function markFirstDifference(s1, s2) {
303
303
  //
304
304
 
305
305
  function ciCompare(a, b) {
306
- // Performs case-insensitive comparison that does differentiate
307
- // between accented characters (as this differentiates between identifiers)
306
+ // Perform case-insensitive comparison that does differentiate
307
+ // between accented characters (as this differentiates between identifiers).
308
308
  return a.localeCompare(b, undefined, {sensitivity: 'accent'});
309
309
  }
310
310
 
311
+ function compareTailFirst(a, b, tail) {
312
+ // Sort strings while prioritizing the group of elements that end on `tail`.
313
+ const
314
+ a_tail = a.endsWith(tail),
315
+ b_tail = b.endsWith(tail);
316
+ if(a_tail && !b_tail) return -1;
317
+ if(!a_tail && b_tail) return 1;
318
+ return ciCompare(a, b);
319
+ }
320
+
311
321
  function endsWithDigits(str) {
312
322
  // Returns trailing digts of `str` (empty string will evaluate as FALSE)
313
323
  let i = str.length - 1,
@@ -1143,6 +1153,7 @@ if(NODE) module.exports = {
1143
1153
  differences: differences,
1144
1154
  markFirstDifference: markFirstDifference,
1145
1155
  ciCompare: ciCompare,
1156
+ compareTailFirst: compareTailFirst,
1146
1157
  endsWithDigits: endsWithDigits,
1147
1158
  indexOfMatchingBracket: indexOfMatchingBracket,
1148
1159
  monoSpaced: monoSpaced,
@@ -951,7 +951,13 @@ class ExpressionParser {
951
951
  // Variable name may start with a colon to denote that the owner
952
952
  // prefix should be added.
953
953
  name = UI.colonPrefixedName(name, this.owner_prefix);
954
- if(x.x) {
954
+ // First check whether name refers to a valid attribute of an
955
+ // existing model entity.
956
+ const check = MODEL.validVariable(name);
957
+ if(check !== true) {
958
+ // If not TRUE, check will be an error message.
959
+ msg = check;
960
+ } else if(x.x) {
955
961
  // Look up name in experiment outcomes list.
956
962
  x.v = x.x.resultIndex(name);
957
963
  if(x.v < 0 && name.indexOf('#') >= 0 &&
@@ -989,7 +995,26 @@ class ExpressionParser {
989
995
  if(x.r === false && x.t === false) {
990
996
  msg = 'Experiment run not specified';
991
997
  } else if(x.v === false) {
992
- msg = `No experiments have variable "${name}" as result`;
998
+ // NOTE: Variable may not be defined as outcome of any experiment.
999
+ // This will be handled at runtime by VMI_push_run_result, but
1000
+ // it will be helpful to notify modelers at compile time when an
1001
+ // experiment is running, and also when they are editing an
1002
+ // expression (so when a modal dialog is showing).
1003
+ const
1004
+ notice = `No experiments have variable "${name}" as result`,
1005
+ tm = UI.topModal;
1006
+ // NOTE: Only notify when expression-editing modals are showing.
1007
+ if(tm) {
1008
+ const mid = tm.id.replace('-modal', '');
1009
+ if(['actor', 'note', 'link', 'boundline-data', 'process',
1010
+ 'product', 'equation', 'expression'].indexOf(mid) >= 0) {
1011
+ UI.notify(notice);
1012
+ }
1013
+ }
1014
+ if(MODEL.running_experiment) {
1015
+ // Log message only for block 1.
1016
+ VM.logMessage(1, VM.WARNING + notice);
1017
+ }
993
1018
  }
994
1019
  }
995
1020
  if(msg) {
@@ -2311,6 +2336,17 @@ class VirtualMachine {
2311
2336
  P: 'process',
2312
2337
  Q: 'product'
2313
2338
  };
2339
+ // Reverse lookup for entity letter codes.
2340
+ this.entity_letter_codes = {
2341
+ actor: 'A',
2342
+ constraint: 'B',
2343
+ cluster: 'C',
2344
+ dataset: 'D',
2345
+ equation: 'E',
2346
+ link: 'L',
2347
+ process: 'P',
2348
+ product: 'Q'
2349
+ };
2314
2350
  this.entity_letters = 'ABCDELPQ';
2315
2351
  // Standard attributes of Linny-R entities.
2316
2352
  this.attribute_names = {