linny-r 1.2.0 → 1.3.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.
@@ -12,7 +12,7 @@ dialogs, the main drawing canvas, and event handler functions.
12
12
  */
13
13
 
14
14
  /*
15
- Copyright (c) 2017-2022 Delft University of Technology
15
+ Copyright (c) 2017-2023 Delft University of Technology
16
16
 
17
17
  Permission is hereby granted, free of charge, to any person obtaining a copy
18
18
  of this software and associated documentation files (the "Software"), to deal
@@ -2877,7 +2877,7 @@ class GUIController extends Controller {
2877
2877
  this.shortcuts = {
2878
2878
  'A': 'actors',
2879
2879
  'B': 'repository', // B for "Browse"
2880
- 'C': 'clone',
2880
+ 'C': 'clone', // button and Ctrl-C now copies; Alt-C clones
2881
2881
  'D': 'dataset',
2882
2882
  'E': 'equation',
2883
2883
  'F': 'finder',
@@ -2887,7 +2887,7 @@ class GUIController extends Controller {
2887
2887
  'J': 'sensitivity', // J for "Jitter"
2888
2888
  'K': 'reset', // reset model and clear results from graph
2889
2889
  'L': 'load',
2890
- 'M': 'monitor',
2890
+ 'M': 'monitor', // Alt-M will open the model settings dialog
2891
2891
  // Ctrl-N will still open a new browser window
2892
2892
  'O': 'chart', // O for "Output", as it can be charts as wel as data
2893
2893
  'P': 'diagram', // P for PNG (Portable Network Graphics image)
@@ -2896,7 +2896,7 @@ class GUIController extends Controller {
2896
2896
  'S': 'save',
2897
2897
  // Ctrl-T will still open a new browser tab
2898
2898
  'U': 'parent', // U for "move UP in cluster hierarchy"
2899
- 'V': 'settings',
2899
+ 'V': 'paste',
2900
2900
  // Ctrl-W will still close the browser window
2901
2901
  'X': 'experiment',
2902
2902
  'Y': 'redo',
@@ -2906,7 +2906,7 @@ class GUIController extends Controller {
2906
2906
  // Initialize controller buttons
2907
2907
  this.node_btns = ['process', 'product', 'link', 'constraint',
2908
2908
  'cluster', 'module', 'note'];
2909
- this.edit_btns = ['clone', 'delete', 'undo', 'redo'];
2909
+ this.edit_btns = ['clone', 'paste', 'delete', 'undo', 'redo'];
2910
2910
  this.model_btns = ['settings', 'save', 'repository', 'actors', 'dataset',
2911
2911
  'equation', 'chart', 'sensitivity', 'experiment', 'diagram',
2912
2912
  'savediagram', 'finder', 'monitor', 'solve'];
@@ -3026,7 +3026,15 @@ class GUIController extends Controller {
3026
3026
  }
3027
3027
  // Vertical tool bar buttons
3028
3028
  this.buttons.clone.addEventListener('click',
3029
- () => UI.promptForCloning());
3029
+ (event) => {
3030
+ if(event.altKey) {
3031
+ UI.promptForCloning();
3032
+ } else {
3033
+ UI.copySelection();
3034
+ }
3035
+ });
3036
+ this.buttons.paste.addEventListener('click',
3037
+ () => UI.pasteSelection());
3030
3038
  this.buttons['delete'].addEventListener('click',
3031
3039
  () => {
3032
3040
  UNDO_STACK.push('delete');
@@ -3057,6 +3065,12 @@ class GUIController extends Controller {
3057
3065
  (event) => UI.stepBack(event));
3058
3066
  this.buttons.stepforward.addEventListener('click',
3059
3067
  (event) => UI.stepForward(event));
3068
+ document.getElementById('prev-issue').addEventListener('click',
3069
+ () => UI.updateIssuePanel(-1));
3070
+ document.getElementById('issue-nr').addEventListener('click',
3071
+ () => UI.jumpToIssue());
3072
+ document.getElementById('next-issue').addEventListener('click',
3073
+ () => UI.updateIssuePanel(1));
3060
3074
  this.buttons.recall.addEventListener('click',
3061
3075
  // Recall button toggles the documentation dialog
3062
3076
  () => UI.buttons.documentation.dispatchEvent(new Event('click')));
@@ -3448,6 +3462,62 @@ class GUIController extends Controller {
3448
3462
  this.start_sel_y = -1;
3449
3463
  }
3450
3464
 
3465
+ updateIssuePanel(change=0) {
3466
+ const
3467
+ count = VM.issue_list.length,
3468
+ panel = document.getElementById('issue-panel');
3469
+ if(count > 0) {
3470
+ const
3471
+ prev = document.getElementById('prev-issue'),
3472
+ next = document.getElementById('next-issue'),
3473
+ nr = document.getElementById('issue-nr');
3474
+ panel.title = pluralS(count, 'issue') +
3475
+ ' occurred - click on number, \u25C1 or \u25B7 to view what and when';
3476
+ if(VM.issue_index === -1) {
3477
+ VM.issue_index = 0;
3478
+ } else if(change) {
3479
+ VM.issue_index += change;
3480
+ setTimeout(() => UI.jumpToIssue(), 10);
3481
+ }
3482
+ nr.innerText = VM.issue_index + 1;
3483
+ if(VM.issue_index <= 0) {
3484
+ prev.classList.add('disab');
3485
+ } else {
3486
+ prev.classList.remove('disab');
3487
+ }
3488
+ if(this.issue_index >= count - 1) {
3489
+ next.classList.add('disab');
3490
+ } else {
3491
+ next.classList.remove('disab');
3492
+ }
3493
+ panel.style.display = 'table-cell';
3494
+ } else {
3495
+ panel.style.display = 'none';
3496
+ VM.issue_index = -1;
3497
+ }
3498
+ }
3499
+
3500
+ jumpToIssue() {
3501
+ // Set time step to the one of the warning message for the issue
3502
+ // index, redraw the diagram if needed, and display the message
3503
+ // on the infoline
3504
+ if(VM.issue_index >= 0) {
3505
+ const
3506
+ issue = VM.issue_list[VM.issue_index],
3507
+ po = issue.indexOf('(t='),
3508
+ pc = issue.indexOf(')', po),
3509
+ t = parseInt(issue.substring(po + 3, pc - 1));
3510
+ if(MODEL.t !== t) {
3511
+ MODEL.t = t;
3512
+ this.updateTimeStep();
3513
+ this.drawDiagram(MODEL);
3514
+ }
3515
+ this.info_line.classList.remove('error', 'notification');
3516
+ this.info_line.classList.add('warning');
3517
+ this.info_line.innerHTML = issue.substring(pc + 2);
3518
+ }
3519
+ }
3520
+
3451
3521
  get doubleClicked() {
3452
3522
  // Return TRUE when a "double-click" occurred
3453
3523
  const
@@ -3722,7 +3792,7 @@ class GUIController extends Controller {
3722
3792
  // Updates the buttons on the main GUI toolbars
3723
3793
  const
3724
3794
  node_btns = 'process product link constraint cluster note ',
3725
- edit_btns = 'clone delete undo redo ',
3795
+ edit_btns = 'clone paste delete undo redo ',
3726
3796
  model_btns = 'settings save actors dataset equation chart ' +
3727
3797
  'diagram savediagram finder monitor solve';
3728
3798
  if(MODEL === null) {
@@ -3749,6 +3819,7 @@ class GUIController extends Controller {
3749
3819
  this.active_button = this.stayActiveButton;
3750
3820
  this.disableButtons(edit_btns);
3751
3821
  if(MODEL.selection.length > 0) this.enableButtons('clone delete');
3822
+ if(this.canPaste) this.enableButtons('paste');
3752
3823
  // Only allow target seeking when some target or process constraint is defined
3753
3824
  if(MODEL.hasTargets) this.enableButtons('solve');
3754
3825
  var u = UNDO_STACK.canUndo;
@@ -4378,6 +4449,13 @@ class GUIController extends Controller {
4378
4449
  const btns = topmod.getElementsByClassName('ok-btn');
4379
4450
  if(btns.length > 0) btns[0].dispatchEvent(new Event('click'));
4380
4451
  }
4452
+ } else if(this.dr_dialog_order.length > 0) {
4453
+ // Send ENTER key event to the top draggable dialog
4454
+ const last = this.dr_dialog_order.length - 1;
4455
+ if(last >= 0) {
4456
+ const mgr = window[this.dr_dialog_order[last].dataset.manager];
4457
+ if(mgr && 'enterKey' in mgr) mgr.enterKey();
4458
+ }
4381
4459
  }
4382
4460
  } else if(e.keyCode === 8 &&
4383
4461
  ttype !== 'text' && ttype !== 'password' && ttype !== 'textarea') {
@@ -4392,7 +4470,18 @@ class GUIController extends Controller {
4392
4470
  return;
4393
4471
  }
4394
4472
  }
4395
- // end. home, Left and right arrow keys
4473
+ // Up and down arrow keys
4474
+ if([38, 40].indexOf(e.keyCode) >= 0) {
4475
+ e.preventDefault();
4476
+ // Send event to the top draggable dialog
4477
+ const last = this.dr_dialog_order.length - 1;
4478
+ if(last >= 0) {
4479
+ const mgr = window[this.dr_dialog_order[last].dataset.manager];
4480
+ // NOTE: pass key direction as -1 for UP and +1 for DOWN
4481
+ if(mgr && 'upDownKey' in mgr) mgr.upDownKey(e.keyCode - 39);
4482
+ }
4483
+ }
4484
+ // end, home, Left and right arrow keys
4396
4485
  if([35, 36, 37, 39].indexOf(e.keyCode) >= 0) e.preventDefault();
4397
4486
  if(e.keyCode === 35) {
4398
4487
  MODEL.t = MODEL.end_period - MODEL.start_period + 1;
@@ -4406,6 +4495,15 @@ class GUIController extends Controller {
4406
4495
  this.stepBack(e);
4407
4496
  } else if(e.keyCode === 39) {
4408
4497
  this.stepForward(e);
4498
+ } else if(e.altKey && [67, 77].indexOf(e.keyCode) >= 0) {
4499
+ // Special shortcut keys for "clone selection" and "model settings"
4500
+ const be = new Event('click');
4501
+ be.altKey = true;
4502
+ if(e.keyCode === 67) {
4503
+ this.buttons.clone.dispatchEvent(be);
4504
+ } else {
4505
+ this.buttons.settings.dispatchEvent(be);
4506
+ }
4409
4507
  } else if(!e.shiftKey && !e.altKey &&
4410
4508
  (!topmod || [65, 67, 86].indexOf(e.keyCode) < 0)) {
4411
4509
  // Interpret special keys as shortcuts unless a modal dialog is open
@@ -5119,7 +5217,42 @@ class GUIController extends Controller {
5119
5217
  cancelCloneSelection() {
5120
5218
  this.modals.clone.hide();
5121
5219
  this.updateButtons();
5122
- }
5220
+ }
5221
+
5222
+ copySelection() {
5223
+ // Save selection as XML in local storage of the browser
5224
+ const xml = MODEL.selectionAsXML;
5225
+ //console.log('HERE copy xml', xml);
5226
+ if(xml) {
5227
+ window.localStorage.setItem('Linny-R-selection-XML', xml);
5228
+ this.updateButtons();
5229
+ this.notify('Selection copied, but cannot be pasted yet -- Use Alt-C to clone');
5230
+ }
5231
+ }
5232
+
5233
+ get canPaste() {
5234
+ const xml = window.localStorage.getItem('Linny-R-selection-XML');
5235
+ if(xml) {
5236
+ const timestamp = xml.match(/<copy timestamp="(\d+)"/);
5237
+ if(timestamp) {
5238
+ if(Date.now() - parseInt(timestamp[1]) < 8*3600000) return true;
5239
+ }
5240
+ // Remove XML from local storage if older than 8 hours
5241
+ window.localStorage.removeItem('Linny-R-selection-XML');
5242
+ }
5243
+ return false;
5244
+ }
5245
+
5246
+ pasteSelection() {
5247
+ // If selection has been saved as XML in local storage, test to
5248
+ // see whether PASTE would result in name conflicts, and if so,
5249
+ // open the name conflict resolution window
5250
+ const xml = window.localStorage.getItem('Linny-R-selection-XML');
5251
+ if(xml) {
5252
+ // @@ TO DO!
5253
+ this.notify('Paste not implemented yet -- WORK IN PROGRESS!');
5254
+ }
5255
+ }
5123
5256
 
5124
5257
  //
5125
5258
  // Interaction with modal dialogs to modify model or entity properties
@@ -5328,17 +5461,20 @@ class GUIController extends Controller {
5328
5461
  if(!this.updateExpressionInput(
5329
5462
  'process-IL', 'initial level', p.initial_level)) return false;
5330
5463
  // Store original expression string
5331
- const pxt = p.pace_expression.text;
5464
+ const
5465
+ px = p.pace_expression,
5466
+ pxt = p.pace_expression.text;
5332
5467
  // Validate expression
5333
5468
  if(!this.updateExpressionInput('process-pace', 'level change frequency',
5334
- p.pace_expression)) return false;
5469
+ px)) return false;
5335
5470
  // NOTE: pace expression must be *static* and >= 1
5336
- n = p.pace_expression.result(1);
5337
- if(!p.pace_expression.isStatic || n < 1) {
5471
+ n = px.result(1);
5472
+ if(!px.isStatic || n < 1) {
5338
5473
  md.element('pace').focus();
5339
5474
  this.warn('Level change frequency must be static and &ge; 1');
5340
5475
  // Restore original expression string
5341
- p.pace_expression.text = pxt;
5476
+ px.text = pxt;
5477
+ px.code = null;
5342
5478
  return false;
5343
5479
  }
5344
5480
  // Ignore fraction if a real number was entered.
@@ -6448,7 +6584,7 @@ class GUIFileManager {
6448
6584
  }
6449
6585
 
6450
6586
  renderDiagramAsPNG() {
6451
- localStorage.removeItem('png-url');
6587
+ window.localStorage.removeItem('png-url');
6452
6588
  UI.paper.fitToSize();
6453
6589
  MODEL.alignToGrid();
6454
6590
  this.renderSVGAsPNG(UI.paper.svg.outerHTML);
@@ -6474,7 +6610,7 @@ class GUIFileManager {
6474
6610
  })
6475
6611
  .then((data) => {
6476
6612
  // Pass URL of image to the newly opened browser window
6477
- localStorage.setItem('png-url', data);
6613
+ window.localStorage.setItem('png-url', data);
6478
6614
  })
6479
6615
  .catch((err) => UI.warn(UI.WARNING.NO_CONNECTION, err));
6480
6616
  }
@@ -8478,7 +8614,7 @@ class GUIRepositoryBrowser extends RepositoryBrowser {
8478
8614
  document.getElementById('repo-include-btn').addEventListener(
8479
8615
  'click', () => REPOSITORY_BROWSER.includeModule());
8480
8616
  document.getElementById('repo-load-btn').addEventListener(
8481
- 'click', () => REPOSITORY_BROWSER.loadModuleAsModel());
8617
+ 'click', () => REPOSITORY_BROWSER.confirmLoadModuleAsModel());
8482
8618
  document.getElementById('repo-store-btn').addEventListener(
8483
8619
  'click', () => REPOSITORY_BROWSER.promptForStoring());
8484
8620
  document.getElementById('repo-black-box-btn').addEventListener(
@@ -8525,6 +8661,12 @@ class GUIRepositoryBrowser extends RepositoryBrowser {
8525
8661
  this.include_modal.element('actor').addEventListener(
8526
8662
  'blur', () => REPOSITORY_BROWSER.updateActors());
8527
8663
 
8664
+ this.confirm_load_modal = new ModalDialog('confirm-load-from-repo');
8665
+ this.confirm_load_modal.ok.addEventListener(
8666
+ 'click', () => REPOSITORY_BROWSER.loadModuleAsModel());
8667
+ this.confirm_load_modal.cancel.addEventListener(
8668
+ 'click', () => REPOSITORY_BROWSER.confirm_load_modal.hide());
8669
+
8528
8670
  this.confirm_delete_modal = new ModalDialog('confirm-delete-from-repo');
8529
8671
  this.confirm_delete_modal.ok.addEventListener(
8530
8672
  'click', () => REPOSITORY_BROWSER.deleteFromRepository());
@@ -8536,6 +8678,31 @@ class GUIRepositoryBrowser extends RepositoryBrowser {
8536
8678
  super.reset();
8537
8679
  this.last_time_selected = 0;
8538
8680
  }
8681
+
8682
+ enterKey() {
8683
+ // Open "edit properties" dialog for the selected entity
8684
+ const srl = this.modules_table.getElementsByClassName('sel-set');
8685
+ if(srl.length > 0) {
8686
+ const r = this.modules_table.rows[srl[0].rowIndex];
8687
+ if(r) {
8688
+ // Ensure that click will be interpreted as double-click
8689
+ this.last_time_selected = Date.now();
8690
+ r.dispatchEvent(new Event('click'));
8691
+ }
8692
+ }
8693
+ }
8694
+
8695
+ upDownKey(dir) {
8696
+ // Select row above or below the selected one (if possible)
8697
+ const srl = this.modules_table.getElementsByClassName('sel-set');
8698
+ if(srl.length > 0) {
8699
+ const r = this.modules_table.rows[srl[0].rowIndex + dir];
8700
+ if(r) {
8701
+ UI.scrollIntoView(r);
8702
+ r.dispatchEvent(new Event('click'));
8703
+ }
8704
+ }
8705
+ }
8539
8706
 
8540
8707
  get isLocalHost() {
8541
8708
  // Returns TRUE if first repository on the list is 'local host'
@@ -8718,7 +8885,7 @@ class GUIRepositoryBrowser extends RepositoryBrowser {
8718
8885
  // Consider click to be "double" if it occurred less than 300 ms ago
8719
8886
  if(dt < 300) {
8720
8887
  this.last_time_selected = 0;
8721
- this.loadModuleAsModel();
8888
+ this.includeModule();
8722
8889
  return;
8723
8890
  }
8724
8891
  }
@@ -8967,6 +9134,7 @@ class GUIRepositoryBrowser extends RepositoryBrowser {
8967
9134
 
8968
9135
  loadModuleAsModel() {
8969
9136
  // Loads selected module as model
9137
+ this.confirm_load_modal.hide();
8970
9138
  if(this.repository_index >= 0 && this.module_index >= 0) {
8971
9139
  // NOTE: when loading new model, the stay-on-top dialogs must be reset
8972
9140
  UI.hideStayOnTopDialogs();
@@ -8983,6 +9151,17 @@ class GUIRepositoryBrowser extends RepositoryBrowser {
8983
9151
  r.loadModule(this.module_index, true);
8984
9152
  }
8985
9153
  }
9154
+
9155
+ confirmLoadModuleAsModel() {
9156
+ // Prompts modeler to confirm loading the selected module as model
9157
+ if(this.repository_index >= 0 && this.module_index >= 0 &&
9158
+ document.getElementById('repo-load-btn').classList.contains('enab')) {
9159
+ const r = this.repositories[this.repository_index];
9160
+ this.confirm_load_modal.element('mod-name').innerText =
9161
+ r.module_names[this.module_index];
9162
+ this.confirm_load_modal.show();
9163
+ }
9164
+ }
8986
9165
 
8987
9166
  confirmDeleteFromRepository() {
8988
9167
  // Prompts modeler to confirm deletion of the selected module
@@ -9019,7 +9198,9 @@ class GUIDatasetManager extends DatasetManager {
9019
9198
  this.close_btn.addEventListener(
9020
9199
  'click', (event) => UI.toggleDialog(event));
9021
9200
  document.getElementById('ds-new-btn').addEventListener(
9022
- 'click', () => DATASET_MANAGER.promptForDataset());
9201
+ // Shift-click on New button => add prefix of selected dataset
9202
+ // (if any) to the name field of the dialog
9203
+ 'click', () => DATASET_MANAGER.promptForDataset(event.shiftKey));
9023
9204
  document.getElementById('ds-data-btn').addEventListener(
9024
9205
  'click', () => DATASET_MANAGER.editData());
9025
9206
  document.getElementById('ds-rename-btn').addEventListener(
@@ -9034,7 +9215,7 @@ class GUIDatasetManager extends DatasetManager {
9034
9215
  this.filter_text = document.getElementById('ds-filter-text');
9035
9216
  this.filter_text.addEventListener(
9036
9217
  'input', () => DATASET_MANAGER.changeFilter());
9037
- this.table = document.getElementById('dataset-table');
9218
+ this.dataset_table = document.getElementById('dataset-table');
9038
9219
  // Data properties pane
9039
9220
  this.properties = document.getElementById('dataset-properties');
9040
9221
  // Toggle buttons at bottom of dialog
@@ -9056,6 +9237,10 @@ class GUIDatasetManager extends DatasetManager {
9056
9237
  'click', () => DATASET_MANAGER.editExpression());
9057
9238
  document.getElementById('ds-delete-modif-btn').addEventListener(
9058
9239
  'click', () => DATASET_MANAGER.deleteModifier());
9240
+ document.getElementById('ds-convert-modif-btn').addEventListener(
9241
+ 'click', () => DATASET_MANAGER.promptToConvertModifiers());
9242
+ // Modifier table
9243
+ this.modifier_table = document.getElementById('dataset-modif-table');
9059
9244
  // Modal dialogs
9060
9245
  this.new_modal = new ModalDialog('new-dataset');
9061
9246
  this.new_modal.ok.addEventListener(
@@ -9067,6 +9252,11 @@ class GUIDatasetManager extends DatasetManager {
9067
9252
  'click', () => DATASET_MANAGER.renameDataset());
9068
9253
  this.rename_modal.cancel.addEventListener(
9069
9254
  'click', () => DATASET_MANAGER.rename_modal.hide());
9255
+ this.conversion_modal = new ModalDialog('convert-modifiers');
9256
+ this.conversion_modal.ok.addEventListener(
9257
+ 'click', () => DATASET_MANAGER.convertModifiers());
9258
+ this.conversion_modal.cancel.addEventListener(
9259
+ 'click', () => DATASET_MANAGER.conversion_modal.hide());
9070
9260
  this.new_selector_modal = new ModalDialog('new-selector');
9071
9261
  this.new_selector_modal.ok.addEventListener(
9072
9262
  'click', () => DATASET_MANAGER.newModifier());
@@ -9101,20 +9291,181 @@ class GUIDatasetManager extends DatasetManager {
9101
9291
 
9102
9292
  reset() {
9103
9293
  super.reset();
9294
+ this.selected_prefix_row = null;
9104
9295
  this.selected_modifier = null;
9105
9296
  this.edited_expression = null;
9106
9297
  this.filter_pattern = null;
9107
- this.last_time_selected = 0;
9298
+ this.clicked_object = null;
9299
+ this.last_time_clicked = 0;
9300
+ this.focal_table = null;
9301
+ this.expanded_rows = [];
9302
+ }
9303
+
9304
+ doubleClicked(obj) {
9305
+ const
9306
+ now = Date.now(),
9307
+ dt = now - this.last_time_clicked;
9308
+ this.last_time_clicked = now;
9309
+ if(obj === this.clicked_object) {
9310
+ // Consider click to be "double" if it occurred less than 300 ms ago
9311
+ if(dt < 300) {
9312
+ this.last_time_clicked = 0;
9313
+ return true;
9314
+ }
9315
+ }
9316
+ this.clicked_object = obj;
9317
+ return false;
9318
+ }
9319
+
9320
+ enterKey() {
9321
+ // Open "edit" dialog for the selected dataset or modifier expression
9322
+ const srl = this.focal_table.getElementsByClassName('sel-set');
9323
+ if(srl.length > 0) {
9324
+ const r = this.focal_table.rows[srl[0].rowIndex];
9325
+ if(r) {
9326
+ const e = new Event('click');
9327
+ if(this.focal_table === this.dataset_table) {
9328
+ // Emulate Alt-click in the table to open the time series dialog
9329
+ e.altKey = true;
9330
+ r.dispatchEvent(e);
9331
+ } else if(this.focal_table === this.modifier_table) {
9332
+ // Emulate a double-click on the second cell to edit the expression
9333
+ this.last_time_clicked = Date.now();
9334
+ r.cells[1].dispatchEvent(e);
9335
+ }
9336
+ }
9337
+ }
9338
+ }
9339
+
9340
+ upDownKey(dir) {
9341
+ // Select row above or below the selected one (if possible)
9342
+ const srl = this.focal_table.getElementsByClassName('sel-set');
9343
+ if(srl.length > 0) {
9344
+ let r = this.focal_table.rows[srl[0].rowIndex + dir];
9345
+ while(r && r.style.display === 'none') {
9346
+ r = (dir > 0 ? r.nextSibling : r.previousSibling);
9347
+ }
9348
+ if(r) {
9349
+ UI.scrollIntoView(r);
9350
+ // NOTE: cell, not row, listens for onclick event
9351
+ if(this.focal_table === this.modifier_table) r = r.cells[1];
9352
+ r.dispatchEvent(new Event('click'));
9353
+ }
9354
+ }
9355
+ }
9356
+
9357
+ hideCollapsedRows() {
9358
+ // Hides all rows except top level and immediate children of expanded
9359
+ for(let i = 0; i < this.dataset_table.rows.length; i++) {
9360
+ const
9361
+ row = this.dataset_table.rows[i],
9362
+ // Get the first DIV in the first TD of this row
9363
+ first_div = row.firstChild.firstElementChild,
9364
+ btn = first_div.dataset.prefix === 'x';
9365
+ let p = row.dataset.prefix,
9366
+ x = this.expanded_rows.indexOf(p) >= 0,
9367
+ show = !p || x;
9368
+ if(btn) {
9369
+ const btn_div = row.getElementsByClassName('tree-btn')[0];
9370
+ // Special expand/collapse row
9371
+ if(show) {
9372
+ // Set triangle to point down
9373
+ btn_div.innerText = '\u25BC';
9374
+ } else {
9375
+ // Set triangle to point right
9376
+ btn_div.innerText = '\u25BA';
9377
+ // See whether "parent prefix" is expanded
9378
+ p = p.split(UI.PREFIXER);
9379
+ p.pop();
9380
+ p = p.join(UI.PREFIXER);
9381
+ // If so, then also show the row
9382
+ show = (!p || this.expanded_rows.indexOf(p) >= 0);
9383
+ }
9384
+ }
9385
+ row.style.display = (show ? 'block' : 'none');
9386
+ }
9387
+ }
9388
+
9389
+ togglePrefixRow(e) {
9390
+ // Shows list items of the next prefix level
9391
+ let r = e.target;
9392
+ while(r.tagName !== 'TR') r = r.parentNode;
9393
+ const
9394
+ p = r.dataset.prefix,
9395
+ i = this.expanded_rows.indexOf(p);
9396
+ if(i >= 0) {
9397
+ this.expanded_rows.splice(i, 1);
9398
+ // Also remove all prefixes that have `p` as prefix
9399
+ for(let j = this.expanded_rows.length - 1; j >= 0; j--) {
9400
+ if(this.expanded_rows[j].startsWith(p + UI.PREFIXER)) {
9401
+ this.expanded_rows.splice(j, 1);
9402
+ }
9403
+ }
9404
+ } else {
9405
+ addDistinct(p, this.expanded_rows);
9406
+ }
9407
+ this.hideCollapsedRows();
9408
+ }
9409
+
9410
+ rowByPrefix(prefix) {
9411
+ // Returns first table row with the specified prefix
9412
+ if(!prefix) return null;
9413
+ let lcp = prefix.toLowerCase(),
9414
+ pl = lcp.split(': ');
9415
+ // Remove trailing ': '
9416
+ if(lcp.endsWith(': ')) {
9417
+ pl.pop();
9418
+ lcp = pl.join(': ');
9419
+ }
9420
+ while(pl.length > 0) {
9421
+ addDistinct(pl.join(': '), this.expanded_rows);
9422
+ pl.pop();
9423
+ }
9424
+ this.hideCollapsedRows();
9425
+ for(let i = 0; i < this.dataset_table.rows.length; i++) {
9426
+ const r = this.dataset_table.rows[i];
9427
+ if(r.dataset.prefix === lcp) return r;
9428
+ }
9429
+ return null;
9430
+ }
9431
+
9432
+ selectPrefixRow(e) {
9433
+ // Selects expand/collapse prefix row
9434
+ this.focal_table = this.dataset_table;
9435
+ // NOTE: `e` can also be a string specifying the prefix to select
9436
+ let r = e.target || this.rowByPrefix(e);
9437
+ if(!r) return;
9438
+ // Modeler may have clicked on the expand/collapse triangle;
9439
+ const toggle = r.classList.contains('tree-btn');
9440
+ while(r.tagName !== 'TR') r = r.parentNode;
9441
+ this.selected_prefix_row = r;
9442
+ const sel = this.dataset_table.getElementsByClassName('sel-set');
9443
+ this.selected_dataset = null;
9444
+ if(sel.length > 0) {
9445
+ sel[0].classList.remove('sel-set');
9446
+ this.updatePanes();
9447
+ }
9448
+ r.classList.add('sel-set');
9449
+ if(!e.target) r.scrollIntoView({block: 'center'});
9450
+ if(toggle || e.altKey || this.doubleClicked(r)) this.togglePrefixRow(e);
9451
+ UI.enableButtons('ds-rename');
9108
9452
  }
9109
9453
 
9110
9454
  updateDialog() {
9111
9455
  const
9456
+ indent_px = 14,
9112
9457
  dl = [],
9113
9458
  dnl = [],
9114
9459
  sd = this.selected_dataset,
9115
- ioclass = ['', 'import', 'export'];
9460
+ ioclass = ['', 'import', 'export'],
9461
+ ciPrefixCompare = (a, b) => {
9462
+ const
9463
+ pa = a.split(':_').join(' '),
9464
+ pb = b.split(':_').join(' ');
9465
+ return ciCompare(pa, pb);
9466
+ };
9116
9467
  for(let d in MODEL.datasets) if(MODEL.datasets.hasOwnProperty(d) &&
9117
- // NOTE: do not list "black-boxed" entities
9468
+ // NOTE: do not list "black-boxed" entities
9118
9469
  !d.startsWith(UI.BLACK_BOX) &&
9119
9470
  // NOTE: do not list the equations dataset
9120
9471
  MODEL.datasets[d] !== MODEL.equations_dataset) {
@@ -9123,10 +9474,76 @@ class GUIDatasetManager extends DatasetManager {
9123
9474
  dnl.push(d);
9124
9475
  }
9125
9476
  }
9126
- dnl.sort(ciCompare);
9127
- let sdid = 'dstr';
9477
+ dnl.sort(ciPrefixCompare);
9478
+ // First determine indentation levels, prefixes and names
9479
+ const
9480
+ indent = [],
9481
+ pref_ids = [],
9482
+ names = [],
9483
+ pref_names = {},
9484
+ xids = [];
9128
9485
  for(let i = 0; i < dnl.length; i++) {
9129
- const d = MODEL.datasets[dnl[i]];
9486
+ const pref = UI.prefixesAndName(MODEL.datasets[dnl[i]].name);
9487
+ // NOTE: only the name part (so no prefixes at all) will be shown
9488
+ names.push(pref.pop());
9489
+ indent.push(pref.length);
9490
+ // NOTE: ignore case but join again with ": " because prefixes
9491
+ // can contain any character; only the prefixer is "reserved"
9492
+ const pref_id = pref.join(UI.PREFIXER).toLowerCase();
9493
+ pref_ids.push(pref_id);
9494
+ pref_names[pref_id] = pref;
9495
+ }
9496
+ let sdid = 'dstr',
9497
+ prev_id = '',
9498
+ ind_div = '';
9499
+ for(let i = 0; i < dnl.length; i++) {
9500
+ const
9501
+ d = MODEL.datasets[dnl[i]],
9502
+ pid = pref_ids[i];
9503
+ if(indent[i]) {
9504
+ ind_div = '<div class="ds-indent" style="width: ' +
9505
+ indent[i] * indent_px + 'px">\u25B9</div>';
9506
+ } else {
9507
+ ind_div = '';
9508
+ }
9509
+ // NOTE: empty string should not add a collapse/expand row
9510
+ if(pid && pid != prev_id && xids.indexOf(pid) < 0) {
9511
+ // NOTE: XX: aa may be followed by XX: YY: ZZ: bb, which requires
9512
+ // *two* collapsable lines: XX: YY and XX: YY: ZZ: before adding
9513
+ // XX: YY: ZZ: bb
9514
+ const
9515
+ ps = pid.split(UI.PREFIXER),
9516
+ pps = prev_id.split(UI.PREFIXER),
9517
+ pn = pref_names[pid],
9518
+ lpl = [];
9519
+ let lindent = 0;
9520
+ // Ignore identical leading prefixes
9521
+ while(ps.length > 0 && pps.length > 0 && ps[0] === pps[0]) {
9522
+ lpl.push(ps.shift());
9523
+ pps.shift();
9524
+ pn.shift();
9525
+ lindent++;
9526
+ }
9527
+ // Add a "collapse" row for each new prefix
9528
+ while(ps.length > 0) {
9529
+ lpl.push(ps.shift());
9530
+ lindent++;
9531
+ const lpid = lpl.join(UI.PREFIXER);
9532
+ dl.push(['<tr data-prefix="', lpid, '" class="dataset',
9533
+ '" onclick="DATASET_MANAGER.selectPrefixRow(event);"><td>',
9534
+ // NOTE: data-prefix="x" signals that this is an extra row
9535
+ (lindent > 0 ?
9536
+ '<div data-prefix="x" style="width: ' + lindent * indent_px +
9537
+ 'px"></div>' :
9538
+ ''),
9539
+ '<div data-prefix="x" class="tree-btn">',
9540
+ (this.expanded_rows.indexOf(lpid) >= 0 ? '\u25BC' : '\u25BA'),
9541
+ '</div>', pn.shift(), '</td></tr>'].join(''));
9542
+ // Add to the list to prevent multiple c/x-rows for the same prefix
9543
+ xids.push(lpid);
9544
+ }
9545
+ }
9546
+ prev_id = pid;
9130
9547
  let cls = ioclass[MODEL.ioType(d)];
9131
9548
  if(d.outcome) {
9132
9549
  cls += ' outcome';
@@ -9138,20 +9555,29 @@ class GUIDatasetManager extends DatasetManager {
9138
9555
  if(Object.keys(d.modifiers).length > 0) cls += ' modif';
9139
9556
  if(d.black_box) cls += ' blackbox';
9140
9557
  cls = cls.trim();
9141
- if(cls) cls = ' class="'+ cls + '"';
9558
+ if(cls) cls = ' class="' + cls + '"';
9142
9559
  if(d === sd) sdid += i;
9143
9560
  dl.push(['<tr id="dstr', i, '" class="dataset',
9144
9561
  (d === sd ? ' sel-set' : ''),
9145
9562
  (d.default_selector ? ' def-sel' : ''),
9563
+ '" data-prefix="', pid,
9146
9564
  '" onclick="DATASET_MANAGER.selectDataset(event, \'',
9147
9565
  dnl[i], '\');" onmouseover="DATASET_MANAGER.showInfo(\'', dnl[i],
9148
- '\', event.shiftKey);"><td', cls, '>', d.displayName,
9149
- '</td></tr>'].join(''));
9566
+ '\', event.shiftKey);"><td>', ind_div, '<div', cls, '>',
9567
+ names[i], '</td></tr>'].join(''));
9150
9568
  }
9151
- this.table.innerHTML = dl.join('');
9152
- const btns = 'ds-data ds-rename ds-clone ds-delete';
9569
+ this.dataset_table.innerHTML = dl.join('');
9570
+ this.hideCollapsedRows();
9571
+ const e = document.getElementById(sdid);
9572
+ if(e) UI.scrollIntoView(e);
9573
+ this.updatePanes();
9574
+ }
9575
+
9576
+ updatePanes() {
9577
+ const
9578
+ sd = this.selected_dataset,
9579
+ btns = 'ds-data ds-clone ds-delete ds-rename';
9153
9580
  if(sd) {
9154
- this.table.innerHTML = dl.join('');
9155
9581
  this.properties.style.display = 'block';
9156
9582
  document.getElementById('dataset-default').innerHTML =
9157
9583
  VM.sig4Dig(sd.default_value) +
@@ -9181,12 +9607,11 @@ class GUIDatasetManager extends DatasetManager {
9181
9607
  this.outcome.classList.add('not-selected');
9182
9608
  }
9183
9609
  UI.setImportExportBox('dataset', MODEL.ioType(sd));
9184
- const e = document.getElementById(sdid);
9185
- UI.scrollIntoView(e);
9186
9610
  UI.enableButtons(btns);
9187
9611
  } else {
9188
9612
  this.properties.style.display = 'none';
9189
9613
  UI.disableButtons(btns);
9614
+ if(this.selected_prefix_row) UI.enableButtons('ds-rename');
9190
9615
  }
9191
9616
  this.updateModifiers();
9192
9617
  }
@@ -9234,7 +9659,7 @@ class GUIDatasetManager extends DatasetManager {
9234
9659
  m.selector, '</td><td class="dataset-expression',
9235
9660
  clk, ');">', m.expression.text, '</td></tr>'].join(''));
9236
9661
  }
9237
- document.getElementById('dataset-modif-table').innerHTML = ml.join('');
9662
+ this.modifier_table.innerHTML = ml.join('');
9238
9663
  ttls.style.display = 'block';
9239
9664
  msa.style.display = 'block';
9240
9665
  mbtns.style.display = 'block';
@@ -9245,6 +9670,17 @@ class GUIDatasetManager extends DatasetManager {
9245
9670
  } else {
9246
9671
  UI.disableButtons(btns);
9247
9672
  }
9673
+ // Check if dataset appears to "misuse" dataset modifiers
9674
+ const
9675
+ pml = sd.inferPrefixableModifiers,
9676
+ e = document.getElementById('ds-convert-modif-btn');
9677
+ if(pml.length > 0) {
9678
+ e.style.display = 'inline-block';
9679
+ e.title = 'Convert '+ pluralS(pml.length, 'modifier') +
9680
+ ' to prefixed dataset(s)';
9681
+ } else {
9682
+ e.style.display = 'none';
9683
+ }
9248
9684
  }
9249
9685
 
9250
9686
  showInfo(id, shift) {
@@ -9279,16 +9715,13 @@ class GUIDatasetManager extends DatasetManager {
9279
9715
 
9280
9716
  selectDataset(event, id) {
9281
9717
  // Select dataset, or edit it when Alt- or double-clicked
9718
+ this.focal_table = this.dataset_table;
9282
9719
  const
9283
9720
  d = MODEL.datasets[id] || null,
9284
- now = Date.now(),
9285
- dt = now - this.last_time_selected,
9286
- // Consider click to be "double" if it occurred less than 300 ms ago
9287
- edit = event.altKey || (d === this.selected_dataset && dt < 300);
9721
+ edit = event.altKey || this.doubleClicked(d);
9288
9722
  this.selected_dataset = d;
9289
- this.last_time_selected = now;
9290
9723
  if(d && edit) {
9291
- this.last_time_selected = 0;
9724
+ this.last_time_clicked = 0;
9292
9725
  this.editData();
9293
9726
  return;
9294
9727
  }
@@ -9298,19 +9731,13 @@ class GUIDatasetManager extends DatasetManager {
9298
9731
  selectModifier(event, id, x=true) {
9299
9732
  // Select modifier, or when double-clicked, edit its expression or the
9300
9733
  // name of the modifier
9734
+ this.focal_table = this.modifier_table;
9301
9735
  if(this.selected_dataset) {
9302
9736
  const m = this.selected_dataset.modifiers[UI.nameToID(id)],
9303
- now = Date.now(),
9304
- dt = now - this.last_time_selected,
9305
- // NOTE: Alt-click and double-click indicate: edit
9306
- // Consider click to be "double" if the same modifier was clicked
9307
- // less than 300 ms ago
9308
- edit = event.altKey || (m === this.selected_modifier && dt < 300);
9309
- this.last_time_selected = now;
9737
+ edit = event.altKey || this.doubleClicked(m);
9310
9738
  if(event.shiftKey) {
9311
9739
  // NOTE: prepare to update HTML class of selected dataset
9312
- const el = document.getElementById('dataset-table')
9313
- .getElementsByClassName('sel-set')[0];
9740
+ const el = this.dataset_table.getElementsByClassName('sel-set')[0];
9314
9741
  // Toggle dataset default selector
9315
9742
  if(m.selector === this.selected_dataset.default_selector) {
9316
9743
  this.selected_dataset.default_selector = '';
@@ -9322,7 +9749,7 @@ class GUIDatasetManager extends DatasetManager {
9322
9749
  }
9323
9750
  this.selected_modifier = m;
9324
9751
  if(edit) {
9325
- this.last_time_selected = 0;
9752
+ this.last_time_clicked = 0;
9326
9753
  if(x) {
9327
9754
  this.editExpression();
9328
9755
  } else {
@@ -9336,8 +9763,36 @@ class GUIDatasetManager extends DatasetManager {
9336
9763
  this.updateModifiers();
9337
9764
  }
9338
9765
 
9339
- promptForDataset() {
9340
- this.new_modal.element('name').value = '';
9766
+ get selectedPrefix() {
9767
+ // Returns the selected prefix (with its trailing colon-space)
9768
+ let prefix = '',
9769
+ tr = this.selected_prefix_row;
9770
+ while(tr) {
9771
+ const td = tr.firstElementChild;
9772
+ if(td && td.firstElementChild.dataset.prefix === 'x') {
9773
+ prefix = td.lastChild.textContent + UI.PREFIXER + prefix;
9774
+ tr = tr.previousSibling;
9775
+ } else {
9776
+ tr = null;
9777
+ }
9778
+ }
9779
+ return prefix;
9780
+ }
9781
+
9782
+ promptForDataset(shift=false) {
9783
+ // Shift signifies: add prefix of selected dataset (if any) to
9784
+ // the name field of the dialog
9785
+ let prefix = '';
9786
+ if(shift) {
9787
+ if(this.selected_dataset) {
9788
+ const p = UI.prefixesAndName(this.selected_dataset.name);
9789
+ p[p.length - 1] = '';
9790
+ prefix = p.join(UI.PREFIXER);
9791
+ } else if(this.selected_prefix) {
9792
+ prefix = this.selectedPrefix;
9793
+ }
9794
+ }
9795
+ this.new_modal.element('name').value = prefix;
9341
9796
  this.new_modal.show('name');
9342
9797
  }
9343
9798
 
@@ -9354,9 +9809,14 @@ class GUIDatasetManager extends DatasetManager {
9354
9809
  promptForName() {
9355
9810
  // Prompts the modeler for a new name for the selected dataset (if any)
9356
9811
  if(this.selected_dataset) {
9812
+ this.rename_modal.element('title').innerText = 'Rename dataset';
9357
9813
  this.rename_modal.element('name').value =
9358
9814
  this.selected_dataset.displayName;
9359
9815
  this.rename_modal.show('name');
9816
+ } else if(this.selected_prefix_row) {
9817
+ this.rename_modal.element('title').innerText = 'Rename datasets by prefix';
9818
+ this.rename_modal.element('name').value = this.selectedPrefix.slice(0, -2);
9819
+ this.rename_modal.show('name');
9360
9820
  }
9361
9821
  }
9362
9822
 
@@ -9371,16 +9831,67 @@ class GUIDatasetManager extends DatasetManager {
9371
9831
  // Then try to rename -- this may generate a warning
9372
9832
  if(this.selected_dataset.rename(n)) {
9373
9833
  this.rename_modal.hide();
9374
- this.updateDialog();
9375
- // Also update Chart manager and Experiment viewer, as these may
9376
- // display a variable name for this dataset
9377
- CHART_MANAGER.updateDialog();
9378
9834
  if(EXPERIMENT_MANAGER.selected_experiment) {
9379
9835
  EXPERIMENT_MANAGER.selected_experiment.inferVariables();
9380
9836
  }
9381
- EXPERIMENT_MANAGER.updateDialog();
9837
+ UI.updateControllerDialogs('CDEFJX');
9838
+ }
9839
+ } else if(this.selected_prefix_row) {
9840
+ // Create a list of datasets to be renamed
9841
+ let e = this.rename_modal.element('name'),
9842
+ prefix = e.value.trim();
9843
+ e.focus();
9844
+ while(prefix.endsWith(':')) prefix = prefix.slice(0, -1);
9845
+ // NOTE: prefix may be empty string, but otherwise should be a valid name
9846
+ if(prefix && !UI.validName(prefix)) {
9847
+ UI.warn('Invalid prefix');
9848
+ return;
9849
+ }
9850
+ prefix += UI.PREFIXER;
9851
+ const
9852
+ oldpref = this.selectedPrefix,
9853
+ key = oldpref.toLowerCase().split(UI.PREFIXER).join(':_'),
9854
+ newkey = prefix.toLowerCase().split(UI.PREFIXER).join(':_'),
9855
+ dsl = [];
9856
+ // No change if new prefix is identical to old prefix
9857
+ if(oldpref !== prefix) {
9858
+ for(let k in MODEL.datasets) if(MODEL.datasets.hasOwnProperty(k)) {
9859
+ if(k.startsWith(key)) dsl.push(k);
9860
+ }
9861
+ // NOTE: no check needed for mere upper/lower case changes
9862
+ if(newkey !== key) {
9863
+ let nc = 0;
9864
+ for(let i = 0; i < dsl.length; i++) {
9865
+ let nk = newkey + dsl[i].substring(key.length);
9866
+ if(MODEL.datasets[nk]) nc++;
9867
+ }
9868
+ if(nc) {
9869
+ UI.warn('Renaming ' + pluralS(dsl.length, 'dataset') +
9870
+ ' would cause ' + pluralS(nc, 'name conflict'));
9871
+ return;
9872
+ }
9873
+ }
9874
+ // Reset counts of effects of a rename operation
9875
+ this.entity_count = 0;
9876
+ this.expression_count = 0;
9877
+ // Rename datasets one by one, suppressing notifications
9878
+ for(let i = 0; i < dsl.length; i++) {
9879
+ const d = MODEL.datasets[dsl[i]];
9880
+ d.rename(d.displayName.replace(oldpref, prefix), false);
9881
+ }
9882
+ let msg = 'Renamed ' + pluralS(dsl.length, 'dataset');
9883
+ if(MODEL.variable_count) msg += ', and updated ' +
9884
+ pluralS(MODEL.variable_count, 'variable') + ' in ' +
9885
+ pluralS(MODEL.expression_count, 'expression');
9886
+ UI.notify(msg);
9887
+ if(EXPERIMENT_MANAGER.selected_experiment) {
9888
+ EXPERIMENT_MANAGER.selected_experiment.inferVariables();
9889
+ }
9890
+ UI.updateControllerDialogs('CDEFJX');
9891
+ this.selectPrefixRow(prefix);
9382
9892
  }
9383
9893
  }
9894
+ this.rename_modal.hide();
9384
9895
  }
9385
9896
 
9386
9897
  cloneDataset() {
@@ -9520,16 +10031,13 @@ class GUIDatasetManager extends DatasetManager {
9520
10031
  this.deleteModifier();
9521
10032
  this.selected_modifier = m;
9522
10033
  // Update all chartvariables referencing this dataset + old selector
10034
+ const vl = MODEL.datasetChartVariables;
9523
10035
  let cv_cnt = 0;
9524
- for(let i = 0; i < MODEL.charts.length; i++) {
9525
- const c = MODEL.charts[i];
9526
- for(let j = 0; j < c.variables.length; j++) {
9527
- const v = c.variables[j];
9528
- if(v.object === this.selected_dataset &&
9529
- v.attribute === oldm.selector) {
9530
- v.attribute = m.selector;
9531
- cv_cnt++;
9532
- }
10036
+ for(let i = 0; i < vl.length; i++) {
10037
+ if(v.object === this.selected_dataset &&
10038
+ v.attribute === oldm.selector) {
10039
+ v.attribute = m.selector;
10040
+ cv_cnt++;
9533
10041
  }
9534
10042
  }
9535
10043
  // Also replace old selector in all expressions (count these as well)
@@ -9543,7 +10051,7 @@ class GUIDatasetManager extends DatasetManager {
9543
10051
  UI.notify('Updated ' + msg.join(' and '));
9544
10052
  // Also update these stay-on-top dialogs, as they may display a
9545
10053
  // variable name for this dataset + modifier
9546
- UI.updateControllerDialogs('CDEFX');
10054
+ UI.updateControllerDialogs('CDEFJX');
9547
10055
  }
9548
10056
  // NOTE: update dimensions only if dataset now has 2 or more modifiers
9549
10057
  // (ignoring those with wildcards)
@@ -9595,6 +10103,138 @@ class GUIDatasetManager extends DatasetManager {
9595
10103
  }
9596
10104
  }
9597
10105
 
10106
+ promptToConvertModifiers() {
10107
+ // Convert modifiers of selected dataset to new prefixed datasets
10108
+ const
10109
+ ds = this.selected_dataset,
10110
+ md = this.conversion_modal;
10111
+ if(ds) {
10112
+ md.element('prefix').value = ds.displayName;
10113
+ md.show('prefix');
10114
+ }
10115
+ }
10116
+
10117
+ convertModifiers() {
10118
+ // Convert modifiers of selected dataset to new prefixed datasets
10119
+ if(!this.selected_dataset) return;
10120
+ const
10121
+ ds = this.selected_dataset,
10122
+ md = this.conversion_modal,
10123
+ e = md.element('prefix');
10124
+ let prefix = e.value.trim(),
10125
+ vcount = 0;
10126
+ e.focus();
10127
+ while(prefix.endsWith(':')) prefix = prefix.slice(0, -1);
10128
+ // NOTE: prefix may be empty string, but otherwise should be a valid name
10129
+ if(!UI.validName(prefix)) {
10130
+ UI.warn('Invalid prefix');
10131
+ return;
10132
+ }
10133
+ prefix += UI.PREFIXER;
10134
+ const
10135
+ dsn = ds.displayName,
10136
+ pml = ds.inferPrefixableModifiers,
10137
+ xl = MODEL.allExpressions,
10138
+ vl = MODEL.datasetVariables,
10139
+ nl = MODEL.notesWithTags;
10140
+ for(let i = 0; i < pml.length; i++) {
10141
+ // Create prefixed dataset with correct default value
10142
+ const
10143
+ m = pml[i],
10144
+ sel = m.selector,
10145
+ newds = MODEL.addDataset(prefix + sel);
10146
+ if(newds) {
10147
+ // Retain properties of the "parent" dataset
10148
+ newds.scale_unit = ds.scale_unit;
10149
+ newds.time_scale = ds.time_scale;
10150
+ newds.time_unit = ds.time_unit;
10151
+ // Set modifier's expression result as default value
10152
+ newds.default_value = m.expression.result(1);
10153
+ // Remove the modifier from the dataset
10154
+ delete ds.modifiers[UI.nameToID(sel)];
10155
+ // If it was the dataset default modifier, clear this default
10156
+ if(sel === ds.default_selector) ds.default_selector = '';
10157
+ // Rename variable in charts
10158
+ const
10159
+ from = dsn + UI.OA_SEPARATOR + sel,
10160
+ to = newds.displayName;
10161
+ for(let j = 0; j < vl.length; j++) {
10162
+ const v = vl[j];
10163
+ // NOTE: variable should match original dataset + selector
10164
+ if(v.displayName === from) {
10165
+ // Change to new dataset WITHOUT selector
10166
+ v.object = newds;
10167
+ v.attribute = '';
10168
+ vcount++;
10169
+ }
10170
+ }
10171
+ // Rename variable in the Sensitivity Analysis
10172
+ for(let j = 0; j < MODEL.sensitivity_parameters.length; j++) {
10173
+ if(MODEL.sensitivity_parameters[j] === from) {
10174
+ MODEL.sensitivity_parameters[j] = to;
10175
+ vcount++;
10176
+ }
10177
+ }
10178
+ for(let j = 0; j < MODEL.sensitivity_outcomes.length; j++) {
10179
+ if(MODEL.sensitivity_outcomes[j] === from) {
10180
+ MODEL.sensitivity_outcomes[j] = to;
10181
+ vcount++;
10182
+ }
10183
+ }
10184
+ // Rename variable in expressions and notes
10185
+ const re = new RegExp(
10186
+ // Handle multiple spaces between words
10187
+ '\\[\\s*' + escapeRegex(from).replace(/\s+/g, '\\s+')
10188
+ // Handle spaces around the separator |
10189
+ .replace('\\|', '\\s*\\|\\s*') +
10190
+ // Pattern ends at any character that is invalid for a
10191
+ // dataset modifier selector (unlike equation names)
10192
+ '\\s*[^a-zA-Z0-9\\+\\-\\%\\_]', 'gi');
10193
+ for(let j = 0; j < xl.length; j++) {
10194
+ const
10195
+ x = xl[j],
10196
+ matches = x.text.match(re);
10197
+ if(matches) {
10198
+ for(let k = 0; k < matches.length; k++) {
10199
+ // NOTE: each match will start with the opening bracket,
10200
+ // but end with the first "non-selector" character, which
10201
+ // will typically be ']', but may also be '@' (and now that
10202
+ // units can be converted, also the '>' of the arrow '->')
10203
+ x.text = x.text.replace(matches[k], '[' + to + matches[k].slice(-1));
10204
+ vcount ++;
10205
+ }
10206
+ // Force recompilation
10207
+ x.code = null;
10208
+ }
10209
+ }
10210
+ for(let j = 0; j < nl.length; j++) {
10211
+ const
10212
+ n = nl[j],
10213
+ matches = n.contents.match(re);
10214
+ if(matches) {
10215
+ for(let k = 0; k < matches.length; k++) {
10216
+ // See NOTE above for the use of `slice` here
10217
+ n.contents = n.contents.replace(matches[k], '[' + to + matches[k].slice(-1));
10218
+ vcount ++;
10219
+ }
10220
+ // Note fields must be parsed again
10221
+ n.parsed = false;
10222
+ }
10223
+ }
10224
+ }
10225
+ }
10226
+ if(vcount) UI.notify('Renamed ' + pluralS(vcount, 'variable') +
10227
+ ' throughout the model');
10228
+ // Delete the original dataset unless it has series data
10229
+ if(ds.data.length === 0) this.deleteDataset();
10230
+ MODEL.updateDimensions();
10231
+ this.selected_dataset = null;
10232
+ this.selected_prefix_row = null;
10233
+ this.updateDialog();
10234
+ md.hide();
10235
+ this.selectPrefixRow(prefix);
10236
+ }
10237
+
9598
10238
  updateLine() {
9599
10239
  const
9600
10240
  ln = document.getElementById('series-line-number'),
@@ -9743,7 +10383,49 @@ class EquationManager {
9743
10383
  this.visible = false;
9744
10384
  this.selected_modifier = null;
9745
10385
  this.edited_expression = null;
9746
- this.last_time_selected = 0;
10386
+ this.last_time_clicked = 0;
10387
+ }
10388
+
10389
+ doubleClicked(obj) {
10390
+ const
10391
+ now = Date.now(),
10392
+ dt = now - this.last_time_clicked;
10393
+ this.last_time_clicked = now;
10394
+ if(obj === this.clicked_object) {
10395
+ // Consider click to be "double" if it occurred less than 300 ms ago
10396
+ if(dt < 300) {
10397
+ this.last_time_clicked = 0;
10398
+ return true;
10399
+ }
10400
+ }
10401
+ this.clicked_object = obj;
10402
+ return false;
10403
+ }
10404
+
10405
+ enterKey() {
10406
+ // Open the expression editor for the selected equation
10407
+ const srl = this.table.getElementsByClassName('sel-set');
10408
+ if(srl.length > 0) {
10409
+ const r = this.table.rows[srl[0].rowIndex];
10410
+ if(r) {
10411
+ // Emulate a double-click on the second cell to edit the expression
10412
+ this.last_time_clicked = Date.now();
10413
+ r.cells[1].dispatchEvent(new Event('click'));
10414
+ }
10415
+ }
10416
+ }
10417
+
10418
+ upDownKey(dir) {
10419
+ // Select row above or below the selected one (if possible)
10420
+ const srl = this.table.getElementsByClassName('sel-set');
10421
+ if(srl.length > 0) {
10422
+ const r = this.table.rows[srl[0].rowIndex + dir];
10423
+ if(r) {
10424
+ UI.scrollIntoView(r);
10425
+ // NOTE: not row but cell listens for onclick
10426
+ r.cells[1].dispatchEvent(new Event('click'));
10427
+ }
10428
+ }
9747
10429
  }
9748
10430
 
9749
10431
  updateDialog() {
@@ -9789,14 +10471,9 @@ class EquationManager {
9789
10471
  if(MODEL.equations_dataset) {
9790
10472
  const
9791
10473
  m = MODEL.equations_dataset.modifiers[UI.nameToID(id)] || null,
9792
- now = Date.now(),
9793
- dt = now - this.last_time_selected,
9794
- // Consider click to be "double" if it occurred less than 300 ms ago
9795
- edit = event.altKey || (m === this.selected_modifier && dt < 300);
9796
- this.last_time_selected = now;
10474
+ edit = event.altKey || this.doubleClicked(m);
9797
10475
  this.selected_modifier = m;
9798
10476
  if(m && edit) {
9799
- this.last_time_selected = 0;
9800
10477
  if(x) {
9801
10478
  this.editEquation();
9802
10479
  } else {
@@ -9918,7 +10595,7 @@ class EquationManager {
9918
10595
  UI.notify('Updated ' + msg.join(' and '));
9919
10596
  // Also update these stay-on-top dialogs, as they may display a
9920
10597
  // variable name for this dataset + modifier
9921
- UI.updateControllerDialogs('CDEFX');
10598
+ UI.updateControllerDialogs('CDEFJX');
9922
10599
  }
9923
10600
  // Always close the name prompt dialog, and update the equation manager
9924
10601
  this.rename_modal.hide();
@@ -10039,6 +10716,12 @@ class GUIChartManager extends ChartManager {
10039
10716
  'click', () => CHART_MANAGER.renameEquation());
10040
10717
  document.getElementById('chart-edit-equation-btn').addEventListener(
10041
10718
  'click', () => CHART_MANAGER.editEquation());
10719
+ document.getElementById('variable-color').addEventListener(
10720
+ 'mouseenter', () => CHART_MANAGER.showPasteColor());
10721
+ document.getElementById('variable-color').addEventListener(
10722
+ 'mouseleave', () => CHART_MANAGER.hidePasteColor());
10723
+ document.getElementById('variable-color').addEventListener(
10724
+ 'click', (event) => CHART_MANAGER.copyPasteColor(event));
10042
10725
  // NOTE: uses the color picker developed by James Daniel
10043
10726
  this.color_picker = new iro.ColorPicker("#color-picker", {
10044
10727
  width: 92,
@@ -10086,6 +10769,32 @@ class GUIChartManager extends ChartManager {
10086
10769
  this.options_shown = true;
10087
10770
  this.setRunsChart(false);
10088
10771
  this.last_time_selected = 0;
10772
+ this.paste_color = '';
10773
+ }
10774
+
10775
+ enterKey() {
10776
+ // Open "edit" dialog for the selected chart variable
10777
+ const srl = this.variables_table.getElementsByClassName('sel-set');
10778
+ if(srl.length > 0) {
10779
+ const r = this.variables_table.rows[srl[0].rowIndex];
10780
+ if(r) {
10781
+ // Emulate a double-click to edit the variable properties
10782
+ this.last_time_selected = Date.now();
10783
+ r.dispatchEvent(new Event('click'));
10784
+ }
10785
+ }
10786
+ }
10787
+
10788
+ upDownKey(dir) {
10789
+ // Select row above or below the selected one (if possible)
10790
+ const srl = this.variables_table.getElementsByClassName('sel-set');
10791
+ if(srl.length > 0) {
10792
+ const r = this.variables_table.rows[srl[0].rowIndex + dir];
10793
+ if(r) {
10794
+ UI.scrollIntoView(r);
10795
+ r.dispatchEvent(new Event('click'));
10796
+ }
10797
+ }
10089
10798
  }
10090
10799
 
10091
10800
  setRunsChart(show) {
@@ -10115,14 +10824,13 @@ class GUIChartManager extends ChartManager {
10115
10824
  const
10116
10825
  n = ev.dataTransfer.getData('text'),
10117
10826
  obj = MODEL.objectByID(n);
10827
+ ev.preventDefault();
10118
10828
  if(!obj) {
10119
10829
  UI.alert(`Unknown entity ID "${n}"`);
10120
10830
  } else if(this.chart_index >= 0) {
10121
- // Only accept when all conditions are met
10122
- ev.preventDefault();
10123
10831
  if(obj instanceof DatasetModifier) {
10124
10832
  // Equations can be added directly as chart variable
10125
- this.addVariable(obj.name);
10833
+ this.addVariable(obj.selector);
10126
10834
  return;
10127
10835
  }
10128
10836
  // For other entities, the attribute must be specified
@@ -10502,7 +11210,16 @@ class GUIChartManager extends ChartManager {
10502
11210
  this.variable_index = vi;
10503
11211
  this.updateDialog();
10504
11212
  }
10505
-
11213
+
11214
+ setColorPicker(color) {
11215
+ // Robust way to set iro color picker color
11216
+ try {
11217
+ this.color_picker.color.hexString = color;
11218
+ } catch(e) {
11219
+ this.color_picker.color.rgbString = color;
11220
+ }
11221
+ }
11222
+
10506
11223
  editVariable() {
10507
11224
  // Shows the edit (or rather: format) variable dialog
10508
11225
  if(this.chart_index >= 0 && this.variable_index >= 0) {
@@ -10512,11 +11229,7 @@ class GUIChartManager extends ChartManager {
10512
11229
  this.variable_modal.element('scale').value = VM.sig4Dig(cv.scale_factor);
10513
11230
  this.variable_modal.element('width').value = VM.sig4Dig(cv.line_width);
10514
11231
  this.variable_modal.element('color').style.backgroundColor = cv.color;
10515
- try {
10516
- this.color_picker.color.hexString = cv.color;
10517
- } catch(e) {
10518
- this.color_picker.color.rgbString = cv.color;
10519
- }
11232
+ this.setColorPicker(cv.color);
10520
11233
  // Show change equation buttons only for equation variables
10521
11234
  if(cv.object === MODEL.equations_dataset) {
10522
11235
  this.change_equation_btns.style.display = 'block';
@@ -10527,6 +11240,34 @@ class GUIChartManager extends ChartManager {
10527
11240
  }
10528
11241
  }
10529
11242
 
11243
+ showPasteColor() {
11244
+ // Show last copied color (if any) as smaller square next to color box
11245
+ if(this.paste_color) {
11246
+ const pc = this.variable_modal.element('paste-color');
11247
+ pc.style.backgroundColor = this.paste_color;
11248
+ pc.style.display = 'inline-block';
11249
+ }
11250
+ }
11251
+
11252
+ hidePasteColor() {
11253
+ // Hide paste color box
11254
+ this.variable_modal.element('paste-color').style.display = 'none';
11255
+ }
11256
+
11257
+ copyPasteColor(event) {
11258
+ // Store the current color as past color, or set it to the current
11259
+ // paste color if this is defined and the Shift key was pressed
11260
+ event.stopPropagation();
11261
+ const cbox = this.variable_modal.element('color');
11262
+ if(event.shiftKey && this.paste_color) {
11263
+ cbox.style.backgroundColor = this.paste_color;
11264
+ this.setColorPicker(this.paste_color);
11265
+ } else {
11266
+ this.paste_color = cbox.style.backgroundColor;
11267
+ this.showPasteColor();
11268
+ }
11269
+ }
11270
+
10530
11271
  toggleVariable(vi) {
10531
11272
  window.event.stopPropagation();
10532
11273
  if(vi >= 0 && this.chart_index >= 0) {
@@ -10757,7 +11498,7 @@ class GUIChartManager extends ChartManager {
10757
11498
  }
10758
11499
 
10759
11500
  renderChartAsPNG() {
10760
- localStorage.removeItem('png-url');
11501
+ window.localStorage.removeItem('png-url');
10761
11502
  FILE_MANAGER.renderSVGAsPNG(MODEL.charts[this.chart_index].svg);
10762
11503
  }
10763
11504
 
@@ -11552,7 +12293,10 @@ class GUIExperimentManager extends ExperimentManager {
11552
12293
  this.default_message = document.getElementById('experiment-default-message');
11553
12294
 
11554
12295
  this.design = document.getElementById('experiment-design');
12296
+ this.experiment_table = document.getElementById('experiment-table');
11555
12297
  this.params_div = document.getElementById('experiment-params-div');
12298
+ this.dimension_table = document.getElementById('experiment-dim-table');
12299
+ this.chart_table = document.getElementById('experiment-chart-table');
11556
12300
  // NOTE: the Exclude input field responds to several events
11557
12301
  this.exclude = document.getElementById('experiment-exclude');
11558
12302
  this.exclude.addEventListener(
@@ -11749,9 +12493,22 @@ class GUIExperimentManager extends ExperimentManager {
11749
12493
  this.edited_dimension_index = -1;
11750
12494
  this.edited_combi_selector_index = -1;
11751
12495
  this.color_scale = new ColorScale('no');
12496
+ this.focal_table = null;
11752
12497
  this.designMode();
11753
12498
  }
11754
12499
 
12500
+ upDownKey(dir) {
12501
+ // Select row above or below the selected one (if possible)
12502
+ const srl = this.focal_table.getElementsByClassName('sel-set');
12503
+ if(srl.length > 0) {
12504
+ const r = this.focal_table.rows[srl[0].rowIndex + dir];
12505
+ if(r) {
12506
+ UI.scrollIntoView(r);
12507
+ r.dispatchEvent(new Event('click'));
12508
+ }
12509
+ }
12510
+ }
12511
+
11755
12512
  updateDialog() {
11756
12513
  this.updateChartList();
11757
12514
  // Warn modeler if no meaningful experiments can be defined
@@ -11785,7 +12542,7 @@ class GUIExperimentManager extends ExperimentManager {
11785
12542
  '\');" onmouseover="EXPERIMENT_MANAGER.showInfo(', xi,
11786
12543
  ', event.shiftKey);"><td>', x.title, '</td></tr>'].join(''));
11787
12544
  }
11788
- document.getElementById('experiment-table').innerHTML = xl.join('');
12545
+ this.experiment_table.innerHTML = xl.join('');
11789
12546
  const
11790
12547
  btns = 'xp-rename xp-view xp-delete xp-ignore',
11791
12548
  icnt = document.getElementById('xp-ignore-count');
@@ -11849,7 +12606,7 @@ class GUIExperimentManager extends ExperimentManager {
11849
12606
  setString(x.dimensions[i]),
11850
12607
  '</td></tr>'].join(''));
11851
12608
  }
11852
- document.getElementById('experiment-dim-table').innerHTML = tr.join('');
12609
+ this.dimension_table.innerHTML = tr.join('');
11853
12610
  // Add button must be enabled only if there still are unused dimensions
11854
12611
  if(x.available_dimensions.length > 0) {
11855
12612
  document.getElementById('xp-d-add-btn').classList.remove('v-disab');
@@ -11865,8 +12622,9 @@ class GUIExperimentManager extends ExperimentManager {
11865
12622
  i, '\');"><td>',
11866
12623
  x.charts[i].title, '</td></tr>'].join(''));
11867
12624
  }
11868
- document.getElementById('experiment-chart-table').innerHTML = tr.join('');
11869
- if(x.charts.length === 0) canview = false;
12625
+ this.chart_table.innerHTML = tr.join('');
12626
+ // Do not show viewer unless at least 1 dependent variable has been defined
12627
+ if(x.charts.length === 0 && MODEL.outcomeNames.length === 0) canview = false;
11870
12628
  if(tr.length >= this.suitable_charts.length) {
11871
12629
  document.getElementById('xp-c-add-btn').classList.add('v-disab');
11872
12630
  } else {
@@ -11886,7 +12644,7 @@ class GUIExperimentManager extends ExperimentManager {
11886
12644
  dbtn.classList.add('v-disab');
11887
12645
  cbtn.classList.add('v-disab');
11888
12646
  }
11889
- // Enable viewing only if > 1 dimensions and > 1 charts
12647
+ // Enable viewing only if > 1 dimensions and > 1 outcome variables
11890
12648
  if(canview) {
11891
12649
  UI.enableButtons('xp-view');
11892
12650
  } else {
@@ -11971,14 +12729,11 @@ class GUIExperimentManager extends ExperimentManager {
11971
12729
  const x = this.selected_experiment;
11972
12730
  if(x) {
11973
12731
  x.inferVariables();
11974
- if(x.selected_variable === '') {
11975
- x.selected_variable = x.variables[0].displayName;
11976
- }
11977
12732
  const
11978
12733
  ol = [],
11979
12734
  vl = MODEL.outcomeNames;
11980
12735
  for(let i = 0; i < x.variables.length; i++) {
11981
- vl.push(x.variables[i].displayName);
12736
+ addDistinct(x.variables[i].displayName, vl);
11982
12737
  }
11983
12738
  vl.sort(ciCompare);
11984
12739
  for(let i = 0; i < vl.length; i++) {
@@ -11987,6 +12742,9 @@ class GUIExperimentManager extends ExperimentManager {
11987
12742
  '>', vl[i], '</option>'].join(''));
11988
12743
  }
11989
12744
  document.getElementById('viewer-variable').innerHTML = ol.join('');
12745
+ if(x.selected_variable === '') {
12746
+ x.selected_variable = vl[0];
12747
+ }
11990
12748
  }
11991
12749
  }
11992
12750
 
@@ -12525,6 +13283,8 @@ N = ${rr.N}, vector length = ${rr.vector.length}` : '')].join('');
12525
13283
 
12526
13284
  selectParameter(p) {
12527
13285
  this.selected_parameter = p;
13286
+ this.focal_table = (p.startsWith('d') ? this.dimension_table :
13287
+ this.chart_table);
12528
13288
  this.updateDialog();
12529
13289
  }
12530
13290
 
@@ -14142,7 +14902,10 @@ class Finder {
14142
14902
  this.copy_btn = document.getElementById('finder-copy-btn');
14143
14903
  this.copy_btn.addEventListener(
14144
14904
  'click', (event) => FINDER.copyAttributesToClipboard(event.shiftKey));
14145
-
14905
+ this.entity_table = document.getElementById('finder-table');
14906
+ this.item_table = document.getElementById('finder-item-table');
14907
+ this.expression_table = document.getElementById('finder-expression-table');
14908
+
14146
14909
  // Attribute headers are used by Finder to output entity attribute values
14147
14910
  this.attribute_headers = {
14148
14911
  A: 'ACTORS:\tWeight\tCash IN\tCash OUT\tCash FLOW',
@@ -14174,6 +14937,47 @@ class Finder {
14174
14937
  this.product_cluster_index = 0;
14175
14938
  }
14176
14939
 
14940
+ doubleClicked(obj) {
14941
+ const
14942
+ now = Date.now(),
14943
+ dt = now - this.last_time_clicked;
14944
+ this.last_time_clicked = now;
14945
+ if(obj === this.clicked_object) {
14946
+ // Consider click to be "double" if it occurred less than 300 ms ago
14947
+ if(dt < 300) {
14948
+ this.last_time_clicked = 0;
14949
+ return true;
14950
+ }
14951
+ }
14952
+ this.clicked_object = obj;
14953
+ return false;
14954
+ }
14955
+
14956
+ enterKey() {
14957
+ // Open "edit properties" dialog for the selected entity
14958
+ const srl = this.entity_table.getElementsByClassName('sel-set');
14959
+ if(srl.length > 0) {
14960
+ const r = this.entity_table.rows[srl[0].rowIndex];
14961
+ if(r) {
14962
+ const e = new Event('click');
14963
+ e.altKey = true;
14964
+ r.dispatchEvent(e);
14965
+ }
14966
+ }
14967
+ }
14968
+
14969
+ upDownKey(dir) {
14970
+ // Select row above or below the selected one (if possible)
14971
+ const srl = this.entity_table.getElementsByClassName('sel-set');
14972
+ if(srl.length > 0) {
14973
+ const r = this.entity_table.rows[srl[0].rowIndex + dir];
14974
+ if(r) {
14975
+ UI.scrollIntoView(r);
14976
+ r.dispatchEvent(new Event('click'));
14977
+ }
14978
+ }
14979
+ }
14980
+
14177
14981
  updateDialog() {
14178
14982
  const
14179
14983
  el = [],
@@ -14289,7 +15093,7 @@ class Finder {
14289
15093
  if(e === se) seid += i;
14290
15094
  el.push(['<tr id="etr', i, '" class="dataset',
14291
15095
  (e === se ? ' sel-set' : ''), '" onclick="FINDER.selectEntity(\'',
14292
- enl[i], '\');" onmouseover="FINDER.showInfo(\'', enl[i],
15096
+ enl[i], '\', event.altKey);" onmouseover="FINDER.showInfo(\'', enl[i],
14293
15097
  '\', event.shiftKey);"><td draggable="true" ',
14294
15098
  'ondragstart="FINDER.drag(event);"><img class="finder" src="images/',
14295
15099
  e.type.toLowerCase(), '.png">', e.displayName,
@@ -14297,7 +15101,7 @@ class Finder {
14297
15101
  }
14298
15102
  // NOTE: reset `selected_entity` if not in the new list
14299
15103
  if(seid === 'etr') this.selected_entity = null;
14300
- document.getElementById('finder-table').innerHTML = el.join('');
15104
+ this.entity_table.innerHTML = el.join('');
14301
15105
  UI.scrollIntoView(document.getElementById(seid));
14302
15106
  document.getElementById('finder-count').innerHTML = pluralS(
14303
15107
  el.length, 'entity', 'entities');
@@ -14362,7 +15166,7 @@ class Finder {
14362
15166
  const
14363
15167
  raw = escapeRegex(se.displayName),
14364
15168
  re = new RegExp(
14365
- '\\[\\s*' + raw.replace(/\s+/g, '\\s+') + '\\s*[\\|\\@\\]]');
15169
+ '\\[\\s*!?' + raw.replace(/\s+/g, '\\s+') + '\\s*[\\|\\@\\]]');
14366
15170
  // Check actor weight expressions
14367
15171
  for(let k in MODEL.actors) if(MODEL.actors.hasOwnProperty(k)) {
14368
15172
  const a = MODEL.actors[k];
@@ -14452,7 +15256,7 @@ class Finder {
14452
15256
  e.type.toLowerCase(), '.png">', e.displayName,
14453
15257
  '</td></tr>'].join(''));
14454
15258
  }
14455
- document.getElementById('finder-item-table').innerHTML = el.join('');
15259
+ this.item_table.innerHTML = el.join('');
14456
15260
  // Clear the table row list
14457
15261
  el.length = 0;
14458
15262
  // Now fill it with entity+attribute having a matching expression
@@ -14480,7 +15284,7 @@ class Finder {
14480
15284
  '<img class="finder" src="images/', img, '.png">', td, '</td></tr>'
14481
15285
  ].join(''));
14482
15286
  }
14483
- document.getElementById('finder-expression-table').innerHTML = el.join('');
15287
+ this.expression_table.innerHTML = el.join('');
14484
15288
  document.getElementById('finder-expression-hdr').innerHTML =
14485
15289
  pluralS(el.length, 'expression');
14486
15290
  }
@@ -14515,10 +15319,37 @@ class Finder {
14515
15319
  if(e) DOCUMENTATION_MANAGER.update(e, shift);
14516
15320
  }
14517
15321
 
14518
- selectEntity(id) {
14519
- // Looks up entity, selects it in the left pane, and updates the right pane
14520
- this.selected_entity = MODEL.objectByID(id);
15322
+ selectEntity(id, alt=false) {
15323
+ // Looks up entity, selects it in the left pane, and updates the
15324
+ // right pane; opens the "edit properties" modal dialog on double-click
15325
+ // and Alt-click if the entity is editable
15326
+ const obj = MODEL.objectByID(id);
15327
+ this.selected_entity = obj;
14521
15328
  this.updateDialog();
15329
+ if(!obj) return;
15330
+ if(alt || this.doubleClicked(obj)) {
15331
+ if(obj instanceof Process) {
15332
+ UI.showProcessPropertiesDialog(obj);
15333
+ } else if(obj instanceof Product) {
15334
+ UI.showProductPropertiesDialog(obj);
15335
+ } else if(obj instanceof Link) {
15336
+ UI.showLinkPropertiesDialog(obj);
15337
+ } else if(obj instanceof Note) {
15338
+ obj.showNotePropertiesDialog();
15339
+ } else if(obj instanceof Dataset) {
15340
+ if(UI.hidden('dataset-dlg')) {
15341
+ UI.buttons.dataset.dispatchEvent(new Event('click'));
15342
+ }
15343
+ DATASET_MANAGER.selected_dataset = obj;
15344
+ DATASET_MANAGER.updateDialog();
15345
+ } else if(obj instanceof DatasetModifier) {
15346
+ if(UI.hidden('equation-dlg')) {
15347
+ UI.buttons.equation.dispatchEvent(new Event('click'));
15348
+ }
15349
+ EQUATION_MANAGER.selected_modifier = obj;
15350
+ EQUATION_MANAGER.updateDialog();
15351
+ }
15352
+ }
14522
15353
  }
14523
15354
 
14524
15355
  reveal(id) {
@@ -14569,22 +15400,12 @@ class Finder {
14569
15400
  // NOTE: return the object to save a second lookup by revealExpression
14570
15401
  return obj;
14571
15402
  }
14572
-
15403
+
14573
15404
  revealExpression(id, attr, shift=false, alt=false) {
14574
- const
14575
- obj = this.reveal(id),
14576
- now = Date.now(),
14577
- dt = now - this.last_time_clicked;
14578
- this.last_time_clicked = now;
14579
- if(obj === this.clicked_object) {
14580
- // Consider click to be "double" if it occurred less than 300 ms ago
14581
- if(dt < 300) {
14582
- this.last_time_clicked = 0;
14583
- shift = true;
14584
- }
14585
- }
14586
- this.clicked_object = obj;
14587
- if(obj && attr && (shift || alt)) {
15405
+ const obj = this.reveal(id);
15406
+ if(!obj) return;
15407
+ shift = shift || this.doubleClicked(obj);
15408
+ if(attr && (shift || alt)) {
14588
15409
  if(obj instanceof Process) {
14589
15410
  // NOTE: the second argument makes the dialog focus on the specified
14590
15411
  // attribute input field; the third makes it open the expression editor