linny-r 1.3.4 → 1.4.0

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