linny-r 2.0.11 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linny-r",
3
- "version": "2.0.11",
3
+ "version": "2.1.0",
4
4
  "description": "Executable graphical language with WYSIWYG editor for MILP models",
5
5
  "main": "server.js",
6
6
  "scripts": {
Binary file
package/static/index.html CHANGED
@@ -914,6 +914,8 @@ NOTE: Products directly linked to such processes should have a proportional unit
914
914
  title="Include module in current model">
915
915
  <img id="repo-load-btn" class="btn disab" src="images/open.png"
916
916
  title="Load model from repository">
917
+ <img id="repo-update-btn" class="btn disab" src="images/update.png"
918
+ title="Update clusters that have been included in the model">
917
919
  <img id="repo-store-btn" class="btn enab" src="images/store.png"
918
920
  title="Store model as module in repository">
919
921
  <img id="repo-black-box-btn" class="btn enab" src="images/black-box.png"
@@ -1061,9 +1063,6 @@ NOTE: Products directly linked to such processes should have a proportional unit
1061
1063
  <img class="cancel-btn" src="images/cancel.png">
1062
1064
  <img class="ok-btn" src="images/ok.png">
1063
1065
  </div>
1064
- <div style="margin:2px; background-color: Yellow">
1065
- <strong>NOTE:</strong> <em>This action cannot be undone!</em>
1066
- </div>
1067
1066
  <div style="margin:2px">
1068
1067
  <label>Cluster:</label>
1069
1068
  <input id="include-prefix" type="text"
@@ -1082,6 +1081,38 @@ NOTE: Products directly linked to such processes should have a proportional unit
1082
1081
  </div>
1083
1082
  </div>
1084
1083
 
1084
+ <!-- the UPDATE dialog prompts for a module name -->
1085
+ <div id="update-modal" class="modal">
1086
+ <div id="update-dlg" class="inp-dlg" style="min-width: 320px">
1087
+ <div class="dlg-title">
1088
+ Update clusters previously included from a module
1089
+ <img class="cancel-btn" src="images/cancel.png">
1090
+ <img class="ok-btn" src="images/ok.png">
1091
+ </div>
1092
+ <div style="padding: 2px">
1093
+ <div>
1094
+ Update using module: <span id="update-name"></span>
1095
+ </div>
1096
+ <div>
1097
+ <label>Clusters based on:</label>
1098
+ <select id="update-module">
1099
+ </select>
1100
+ <div id="update-count" class="update-cc"></div>
1101
+ </div>
1102
+ <div id="update-issues">
1103
+ <div id="update-issues-header"></div>
1104
+ <div id="update-issues-area"></div>
1105
+ </div>
1106
+ <div id="update-remove">
1107
+ <div id="update-remove-header">
1108
+ <span id="update-remove-count"></span> will be removed
1109
+ </div>
1110
+ <div id="update-remove-area"></div>
1111
+ </div>
1112
+ </div>
1113
+ </div>
1114
+ </div>
1115
+
1085
1116
  <!-- the CONFIRM LOAD modal asks to confirm to load model from repository -->
1086
1117
  <div id="confirm-load-from-repo-modal" class="modal">
1087
1118
  <div id="confirm-load-from-repo-dlg" class="inp-dlg">
@@ -607,6 +607,11 @@ span.node-details {
607
607
  margin-left: 15px;
608
608
  }
609
609
 
610
+ span.mod-name {
611
+ font-style: normal;
612
+ color: #403080;
613
+ }
614
+
610
615
  #issue-panel {
611
616
  display: none;
612
617
  background-color: Yellow;
@@ -707,6 +712,11 @@ img.inline-cancel-btn:hover {
707
712
  filter: brightness(120%);
708
713
  }
709
714
 
715
+ img.ok-btn.disab {
716
+ filter: saturate(0%) brightness(150%) contrast(70%);
717
+ pointer-events: none;
718
+ }
719
+
710
720
  #actor-group,
711
721
  #constraint-group,
712
722
  #cluster-group,
@@ -4152,8 +4162,9 @@ td.sa-not-run {
4152
4162
 
4153
4163
  #xp-ignore-count {
4154
4164
  position: absolute;
4155
- right: 14px;
4165
+ right: 2px;
4156
4166
  bottom: 3px;
4167
+ width: 18px;
4157
4168
  height: 16px;
4158
4169
  font-size: 9px;
4159
4170
  color: #806070;
@@ -4882,6 +4893,7 @@ span.sd-clear {
4882
4893
  }
4883
4894
 
4884
4895
  #paste-dlg,
4896
+ #update-dlg,
4885
4897
  #include-dlg {
4886
4898
  width: 320px;
4887
4899
  height: min-content;
@@ -4907,9 +4919,29 @@ span.sd-clear {
4907
4919
  margin-left: 9px;
4908
4920
  }
4909
4921
 
4922
+ #update-name {
4923
+ font-family: monospace;
4924
+ font-size: 14px;
4925
+ }
4926
+
4927
+ div.update-cc {
4928
+ display: inline-block;
4929
+ margin-left: 6px;
4930
+ font-style: italic;
4931
+ color: Gray;
4932
+ }
4933
+
4934
+ #update-remove-header,
4935
+ #update-issues-header {
4936
+ font-weight: bold;
4937
+ margin: 4px 2px 2px 2px;
4938
+ }
4939
+
4910
4940
  #paste-scroll-area,
4911
- #include-scroll-area {
4912
- margin: 2px;
4941
+ #include-scroll-area,
4942
+ #update-remove-area,
4943
+ #update-issues-area {
4944
+ margin: 0 2px;
4913
4945
  height: min-content;
4914
4946
  max-height: 350px !important;
4915
4947
  width: calc(100% - 4px);
@@ -4918,6 +4950,13 @@ span.sd-clear {
4918
4950
  overflow-y: auto;
4919
4951
  }
4920
4952
 
4953
+ #update-remove-area,
4954
+ #update-issues-area {
4955
+ max-height: 150px !important;
4956
+ background-color: white;
4957
+ border: 1px solid Silver;
4958
+ }
4959
+
4921
4960
  div.paste-tactic {
4922
4961
  display: inline-block;
4923
4962
  vertical-align: top;
@@ -311,6 +311,12 @@ class Controller {
311
311
  (name.startsWith(this.BLACK_BOX) || name[0].match(/[\w]/));
312
312
  }
313
313
 
314
+ realActorName(name) {
315
+ // Return `name` unless it is '(no actor)'; then return empty string.
316
+ if(name === this.NO_ACTOR) return '';
317
+ return name;
318
+ }
319
+
314
320
  prefixesAndName(name, key=false) {
315
321
  // Returns name split exclusively at '[non-space]: [non-space]'
316
322
  let sep = this.PREFIXER,
@@ -1266,30 +1272,30 @@ class ExperimentManager {
1266
1272
  }
1267
1273
 
1268
1274
  startExperiment(n=-1) {
1269
- // Recompile expressions, as these may have been changed by the modeler
1275
+ // Recompile expressions, as these may have been changed by the modeler.
1270
1276
  MODEL.compileExpressions();
1271
- // Start sequence of solving model parametrizations
1277
+ // Start sequence of solving model parametrizations.
1272
1278
  const x = this.selected_experiment;
1273
1279
  if(x) {
1274
- // Store original model settings
1280
+ // Store original model settings.
1275
1281
  x.original_model_settings = MODEL.settingsString;
1276
1282
  x.original_round_sequence = MODEL.round_sequence;
1277
- // NOTE: switch off run chart display
1283
+ // NOTE: Switch off run chart display.
1278
1284
  CHART_MANAGER.setRunsChart(false);
1279
1285
  // When Chart manager is showing, close it and notify modeler that charts
1280
- // should not be viewed during experiments
1286
+ // should not be viewed during experiments.
1281
1287
  if(CHART_MANAGER.visible) {
1282
1288
  UI.buttons.chart.dispatchEvent(new Event('click'));
1283
1289
  UI.notify(UI.NOTICE.NO_CHARTS);
1284
1290
  }
1285
- // Change the buttons -- will return TRUE if experiment was paused
1291
+ // Change the buttons -- will return TRUE if experiment was paused.
1286
1292
  const paused = this.resumeButtons();
1287
1293
  if(x.completed && n >= 0) {
1288
1294
  x.single_run = n;
1289
1295
  x.active_combination_index = n;
1290
1296
  MODEL.running_experiment = x;
1291
1297
  } else if(!paused) {
1292
- // Clear previous run results (if any) unless resuming
1298
+ // Clear previous run results (if any) unless resuming.
1293
1299
  x.clearRuns();
1294
1300
  x.inferVariables();
1295
1301
  x.time_started = new Date().getTime();
@@ -1369,11 +1375,14 @@ class ExperimentManager {
1369
1375
  // Perform post-processing after run results have been added.
1370
1376
  const x = MODEL.running_experiment;
1371
1377
  if(!x) return;
1372
- const aci = x.active_combination_index;
1378
+ const
1379
+ aci = x.active_combination_index,
1380
+ single = (aci == x.single_run);
1373
1381
  // Always add solver messages.
1374
1382
  x.runs[aci].addMessages();
1375
1383
  const n = x.combinations.length;
1376
- if(!VM.halted && aci < n - 1 && aci != x.single_run) {
1384
+ if(!VM.halted && aci < n - 1 && !single) {
1385
+ // Continue with the next run.
1377
1386
  if(this.must_pause) {
1378
1387
  this.pausedButtons(aci);
1379
1388
  this.must_pause = false;
@@ -1384,12 +1393,13 @@ class ExperimentManager {
1384
1393
  // NOTE: When executing a remote command, wait for 1 second to
1385
1394
  // allow enough time for report writing.
1386
1395
  if(RECEIVER.active && RECEIVER.experiment) {
1387
- UI.setMessage('Reporting run #' + (x.active_combination_index - 1));
1396
+ UI.setMessage('Reporting run #' + aci);
1388
1397
  delay = 1000;
1389
1398
  }
1390
1399
  setTimeout(() => EXPERIMENT_MANAGER.runModel(), delay);
1391
1400
  }
1392
1401
  } else {
1402
+ // Stop the run sequence.
1393
1403
  x.time_stopped = new Date().getTime();
1394
1404
  if(x.single_run >= 0) {
1395
1405
  x.single_run = -1;
@@ -1409,18 +1419,19 @@ class ExperimentManager {
1409
1419
  RECEIVER.experiment = '';
1410
1420
  RECEIVER.callBack();
1411
1421
  }
1412
- // Restore original model settings
1422
+ // Restore original model settings.
1413
1423
  MODEL.running_experiment = null;
1414
1424
  MODEL.parseSettings(x.original_model_settings);
1415
1425
  MODEL.round_sequence = x.original_round_sequence;
1416
1426
  // Reset the Virtual Machine so t=0 at the status line,
1417
1427
  // and ALL expressions are reset as well.
1418
- VM.reset();
1428
+ if(!single) VM.reset();
1419
1429
  this.readyButtons();
1420
1430
  }
1421
1431
  this.drawTable();
1422
1432
  // Reset the model, as results of last run will be showing still.
1423
- UI.resetModel();
1433
+ // NOTE: Do NOT do this after a single run.
1434
+ if(!single) UI.resetModel();
1424
1435
  CHART_MANAGER.resetChartVectors();
1425
1436
  // NOTE: Clear chart only when done; charts do not update when an
1426
1437
  // experiment is running.
@@ -224,10 +224,10 @@ class ActorManager {
224
224
  }
225
225
 
226
226
  showEditActorDialog(name, expr) {
227
- // Display modal for editing properties of one actor
227
+ // Display modal for editing properties of one actor.
228
228
  this.actor_span.innerHTML = name;
229
229
  this.actor_name.value = name;
230
- // Do not allow modification of the name '(no actor)'
230
+ // Do not allow modification of the name '(no actor)'.
231
231
  if(name === UI.NO_ACTOR) {
232
232
  this.actor_name.disabled = true;
233
233
  this.actor_io.style.display = 'none';
@@ -242,22 +242,22 @@ class ActorManager {
242
242
 
243
243
  modifyActorEntry() {
244
244
  // This method is called when the modeler submits the "actor properties"
245
- // dialog
245
+ // dialog.
246
246
  let n = this.actor_span.innerHTML,
247
247
  nn = UI.NO_ACTOR,
248
248
  x = this.actor_weight.value.trim(),
249
249
  xp = new ExpressionParser(x);
250
250
  if(n !== UI.NO_ACTOR) {
251
251
  nn = this.actor_name.value.trim();
252
- // NOTE: prohibit colons in actor names to avoid confusion with
253
- // prefixed entities
252
+ // NOTE: Prohibit colons in actor names to avoid confusion with
253
+ // prefixed entities.
254
254
  if(!UI.validName(nn) || nn.indexOf(':') >= 0) {
255
255
  UI.warn(UI.WARNING.INVALID_ACTOR_NAME);
256
256
  return false;
257
257
  }
258
258
  }
259
259
  if(xp.error) {
260
- // NOTE: do not pass the actor, as its name is being edited as well
260
+ // NOTE: Do not pass the actor, as its name is being edited as well.
261
261
  UI.warningInvalidWeightExpression(null, xp.error);
262
262
  return false;
263
263
  }
@@ -637,13 +637,7 @@ class GUIChartManager extends ChartManager {
637
637
  deleteChart() {
638
638
  // Delete the shown chart (if any).
639
639
  if(this.chart_index >= 0) {
640
- // NOTE: Do not delete the default chart, but clear it instead.
641
- if(MODEL.charts[this.chart_index].title === this.new_chart_title) {
642
- MODEL.charts[this.chart_index].reset();
643
- } else {
644
- MODEL.charts.splice(this.chart_index, 1);
645
- this.chart_index = -1;
646
- }
640
+ MODEL.deleteChart(this.chart_index);
647
641
  // Also update the experiment viewer, because this chart may be
648
642
  // one of the output charts of the selected experiment.
649
643
  UI.updateControllerDialogs('CFX');
@@ -1710,14 +1710,14 @@ class GUIController extends Controller {
1710
1710
  //
1711
1711
 
1712
1712
  draggableDialog(d) {
1713
- // Make dialog draggable
1713
+ // Make dialog draggable.
1714
1714
  const
1715
1715
  dlg = document.getElementById(d + '-dlg'),
1716
1716
  hdr = document.getElementById(d + '-hdr');
1717
1717
  let cx = 0,
1718
1718
  cy = 0;
1719
1719
  if(dlg && hdr) {
1720
- // NOTE: dialogs are draggable only by their header
1720
+ // NOTE: Dialogs are draggable only by their header.
1721
1721
  hdr.onmousedown = dialogHeaderMouseDown;
1722
1722
  dlg.onmousedown = dialogMouseDown;
1723
1723
  return dlg;
@@ -1728,13 +1728,13 @@ class GUIController extends Controller {
1728
1728
 
1729
1729
  function dialogMouseDown(e) {
1730
1730
  e = e || window.event;
1731
- // NOTE: no `preventDefault` so the header will also receive it
1732
- // Find the dialog element
1731
+ // NOTE: No `preventDefault`, as this disables selector elements.
1732
+ // Find the dialog element.
1733
1733
  let de = e.target;
1734
1734
  while(de && !de.id.endsWith('-dlg')) { de = de.parentElement; }
1735
- // Moves the dialog (`this`) to the top of the order
1735
+ // Move the dialog (`this`) to the top of the order.
1736
1736
  const doi = UI.dr_dialog_order.indexOf(de);
1737
- // NOTE: do not reorder when already at end of list (= at top)
1737
+ // NOTE: Do not reorder when already at end of list (= at top).
1738
1738
  if(doi >= 0 && doi !== UI.dr_dialog_order.length - 1) {
1739
1739
  UI.dr_dialog_order.splice(doi, 1);
1740
1740
  UI.dr_dialog_order.push(de);
@@ -1745,12 +1745,12 @@ class GUIController extends Controller {
1745
1745
  function dialogHeaderMouseDown(e) {
1746
1746
  e = e || window.event;
1747
1747
  e.preventDefault();
1748
- // Find the dialog element
1748
+ // Find the dialog element.
1749
1749
  let de = e.target;
1750
1750
  while(de && !de.id.endsWith('-dlg')) { de = de.parentElement; }
1751
- // Record the affected dialog
1751
+ // Record the affected dialog.
1752
1752
  UI.dr_dialog = de;
1753
- // Get the mouse cursor position at startup
1753
+ // Get the mouse cursor position at startup.
1754
1754
  cx = e.clientX;
1755
1755
  cy = e.clientY;
1756
1756
  document.onmouseup = stopDragDialog;
@@ -2258,6 +2258,8 @@ class GUIController extends Controller {
2258
2258
  this.net_move_y = 0;
2259
2259
  // Get the paper coordinates indicated by the cursor.
2260
2260
  const cp = this.paper.cursorPosition(e.pageX, e.pageY);
2261
+ this.mouse_x = cp[0];
2262
+ this.mouse_y = cp[1];
2261
2263
  this.mouse_down_x = cp[0];
2262
2264
  this.mouse_down_y = cp[1];
2263
2265
  // De-activate "stay active" buttons if dysfunctional, or if SHIFT,
@@ -2977,7 +2979,7 @@ class GUIController extends Controller {
2977
2979
  //
2978
2980
 
2979
2981
  validNames(nn, an='') {
2980
- // Check whether names meet conventions; if not, warn user
2982
+ // Check whether names meet conventions. If not, warn user.
2981
2983
  if(!UI.validName(nn) || nn.indexOf(UI.BLACK_BOX) >= 0) {
2982
2984
  this.warningInvalidName(nn);
2983
2985
  return false;
@@ -3146,10 +3148,10 @@ class GUIController extends Controller {
3146
3148
  UI.info_line.classList.remove(...UI.info_line.classList);
3147
3149
  }
3148
3150
 
3149
- setMessage(msg, type=null) {
3151
+ setMessage(msg, type=null, cause=null) {
3150
3152
  // Display `msg` on infoline unless no type (= plain text) and some
3151
3153
  // info, warning or error message is already displayed.
3152
- super.setMessage(msg, type);
3154
+ super.setMessage(msg, type, cause);
3153
3155
  const types = ['notification', 'warning', 'error'];
3154
3156
  let d = new Date(),
3155
3157
  t = d.getTime(),
@@ -3664,7 +3666,7 @@ class GUIController extends Controller {
3664
3666
  // proceed to paste.
3665
3667
  const
3666
3668
  md = this.paste_modal,
3667
- mapping = Object.assign(md.mapping, {}),
3669
+ mapping = Object.assign({}, md.mapping),
3668
3670
  tc = (mapping.top_clusters ?
3669
3671
  Object.keys(mapping.top_clusters).sort(ciCompare) : []),
3670
3672
  ft = (mapping.from_to ?
@@ -3906,8 +3908,8 @@ class GUIController extends Controller {
3906
3908
  mapping.shared_prefix = sp;
3907
3909
  mapping.from_prefix = (fpn ? sp + fpn + UI.PREFIXER : sp);
3908
3910
  mapping.to_prefix = (tpn ? sp + tpn + UI.PREFIXER : sp);
3909
- mapping.from_actor = (ca === UI.NO_ACTOR ? '' : ca);
3910
- mapping.to_actor = (fca === UI.NO_ACTOR ? '' : fca);
3911
+ mapping.from_actor = UI.realActorName(ca);
3912
+ mapping.to_actor = UI.realActorName(fca);
3911
3913
  // Prompt for mapping when pasting to the same model and cluster.
3912
3914
  if(parseInt(mts) === MODEL.time_created.getTime() &&
3913
3915
  ca === fca && mapping.from_prefix === mapping.to_prefix &&
@@ -4544,8 +4546,7 @@ console.log('HERE name conflicts', name_conflicts, mapping);
4544
4546
  md.group = group;
4545
4547
  md.element('action').innerText = 'Edit';
4546
4548
  md.element('name').value = c.name;
4547
- md.element('actor').value = (c.actor.name == UI.NO_ACTOR ?
4548
- '' : c.actor.name);
4549
+ md.element('actor').value = UI.realActorName(c.actor.name);
4549
4550
  md.element('options').style.display = 'block';
4550
4551
  this.setBox('cluster-collapsed', c.collapsed);
4551
4552
  this.setBox('cluster-ignore', c.ignore);
@@ -289,17 +289,6 @@ class GUIDatasetManager extends DatasetManager {
289
289
  return null;
290
290
  }
291
291
 
292
- datasetsByPrefix(prefix) {
293
- // Return the list of datasets having the specified prefix.
294
- const
295
- pid = UI.nameToID(prefix + UI.PREFIXER),
296
- dsl = [];
297
- for(const k of Object.keys(MODEL.datasets)) {
298
- if(k.startsWith(pid)) dsl.push(k);
299
- }
300
- return dsl;
301
- }
302
-
303
292
  selectPrefixRow(e) {
304
293
  // Select expand/collapse prefix row.
305
294
  this.focal_table = this.dataset_table;
@@ -310,7 +299,7 @@ class GUIDatasetManager extends DatasetManager {
310
299
  const toggle = r.classList.contains('tree-btn');
311
300
  while(r.tagName !== 'TR') r = r.parentNode;
312
301
  this.selected_prefix_row = r;
313
- this.prefixed_datasets = this.datasetsByPrefix(r.dataset.prefix);
302
+ this.prefixed_datasets = MODEL.datasetKeysByPrefix(r.dataset.prefix);
314
303
  const sel = this.dataset_table.getElementsByClassName('sel-set');
315
304
  this.selected_dataset = null;
316
305
  if(sel.length > 0) {
@@ -746,7 +735,7 @@ class GUIDatasetManager extends DatasetManager {
746
735
  }
747
736
 
748
737
  get selectedAsList() {
749
- // Return list of datasets selected directly or by prefix.
738
+ // Return list of datasets selected directly or by prefix.
750
739
  const dsl = [];
751
740
  // Prevent including the equations dataset (just in case).
752
741
  if(this.selected_dataset && this.selected_dataset !== MODEL.equations_dataset) {
@@ -1243,12 +1232,14 @@ class GUIDatasetManager extends DatasetManager {
1243
1232
  for(let j = 0; j < ncol; j++) {
1244
1233
  const
1245
1234
  v = dsv[j].trim(),
1246
- sf = safeStrToFloat(v, '');
1247
- if(sf === '' && v !== '') {
1235
+ sf = safeStrToFloat(v, NaN);
1236
+ // NOTE: Ignore empty strings, but this may "shift up" numerical values on later rows.
1237
+ if(isNaN(sf) && v !== '') {
1248
1238
  UI.warn(`Invalid numerical value "${v}" for <strong>${dsn[j]}</strong> on line ${i}`);
1249
1239
  return false;
1240
+ } else if(!isNaN(sf)) {
1241
+ dsa[j].push(sf);
1250
1242
  }
1251
- dsa[j].push(sf);
1252
1243
  }
1253
1244
  }
1254
1245
  // Add or update datasets.
@@ -1261,7 +1252,7 @@ class GUIDatasetManager extends DatasetManager {
1261
1252
  ods = MODEL.namedObjectByID(id),
1262
1253
  ds = ods || MODEL.addDataset(n);
1263
1254
  // NOTE: `ds` will now be either a new dataset or an existing one.
1264
- if(ds) {
1255
+ if(ds instanceof Dataset) {
1265
1256
  // Keep track of added/updated datasets.
1266
1257
  const
1267
1258
  odv = ds.default_value,
@@ -1274,6 +1265,8 @@ class GUIDatasetManager extends DatasetManager {
1274
1265
  added++;
1275
1266
  }
1276
1267
  ds.computeStatistics();
1268
+ } else {
1269
+ UI.warn(`Name conflict: ${ds.type} "${ds.displayName}" already exists`);
1277
1270
  }
1278
1271
  }
1279
1272
  // Notify modeler of changes (if any).
@@ -844,8 +844,8 @@ class GUIExperimentManager extends ExperimentManager {
844
844
  }
845
845
 
846
846
  showInfo(n, shift) {
847
- // Display documentation for the n-th experiment defined in the model
848
- // NOTE: skip when viewer is showing!
847
+ // Display documentation for the n-th experiment defined in the model.
848
+ // NOTE: Skip when viewer is showing!
849
849
  if(!UI.hidden('experiment-viewer')) return;
850
850
  if(n < MODEL.experiments.length) {
851
851
  // NOTE: mouse move over title in viewer passes n = -1
@@ -856,7 +856,7 @@ class GUIExperimentManager extends ExperimentManager {
856
856
 
857
857
  showRunInfo(n, shift) {
858
858
  // Display information on the n-th combination if docu-viewer is visible
859
- // and cursor is moved over run cell while Shift button is held down
859
+ // and cursor is moved over run cell while Shift button is held down.
860
860
  if(shift && DOCUMENTATION_MANAGER.visible) {
861
861
  const info = this.runInfo(n);
862
862
  if(info) {
@@ -949,7 +949,16 @@ class Finder {
949
949
  }
950
950
  }
951
951
  // Set the new default selector (if changed).
952
- if(md.new_defsel !== false) ds.default_selector = md.new_defsel;
952
+ if(md.new_defsel !== false) {
953
+ // NOTE: `new_defsel` is a key; the actual selector name may have upper case
954
+ // letters, so get the selector name.
955
+ const dsm = ds.modifiers[md.new_defsel];
956
+ if(dsm) {
957
+ ds.default_selector = dsm.selector;
958
+ } else {
959
+ throw(`Unknown selector: ${md.new_defsel}`);
960
+ }
961
+ }
953
962
  }
954
963
  // Notify modeler of changes (if any).
955
964
  const msg = [];
@@ -1955,7 +1955,7 @@ class Paper {
1955
1955
  let l = (MODEL.solved ? proc.actualLevel(MODEL.t) : VM.NOT_COMPUTED),
1956
1956
  lb = proc.lower_bound.result(MODEL.t),
1957
1957
  ub = (proc.equal_bounds ? lb : proc.upper_bound.result(MODEL.t));
1958
- // NOTE: by default, lower bound = 0 (but do show exceptional values)
1958
+ // NOTE: By default, lower bound = 0 (but do show exceptional values).
1959
1959
  if(lb === VM.UNDEFINED && !proc.lower_bound.defined) lb = 0;
1960
1960
  let hw,
1961
1961
  hh,
@@ -2766,6 +2766,15 @@ class Paper {
2766
2766
  'h-', w - shadow_width, 'v-', shadow_width,
2767
2767
  'h', w - 2*shadow_width, 'z'],
2768
2768
  {fill:stroke_color, stroke:stroke_color, 'stroke-width':stroke_width});
2769
+ if(clstr.module) {
2770
+ // Add three white dots at middle of bottom shade.
2771
+ const
2772
+ ely = y + hh - shadow_width / 2,
2773
+ elfill = {fill: 'white'};
2774
+ clstr.shape.addEllipse(x - 4, ely, 1, 1, elfill);
2775
+ clstr.shape.addEllipse(x, ely, 1, 1, elfill);
2776
+ clstr.shape.addEllipse(x + 4, ely, 1, 1, elfill);
2777
+ }
2769
2778
  // Set fill color if slack used by some product contained by this cluster
2770
2779
  if(MODEL.t in clstr.slack_info) {
2771
2780
  const s = clstr.slack_info[MODEL.t];