linny-r 1.3.4 → 1.4.1

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.
@@ -96,6 +96,7 @@ class LinnyRModel {
96
96
  this.grid_pixels = 20;
97
97
  this.align_to_grid = true;
98
98
  this.infer_cost_prices = false;
99
+ this.report_results = false;
99
100
  this.show_block_arrows = true;
100
101
  this.last_zoom_factor = 1;
101
102
 
@@ -328,6 +329,31 @@ class LinnyRModel {
328
329
  return null;
329
330
  }
330
331
 
332
+ wildcardEquationByID(id) {
333
+ // Returns the tuple [dataset modifier, number] holding the first
334
+ // wildcard equation for which the ID (e.g., "abc ??") matches with
335
+ // `id`, or NULL if no match is found.
336
+ // NOTE: `id` must contain a number, not a wildcard.
337
+ if(!this.equations_dataset) return null;
338
+ const ids = Object.keys(this.equations_dataset.modifiers);
339
+ for(let i = 0; i < ids.length; i++) {
340
+ // Skip the modifier ID is identical to `id` (see NOTE above).
341
+ if(ids[i] !== id) {
342
+ const re = wildcardMatchRegex(ids[i], true);
343
+ if(re) {
344
+ const m = [...id.matchAll(re)];
345
+ if(m.length > 0) {
346
+ const n = parseInt(m[0][1]);
347
+ if(n || n === 0) {
348
+ return [this.equations_dataset.modifiers[ids[i]], n];
349
+ }
350
+ }
351
+ }
352
+ }
353
+ }
354
+ return null;
355
+ }
356
+
331
357
  namedObjectByID(id) {
332
358
  // NOTE: not only entities, but also equations are "named objects", meaning
333
359
  // that their name must be unique in a model (unlike the titles of charts
@@ -361,8 +387,8 @@ class LinnyRModel {
361
387
  }
362
388
 
363
389
  objectByName(name) {
364
- // Looks up a named object based on its display name
365
- // NOTE: top cluster is uniquely identified by its name
390
+ // Looks up a named object based on its display name.
391
+ // NOTE: Top cluster is uniquely identified by its name.
366
392
  if(name === UI.TOP_CLUSTER_NAME || name === UI.FORMER_TOP_CLUSTER_NAME) {
367
393
  return this.clusters[UI.nameToID(UI.TOP_CLUSTER_NAME)];
368
394
  }
@@ -389,7 +415,7 @@ class LinnyRModel {
389
415
  // No link? then standard conversion to ID
390
416
  return this.namedObjectByID(UI.nameToID(name));
391
417
  }
392
-
418
+
393
419
  setByType(type) {
394
420
  // Returns a "dictionary" object with entities of the specified types
395
421
  if(type === 'Process') return this.processes;
@@ -450,6 +476,46 @@ class LinnyRModel {
450
476
  return list;
451
477
  }
452
478
 
479
+ allMatchingEntities(re, attr='') {
480
+ // NOTE: this routine is computationally intensive as it performs matches
481
+ // on the display names of entities while iterating over all relevant sets
482
+ const
483
+ me = [],
484
+ res = re.toString();
485
+
486
+ function scan(dict) {
487
+ // Try to match all entities in `dict`
488
+ for(let k in dict) if(dict.hasOwnProperty(k)) {
489
+ const
490
+ e = dict[k],
491
+ m = [...e.displayName.matchAll(re)];
492
+ if(m.length > 0) {
493
+ // If matches, ensure that the groups have identical values
494
+ const n = parseInt(m[0][1]);
495
+ let same = true;
496
+ for(let i = 1; same && i < m.length; i++) {
497
+ same = parseInt(m[i][1]) === n;
498
+ }
499
+ // If so, add the entity to the set
500
+ if(same) me.push(e);
501
+ }
502
+ }
503
+ }
504
+
505
+ // Links limit the search (constraints have no attributes => skip)
506
+ if(res.indexOf(UI.LINK_ARROW) >= 0) {
507
+ scan(this.links);
508
+ } else {
509
+ if(!attr || VM.actor_attr.indexOf(attr) >= 0) scan(this.actors);
510
+ if(!attr || VM.cluster_attr.indexOf(attr) >= 0) scan(this.clusters);
511
+ if(!attr) scan(this.datasets);
512
+ if(!attr || VM.process_attr.indexOf(attr) >= 0) scan(this.processes);
513
+ if(!attr || VM.product_attr.indexOf(attr) >= 0) scan(this.products);
514
+ if(!attr && this.equations_dataset) scan(this.equations_dataset.modifiers);
515
+ }
516
+ return me;
517
+ }
518
+
453
519
  get clustersToIgnore() {
454
520
  // Returns a "dictionary" with all clusters that are to be ignored
455
521
  const cti = {};
@@ -1221,17 +1287,17 @@ class LinnyRModel {
1221
1287
  if(name === UI.EQUATIONS_DATASET_NAME) {
1222
1288
  // When including a module, the current equations must be saved,
1223
1289
  // then the newly parsed dataset must have its modifiers prefixed,
1224
- // and then be merged with the original equations dataset
1290
+ // and then be merged with the original equations dataset.
1225
1291
  if(IO_CONTEXT) eqds = this.equations_dataset;
1226
1292
  // When equations dataset is added, recognize it as such, or its
1227
- // modifier selectors may be rejected while initializing from XML
1293
+ // modifier selectors may be rejected while initializing from XML.
1228
1294
  this.equations_dataset = d;
1229
1295
  }
1230
1296
  if(node) d.initFromXML(node);
1231
1297
  if(eqds) {
1232
- // Restore pointer to original equations dataset
1298
+ // Restore pointer to original equations dataset.
1233
1299
  this.equations_dataset = eqds;
1234
- // Return the extended equations dataset
1300
+ // Return the extended equations dataset.
1235
1301
  return eqds;
1236
1302
  } else {
1237
1303
  this.datasets[id] = d;
@@ -1357,9 +1423,9 @@ class LinnyRModel {
1357
1423
  if(!this.align_to_grid) return;
1358
1424
  let move = false;
1359
1425
  const fc = this.focal_cluster;
1360
- for(let i = 0; i < fc.notes.length; i++) {
1361
- move = fc.notes[i].alignToGrid() || move;
1362
- }
1426
+ // NOTE: Do not align notes to the grid. This will permit more
1427
+ // precise positioning, while aligning will not improve the layout
1428
+ // of the diagram because notes are not connected to arrows.
1363
1429
  for(let i = 0; i < fc.processes.length; i++) {
1364
1430
  move = fc.processes[i].alignToGrid() || move;
1365
1431
  }
@@ -1510,7 +1576,8 @@ class LinnyRModel {
1510
1576
  }
1511
1577
 
1512
1578
  get clusterOrProcessInSelection() {
1513
- // Return TRUE if current selection contains at least one cluster or process
1579
+ // Return TRUE if current selection contains at least one cluster
1580
+ // or process.
1514
1581
  for(let i = 0; i < this.selection.length; i++) {
1515
1582
  const obj = this.selection[i];
1516
1583
  if(obj instanceof Cluster || obj instanceof Process) return true;
@@ -1519,9 +1586,9 @@ class LinnyRModel {
1519
1586
  }
1520
1587
 
1521
1588
  moveSelection(dx, dy){
1522
- // Move all selected nodes unless cursor was not moved
1523
- // NOTE: no undo, as moves are incremental; the original positions have been
1524
- // stored on MOUSE DOWN
1589
+ // Move all selected nodes unless cursor was not moved.
1590
+ // NOTE: No undo, as moves are incremental; the original positions
1591
+ // have been stored on MOUSE DOWN.
1525
1592
  if(dx === 0 && dy === 0) return;
1526
1593
  let obj,
1527
1594
  minx = 0,
@@ -1587,6 +1654,23 @@ class LinnyRModel {
1587
1654
  }
1588
1655
  return true;
1589
1656
  }
1657
+
1658
+ eligibleFromToNodes(type) {
1659
+ // Returns a list of nodes of given type (Process, Product or Data)
1660
+ // that are visible in the focal cluster
1661
+ const
1662
+ fc = this.focal_cluster,
1663
+ el = [];
1664
+ if(type === 'Process') {
1665
+ for(let i = 0; i < fc.processes.length; i++) el.push(fc.processes[i]);
1666
+ } else {
1667
+ for(let i = 0; i < fc.product_positions.length; i++) {
1668
+ const p = fc.product_positions[i].product;
1669
+ if((type === 'Data' && p.is_data) || !p.is_data) el.push(p);
1670
+ }
1671
+ }
1672
+ return el;
1673
+ }
1590
1674
 
1591
1675
  get selectionAsXML() {
1592
1676
  // Returns XML for the selected entities, and also for the entities
@@ -1649,7 +1733,7 @@ class LinnyRModel {
1649
1733
  }
1650
1734
  // Only add the XML for notes in the selection
1651
1735
  for(let i = 0; i < entities.Note.length; i++) {
1652
- xml.push(n.asXML);
1736
+ xml.push(entities.Note[i].asXML);
1653
1737
  }
1654
1738
  for(let i = 0; i < entities.Product.length; i++) {
1655
1739
  const p = entities.Product[i];
@@ -1705,7 +1789,11 @@ class LinnyRModel {
1705
1789
  for(let i = 0; i < from_tos.length; i++) {
1706
1790
  const p = from_tos[i];
1707
1791
  ft_xml.push('<from-to type="', p.type, '" name="', xmlEncoded(p.name));
1708
- if(p instanceof Process) ft_xml.push('" actor-name="', xmlEncoded(p.actor.name));
1792
+ if(p instanceof Process) {
1793
+ ft_xml.push('" actor-name="', xmlEncoded(p.actor.name));
1794
+ } else if(p.is_data) {
1795
+ ft_xml.push('" is-data="1');
1796
+ }
1709
1797
  ft_xml.push('"></from-to>');
1710
1798
  }
1711
1799
  for(let i = 0; i < extras.length; i++) {
@@ -1824,7 +1912,7 @@ class LinnyRModel {
1824
1912
  for(let i = 0; i < notes.length; i++) {
1825
1913
  const c = this.addNote();
1826
1914
  if(c) {
1827
- c.copyPropertiesFrom(notes[i]);
1915
+ c.copyPropertiesFrom(notes[i], renumber);
1828
1916
  c.x += 100;
1829
1917
  c.y += 100;
1830
1918
  cloned_selection.push(c);
@@ -2188,7 +2276,7 @@ class LinnyRModel {
2188
2276
 
2189
2277
  get datasetVariables() {
2190
2278
  // Returns list with all ChartVariable objects in this model that
2191
- // reference a regular dataset, i.e., not an equation
2279
+ // reference a regular dataset, i.e., not an equation.
2192
2280
  const vl = [];
2193
2281
  for(let i = 0; i < MODEL.charts.length; i++) {
2194
2282
  const c = MODEL.charts[i];
@@ -2354,19 +2442,21 @@ class LinnyRModel {
2354
2442
 
2355
2443
  parseXML(data) {
2356
2444
  // Parse data string into XML tree
2357
- try {
2445
+ // try {
2358
2446
  // NOTE: Convert %23 back to # (escaped by function saveModel)
2359
2447
  const xml = parseXML(data.replace(/%23/g, '#'));
2360
2448
  // NOTE: loading, not including => make sure that IO context is NULL
2361
2449
  IO_CONTEXT = null;
2362
2450
  this.initFromXML(xml);
2363
2451
  return true;
2452
+ /*
2364
2453
  } catch(err) {
2365
2454
  // Cursor is set to WAITING when loading starts
2366
2455
  UI.normalCursor();
2367
2456
  UI.alert('Error while parsing model: ' + err);
2368
2457
  return false;
2369
2458
  }
2459
+ */
2370
2460
  }
2371
2461
 
2372
2462
  initFromXML(node) {
@@ -2397,6 +2487,7 @@ class LinnyRModel {
2397
2487
  this.decimal_comma = nodeParameterValue(node, 'decimal-comma') === '1';
2398
2488
  this.align_to_grid = nodeParameterValue(node, 'align-to-grid') === '1';
2399
2489
  this.infer_cost_prices = nodeParameterValue(node, 'cost-prices') === '1';
2490
+ this.report_results = nodeParameterValue(node, 'report-results') === '1';
2400
2491
  this.show_block_arrows = nodeParameterValue(node, 'block-arrows') === '1';
2401
2492
  this.name = xmlDecoded(nodeContentByTag(node, 'name'));
2402
2493
  this.author = xmlDecoded(nodeContentByTag(node, 'author'));
@@ -2739,6 +2830,7 @@ class LinnyRModel {
2739
2830
  if(this.decimal_comma) p += ' decimal-comma="1"';
2740
2831
  if(this.align_to_grid) p += ' align-to-grid="1"';
2741
2832
  if(this.infer_cost_prices) p += ' cost-prices="1"';
2833
+ if(this.report_results) p += ' report-results="1"';
2742
2834
  if(this.show_block_arrows) p += ' block-arrows="1"';
2743
2835
  let xml = this.xml_header + ['<model', p, '><name>', xmlEncoded(this.name),
2744
2836
  '</name><author>', xmlEncoded(this.author),
@@ -2961,7 +3053,14 @@ class LinnyRModel {
2961
3053
  }
2962
3054
 
2963
3055
  resetExpressions() {
2964
- // Create a new vector for all expression attributes of all model entities
3056
+ // Create a new vector for all expression attributes of all model
3057
+ // entities, using the appropriate default value.
3058
+
3059
+ // Ensure that the equations dataset must have default value UNDEFINED
3060
+ // so the modeler is warned when a wildcard equation fails to obtain
3061
+ // a valid wildcard number.
3062
+ this.equations_dataset.default_value = VM.UNDEFINED;
3063
+
2965
3064
  let obj, l, p;
2966
3065
  for(obj in this.actors) if(this.actors.hasOwnProperty(obj)) {
2967
3066
  p = this.actors[obj];
@@ -2979,9 +3078,7 @@ class LinnyRModel {
2979
3078
  this.cleanVector(p.cash_in, 0, 0);
2980
3079
  this.cleanVector(p.cash_out, 0, 0);
2981
3080
  // NOTE: note fields also must be reset
2982
- for(let i = 0; i < p.notes.length; i++) {
2983
- p.notes[i].parsed = false;
2984
- }
3081
+ p.resetNoteFields();
2985
3082
  }
2986
3083
  for(obj in this.processes) if(this.processes.hasOwnProperty(obj)) {
2987
3084
  p = this.processes[obj];
@@ -4521,7 +4618,8 @@ class ObjectWithXYWH {
4521
4618
  }
4522
4619
 
4523
4620
  alignToGrid() {
4524
- // Align this object to the grid, and return TRUE if this involved a move
4621
+ // Align this object to the grid, and return TRUE if this involved
4622
+ // a move
4525
4623
  const
4526
4624
  ox = this.x,
4527
4625
  oy = this.y,
@@ -4542,22 +4640,29 @@ class ObjectWithXYWH {
4542
4640
  } // END of CLASS ObjectWithXYWH
4543
4641
 
4544
4642
 
4545
- // CLASS NoteField: numeric value of "field" [[dataset]] in note text
4643
+ // CLASS NoteField: numeric value of "field" [[variable]] in note text
4546
4644
  class NoteField {
4547
- constructor(f, o, u='1', m=1) {
4645
+ constructor(n, f, o, u='1', m=1, w=false) {
4646
+ // `n` is the note that "owns" this note field
4548
4647
  // `f` holds the unmodified tag string [[dataset]] to be replaced by
4549
4648
  // the value of vector or expression `o` for the current time step;
4550
- // if specified, `u` is the unit of the value to be displayed, and
4551
- // `m` is the multiplier for the value to be displayed
4649
+ // if specified, `u` is the unit of the value to be displayed,
4650
+ // `m` is the multiplier for the value to be displayed, and `w` is
4651
+ // the wildcard number to use in a wildcard equation
4652
+ this.note = n;
4552
4653
  this.field = f;
4553
4654
  this.object = o;
4554
4655
  this.unit = u;
4555
4656
  this.multiplier = m;
4657
+ this.wildcard_number = (w ? parseInt(w) : false);
4556
4658
  }
4557
4659
 
4558
4660
  get value() {
4559
4661
  // Returns the numeric value of this note field as a numeric string
4560
4662
  // followed by its unit (unless this is 1)
4663
+ // If object is the note, this means field [[#]] (note number context)
4664
+ // If this is undefined (empty string) display a double question mark
4665
+ if(this.object === this.note) return this.note.numberContext || '\u2047';
4561
4666
  let v = VM.UNDEFINED;
4562
4667
  const t = MODEL.t;
4563
4668
  if(Array.isArray(this.object)) {
@@ -4569,7 +4674,7 @@ class NoteField {
4569
4674
  v = MODEL.flowBalance(this.object, t);
4570
4675
  } else if(this.object instanceof Expression) {
4571
4676
  // Object is an expression
4572
- v = this.object.result(t);
4677
+ v = this.object.result(t, this.wildcard_number);
4573
4678
  } else if(typeof this.object === 'number') {
4574
4679
  v = this.object;
4575
4680
  } else {
@@ -4594,7 +4699,7 @@ class Note extends ObjectWithXYWH {
4594
4699
  super(cluster);
4595
4700
  const dt = new Date();
4596
4701
  // NOTE: use timestamp in msec to generate a unique identifier
4597
- this.timestamp = dt.getTime();
4702
+ this.timestamp = dt.getTime();
4598
4703
  this.contents = '';
4599
4704
  this.lines = [];
4600
4705
  this.fields = [];
@@ -4610,17 +4715,68 @@ class Note extends ObjectWithXYWH {
4610
4715
  return 'Note';
4611
4716
  }
4612
4717
 
4718
+ get clusterPrefix() {
4719
+ // Returns the name of the cluster containing this note, followed
4720
+ // by a colon+space, except when this cluster is the top cluster.
4721
+ if(this.cluster === MODEL.top_cluster) return '';
4722
+ return this.cluster.displayName + UI.PREFIXER;
4723
+ }
4724
+
4613
4725
  get displayName() {
4614
- return `Note #${this.cluster.notes.indexOf(this) + 1} in ` +
4615
- this.cluster.displayName;
4726
+ const
4727
+ n = this.number,
4728
+ type = (n ? `Numbered note #${n}` : 'Note');
4729
+ return `${this.clusterPrefix}${type} at (${this.x}, ${this.y})`;
4616
4730
  }
4617
4731
 
4618
- get numberContext() {
4619
- // Returns the string to be used to evaluate #
4620
- // NOTE: this does not apply to notes, so return empty string
4732
+ get number() {
4733
+ // Returns the number of this note if specified (e.g. as #123).
4734
+ // NOTE: this only applies to notes having note fields.
4735
+ const m = this.contents.replace(/\s+/g, ' ')
4736
+ .match(/^[^\]]*#(\d+).*\[\[[^\]]+\]\]/);
4737
+ if(m) return m[1];
4621
4738
  return '';
4622
4739
  }
4623
4740
 
4741
+ get numberContext() {
4742
+ // Returns the string to be used to evaluate #. For notes this is
4743
+ // their note number if specified, otherwise the number context of a
4744
+ // nearby node, and otherwise the number context of their cluster.
4745
+ let n = this.number;
4746
+ if(n) return n;
4747
+ n = this.nearbyNode;
4748
+ if(n) return n.numberContext;
4749
+ return this.cluster.numberContext;
4750
+ }
4751
+
4752
+ get nearbyNode() {
4753
+ // Returns a node in the cluster of this note that is closest to this
4754
+ // note (Euclidian distance between center points), but with at most
4755
+ // 30 pixel units between their rims.
4756
+ const
4757
+ c = this.cluster,
4758
+ nodes = c.processes.concat(c.product_positions, c.sub_clusters);
4759
+ let nn = nodes[0] || null;
4760
+ if(nn) {
4761
+ let md = 1e+10;
4762
+ // Find the nearest node
4763
+ for(let i = 0; i < nodes.length; i++) {
4764
+ const n = nodes[i];
4765
+ const
4766
+ dx = (n.x - this.x),
4767
+ dy = (n.y - this.y),
4768
+ d = Math.sqrt(dx*dx + dy*dy);
4769
+ if(d < md) {
4770
+ nn = n;
4771
+ md = d;
4772
+ }
4773
+ }
4774
+ if(Math.abs(nn.x - this.x) < (nn.width + this.width) / 2 + 30 &&
4775
+ Math.abs(nn.y - this.y) < (nn.height + this.height) / 2 + 30) return nn;
4776
+ }
4777
+ return null;
4778
+ }
4779
+
4624
4780
  get asXML() {
4625
4781
  return ['<note><timestamp>', this.timestamp,
4626
4782
  '</timestamp><contents>', xmlEncoded(this.contents),
@@ -4655,27 +4811,27 @@ class Note extends ObjectWithXYWH {
4655
4811
  }
4656
4812
 
4657
4813
  setCluster(c) {
4658
- // Place this note into the specified cluster `c`
4814
+ // Place this note into the specified cluster `c`.
4659
4815
  if(this.cluster) {
4660
- // Remove this note from its current cluster's note list
4816
+ // Remove this note from its current cluster's note list.
4661
4817
  const i = this.cluster.notes.indexOf(this);
4662
4818
  if(i >= 0) this.cluster.notes.splice(i, 1);
4663
4819
  // Set its new cluster pointer...
4664
4820
  this.cluster = c;
4665
- // ... and add it to the new cluster's note list
4821
+ // ... and add it to the new cluster's note list.
4666
4822
  if(c.notes.indexOf(this) < 0) c.notes.push(this);
4667
4823
  }
4668
4824
  }
4669
4825
 
4670
4826
  get tagList() {
4671
- // Returns a list of matches for [[...]], or NULL if none
4827
+ // Returns a list of matches for [[...]], or NULL if none.
4672
4828
  return this.contents.match(/\[\[[^\]]+\]\]/g);
4673
4829
  }
4674
4830
 
4675
4831
  parseFields() {
4676
- // Fills the list of fields by parsing all [[...]] tags in the text
4832
+ // Fills the list of fields by parsing all [[...]] tags in the text.
4677
4833
  // NOTE: this does not affect the text itself; tags will be replaced
4678
- // by numerical values only when drawing the note
4834
+ // by numerical values only when drawing the note.
4679
4835
  this.fields.length = 0;
4680
4836
  const tags = this.tagList;
4681
4837
  if(tags) {
@@ -4685,19 +4841,25 @@ class Note extends ObjectWithXYWH {
4685
4841
  inner = tag.slice(2, tag.length - 2).trim(),
4686
4842
  bar = inner.lastIndexOf('|'),
4687
4843
  arrow = inner.lastIndexOf('->');
4688
- // Check if a unit conversion scalar was specified
4844
+ // Special case: [[#]] denotes the number context of this note.
4845
+ if(tag.replace(/\s+/, '') === '[[#]]') {
4846
+ this.fields.push(new NoteField(this, tag, this));
4847
+ // Done, so move on to the next tag
4848
+ continue;
4849
+ }
4850
+ // Check if a unit conversion scalar was specified.
4689
4851
  let ena,
4690
4852
  from_unit = '1',
4691
4853
  to_unit = '',
4692
4854
  multiplier = 1;
4693
4855
  if(arrow > bar) {
4694
- // Now for sure it is entity->unit or entity|attr->unit
4856
+ // Now for sure it is entity->unit or entity|attr->unit.
4695
4857
  ena = inner.split('->');
4696
4858
  // As example, assume that unit = 'kWh' (so the value of the
4697
- // field should be displayed in kilowatthour)
4698
- // NOTE: use .trim() instead of UI.cleanName(...) here;
4699
- // this forces the modeler to be exact, and that permits proper
4700
- // renaming of scale units in note fields
4859
+ // field should be displayed in kilowatthour).
4860
+ // NOTE: use .trim() instead of UI.cleanName(...) here. This
4861
+ // forces the modeler to be exact, and that permits proper
4862
+ // renaming of scale units in note fields.
4701
4863
  to_unit = ena[1].trim();
4702
4864
  ena = ena[0].split('|');
4703
4865
  if(!MODEL.scale_units.hasOwnProperty(to_unit)) {
@@ -4707,27 +4869,56 @@ class Note extends ObjectWithXYWH {
4707
4869
  } else {
4708
4870
  ena = inner.split('|');
4709
4871
  }
4710
- // Look up entity for name and attribute
4711
- const obj = MODEL.objectByName(ena[0].trim());
4712
- if(obj instanceof DatasetModifier) {
4713
- // NOTE: equations are (for now) dimensionless => unit '1'
4872
+ // Look up entity for name and attribute.
4873
+ // NOTE: A leading colon denotes "prefix with cluster name".
4874
+ let en = UI.colonPrefixedName(ena[0].trim(), this.clusterPrefix),
4875
+ id = UI.nameToID(en),
4876
+ // First try to match `id` with the IDs of wildcard equations,
4877
+ // (e.g., "abc 123" would match with "abc ??").
4878
+ w = MODEL.wildcardEquationByID(id),
4879
+ obj = null,
4880
+ wildcard = false;
4881
+ if(w) {
4882
+ // If wildcard equation match, w[0] is the equation (instance
4883
+ // of DatasetModifier), and w[1] the matching number.
4884
+ obj = w[0];
4885
+ wildcard = w[1];
4886
+ } else {
4887
+ obj = MODEL.objectByID(id);
4888
+ }
4889
+ // If not found, this may be due to # wildcards in the name.
4890
+ if(!obj && en.indexOf('#') >= 0) {
4891
+ // First try substituting # by the context number.
4892
+ const numcon = this.numberContext;
4893
+ obj = MODEL.objectByName(en.replace('#', numcon));
4894
+ // If no match, check whether the name matches a wildcard equation.
4895
+ if(!obj) {
4896
+ obj = MODEL.equationByID(UI.nameToID(en.replace('#', '??')));
4897
+ if(obj) wildcard = numcon;
4898
+ }
4899
+ }
4900
+ if(!obj) {
4901
+ UI.warn(`Unknown model entity "${en}"`);
4902
+ } else if(obj instanceof DatasetModifier) {
4903
+ // NOTE: equations are (for now) dimensionless => unit '1'.
4714
4904
  if(obj.dataset !== MODEL.equations_dataset) {
4715
4905
  from_unit = obj.dataset.scale_unit;
4716
4906
  multiplier = MODEL.unitConversionMultiplier(from_unit, to_unit);
4717
4907
  }
4718
- this.fields.push(new NoteField(tag, obj.expression, to_unit, multiplier));
4908
+ this.fields.push(new NoteField(this, tag, obj.expression, to_unit,
4909
+ multiplier, wildcard));
4719
4910
  } else if(obj) {
4720
- // If attribute omitted, use default attribute of entity type
4911
+ // If attribute omitted, use default attribute of entity type.
4721
4912
  const attr = (ena.length > 1 ? ena[1].trim() : obj.defaultAttribute);
4722
4913
  let val = null;
4723
- // NOTE: for datasets, use the active modifier
4914
+ // NOTE: for datasets, use the active modifier if no attribute.
4724
4915
  if(!attr && obj instanceof Dataset) {
4725
4916
  val = obj.activeModifierExpression;
4726
4917
  } else {
4727
- // Variable may specify a vector-type attribute
4918
+ // Variable may specify a vector-type attribute.
4728
4919
  val = obj.attributeValue(attr);
4729
4920
  }
4730
- // If not, it may be a cluster unit balance
4921
+ // If not, it may be a cluster unit balance.
4731
4922
  if(!val && attr.startsWith('=') && obj instanceof Cluster) {
4732
4923
  val = {c: obj, u: attr.substring(1).trim()};
4733
4924
  from_unit = val.u;
@@ -4753,9 +4944,20 @@ class Note extends ObjectWithXYWH {
4753
4944
  } else if(attr === 'CI' || attr === 'CO' || attr === 'CF') {
4754
4945
  from_unit = MODEL.currency_unit;
4755
4946
  }
4756
- // If not, it may be an expression-type attribute
4947
+ // If still no value, `attr` may be an expression-type attribute.
4757
4948
  if(!val) {
4758
4949
  val = obj.attributeExpression(attr);
4950
+ // For wildcard expressions, provide the tail number of `attr`
4951
+ // as number context.
4952
+ if(val && val.isWildcardExpression) {
4953
+ const nr = matchingNumber(attr, val.attribute);
4954
+ if(nr) {
4955
+ wildcard = nr;
4956
+ } else {
4957
+ UI.warn(`Attribute "${attr}" does not provide a number`);
4958
+ continue;
4959
+ }
4960
+ }
4759
4961
  if(obj instanceof Product) {
4760
4962
  if(attr === 'IL' || attr === 'LB' || attr === 'UB') {
4761
4963
  from_unit = obj.scale_unit;
@@ -4764,16 +4966,15 @@ class Note extends ObjectWithXYWH {
4764
4966
  }
4765
4967
  }
4766
4968
  }
4767
- // If no TO unit, add the FROM unit
4969
+ // If no TO unit, add the FROM unit.
4768
4970
  if(to_unit === '') to_unit = from_unit;
4769
4971
  if(val) {
4770
4972
  multiplier = MODEL.unitConversionMultiplier(from_unit, to_unit);
4771
- this.fields.push(new NoteField(tag, val, to_unit, multiplier));
4973
+ this.fields.push(new NoteField(this, tag, val, to_unit,
4974
+ multiplier, wildcard));
4772
4975
  } else {
4773
4976
  UI.warn(`Unknown ${obj.type.toLowerCase()} attribute "${attr}"`);
4774
4977
  }
4775
- } else {
4776
- UI.warn(`Unknown model entity "${ena[0].trim()}"`);
4777
4978
  }
4778
4979
  }
4779
4980
  }
@@ -4781,20 +4982,24 @@ class Note extends ObjectWithXYWH {
4781
4982
  }
4782
4983
 
4783
4984
  get fieldEntities() {
4784
- // Return a list with names of entities used in fields
4985
+ // Return a list with names of entities used in fields.
4785
4986
  const
4786
4987
  fel = [],
4787
4988
  tags = this.tagList;
4788
4989
  for(let i = 0; i < tags.length; i++) {
4789
4990
  const
4790
4991
  tag = tags[i],
4791
- inner = tag.slice(2, tag.length - 2).trim(),
4992
+ // Trim brackets and padding spaces on both sides, and then
4993
+ // expand leading colons that denote prefixes.
4994
+ inner = UI.colonPrefixedName(tag.slice(2, tag.length - 2).trim()),
4792
4995
  vb = inner.lastIndexOf('|'),
4793
4996
  ua = inner.lastIndexOf('->');
4794
4997
  if(vb >= 0) {
4998
+ // Vertical bar? Then the entity name is the left part.
4795
4999
  addDistinct(inner.slice(0, vb), fel);
4796
5000
  } else if(ua >= 0 &&
4797
5001
  MODEL.scale_units.hasOwnProperty(inner.slice(ua + 2))) {
5002
+ // Unit arrow? Then trim the "->unit" part.
4798
5003
  addDistinct(inner.slice(0, ua), fel);
4799
5004
  } else {
4800
5005
  addDistinct(inner, fel);
@@ -4818,29 +5023,30 @@ class Note extends ObjectWithXYWH {
4818
5023
  }
4819
5024
 
4820
5025
  rewriteFields(en1, en2) {
4821
- // Rename fields that reference entity name `en1` to reference `en2` instead
4822
- // NOTE: this does not affect the expression code
5026
+ // Rename fields that reference entity name `en1` to reference `en2`
5027
+ // instead.
5028
+ // NOTE: This does not affect the expression code.
4823
5029
  if(en1 === en2) return;
4824
5030
  for(let i = 0; i < this.fields.length; i++) {
4825
5031
  const
4826
5032
  f = this.fields[i],
4827
- // Trim the double brackets on both sides
4828
- tag = f.field.slice(2, f.field.length - 2);
4829
- // Separate tag into variable and attribute + offset string (if any)
5033
+ // Trim the double brackets and padding spaces on both sides.
5034
+ tag = f.field.slice(2, f.field.length - 2).trim();
5035
+ // Separate tag into variable and attribute + offset string (if any).
4830
5036
  let e = tag,
4831
5037
  a = '',
4832
5038
  vb = tag.lastIndexOf('|'),
4833
5039
  ua = tag.lastIndexOf('->');
4834
5040
  if(vb >= 0) {
4835
5041
  e = tag.slice(0, vb);
4836
- // NOTE: attribute string includes the vertical bar '|'
5042
+ // NOTE: Attribute string includes the vertical bar '|'.
4837
5043
  a = tag.slice(vb);
4838
5044
  } else if(ua >= 0 && MODEL.scale_units.hasOwnProperty(tag.slice(ua + 2))) {
4839
5045
  e = tag.slice(0, ua);
4840
- // NOTE: attribute string includes the unit conversion arrow '->'
5046
+ // NOTE: Attribute string includes the unit conversion arrow '->'.
4841
5047
  a = tag.slice(ua);
4842
5048
  }
4843
- // Check for match
5049
+ // Check for match.
4844
5050
  const r = UI.replaceEntity(e, en1, en2);
4845
5051
  if(r) {
4846
5052
  e = `[[${r}${a}]]`;
@@ -4851,6 +5057,8 @@ class Note extends ObjectWithXYWH {
4851
5057
  }
4852
5058
 
4853
5059
  get evaluateFields() {
5060
+ // Returns the text content of this note with all tags replaced
5061
+ // by their note field values.
4854
5062
  if(!this.parsed) this.parseFields();
4855
5063
  let txt = this.contents;
4856
5064
  for(let i = 0; i < this.fields.length; i++) {
@@ -4861,51 +5069,66 @@ class Note extends ObjectWithXYWH {
4861
5069
  }
4862
5070
 
4863
5071
  resize() {
4864
- // Resizes the note; returns TRUE iff size has changed
5072
+ // Resizes the note; returns TRUE iff size has changed.
4865
5073
  let txt = this.evaluateFields;
4866
5074
  const
4867
5075
  w = this.width,
4868
5076
  h = this.height,
4869
- // Minimumm note width of 10 characters
5077
+ // Minimumm note width of 10 characters.
4870
5078
  n = Math.max(txt.length, 10),
4871
5079
  fh = UI.textSize('hj').height;
4872
- // Approximate the width to obtain a rectangle
5080
+ // Approximate the width to obtain a rectangle.
4873
5081
  // NOTE: 3:1 may seem exagerated, but characters are higher than wide,
4874
- // and there will be more (short) lines due to newlines and wrapping
5082
+ // and there will be more (short) lines due to newlines and wrapping.
4875
5083
  let tw = Math.ceil(3*Math.sqrt(n)) * fh / 2;
4876
5084
  this.lines = UI.stringToLineArray(txt, tw).join('\n');
4877
5085
  let bb = UI.textSize(this.lines, 8);
4878
- // Ensure that shape is wider than tall
5086
+ // Aim to make the shape wider than tall.
4879
5087
  let nw = bb.width,
4880
5088
  nh = bb.height;
4881
5089
  while(bb.width < bb.height * 1.7) {
4882
5090
  tw *= 1.2;
4883
5091
  this.lines = UI.stringToLineArray(txt, tw).join('\n');
4884
5092
  bb = UI.textSize(this.lines, 8);
4885
- // Prevent infinite loop
5093
+ // Prevent infinite loop.
4886
5094
  if(nw <= bb.width || nh > bb.height) break;
4887
5095
  }
4888
5096
  this.height = 1.05 * (bb.height + 6);
4889
5097
  this.width = bb.width + 6;
5098
+ // Boolean return value indicates whether size has changed.
4890
5099
  return this.width != w || this.height != h;
4891
5100
  }
4892
5101
 
4893
5102
  containsPoint(mpx, mpy) {
5103
+ // Returns TRUE iff given coordinates lie within the note rectangle.
4894
5104
  return (Math.abs(mpx - this.x) <= this.width / 2 &&
4895
5105
  Math.abs(mpy - this.y) <= this.height / 2);
4896
5106
  }
4897
5107
 
4898
- copyPropertiesFrom(n) {
4899
- // Set properties to be identical to those of note `n`
5108
+ copyPropertiesFrom(n, renumber=false) {
5109
+ // Sets properties to be identical to those of note `n`.
4900
5110
  this.x = n.x;
4901
5111
  this.y = n.y;
4902
- this.contents = n.contents;
5112
+ let cont = n.contents;
5113
+ if(renumber) {
5114
+ // Renumbering only applies to notes having note fields; then the
5115
+ // note number must be denoted like #123, and occur before the first
5116
+ // note field.
5117
+ const m = cont.match(/^[^\]]*#(\d+).*\[\[[^\]]+\]\]/);
5118
+ if(m) {
5119
+ const nn = this.cluster.nextAvailableNoteNumber(m[1]);
5120
+ cont = cont.replace(/#\d+/, `#${nn}`);
5121
+ }
5122
+ }
5123
+ this.contents = cont;
5124
+ // NOTE: Renumbering does not affect the note fields or the color
5125
+ // expression. This is a design choice; the modeler can use wildcards.
4903
5126
  this.color.text = n.color.text;
4904
5127
  this.parsed = false;
4905
5128
  }
4906
5129
 
4907
5130
  differences(n) {
4908
- // Return "dictionary" of differences, or NULL if none
5131
+ // Return "dictionary" of differences, or NULL if none.
4909
5132
  const d = differences(this, n, UI.MC.NOTE_PROPS);
4910
5133
  if(Object.keys(d).length > 0) return d;
4911
5134
  return null;
@@ -4947,6 +5170,17 @@ class NodeBox extends ObjectWithXYWH {
4947
5170
  // NOTE: Display nothing if entity is "black-boxed"
4948
5171
  if(n.startsWith(UI.BLACK_BOX)) return '';
4949
5172
  n = `<em>${this.type}:</em> ${n}`;
5173
+ // For clusters, add how many processes and products they contain
5174
+ if(this instanceof Cluster) {
5175
+ let d = '';
5176
+ if(this.all_processes) {
5177
+ const dl = [];
5178
+ dl.push(pluralS(this.all_processes.length, 'process'));
5179
+ dl.push(pluralS(this.all_products.length, 'product'));
5180
+ d = dl.join(', ').toLowerCase();
5181
+ }
5182
+ if(d) n += `<span class="node-details">${d}</span>`;
5183
+ }
4950
5184
  if(DEBUGGING && MODEL.solved) {
4951
5185
  n += ' [';
4952
5186
  if(this instanceof Process || this instanceof Product) {
@@ -4971,45 +5205,55 @@ class NodeBox extends ObjectWithXYWH {
4971
5205
  }
4972
5206
 
4973
5207
  get numberContext() {
4974
- // Returns the string to be used to evaluate #, so for clusters, processes
4975
- // and products this is the string of trailing digits (or empty if none)
4976
- // of the node name, or if that does not end with a number, the trailing
4977
- // digits of the first prefix (from right to left) that does
4978
- const sn = UI.prefixesAndName(this.name);
4979
- let nc = endsWithDigits(sn.pop());
4980
- while(!nc && sn.length > 0) {
4981
- nc = endsWithDigits(sn.pop());
5208
+ // Returns the string to be used to evaluate #, so for clusters,
5209
+ // processes and products this is their "tail number".
5210
+ return UI.tailNumber(this.name);
5211
+ }
5212
+
5213
+ get similarNumberedEntities() {
5214
+ // Returns a list of nodes of the same type that have a number
5215
+ // context similar to this node.
5216
+ const nc = this.numberContext;
5217
+ if(!nc) return [];
5218
+ const
5219
+ re = wildcardMatchRegex(this.displayName.replace(nc, '#')),
5220
+ nodes = MODEL.setByType(this.type),
5221
+ similar = [];
5222
+ for(let id in nodes) if(nodes.hasOwnProperty(id)) {
5223
+ const n = nodes[id];
5224
+ if(n.displayName.match(re)) similar.push(n);
4982
5225
  }
4983
- return nc;
5226
+ return similar;
4984
5227
  }
4985
5228
 
4986
5229
  rename(name, actor_name) {
4987
- // Changes the name and/or actor name of this process, product or cluster
4988
- // NOTE: returns TRUE if rename was successful, FALSE on error, and a
4989
- // process, product or cluster if such entity having the new name already
4990
- // exists
5230
+ // Changes the name and/or actor name of this node (process, product
5231
+ // or cluster).
5232
+ // NOTE: Returns TRUE if rename was successful, FALSE on error, and
5233
+ // a process, product or cluster if such entity having the new name
5234
+ // already exists.
4991
5235
  name = UI.cleanName(name);
4992
5236
  if(!UI.validName(name)) {
4993
5237
  UI.warningInvalidName(name);
4994
5238
  return false;
4995
5239
  }
4996
- // Compose the full name
5240
+ // Compose the full name.
4997
5241
  if(actor_name === '') actor_name = UI.NO_ACTOR;
4998
5242
  let fn = name;
4999
5243
  if(actor_name != UI.NO_ACTOR) fn += ` (${actor_name})`;
5000
5244
  // Get the ID (derived from the full name) and check if MODEL already
5001
- // contains another entity with this ID
5245
+ // contains another entity with this ID.
5002
5246
  const
5003
5247
  old_name = this.displayName,
5004
5248
  old_id = this.identifier,
5005
5249
  new_id = UI.nameToID(fn),
5006
5250
  n = MODEL.nodeBoxByID(new_id);
5007
- // If so, do NOT rename, but return this object instead
5008
- // NOTE: if entity with this name is THIS entity, it typically means
5009
- // a cosmetic name change (upper/lower case) which SHOULD be performed
5251
+ // If so, do NOT rename, but return this object instead.
5252
+ // NOTE: If entity with this name is THIS entity, it typically means
5253
+ // a cosmetic name change (upper/lower case) which SHOULD be performed.
5010
5254
  if(n && n !== this) return n;
5011
- // Otherwise, if IDs differ, add this object under its new key, and remove
5012
- // its old entry
5255
+ // Otherwise, if IDs differ, add this object under its new key, and
5256
+ // remove its old entry.
5013
5257
  if(old_id != new_id) {
5014
5258
  if(this instanceof Process) {
5015
5259
  MODEL.processes[new_id] = this;
@@ -5021,21 +5265,21 @@ class NodeBox extends ObjectWithXYWH {
5021
5265
  MODEL.clusters[new_id] = this;
5022
5266
  delete MODEL.clusters[old_id];
5023
5267
  } else {
5024
- // NOTE: this should never happen => report an error
5268
+ // NOTE: This should never happen => report an error.
5025
5269
  UI.alert('Can only rename processes, products and clusters');
5026
5270
  return false;
5027
5271
  }
5028
5272
  }
5029
- // Change this object's name and actor
5273
+ // Change this object's name and actor.
5030
5274
  this.actor = MODEL.addActor(actor_name);
5031
5275
  this.name = name;
5032
- // Update actor list in case some actor name is no longer used
5276
+ // Update actor list in case some actor name is no longer used.
5033
5277
  MODEL.cleanUpActors();
5034
5278
  MODEL.replaceEntityInExpressions(old_name, this.displayName);
5035
5279
  MODEL.inferIgnoredEntities();
5036
- // NOTE: renaming may affect the node's display size
5280
+ // NOTE: Renaming may affect the node's display size.
5037
5281
  if(this.resize()) this.drawWithLinks();
5038
- // NOTE: only TRUE indicates a successful (cosmetic) name change
5282
+ // NOTE: Only TRUE indicates a successful (cosmetic) name change.
5039
5283
  return true;
5040
5284
  }
5041
5285
 
@@ -5484,7 +5728,8 @@ class Cluster extends NodeBox {
5484
5728
  }
5485
5729
 
5486
5730
  attributeValue(a) {
5487
- // Return the computed result for attribute a (for clusters always a vector)
5731
+ // Return the computed result for attribute `a`
5732
+ // For clusters, this is always a vector
5488
5733
  if(a === 'CF') return this.cash_flow;
5489
5734
  if(a === 'CI') return this.cash_in;
5490
5735
  if(a === 'CO') return this.cash_out;
@@ -5751,6 +5996,27 @@ class Cluster extends NodeBox {
5751
5996
  return notes;
5752
5997
  }
5753
5998
 
5999
+ resetNoteFields() {
6000
+ // Ensure that all note fields are parsed anew when a note in this
6001
+ // cluster are drawn.
6002
+ for(let i = 0; i < this.notes.length; i++) {
6003
+ this.notes[i].parsed = false;
6004
+ }
6005
+ }
6006
+
6007
+ nextAvailableNoteNumber(n) {
6008
+ // Returns the first integer greater than `n` that is not already in use
6009
+ // by a note of this cluster
6010
+ let nn = parseInt(n) + 1;
6011
+ const nrs = [];
6012
+ for(let i = 0; i < this.notes.length; i++) {
6013
+ const nr = this.notes[i].number;
6014
+ if(nr) nrs.push(parseInt(nr));
6015
+ }
6016
+ while(nrs.indexOf(nn) >= 0) nn++;
6017
+ return nn;
6018
+ }
6019
+
5754
6020
  clearAllProcesses() {
5755
6021
  // Clear `all_processes` property of this cluster AND of all its parent clusters
5756
6022
  this.all_processes = null;
@@ -7153,6 +7419,7 @@ class Process extends Node {
7153
7419
  }
7154
7420
 
7155
7421
  get defaultAttribute() {
7422
+ // Default attribute of processes is their level
7156
7423
  return 'L';
7157
7424
  }
7158
7425
 
@@ -7168,6 +7435,7 @@ class Process extends Node {
7168
7435
  }
7169
7436
 
7170
7437
  attributeExpression(a) {
7438
+ // Processes have three expression attributes
7171
7439
  if(a === 'LB') return this.lower_bound;
7172
7440
  if(a === 'UB') {
7173
7441
  return (this.equal_bounds ? this.lower_bound : this.upper_bound);
@@ -7563,6 +7831,7 @@ class Product extends Node {
7563
7831
  }
7564
7832
 
7565
7833
  get defaultAttribute() {
7834
+ // Products have their level as default attribute
7566
7835
  return 'L';
7567
7836
  }
7568
7837
 
@@ -7576,6 +7845,7 @@ class Product extends Node {
7576
7845
  }
7577
7846
 
7578
7847
  attributeExpression(a) {
7848
+ // Products have four expression attributes
7579
7849
  if(a === 'LB') return this.lower_bound;
7580
7850
  if(a === 'UB') {
7581
7851
  return (this.equal_bounds ? this.lower_bound : this.upper_bound);
@@ -7586,6 +7856,7 @@ class Product extends Node {
7586
7856
  }
7587
7857
 
7588
7858
  changeScaleUnit(name) {
7859
+ // Changes the scale unit for this product to `name`
7589
7860
  let su = MODEL.addScaleUnit(name);
7590
7861
  if(su !== this.scale_unit) {
7591
7862
  this.scale_unit = su;
@@ -7873,6 +8144,7 @@ class Link {
7873
8144
  }
7874
8145
 
7875
8146
  get defaultAttribute() {
8147
+ // For links, the default attribute is their actual flow
7876
8148
  return 'F';
7877
8149
  }
7878
8150
 
@@ -7884,6 +8156,7 @@ class Link {
7884
8156
  }
7885
8157
 
7886
8158
  attributeExpression(a) {
8159
+ // Links have two expression attributes
7887
8160
  if(a === 'R') return this.relative_rate;
7888
8161
  if(a === 'D') return this.flow_delay;
7889
8162
  return null;
@@ -7984,20 +8257,38 @@ class DatasetModifier {
7984
8257
  }
7985
8258
 
7986
8259
  get hasWildcards() {
7987
- return this.selector.indexOf('*') >= 0 || this.selector.indexOf('?') >= 0;
8260
+ // Returns TRUE if this modifier contains wildcards
8261
+ return this.dataset.isWildcardSelector(this.selector);
8262
+ }
8263
+
8264
+ get numberContext() {
8265
+ // Returns the string to be used to evaluate #.
8266
+ // NOTE: If the selector contains wildcards, return "?" to indicate
8267
+ // that the value of # cannot be inferred at compile time.
8268
+ if(this.hasWildcards) return '?';
8269
+ // Otherwise, return the "tail number" of the selector, or if the
8270
+ // selector has no tail number, return the number context of the
8271
+ // dataset of this modifier.
8272
+ return UI.tailnumber(this.name) || this.dataset.numberContext;
7988
8273
  }
7989
8274
 
7990
8275
  match(s) {
7991
- if(this.hasWildcards) {
7992
- // NOTE: replace ? by . (any character) in pattern and * by .*
7993
- const re = new RegExp(
7994
- this.selector.replace(/\?/g, '.').replace(/\*/g, '.*'));
7995
- return re.test(s);
8276
+ // Returns TRUE if string `s` matches with the wildcard pattern of
8277
+ // the selector.
8278
+ if(!this.hasWildcards) return s === this.selector;
8279
+ let re;
8280
+ if(this.dataset === MODEL.equations_dataset) {
8281
+ // Equations wildcards only match with digits
8282
+ re = wildcardMatchRegex(this.selector, true);
7996
8283
  } else {
7997
- return s === this.selector;
8284
+ // Selector wildcards match with any character, so replace ? by .
8285
+ // (any character) in pattern, and * by .*
8286
+ const raw = this.selector.replace(/\?/g, '.').replace(/\*/g, '.*');
8287
+ re = new RegExp(`^${raw}$`);
7998
8288
  }
8289
+ return re.test(s);
7999
8290
  }
8000
-
8291
+
8001
8292
  } // END of class DatasetModifier
8002
8293
 
8003
8294
 
@@ -8062,15 +8353,11 @@ class Dataset {
8062
8353
  }
8063
8354
 
8064
8355
  get numberContext() {
8065
- // Returns the string to be used to evaluate # (empty string if undefined)
8066
- const sn = UI.prefixesAndName(this.name);
8067
- let nc = endsWithDigits(sn.pop());
8068
- while(!nc && sn.length > 0) {
8069
- nc = endsWithDigits(sn.pop());
8070
- }
8071
- return nc;
8356
+ // Returns the string to be used to evaluate #
8357
+ // Like for nodes, this is the "tail number" of the dataset name.
8358
+ return UI.tailNumber(this.name);
8072
8359
  }
8073
-
8360
+
8074
8361
  get selectorList() {
8075
8362
  // Returns sorted list of selectors (those with wildcards last)
8076
8363
  const sl = [];
@@ -8090,14 +8377,45 @@ class Dataset {
8090
8377
  return sl;
8091
8378
  }
8092
8379
 
8093
- get allModifiersAreStatic() {
8094
- // Returns TRUE if all modifier expressions are static
8095
- for(let k in this.modifiers) if(this.modifiers.hasOwnProperty(k)) {
8096
- if(!this.modifiers[k].expression.isStatic) return false;
8380
+ isWildcardSelector(s) {
8381
+ // Returns TRUE if `s` contains * or ?
8382
+ // NOTE: for equations, the wildcard must be ??
8383
+ if(this.dataset === MODEL.equations_dataset) return s.indexOf('??') >= 0;
8384
+ return s.indexOf('*') >= 0 || s.indexOf('?') >= 0;
8385
+ }
8386
+
8387
+ matchingModifiers(l) {
8388
+ // Returns the list of modifiers of this dataset (in order: from most
8389
+ // to least specific) that match with 1 or more elements of `l`
8390
+ const
8391
+ sl = this.selectorList,
8392
+ shared = [];
8393
+ for(let i = 0; i < l.length; i++) {
8394
+ for(let j = 0; j < sl.length; j++) {
8395
+ const m = this.modifiers[UI.nameToID(sl[j])];
8396
+ if(m.match(l[i])) addDistinct(m, shared);
8397
+ }
8398
+ }
8399
+ return shared;
8400
+ }
8401
+
8402
+ modifiersAreStatic(l) {
8403
+ // Returns TRUE if expressions for all modifiers in `l` are static
8404
+ // NOTE: `l` may be a list of modifiers or strings
8405
+ for(let i = 0; i < l.length; i++) {
8406
+ let sel = l[i];
8407
+ if(sel instanceof DatasetModifier) sel = sel.selector;
8408
+ if(this.modifiers.hasOwnProperty(sel) &&
8409
+ !this.modifiers[sel].expression.isStatic) return false;
8097
8410
  }
8098
8411
  return true;
8099
8412
  }
8100
8413
 
8414
+ get allModifiersAreStatic() {
8415
+ // Returns TRUE if all modifier expressions are static
8416
+ return this.modifiersAreStatic(Object.keys(this.modifiers));
8417
+ }
8418
+
8101
8419
  get inferPrefixableModifiers() {
8102
8420
  // Returns list of dataset modifiers with expressions that do not
8103
8421
  // reference any variable and hence could probably better be represented
@@ -8141,21 +8459,6 @@ class Dataset {
8141
8459
  }
8142
8460
  }
8143
8461
 
8144
- matchingModifiers(l) {
8145
- // Returns the list of selectors of this dataset (in order: from most to
8146
- // least specific) that match with 1 or more elements of `l`
8147
- const
8148
- sl = this.selectorList,
8149
- shared = [];
8150
- for(let i = 0; i < l.length; i++) {
8151
- for(let j = 0; j < sl.length; j++) {
8152
- const m = this.modifiers[UI.nameToID(sl[j])];
8153
- if(m.match(l[i])) addDistinct(m, shared);
8154
- }
8155
- }
8156
- return shared;
8157
- }
8158
-
8159
8462
  get dataString() {
8160
8463
  // Data is stored simply as semicolon-separated floating point numbers,
8161
8464
  // with N-digit precision to keep model files compact (default: N = 8)
@@ -8258,21 +8561,21 @@ class Dataset {
8258
8561
  }
8259
8562
 
8260
8563
  attributeValue(a) {
8261
- // Returns the computed result for attribute `a`
8564
+ // Returns the computed result for attribute `a`.
8262
8565
  // NOTE: Datasets have ONE attribute (their vector) denoted by the empty
8263
- // string; all other "attributes" should be modifier selectors
8566
+ // string; all other "attributes" should be modifier selectors, and
8567
+ // their value should be obtained using attributeExpression (see below).
8264
8568
  if(a === '') return this.vector;
8265
8569
  return null;
8266
8570
  }
8267
8571
 
8268
8572
  attributeExpression(a) {
8269
- // Returns expression for selector `a`, or NULL if no such selector exists
8270
- // NOTE: selectors no longer are case-sensitive
8573
+ // Returns expression for selector `a` (also considering wildcard
8574
+ // modifiers), or NULL if no such selector exists.
8575
+ // NOTE: selectors no longer are case-sensitive.
8271
8576
  if(a) {
8272
- a = UI.nameToID(a);
8273
- for(let m in this.modifiers) if(this.modifiers.hasOwnProperty(m)) {
8274
- if(m === a) return this.modifiers[m].expression;
8275
- }
8577
+ const mm = this.matchingModifiers([a]);
8578
+ if(mm.length > 0) return mm[0].expression;
8276
8579
  }
8277
8580
  return null;
8278
8581
  }
@@ -8303,15 +8606,22 @@ class Dataset {
8303
8606
  if(this === MODEL.equations_dataset) {
8304
8607
  // Equation identifiers cannot contain characters that have special
8305
8608
  // meaning in a variable identifier
8306
- s = s.replace(/[\*\?\|\[\]\{\}\@\#]/g, '');
8609
+ s = s.replace(/[\*\|\[\]\{\}\@\#]/g, '');
8307
8610
  if(s !== selector) {
8308
- UI.warn('Equation name cannot contain [, ], {, }, |, @, #, * or ?');
8611
+ UI.warn('Equation name cannot contain [, ], {, }, |, @, # or *');
8612
+ return null;
8613
+ }
8614
+ // Wildcard selectors must be exactly 2 consecutive question marks,
8615
+ // so reduce longer sequences (no warning)
8616
+ s = s.replace(/\?\?+/g, '??');
8617
+ if(s.split('??').length > 2) {
8618
+ UI.warn('Equation name can contain only 1 wildcard');
8309
8619
  return null;
8310
8620
  }
8311
8621
  // Reduce inner spaces to one, and trim outer spaces
8312
8622
  s = s.replace(/\s+/g, ' ').trim();
8313
- // Then prefix it when the IO context argument is defined
8314
- if(ioc) s = ioc.actualName(s);
8623
+ // Then prefix it when the IO context argument is defined
8624
+ if(ioc) s = ioc.actualName(s);
8315
8625
  // If equation already exists, return its modifier
8316
8626
  const id = UI.nameToID(s);
8317
8627
  if(this.modifiers.hasOwnProperty(id)) return this.modifiers[id];
@@ -9749,6 +10059,9 @@ class ExperimentRunResult {
9749
10059
  }
9750
10060
  } else if(v instanceof Dataset) {
9751
10061
  // This dataset will be an "outcome" dataset => store statistics only
10062
+ // @@TO DO: deal with wildcard equations: these will have *multiple*
10063
+ // vectors associated with numbered entities (via #) and therefore
10064
+ // *all* these results should be stored (with # replaced by its value)
9752
10065
  this.x_variable = false;
9753
10066
  this.object_id = v.identifier;
9754
10067
  if(v === MODEL.equations_dataset && a) {