linny-r 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -3485,81 +3555,6 @@ class GUIController extends Controller {
3485
3555
  // Methods related to draggable & resizable dialogs
3486
3556
  //
3487
3557
 
3488
- toggleDialog(e) {
3489
- e = e || window.event;
3490
- e.preventDefault();
3491
- e.stopImmediatePropagation();
3492
- // Infer dialog identifier from target element
3493
- const
3494
- dlg = e.target.id.split('-')[0],
3495
- tde = document.getElementById(dlg + '-dlg'),
3496
- was_hidden = this.hidden(tde.id);
3497
- let mgr = tde.getAttribute('data-manager');
3498
- if(mgr) mgr = window[mgr];
3499
- // NOTE: prevent modeler from viewing charts while an experiment is running
3500
- if(dlg === 'chart' && was_hidden && MODEL.running_experiment) {
3501
- UI.notify(UI.NOTICE.NO_CHARTS);
3502
- mgr.visible = false;
3503
- return;
3504
- }
3505
- this.toggle(tde.id);
3506
- if(mgr) mgr.visible = was_hidden;
3507
- // Open at position after last drag (recorded in DOM data attributes)
3508
- let t = tde.getAttribute('data-top'),
3509
- l = tde.getAttribute('data-left');
3510
- // Make dialog appear in screen center the first time it is shown
3511
- if(t === null || l === null) {
3512
- const cs = window.getComputedStyle(tde);
3513
- t = ((window.innerHeight - parseFloat(cs.height)) / 2) + 'px';
3514
- l = ((window.innerWidth - parseFloat(cs.width)) / 2) + 'px';
3515
- tde.style.top = t;
3516
- tde.style.left = l;
3517
- }
3518
- if(!this.hidden(tde.id)) {
3519
- // Add dialog to "showing" list, and adjust z-indices
3520
- this.dr_dialog_order.push(tde);
3521
- this.reorderDialogs();
3522
- // Update the diagram if its manager has been specified
3523
- if(mgr) {
3524
- mgr.visible = true;
3525
- mgr.updateDialog();
3526
- if(mgr === DOCUMENTATION_MANAGER) {
3527
- if(this.info_line.innerHTML.length === 0) {
3528
- mgr.title.innerHTML = 'About Linny-R';
3529
- mgr.viewer.innerHTML = mgr.about_linny_r;
3530
- mgr.edit_btn.classList.remove('enab');
3531
- mgr.edit_btn.classList.add('disab');
3532
- }
3533
- UI.drawDiagram(MODEL);
3534
- }
3535
- }
3536
- } else {
3537
- const doi = this.dr_dialog_order.indexOf(tde);
3538
- // NOTE: doi should ALWAYS be >= 0 because dialog WAS showing
3539
- if(doi >= 0) {
3540
- this.dr_dialog_order.splice(doi, 1);
3541
- this.reorderDialogs();
3542
- }
3543
- if(mgr) {
3544
- mgr.visible = true;
3545
- if(mgr === DOCUMENTATION_MANAGER) {
3546
- mgr.visible = false;
3547
- mgr.title.innerHTML = 'Documentation';
3548
- UI.drawDiagram(MODEL);
3549
- }
3550
- }
3551
- }
3552
- UI.buttons[dlg].classList.toggle('stay-activ');
3553
- }
3554
-
3555
- reorderDialogs() {
3556
- let z = 10;
3557
- for(let i = 0; i < this.dr_dialog_order.length; i++) {
3558
- this.dr_dialog_order[i].style.zIndex = z;
3559
- z += 5;
3560
- }
3561
- }
3562
-
3563
3558
  draggableDialog(d) {
3564
3559
  // Make dialog draggable
3565
3560
  const
@@ -3685,7 +3680,7 @@ class GUIController extends Controller {
3685
3680
  UI.dr_dialog.style.width = Math.max(minw, w + dw) + 'px';
3686
3681
  UI.dr_dialog.style.height = Math.max(minh, h + dh) + 'px';
3687
3682
  // Update the dialog if its manager has been specified
3688
- const mgr = UI.dr_dialog.getAttribute('data-manager');
3683
+ const mgr = UI.dr_dialog.dataset.manager;
3689
3684
  if(mgr) window[mgr].updateDialog();
3690
3685
  }
3691
3686
 
@@ -3696,6 +3691,90 @@ class GUIController extends Controller {
3696
3691
  }
3697
3692
  }
3698
3693
 
3694
+ toggleDialog(e) {
3695
+ // Hide dialog if visible, or show it if not, and update the
3696
+ // order of appearance so that this dialog appears on top
3697
+ e = e || window.event;
3698
+ e.preventDefault();
3699
+ e.stopImmediatePropagation();
3700
+ // Infer dialog identifier from target element
3701
+ const
3702
+ dlg = e.target.id.split('-')[0],
3703
+ tde = document.getElementById(dlg + '-dlg');
3704
+ // NOTE: manager attribute is a string, e.g. 'MONITOR' or 'CHART_MANAGER'
3705
+ let mgr = tde.dataset.manager,
3706
+ was_hidden = this.hidden(tde.id);
3707
+ if(mgr) {
3708
+ // Dialog has a manager object => let `mgr` point to it
3709
+ mgr = window[mgr];
3710
+ // Manager object attributes are more reliable than DOM element
3711
+ // style attributes, so update the visibility status
3712
+ was_hidden = !mgr.visible;
3713
+ }
3714
+ // NOTE: modeler should not view charts while an experiment is
3715
+ // running, so do NOT toggle when the Chart Manager is NOT visible
3716
+ if(dlg === 'chart' && was_hidden && MODEL.running_experiment) {
3717
+ UI.notify(UI.NOTICE.NO_CHARTS);
3718
+ return;
3719
+ }
3720
+ // Otherwise, toggle the dialog visibility
3721
+ this.toggle(tde.id);
3722
+ UI.buttons[dlg].classList.toggle('stay-activ');
3723
+ if(mgr) mgr.visible = was_hidden;
3724
+ let t, l;
3725
+ if(top in tde.dataset && left in tde.dataset) {
3726
+ // Open at position after last drag (recorded in DOM data attributes)
3727
+ t = tde.dataset.top;
3728
+ l = tde.dataset.left;
3729
+ } else {
3730
+ // Make dialog appear in screen center the first time it is shown
3731
+ const cs = window.getComputedStyle(tde);
3732
+ t = ((window.innerHeight - parseFloat(cs.height)) / 2) + 'px';
3733
+ l = ((window.innerWidth - parseFloat(cs.width)) / 2) + 'px';
3734
+ tde.style.top = t;
3735
+ tde.style.left = l;
3736
+ }
3737
+ if(was_hidden) {
3738
+ // Add activated dialog to "showing" list, and adjust z-indices
3739
+ this.dr_dialog_order.push(tde);
3740
+ this.reorderDialogs();
3741
+ // Update the diagram if its manager has been specified
3742
+ if(mgr) {
3743
+ mgr.updateDialog();
3744
+ if(mgr === DOCUMENTATION_MANAGER) {
3745
+ if(this.info_line.innerHTML.length === 0) {
3746
+ mgr.title.innerHTML = 'About Linny-R';
3747
+ mgr.viewer.innerHTML = mgr.about_linny_r;
3748
+ mgr.edit_btn.classList.remove('enab');
3749
+ mgr.edit_btn.classList.add('disab');
3750
+ }
3751
+ UI.drawDiagram(MODEL);
3752
+ }
3753
+ }
3754
+ } else {
3755
+ const doi = this.dr_dialog_order.indexOf(tde);
3756
+ // NOTE: doi should ALWAYS be >= 0 because dialog WAS showing
3757
+ if(doi >= 0) {
3758
+ this.dr_dialog_order.splice(doi, 1);
3759
+ this.reorderDialogs();
3760
+ }
3761
+ if(mgr === DOCUMENTATION_MANAGER) {
3762
+ mgr.title.innerHTML = 'Documentation';
3763
+ UI.drawDiagram(MODEL);
3764
+ }
3765
+ }
3766
+ }
3767
+
3768
+ reorderDialogs() {
3769
+ // Set z-index of draggable dialogs according to their order
3770
+ // (most recently shown or clicked on top)
3771
+ let z = 10;
3772
+ for(let i = 0; i < this.dr_dialog_order.length; i++) {
3773
+ this.dr_dialog_order[i].style.zIndex = z;
3774
+ z += 5;
3775
+ }
3776
+ }
3777
+
3699
3778
  //
3700
3779
  // Button functionality
3701
3780
  //
@@ -3722,7 +3801,7 @@ class GUIController extends Controller {
3722
3801
  // Updates the buttons on the main GUI toolbars
3723
3802
  const
3724
3803
  node_btns = 'process product link constraint cluster note ',
3725
- edit_btns = 'clone delete undo redo ',
3804
+ edit_btns = 'clone paste delete undo redo ',
3726
3805
  model_btns = 'settings save actors dataset equation chart ' +
3727
3806
  'diagram savediagram finder monitor solve';
3728
3807
  if(MODEL === null) {
@@ -3749,6 +3828,7 @@ class GUIController extends Controller {
3749
3828
  this.active_button = this.stayActiveButton;
3750
3829
  this.disableButtons(edit_btns);
3751
3830
  if(MODEL.selection.length > 0) this.enableButtons('clone delete');
3831
+ if(this.canPaste) this.enableButtons('paste');
3752
3832
  // Only allow target seeking when some target or process constraint is defined
3753
3833
  if(MODEL.hasTargets) this.enableButtons('solve');
3754
3834
  var u = UNDO_STACK.canUndo;
@@ -4424,6 +4504,15 @@ class GUIController extends Controller {
4424
4504
  this.stepBack(e);
4425
4505
  } else if(e.keyCode === 39) {
4426
4506
  this.stepForward(e);
4507
+ } else if(e.altKey && [67, 77].indexOf(e.keyCode) >= 0) {
4508
+ // Special shortcut keys for "clone selection" and "model settings"
4509
+ const be = new Event('click');
4510
+ be.altKey = true;
4511
+ if(e.keyCode === 67) {
4512
+ this.buttons.clone.dispatchEvent(be);
4513
+ } else {
4514
+ this.buttons.settings.dispatchEvent(be);
4515
+ }
4427
4516
  } else if(!e.shiftKey && !e.altKey &&
4428
4517
  (!topmod || [65, 67, 86].indexOf(e.keyCode) < 0)) {
4429
4518
  // Interpret special keys as shortcuts unless a modal dialog is open
@@ -5137,7 +5226,42 @@ class GUIController extends Controller {
5137
5226
  cancelCloneSelection() {
5138
5227
  this.modals.clone.hide();
5139
5228
  this.updateButtons();
5140
- }
5229
+ }
5230
+
5231
+ copySelection() {
5232
+ // Save selection as XML in local storage of the browser
5233
+ const xml = MODEL.selectionAsXML;
5234
+ //console.log('HERE copy xml', xml);
5235
+ if(xml) {
5236
+ window.localStorage.setItem('Linny-R-selection-XML', xml);
5237
+ this.updateButtons();
5238
+ this.notify('Selection copied, but cannot be pasted yet -- Use Alt-C to clone');
5239
+ }
5240
+ }
5241
+
5242
+ get canPaste() {
5243
+ const xml = window.localStorage.getItem('Linny-R-selection-XML');
5244
+ if(xml) {
5245
+ const timestamp = xml.match(/<copy timestamp="(\d+)"/);
5246
+ if(timestamp) {
5247
+ if(Date.now() - parseInt(timestamp[1]) < 8*3600000) return true;
5248
+ }
5249
+ // Remove XML from local storage if older than 8 hours
5250
+ window.localStorage.removeItem('Linny-R-selection-XML');
5251
+ }
5252
+ return false;
5253
+ }
5254
+
5255
+ pasteSelection() {
5256
+ // If selection has been saved as XML in local storage, test to
5257
+ // see whether PASTE would result in name conflicts, and if so,
5258
+ // open the name conflict resolution window
5259
+ const xml = window.localStorage.getItem('Linny-R-selection-XML');
5260
+ if(xml) {
5261
+ // @@ TO DO!
5262
+ this.notify('Paste not implemented yet -- WORK IN PROGRESS!');
5263
+ }
5264
+ }
5141
5265
 
5142
5266
  //
5143
5267
  // Interaction with modal dialogs to modify model or entity properties
@@ -5346,17 +5470,20 @@ class GUIController extends Controller {
5346
5470
  if(!this.updateExpressionInput(
5347
5471
  'process-IL', 'initial level', p.initial_level)) return false;
5348
5472
  // Store original expression string
5349
- const pxt = p.pace_expression.text;
5473
+ const
5474
+ px = p.pace_expression,
5475
+ pxt = p.pace_expression.text;
5350
5476
  // Validate expression
5351
5477
  if(!this.updateExpressionInput('process-pace', 'level change frequency',
5352
- p.pace_expression)) return false;
5478
+ px)) return false;
5353
5479
  // NOTE: pace expression must be *static* and >= 1
5354
- n = p.pace_expression.result(1);
5355
- if(!p.pace_expression.isStatic || n < 1) {
5480
+ n = px.result(1);
5481
+ if(!px.isStatic || n < 1) {
5356
5482
  md.element('pace').focus();
5357
5483
  this.warn('Level change frequency must be static and &ge; 1');
5358
5484
  // Restore original expression string
5359
- p.pace_expression.text = pxt;
5485
+ px.text = pxt;
5486
+ px.code = null;
5360
5487
  return false;
5361
5488
  }
5362
5489
  // Ignore fraction if a real number was entered.
@@ -5857,7 +5984,7 @@ class GUIMonitor {
5857
5984
  (event) => {
5858
5985
  const el = event.target;
5859
5986
  el.classList.add('sel-pb');
5860
- MONITOR.showBlock(el.getAttribute('data-blk'));
5987
+ MONITOR.showBlock(el.dataset.blk);
5861
5988
  },
5862
5989
  false);
5863
5990
  this.progress_bar.appendChild(n);
@@ -6466,7 +6593,7 @@ class GUIFileManager {
6466
6593
  }
6467
6594
 
6468
6595
  renderDiagramAsPNG() {
6469
- localStorage.removeItem('png-url');
6596
+ window.localStorage.removeItem('png-url');
6470
6597
  UI.paper.fitToSize();
6471
6598
  MODEL.alignToGrid();
6472
6599
  this.renderSVGAsPNG(UI.paper.svg.outerHTML);
@@ -6492,7 +6619,7 @@ class GUIFileManager {
6492
6619
  })
6493
6620
  .then((data) => {
6494
6621
  // Pass URL of image to the newly opened browser window
6495
- localStorage.setItem('png-url', data);
6622
+ window.localStorage.setItem('png-url', data);
6496
6623
  })
6497
6624
  .catch((err) => UI.warn(UI.WARNING.NO_CONNECTION, err));
6498
6625
  }
@@ -9080,7 +9207,9 @@ class GUIDatasetManager extends DatasetManager {
9080
9207
  this.close_btn.addEventListener(
9081
9208
  'click', (event) => UI.toggleDialog(event));
9082
9209
  document.getElementById('ds-new-btn').addEventListener(
9083
- 'click', () => DATASET_MANAGER.promptForDataset());
9210
+ // Shift-click on New button => add prefix of selected dataset
9211
+ // (if any) to the name field of the dialog
9212
+ 'click', () => DATASET_MANAGER.promptForDataset(event.shiftKey));
9084
9213
  document.getElementById('ds-data-btn').addEventListener(
9085
9214
  'click', () => DATASET_MANAGER.editData());
9086
9215
  document.getElementById('ds-rename-btn').addEventListener(
@@ -9117,6 +9246,8 @@ class GUIDatasetManager extends DatasetManager {
9117
9246
  'click', () => DATASET_MANAGER.editExpression());
9118
9247
  document.getElementById('ds-delete-modif-btn').addEventListener(
9119
9248
  'click', () => DATASET_MANAGER.deleteModifier());
9249
+ document.getElementById('ds-convert-modif-btn').addEventListener(
9250
+ 'click', () => DATASET_MANAGER.promptToConvertModifiers());
9120
9251
  // Modifier table
9121
9252
  this.modifier_table = document.getElementById('dataset-modif-table');
9122
9253
  // Modal dialogs
@@ -9130,6 +9261,11 @@ class GUIDatasetManager extends DatasetManager {
9130
9261
  'click', () => DATASET_MANAGER.renameDataset());
9131
9262
  this.rename_modal.cancel.addEventListener(
9132
9263
  'click', () => DATASET_MANAGER.rename_modal.hide());
9264
+ this.conversion_modal = new ModalDialog('convert-modifiers');
9265
+ this.conversion_modal.ok.addEventListener(
9266
+ 'click', () => DATASET_MANAGER.convertModifiers());
9267
+ this.conversion_modal.cancel.addEventListener(
9268
+ 'click', () => DATASET_MANAGER.conversion_modal.hide());
9133
9269
  this.new_selector_modal = new ModalDialog('new-selector');
9134
9270
  this.new_selector_modal.ok.addEventListener(
9135
9271
  'click', () => DATASET_MANAGER.newModifier());
@@ -9164,12 +9300,14 @@ class GUIDatasetManager extends DatasetManager {
9164
9300
 
9165
9301
  reset() {
9166
9302
  super.reset();
9303
+ this.selected_prefix_row = null;
9167
9304
  this.selected_modifier = null;
9168
9305
  this.edited_expression = null;
9169
9306
  this.filter_pattern = null;
9170
9307
  this.clicked_object = null;
9171
9308
  this.last_time_clicked = 0;
9172
9309
  this.focal_table = null;
9310
+ this.expanded_rows = [];
9173
9311
  }
9174
9312
 
9175
9313
  doubleClicked(obj) {
@@ -9213,6 +9351,9 @@ class GUIDatasetManager extends DatasetManager {
9213
9351
  const srl = this.focal_table.getElementsByClassName('sel-set');
9214
9352
  if(srl.length > 0) {
9215
9353
  let r = this.focal_table.rows[srl[0].rowIndex + dir];
9354
+ while(r && r.style.display === 'none') {
9355
+ r = (dir > 0 ? r.nextSibling : r.previousSibling);
9356
+ }
9216
9357
  if(r) {
9217
9358
  UI.scrollIntoView(r);
9218
9359
  // NOTE: cell, not row, listens for onclick event
@@ -9222,14 +9363,118 @@ class GUIDatasetManager extends DatasetManager {
9222
9363
  }
9223
9364
  }
9224
9365
 
9366
+ hideCollapsedRows() {
9367
+ // Hides all rows except top level and immediate children of expanded
9368
+ for(let i = 0; i < this.dataset_table.rows.length; i++) {
9369
+ const
9370
+ row = this.dataset_table.rows[i],
9371
+ // Get the first DIV in the first TD of this row
9372
+ first_div = row.firstChild.firstElementChild,
9373
+ btn = first_div.dataset.prefix === 'x';
9374
+ let p = row.dataset.prefix,
9375
+ x = this.expanded_rows.indexOf(p) >= 0,
9376
+ show = !p || x;
9377
+ if(btn) {
9378
+ const btn_div = row.getElementsByClassName('tree-btn')[0];
9379
+ // Special expand/collapse row
9380
+ if(show) {
9381
+ // Set triangle to point down
9382
+ btn_div.innerText = '\u25BC';
9383
+ } else {
9384
+ // Set triangle to point right
9385
+ btn_div.innerText = '\u25BA';
9386
+ // See whether "parent prefix" is expanded
9387
+ p = p.split(UI.PREFIXER);
9388
+ p.pop();
9389
+ p = p.join(UI.PREFIXER);
9390
+ // If so, then also show the row
9391
+ show = (!p || this.expanded_rows.indexOf(p) >= 0);
9392
+ }
9393
+ }
9394
+ row.style.display = (show ? 'block' : 'none');
9395
+ }
9396
+ }
9397
+
9398
+ togglePrefixRow(e) {
9399
+ // Shows list items of the next prefix level
9400
+ let r = e.target;
9401
+ while(r.tagName !== 'TR') r = r.parentNode;
9402
+ const
9403
+ p = r.dataset.prefix,
9404
+ i = this.expanded_rows.indexOf(p);
9405
+ if(i >= 0) {
9406
+ this.expanded_rows.splice(i, 1);
9407
+ // Also remove all prefixes that have `p` as prefix
9408
+ for(let j = this.expanded_rows.length - 1; j >= 0; j--) {
9409
+ if(this.expanded_rows[j].startsWith(p + UI.PREFIXER)) {
9410
+ this.expanded_rows.splice(j, 1);
9411
+ }
9412
+ }
9413
+ } else {
9414
+ addDistinct(p, this.expanded_rows);
9415
+ }
9416
+ this.hideCollapsedRows();
9417
+ }
9418
+
9419
+ rowByPrefix(prefix) {
9420
+ // Returns first table row with the specified prefix
9421
+ if(!prefix) return null;
9422
+ let lcp = prefix.toLowerCase(),
9423
+ pl = lcp.split(': ');
9424
+ // Remove trailing ': '
9425
+ if(lcp.endsWith(': ')) {
9426
+ pl.pop();
9427
+ lcp = pl.join(': ');
9428
+ }
9429
+ while(pl.length > 0) {
9430
+ addDistinct(pl.join(': '), this.expanded_rows);
9431
+ pl.pop();
9432
+ }
9433
+ this.hideCollapsedRows();
9434
+ for(let i = 0; i < this.dataset_table.rows.length; i++) {
9435
+ const r = this.dataset_table.rows[i];
9436
+ if(r.dataset.prefix === lcp) return r;
9437
+ }
9438
+ return null;
9439
+ }
9440
+
9441
+ selectPrefixRow(e) {
9442
+ // Selects expand/collapse prefix row
9443
+ this.focal_table = this.dataset_table;
9444
+ // NOTE: `e` can also be a string specifying the prefix to select
9445
+ let r = e.target || this.rowByPrefix(e);
9446
+ if(!r) return;
9447
+ // Modeler may have clicked on the expand/collapse triangle;
9448
+ const toggle = r.classList.contains('tree-btn');
9449
+ while(r.tagName !== 'TR') r = r.parentNode;
9450
+ this.selected_prefix_row = r;
9451
+ const sel = this.dataset_table.getElementsByClassName('sel-set');
9452
+ this.selected_dataset = null;
9453
+ if(sel.length > 0) {
9454
+ sel[0].classList.remove('sel-set');
9455
+ this.updatePanes();
9456
+ }
9457
+ r.classList.add('sel-set');
9458
+ if(!e.target) r.scrollIntoView({block: 'center'});
9459
+ if(toggle || e.altKey || this.doubleClicked(r)) this.togglePrefixRow(e);
9460
+ UI.enableButtons('ds-rename');
9461
+ }
9462
+
9225
9463
  updateDialog() {
9226
9464
  const
9465
+ indent_px = 14,
9227
9466
  dl = [],
9228
9467
  dnl = [],
9229
9468
  sd = this.selected_dataset,
9230
- ioclass = ['', 'import', 'export'];
9469
+ ioclass = ['', 'import', 'export'],
9470
+ ciPrefixCompare = (a, b) => {
9471
+ const
9472
+ pa = a.split(':_').join(' '),
9473
+ pb = b.split(':_').join(' ');
9474
+ return ciCompare(pa, pb);
9475
+ };
9231
9476
  for(let d in MODEL.datasets) if(MODEL.datasets.hasOwnProperty(d) &&
9232
- // NOTE: do not list "black-boxed" entities
9477
+ // NOTE: do not list "black-boxed" entities
9233
9478
  !d.startsWith(UI.BLACK_BOX) &&
9234
9479
  // NOTE: do not list the equations dataset
9235
9480
  MODEL.datasets[d] !== MODEL.equations_dataset) {
@@ -9238,10 +9483,76 @@ class GUIDatasetManager extends DatasetManager {
9238
9483
  dnl.push(d);
9239
9484
  }
9240
9485
  }
9241
- dnl.sort(ciCompare);
9242
- let sdid = 'dstr';
9486
+ dnl.sort(ciPrefixCompare);
9487
+ // First determine indentation levels, prefixes and names
9488
+ const
9489
+ indent = [],
9490
+ pref_ids = [],
9491
+ names = [],
9492
+ pref_names = {},
9493
+ xids = [];
9494
+ for(let i = 0; i < dnl.length; i++) {
9495
+ const pref = UI.prefixesAndName(MODEL.datasets[dnl[i]].name);
9496
+ // NOTE: only the name part (so no prefixes at all) will be shown
9497
+ names.push(pref.pop());
9498
+ indent.push(pref.length);
9499
+ // NOTE: ignore case but join again with ": " because prefixes
9500
+ // can contain any character; only the prefixer is "reserved"
9501
+ const pref_id = pref.join(UI.PREFIXER).toLowerCase();
9502
+ pref_ids.push(pref_id);
9503
+ pref_names[pref_id] = pref;
9504
+ }
9505
+ let sdid = 'dstr',
9506
+ prev_id = '',
9507
+ ind_div = '';
9243
9508
  for(let i = 0; i < dnl.length; i++) {
9244
- const d = MODEL.datasets[dnl[i]];
9509
+ const
9510
+ d = MODEL.datasets[dnl[i]],
9511
+ pid = pref_ids[i];
9512
+ if(indent[i]) {
9513
+ ind_div = '<div class="ds-indent" style="width: ' +
9514
+ indent[i] * indent_px + 'px">\u25B9</div>';
9515
+ } else {
9516
+ ind_div = '';
9517
+ }
9518
+ // NOTE: empty string should not add a collapse/expand row
9519
+ if(pid && pid != prev_id && xids.indexOf(pid) < 0) {
9520
+ // NOTE: XX: aa may be followed by XX: YY: ZZ: bb, which requires
9521
+ // *two* collapsable lines: XX: YY and XX: YY: ZZ: before adding
9522
+ // XX: YY: ZZ: bb
9523
+ const
9524
+ ps = pid.split(UI.PREFIXER),
9525
+ pps = prev_id.split(UI.PREFIXER),
9526
+ pn = pref_names[pid],
9527
+ lpl = [];
9528
+ let lindent = 0;
9529
+ // Ignore identical leading prefixes
9530
+ while(ps.length > 0 && pps.length > 0 && ps[0] === pps[0]) {
9531
+ lpl.push(ps.shift());
9532
+ pps.shift();
9533
+ pn.shift();
9534
+ lindent++;
9535
+ }
9536
+ // Add a "collapse" row for each new prefix
9537
+ while(ps.length > 0) {
9538
+ lpl.push(ps.shift());
9539
+ lindent++;
9540
+ const lpid = lpl.join(UI.PREFIXER);
9541
+ dl.push(['<tr data-prefix="', lpid, '" class="dataset',
9542
+ '" onclick="DATASET_MANAGER.selectPrefixRow(event);"><td>',
9543
+ // NOTE: data-prefix="x" signals that this is an extra row
9544
+ (lindent > 0 ?
9545
+ '<div data-prefix="x" style="width: ' + lindent * indent_px +
9546
+ 'px"></div>' :
9547
+ ''),
9548
+ '<div data-prefix="x" class="tree-btn">',
9549
+ (this.expanded_rows.indexOf(lpid) >= 0 ? '\u25BC' : '\u25BA'),
9550
+ '</div>', pn.shift(), '</td></tr>'].join(''));
9551
+ // Add to the list to prevent multiple c/x-rows for the same prefix
9552
+ xids.push(lpid);
9553
+ }
9554
+ }
9555
+ prev_id = pid;
9245
9556
  let cls = ioclass[MODEL.ioType(d)];
9246
9557
  if(d.outcome) {
9247
9558
  cls += ' outcome';
@@ -9253,20 +9564,29 @@ class GUIDatasetManager extends DatasetManager {
9253
9564
  if(Object.keys(d.modifiers).length > 0) cls += ' modif';
9254
9565
  if(d.black_box) cls += ' blackbox';
9255
9566
  cls = cls.trim();
9256
- if(cls) cls = ' class="'+ cls + '"';
9567
+ if(cls) cls = ' class="' + cls + '"';
9257
9568
  if(d === sd) sdid += i;
9258
9569
  dl.push(['<tr id="dstr', i, '" class="dataset',
9259
9570
  (d === sd ? ' sel-set' : ''),
9260
9571
  (d.default_selector ? ' def-sel' : ''),
9572
+ '" data-prefix="', pid,
9261
9573
  '" onclick="DATASET_MANAGER.selectDataset(event, \'',
9262
9574
  dnl[i], '\');" onmouseover="DATASET_MANAGER.showInfo(\'', dnl[i],
9263
- '\', event.shiftKey);"><td', cls, '>', d.displayName,
9264
- '</td></tr>'].join(''));
9575
+ '\', event.shiftKey);"><td>', ind_div, '<div', cls, '>',
9576
+ names[i], '</td></tr>'].join(''));
9265
9577
  }
9266
9578
  this.dataset_table.innerHTML = dl.join('');
9267
- const btns = 'ds-data ds-rename ds-clone ds-delete';
9579
+ this.hideCollapsedRows();
9580
+ const e = document.getElementById(sdid);
9581
+ if(e) UI.scrollIntoView(e);
9582
+ this.updatePanes();
9583
+ }
9584
+
9585
+ updatePanes() {
9586
+ const
9587
+ sd = this.selected_dataset,
9588
+ btns = 'ds-data ds-clone ds-delete ds-rename';
9268
9589
  if(sd) {
9269
- this.dataset_table.innerHTML = dl.join('');
9270
9590
  this.properties.style.display = 'block';
9271
9591
  document.getElementById('dataset-default').innerHTML =
9272
9592
  VM.sig4Dig(sd.default_value) +
@@ -9296,12 +9616,11 @@ class GUIDatasetManager extends DatasetManager {
9296
9616
  this.outcome.classList.add('not-selected');
9297
9617
  }
9298
9618
  UI.setImportExportBox('dataset', MODEL.ioType(sd));
9299
- const e = document.getElementById(sdid);
9300
- UI.scrollIntoView(e);
9301
9619
  UI.enableButtons(btns);
9302
9620
  } else {
9303
9621
  this.properties.style.display = 'none';
9304
9622
  UI.disableButtons(btns);
9623
+ if(this.selected_prefix_row) UI.enableButtons('ds-rename');
9305
9624
  }
9306
9625
  this.updateModifiers();
9307
9626
  }
@@ -9360,6 +9679,17 @@ class GUIDatasetManager extends DatasetManager {
9360
9679
  } else {
9361
9680
  UI.disableButtons(btns);
9362
9681
  }
9682
+ // Check if dataset appears to "misuse" dataset modifiers
9683
+ const
9684
+ pml = sd.inferPrefixableModifiers,
9685
+ e = document.getElementById('ds-convert-modif-btn');
9686
+ if(pml.length > 0) {
9687
+ e.style.display = 'inline-block';
9688
+ e.title = 'Convert '+ pluralS(pml.length, 'modifier') +
9689
+ ' to prefixed dataset(s)';
9690
+ } else {
9691
+ e.style.display = 'none';
9692
+ }
9363
9693
  }
9364
9694
 
9365
9695
  showInfo(id, shift) {
@@ -9442,8 +9772,36 @@ class GUIDatasetManager extends DatasetManager {
9442
9772
  this.updateModifiers();
9443
9773
  }
9444
9774
 
9445
- promptForDataset() {
9446
- this.new_modal.element('name').value = '';
9775
+ get selectedPrefix() {
9776
+ // Returns the selected prefix (with its trailing colon-space)
9777
+ let prefix = '',
9778
+ tr = this.selected_prefix_row;
9779
+ while(tr) {
9780
+ const td = tr.firstElementChild;
9781
+ if(td && td.firstElementChild.dataset.prefix === 'x') {
9782
+ prefix = td.lastChild.textContent + UI.PREFIXER + prefix;
9783
+ tr = tr.previousSibling;
9784
+ } else {
9785
+ tr = null;
9786
+ }
9787
+ }
9788
+ return prefix;
9789
+ }
9790
+
9791
+ promptForDataset(shift=false) {
9792
+ // Shift signifies: add prefix of selected dataset (if any) to
9793
+ // the name field of the dialog
9794
+ let prefix = '';
9795
+ if(shift) {
9796
+ if(this.selected_dataset) {
9797
+ const p = UI.prefixesAndName(this.selected_dataset.name);
9798
+ p[p.length - 1] = '';
9799
+ prefix = p.join(UI.PREFIXER);
9800
+ } else if(this.selected_prefix) {
9801
+ prefix = this.selectedPrefix;
9802
+ }
9803
+ }
9804
+ this.new_modal.element('name').value = prefix;
9447
9805
  this.new_modal.show('name');
9448
9806
  }
9449
9807
 
@@ -9460,9 +9818,14 @@ class GUIDatasetManager extends DatasetManager {
9460
9818
  promptForName() {
9461
9819
  // Prompts the modeler for a new name for the selected dataset (if any)
9462
9820
  if(this.selected_dataset) {
9821
+ this.rename_modal.element('title').innerText = 'Rename dataset';
9463
9822
  this.rename_modal.element('name').value =
9464
9823
  this.selected_dataset.displayName;
9465
9824
  this.rename_modal.show('name');
9825
+ } else if(this.selected_prefix_row) {
9826
+ this.rename_modal.element('title').innerText = 'Rename datasets by prefix';
9827
+ this.rename_modal.element('name').value = this.selectedPrefix.slice(0, -2);
9828
+ this.rename_modal.show('name');
9466
9829
  }
9467
9830
  }
9468
9831
 
@@ -9477,16 +9840,67 @@ class GUIDatasetManager extends DatasetManager {
9477
9840
  // Then try to rename -- this may generate a warning
9478
9841
  if(this.selected_dataset.rename(n)) {
9479
9842
  this.rename_modal.hide();
9480
- this.updateDialog();
9481
- // Also update Chart manager and Experiment viewer, as these may
9482
- // display a variable name for this dataset
9483
- CHART_MANAGER.updateDialog();
9484
9843
  if(EXPERIMENT_MANAGER.selected_experiment) {
9485
9844
  EXPERIMENT_MANAGER.selected_experiment.inferVariables();
9486
9845
  }
9487
- EXPERIMENT_MANAGER.updateDialog();
9846
+ UI.updateControllerDialogs('CDEFJX');
9847
+ }
9848
+ } else if(this.selected_prefix_row) {
9849
+ // Create a list of datasets to be renamed
9850
+ let e = this.rename_modal.element('name'),
9851
+ prefix = e.value.trim();
9852
+ e.focus();
9853
+ while(prefix.endsWith(':')) prefix = prefix.slice(0, -1);
9854
+ // NOTE: prefix may be empty string, but otherwise should be a valid name
9855
+ if(prefix && !UI.validName(prefix)) {
9856
+ UI.warn('Invalid prefix');
9857
+ return;
9858
+ }
9859
+ prefix += UI.PREFIXER;
9860
+ const
9861
+ oldpref = this.selectedPrefix,
9862
+ key = oldpref.toLowerCase().split(UI.PREFIXER).join(':_'),
9863
+ newkey = prefix.toLowerCase().split(UI.PREFIXER).join(':_'),
9864
+ dsl = [];
9865
+ // No change if new prefix is identical to old prefix
9866
+ if(oldpref !== prefix) {
9867
+ for(let k in MODEL.datasets) if(MODEL.datasets.hasOwnProperty(k)) {
9868
+ if(k.startsWith(key)) dsl.push(k);
9869
+ }
9870
+ // NOTE: no check needed for mere upper/lower case changes
9871
+ if(newkey !== key) {
9872
+ let nc = 0;
9873
+ for(let i = 0; i < dsl.length; i++) {
9874
+ let nk = newkey + dsl[i].substring(key.length);
9875
+ if(MODEL.datasets[nk]) nc++;
9876
+ }
9877
+ if(nc) {
9878
+ UI.warn('Renaming ' + pluralS(dsl.length, 'dataset') +
9879
+ ' would cause ' + pluralS(nc, 'name conflict'));
9880
+ return;
9881
+ }
9882
+ }
9883
+ // Reset counts of effects of a rename operation
9884
+ this.entity_count = 0;
9885
+ this.expression_count = 0;
9886
+ // Rename datasets one by one, suppressing notifications
9887
+ for(let i = 0; i < dsl.length; i++) {
9888
+ const d = MODEL.datasets[dsl[i]];
9889
+ d.rename(d.displayName.replace(oldpref, prefix), false);
9890
+ }
9891
+ let msg = 'Renamed ' + pluralS(dsl.length, 'dataset');
9892
+ if(MODEL.variable_count) msg += ', and updated ' +
9893
+ pluralS(MODEL.variable_count, 'variable') + ' in ' +
9894
+ pluralS(MODEL.expression_count, 'expression');
9895
+ UI.notify(msg);
9896
+ if(EXPERIMENT_MANAGER.selected_experiment) {
9897
+ EXPERIMENT_MANAGER.selected_experiment.inferVariables();
9898
+ }
9899
+ UI.updateControllerDialogs('CDEFJX');
9900
+ this.selectPrefixRow(prefix);
9488
9901
  }
9489
9902
  }
9903
+ this.rename_modal.hide();
9490
9904
  }
9491
9905
 
9492
9906
  cloneDataset() {
@@ -9626,16 +10040,13 @@ class GUIDatasetManager extends DatasetManager {
9626
10040
  this.deleteModifier();
9627
10041
  this.selected_modifier = m;
9628
10042
  // Update all chartvariables referencing this dataset + old selector
10043
+ const vl = MODEL.datasetChartVariables;
9629
10044
  let cv_cnt = 0;
9630
- for(let i = 0; i < MODEL.charts.length; i++) {
9631
- const c = MODEL.charts[i];
9632
- for(let j = 0; j < c.variables.length; j++) {
9633
- const v = c.variables[j];
9634
- if(v.object === this.selected_dataset &&
9635
- v.attribute === oldm.selector) {
9636
- v.attribute = m.selector;
9637
- cv_cnt++;
9638
- }
10045
+ for(let i = 0; i < vl.length; i++) {
10046
+ if(v.object === this.selected_dataset &&
10047
+ v.attribute === oldm.selector) {
10048
+ v.attribute = m.selector;
10049
+ cv_cnt++;
9639
10050
  }
9640
10051
  }
9641
10052
  // Also replace old selector in all expressions (count these as well)
@@ -9649,7 +10060,7 @@ class GUIDatasetManager extends DatasetManager {
9649
10060
  UI.notify('Updated ' + msg.join(' and '));
9650
10061
  // Also update these stay-on-top dialogs, as they may display a
9651
10062
  // variable name for this dataset + modifier
9652
- UI.updateControllerDialogs('CDEFX');
10063
+ UI.updateControllerDialogs('CDEFJX');
9653
10064
  }
9654
10065
  // NOTE: update dimensions only if dataset now has 2 or more modifiers
9655
10066
  // (ignoring those with wildcards)
@@ -9701,6 +10112,138 @@ class GUIDatasetManager extends DatasetManager {
9701
10112
  }
9702
10113
  }
9703
10114
 
10115
+ promptToConvertModifiers() {
10116
+ // Convert modifiers of selected dataset to new prefixed datasets
10117
+ const
10118
+ ds = this.selected_dataset,
10119
+ md = this.conversion_modal;
10120
+ if(ds) {
10121
+ md.element('prefix').value = ds.displayName;
10122
+ md.show('prefix');
10123
+ }
10124
+ }
10125
+
10126
+ convertModifiers() {
10127
+ // Convert modifiers of selected dataset to new prefixed datasets
10128
+ if(!this.selected_dataset) return;
10129
+ const
10130
+ ds = this.selected_dataset,
10131
+ md = this.conversion_modal,
10132
+ e = md.element('prefix');
10133
+ let prefix = e.value.trim(),
10134
+ vcount = 0;
10135
+ e.focus();
10136
+ while(prefix.endsWith(':')) prefix = prefix.slice(0, -1);
10137
+ // NOTE: prefix may be empty string, but otherwise should be a valid name
10138
+ if(!UI.validName(prefix)) {
10139
+ UI.warn('Invalid prefix');
10140
+ return;
10141
+ }
10142
+ prefix += UI.PREFIXER;
10143
+ const
10144
+ dsn = ds.displayName,
10145
+ pml = ds.inferPrefixableModifiers,
10146
+ xl = MODEL.allExpressions,
10147
+ vl = MODEL.datasetVariables,
10148
+ nl = MODEL.notesWithTags;
10149
+ for(let i = 0; i < pml.length; i++) {
10150
+ // Create prefixed dataset with correct default value
10151
+ const
10152
+ m = pml[i],
10153
+ sel = m.selector,
10154
+ newds = MODEL.addDataset(prefix + sel);
10155
+ if(newds) {
10156
+ // Retain properties of the "parent" dataset
10157
+ newds.scale_unit = ds.scale_unit;
10158
+ newds.time_scale = ds.time_scale;
10159
+ newds.time_unit = ds.time_unit;
10160
+ // Set modifier's expression result as default value
10161
+ newds.default_value = m.expression.result(1);
10162
+ // Remove the modifier from the dataset
10163
+ delete ds.modifiers[UI.nameToID(sel)];
10164
+ // If it was the dataset default modifier, clear this default
10165
+ if(sel === ds.default_selector) ds.default_selector = '';
10166
+ // Rename variable in charts
10167
+ const
10168
+ from = dsn + UI.OA_SEPARATOR + sel,
10169
+ to = newds.displayName;
10170
+ for(let j = 0; j < vl.length; j++) {
10171
+ const v = vl[j];
10172
+ // NOTE: variable should match original dataset + selector
10173
+ if(v.displayName === from) {
10174
+ // Change to new dataset WITHOUT selector
10175
+ v.object = newds;
10176
+ v.attribute = '';
10177
+ vcount++;
10178
+ }
10179
+ }
10180
+ // Rename variable in the Sensitivity Analysis
10181
+ for(let j = 0; j < MODEL.sensitivity_parameters.length; j++) {
10182
+ if(MODEL.sensitivity_parameters[j] === from) {
10183
+ MODEL.sensitivity_parameters[j] = to;
10184
+ vcount++;
10185
+ }
10186
+ }
10187
+ for(let j = 0; j < MODEL.sensitivity_outcomes.length; j++) {
10188
+ if(MODEL.sensitivity_outcomes[j] === from) {
10189
+ MODEL.sensitivity_outcomes[j] = to;
10190
+ vcount++;
10191
+ }
10192
+ }
10193
+ // Rename variable in expressions and notes
10194
+ const re = new RegExp(
10195
+ // Handle multiple spaces between words
10196
+ '\\[\\s*' + escapeRegex(from).replace(/\s+/g, '\\s+')
10197
+ // Handle spaces around the separator |
10198
+ .replace('\\|', '\\s*\\|\\s*') +
10199
+ // Pattern ends at any character that is invalid for a
10200
+ // dataset modifier selector (unlike equation names)
10201
+ '\\s*[^a-zA-Z0-9\\+\\-\\%\\_]', 'gi');
10202
+ for(let j = 0; j < xl.length; j++) {
10203
+ const
10204
+ x = xl[j],
10205
+ matches = x.text.match(re);
10206
+ if(matches) {
10207
+ for(let k = 0; k < matches.length; k++) {
10208
+ // NOTE: each match will start with the opening bracket,
10209
+ // but end with the first "non-selector" character, which
10210
+ // will typically be ']', but may also be '@' (and now that
10211
+ // units can be converted, also the '>' of the arrow '->')
10212
+ x.text = x.text.replace(matches[k], '[' + to + matches[k].slice(-1));
10213
+ vcount ++;
10214
+ }
10215
+ // Force recompilation
10216
+ x.code = null;
10217
+ }
10218
+ }
10219
+ for(let j = 0; j < nl.length; j++) {
10220
+ const
10221
+ n = nl[j],
10222
+ matches = n.contents.match(re);
10223
+ if(matches) {
10224
+ for(let k = 0; k < matches.length; k++) {
10225
+ // See NOTE above for the use of `slice` here
10226
+ n.contents = n.contents.replace(matches[k], '[' + to + matches[k].slice(-1));
10227
+ vcount ++;
10228
+ }
10229
+ // Note fields must be parsed again
10230
+ n.parsed = false;
10231
+ }
10232
+ }
10233
+ }
10234
+ }
10235
+ if(vcount) UI.notify('Renamed ' + pluralS(vcount, 'variable') +
10236
+ ' throughout the model');
10237
+ // Delete the original dataset unless it has series data
10238
+ if(ds.data.length === 0) this.deleteDataset();
10239
+ MODEL.updateDimensions();
10240
+ this.selected_dataset = null;
10241
+ this.selected_prefix_row = null;
10242
+ this.updateDialog();
10243
+ md.hide();
10244
+ this.selectPrefixRow(prefix);
10245
+ }
10246
+
9704
10247
  updateLine() {
9705
10248
  const
9706
10249
  ln = document.getElementById('series-line-number'),
@@ -10061,7 +10604,7 @@ class EquationManager {
10061
10604
  UI.notify('Updated ' + msg.join(' and '));
10062
10605
  // Also update these stay-on-top dialogs, as they may display a
10063
10606
  // variable name for this dataset + modifier
10064
- UI.updateControllerDialogs('CDEFX');
10607
+ UI.updateControllerDialogs('CDEFJX');
10065
10608
  }
10066
10609
  // Always close the name prompt dialog, and update the equation manager
10067
10610
  this.rename_modal.hide();
@@ -10182,6 +10725,12 @@ class GUIChartManager extends ChartManager {
10182
10725
  'click', () => CHART_MANAGER.renameEquation());
10183
10726
  document.getElementById('chart-edit-equation-btn').addEventListener(
10184
10727
  'click', () => CHART_MANAGER.editEquation());
10728
+ document.getElementById('variable-color').addEventListener(
10729
+ 'mouseenter', () => CHART_MANAGER.showPasteColor());
10730
+ document.getElementById('variable-color').addEventListener(
10731
+ 'mouseleave', () => CHART_MANAGER.hidePasteColor());
10732
+ document.getElementById('variable-color').addEventListener(
10733
+ 'click', (event) => CHART_MANAGER.copyPasteColor(event));
10185
10734
  // NOTE: uses the color picker developed by James Daniel
10186
10735
  this.color_picker = new iro.ColorPicker("#color-picker", {
10187
10736
  width: 92,
@@ -10229,6 +10778,7 @@ class GUIChartManager extends ChartManager {
10229
10778
  this.options_shown = true;
10230
10779
  this.setRunsChart(false);
10231
10780
  this.last_time_selected = 0;
10781
+ this.paste_color = '';
10232
10782
  }
10233
10783
 
10234
10784
  enterKey() {
@@ -10283,14 +10833,13 @@ class GUIChartManager extends ChartManager {
10283
10833
  const
10284
10834
  n = ev.dataTransfer.getData('text'),
10285
10835
  obj = MODEL.objectByID(n);
10836
+ ev.preventDefault();
10286
10837
  if(!obj) {
10287
10838
  UI.alert(`Unknown entity ID "${n}"`);
10288
10839
  } else if(this.chart_index >= 0) {
10289
- // Only accept when all conditions are met
10290
- ev.preventDefault();
10291
10840
  if(obj instanceof DatasetModifier) {
10292
10841
  // Equations can be added directly as chart variable
10293
- this.addVariable(obj.name);
10842
+ this.addVariable(obj.selector);
10294
10843
  return;
10295
10844
  }
10296
10845
  // For other entities, the attribute must be specified
@@ -10670,7 +11219,16 @@ class GUIChartManager extends ChartManager {
10670
11219
  this.variable_index = vi;
10671
11220
  this.updateDialog();
10672
11221
  }
10673
-
11222
+
11223
+ setColorPicker(color) {
11224
+ // Robust way to set iro color picker color
11225
+ try {
11226
+ this.color_picker.color.hexString = color;
11227
+ } catch(e) {
11228
+ this.color_picker.color.rgbString = color;
11229
+ }
11230
+ }
11231
+
10674
11232
  editVariable() {
10675
11233
  // Shows the edit (or rather: format) variable dialog
10676
11234
  if(this.chart_index >= 0 && this.variable_index >= 0) {
@@ -10680,11 +11238,7 @@ class GUIChartManager extends ChartManager {
10680
11238
  this.variable_modal.element('scale').value = VM.sig4Dig(cv.scale_factor);
10681
11239
  this.variable_modal.element('width').value = VM.sig4Dig(cv.line_width);
10682
11240
  this.variable_modal.element('color').style.backgroundColor = cv.color;
10683
- try {
10684
- this.color_picker.color.hexString = cv.color;
10685
- } catch(e) {
10686
- this.color_picker.color.rgbString = cv.color;
10687
- }
11241
+ this.setColorPicker(cv.color);
10688
11242
  // Show change equation buttons only for equation variables
10689
11243
  if(cv.object === MODEL.equations_dataset) {
10690
11244
  this.change_equation_btns.style.display = 'block';
@@ -10695,6 +11249,34 @@ class GUIChartManager extends ChartManager {
10695
11249
  }
10696
11250
  }
10697
11251
 
11252
+ showPasteColor() {
11253
+ // Show last copied color (if any) as smaller square next to color box
11254
+ if(this.paste_color) {
11255
+ const pc = this.variable_modal.element('paste-color');
11256
+ pc.style.backgroundColor = this.paste_color;
11257
+ pc.style.display = 'inline-block';
11258
+ }
11259
+ }
11260
+
11261
+ hidePasteColor() {
11262
+ // Hide paste color box
11263
+ this.variable_modal.element('paste-color').style.display = 'none';
11264
+ }
11265
+
11266
+ copyPasteColor(event) {
11267
+ // Store the current color as past color, or set it to the current
11268
+ // paste color if this is defined and the Shift key was pressed
11269
+ event.stopPropagation();
11270
+ const cbox = this.variable_modal.element('color');
11271
+ if(event.shiftKey && this.paste_color) {
11272
+ cbox.style.backgroundColor = this.paste_color;
11273
+ this.setColorPicker(this.paste_color);
11274
+ } else {
11275
+ this.paste_color = cbox.style.backgroundColor;
11276
+ this.showPasteColor();
11277
+ }
11278
+ }
11279
+
10698
11280
  toggleVariable(vi) {
10699
11281
  window.event.stopPropagation();
10700
11282
  if(vi >= 0 && this.chart_index >= 0) {
@@ -10925,7 +11507,7 @@ class GUIChartManager extends ChartManager {
10925
11507
  }
10926
11508
 
10927
11509
  renderChartAsPNG() {
10928
- localStorage.removeItem('png-url');
11510
+ window.localStorage.removeItem('png-url');
10929
11511
  FILE_MANAGER.renderSVGAsPNG(MODEL.charts[this.chart_index].svg);
10930
11512
  }
10931
11513
 
@@ -12050,7 +12632,8 @@ class GUIExperimentManager extends ExperimentManager {
12050
12632
  x.charts[i].title, '</td></tr>'].join(''));
12051
12633
  }
12052
12634
  this.chart_table.innerHTML = tr.join('');
12053
- if(x.charts.length === 0) canview = false;
12635
+ // Do not show viewer unless at least 1 dependent variable has been defined
12636
+ if(x.charts.length === 0 && MODEL.outcomeNames.length === 0) canview = false;
12054
12637
  if(tr.length >= this.suitable_charts.length) {
12055
12638
  document.getElementById('xp-c-add-btn').classList.add('v-disab');
12056
12639
  } else {
@@ -12070,7 +12653,7 @@ class GUIExperimentManager extends ExperimentManager {
12070
12653
  dbtn.classList.add('v-disab');
12071
12654
  cbtn.classList.add('v-disab');
12072
12655
  }
12073
- // Enable viewing only if > 1 dimensions and > 1 charts
12656
+ // Enable viewing only if > 1 dimensions and > 1 outcome variables
12074
12657
  if(canview) {
12075
12658
  UI.enableButtons('xp-view');
12076
12659
  } else {
@@ -12155,14 +12738,11 @@ class GUIExperimentManager extends ExperimentManager {
12155
12738
  const x = this.selected_experiment;
12156
12739
  if(x) {
12157
12740
  x.inferVariables();
12158
- if(x.selected_variable === '') {
12159
- x.selected_variable = x.variables[0].displayName;
12160
- }
12161
12741
  const
12162
12742
  ol = [],
12163
12743
  vl = MODEL.outcomeNames;
12164
12744
  for(let i = 0; i < x.variables.length; i++) {
12165
- vl.push(x.variables[i].displayName);
12745
+ addDistinct(x.variables[i].displayName, vl);
12166
12746
  }
12167
12747
  vl.sort(ciCompare);
12168
12748
  for(let i = 0; i < vl.length; i++) {
@@ -12171,6 +12751,9 @@ class GUIExperimentManager extends ExperimentManager {
12171
12751
  '>', vl[i], '</option>'].join(''));
12172
12752
  }
12173
12753
  document.getElementById('viewer-variable').innerHTML = ol.join('');
12754
+ if(x.selected_variable === '') {
12755
+ x.selected_variable = vl[0];
12756
+ }
12174
12757
  }
12175
12758
  }
12176
12759
 
@@ -14592,7 +15175,7 @@ class Finder {
14592
15175
  const
14593
15176
  raw = escapeRegex(se.displayName),
14594
15177
  re = new RegExp(
14595
- '\\[\\s*' + raw.replace(/\s+/g, '\\s+') + '\\s*[\\|\\@\\]]');
15178
+ '\\[\\s*!?' + raw.replace(/\s+/g, '\\s+') + '\\s*[\\|\\@\\]]');
14596
15179
  // Check actor weight expressions
14597
15180
  for(let k in MODEL.actors) if(MODEL.actors.hasOwnProperty(k)) {
14598
15181
  const a = MODEL.actors[k];