linny-r 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/console.js CHANGED
@@ -86,6 +86,9 @@ console.log('Platform:', PLATFORM, '(' + os.type() + ')');
86
86
  console.log('Module directory:', MODULE_DIRECTORY);
87
87
  console.log('Working directory:', WORKING_DIRECTORY);
88
88
 
89
+ // Currently, these external solvers are supported:
90
+ const SUPPORTED_SOLVERS = ['gurobi', 'scip', 'lp_solve'];
91
+
89
92
  const
90
93
  // Load the MILP solver (dependent on Node.js: `fs`, `os` and `path`)
91
94
  MILPSolver = require('./static/scripts/linny-r-milp.js'),
@@ -113,13 +116,17 @@ Possible options are:
113
116
  channel=[identifier] will start listening at the specified channel
114
117
  (FUTURE OPTION)
115
118
  check will report whether current version is up-to-date
116
- model=[path] will load model file in [path]
119
+ data-dir=[path] will look for series data files in [path] instead of
120
+ (main)/user/data
121
+ model=[path] will load model file specified by [path]
117
122
  module=[name@repo] will load model [name] from repository [repo]
118
123
  (if @repo is blank, repository "local host" is used)
119
124
  (FUTURE OPTION)
125
+ report=[name] will write run results to [name]-series.txt and
126
+ [name]-stats.txt in (workspace)/reports
120
127
  run will run the loaded model
121
128
  solver=[name] will select solver [name], or warn if not found
122
- (name choices: Gurobi or LP_solve)
129
+ (name choices: Gurobi, SCIP or LP_solve)
123
130
  user=[identifier] user ID will be used to log onto remote servers
124
131
  verbose will output solver messages to the console
125
132
  workspace=[path] will create workspace in [path] instead of (main)/user
@@ -247,11 +254,81 @@ class ConsoleMonitor {
247
254
  } // END of class ConsoleMonitor
248
255
 
249
256
 
257
+ // NOTE: This implementation is very incomplete, still!
258
+ class ConsoleRepositoryBrowser {
259
+ constructor() {
260
+ this.repositories = [];
261
+ this.repository_index = -1;
262
+ this.module_index = -1;
263
+ // Get the repository list from the modules
264
+ this.getRepositories();
265
+ this.reset();
266
+ }
267
+
268
+ reset() {
269
+ this.visible = false;
270
+ }
271
+
272
+ get isLocalHost() {
273
+ // Returns TRUE if first repository on the list is 'local host'
274
+ return this.repositories.length > 0 &&
275
+ this.repositories[0].name === 'local host';
276
+ }
277
+
278
+ getRepositories() {
279
+ // Gets the list of repository names from the server
280
+ this.repositories.length = 0;
281
+ // @@TO DO!!
282
+ }
283
+
284
+ repositoryByName(n) {
285
+ // Returns the repository having name `n` if already known, otherwise NULL
286
+ for(let i = 0; i < this.repositories.length; i++) {
287
+ if(this.repositories[i].name === n) {
288
+ return this.repositories[i];
289
+ }
290
+ }
291
+ return null;
292
+ }
293
+
294
+ asFileName(s) {
295
+ // NOTE: asFileName is implemented as function (see below) to permit
296
+ // its use prior to instantiation of the RepositoryBrowser
297
+ return stringToFileName(s);
298
+ }
299
+
300
+ }
301
+
302
+ function stringToFileName(s) {
303
+ // Returns string `s` with whitespace converted to a single dash, and
304
+ // special characters converted to underscores
305
+ return s.normalize('NFKD').trim()
306
+ .replace(/[\s\-]+/g, '-')
307
+ .replace(/[^A-Za-z0-9_\-]/g, '_')
308
+ .replace(/^[\-\_]+|[\-\_]+$/g, '');
309
+ }
310
+
250
311
  // CLASS ConsoleFileManager allows loading and saving models and diagrams, and
251
312
  // handles the interaction with the MILP solver via `exec` calls and files
252
313
  // stored on the modeler's computer
253
314
  class ConsoleFileManager {
254
315
 
316
+ anyOSpath(p) {
317
+ // Helper function that converts any path notation to platform notation
318
+ // based on the predominant separator
319
+ const
320
+ s_parts = p.split('/'),
321
+ bs_parts = p.split('\\'),
322
+ parts = (s_parts.length > bs_parts.length ? s_parts : bs_parts);
323
+ // On macOS machines, paths start with a slash, so first substring is empty
324
+ if(parts[0].endsWith(':') && path.sep === '\\') {
325
+ // On Windows machines, add a backslash after the disk (if specified)
326
+ parts[0] += path.sep;
327
+ }
328
+ // Reassemble path for the OS of this machine
329
+ return path.join(...parts);
330
+ }
331
+
255
332
  getRemoteData(dataset, url) {
256
333
  // Gets data from a URL, or from a file on the local host
257
334
  if(url === '') return;
@@ -281,7 +358,13 @@ class ConsoleFileManager {
281
358
  console.log('ERROR: Invalid URL', url);
282
359
  }
283
360
  } else {
284
- const fp = anyOSpath(url);
361
+ let fp = this.anyOSpath(url);
362
+ if(!(fp.startsWith('/') || fp.startsWith('\\') || fp.indexOf(':\\') > 0)) {
363
+ // Relative path => add path to specified data path or to the
364
+ // default location user/data
365
+ fp = path.join(SETTINGS.data_path || WORKSPACE.data, fp);
366
+ console.log('Full path: ', fp);
367
+ }
285
368
  fs.readFile(fp, 'utf8', (err, data) => {
286
369
  if(err) {
287
370
  console.log(err);
@@ -354,6 +437,17 @@ class ConsoleFileManager {
354
437
  });
355
438
  }
356
439
 
440
+ writeStringToFile(s, fp) {
441
+ // Write string `s` to path `fp`
442
+ try {
443
+ fs.writeFileSync(fp, s);
444
+ console.log(pluralS(s.length, 'character') + ' written to file ' + fp);
445
+ } catch(err) {
446
+ console.log(err);
447
+ console.log('ERROR: Failed to write data to file ' + fp);
448
+ }
449
+ }
450
+
357
451
  } // END of class ConsoleFileManager
358
452
 
359
453
  // CLASS ConsoleReceiver defines a listener/interpreter for channel commands
@@ -702,7 +796,9 @@ function commandLineSettings() {
702
796
  const settings = {
703
797
  cli_name: (PLATFORM.startsWith('win') ? 'Command Prompt' : 'Terminal'),
704
798
  check: false,
799
+ data_path: '',
705
800
  preferred_solver: '',
801
+ report: '',
706
802
  run: false,
707
803
  solver: '',
708
804
  solver_path: '',
@@ -724,7 +820,7 @@ function commandLineSettings() {
724
820
  const av = lca.split('=');
725
821
  if(av.length === 1) av.push('');
726
822
  if(av[0] === 'solver') {
727
- if(av[1] !== 'gurobi' && av[1] !== 'lp_solve') {
823
+ if(SUPPORTED_SOLVERS.indexOf(av[1]) < 0) {
728
824
  console.log(`WARNING: Unknown solver "${av[1]}"`);
729
825
  } else {
730
826
  settings.preferred_solver = av[1];
@@ -759,6 +855,31 @@ function commandLineSettings() {
759
855
  console.log(`ERROR: File "${av[1]}" not found`);
760
856
  process.exit();
761
857
  }
858
+ } else if(av[0] === 'data-dir') {
859
+ // Set path (if valid) to override default data directory
860
+ const dp = av[1];
861
+ try {
862
+ // See whether the directory already exists
863
+ try {
864
+ fs.accessSync(dp, fs.constants.R_OK | fs.constants.W_O);
865
+ } catch(err) {
866
+ // If not, try to create it
867
+ fs.mkdirSync(dp);
868
+ console.log('Created data directory:', dp);
869
+ }
870
+ settings.data_path = dp;
871
+ } catch(err) {
872
+ console.log(err.message);
873
+ console.log('ERROR: Failed to create data directory:', dp);
874
+ }
875
+ } else if(av[0] === 'report') {
876
+ // Set report file name (if valid)
877
+ const rfn = stringToFileName(av[1]);
878
+ if(/^[A-Za-z0-9]+/.test(rfn)) {
879
+ settings.report = path.join(settings.user_dir, 'reports', rfn);
880
+ } else {
881
+ console.log(`WARNING: Invalid report file name "{$rfn}"`);
882
+ }
762
883
  } else if(av[0] === 'module') {
763
884
  // Add default repository is none specified
764
885
  if(av[1].indexOf('@') < 0) av[1] += '@local host';
@@ -802,6 +923,7 @@ function commandLineSettings() {
802
923
  // Check whether MILP solver(s) and Inkscape have been installed
803
924
  const path_list = process.env.PATH.split(path.delimiter);
804
925
  let gurobi_path = '',
926
+ scip_path = '',
805
927
  match,
806
928
  max_v = -1;
807
929
  for(let i = 0; i < path_list.length; i++) {
@@ -810,6 +932,8 @@ function commandLineSettings() {
810
932
  gurobi_path = path_list[i];
811
933
  max_v = parseInt(match[1]);
812
934
  }
935
+ match = path_list[i].match(/[\/\\]scip[^\/\\]+[\/\\]bin/i);
936
+ if(match) scip_path = path_list[i];
813
937
  match = path_list[i].match(/inkscape/i);
814
938
  if(match) settings.inkscape = path_list[i];
815
939
  }
@@ -840,6 +964,23 @@ function commandLineSettings() {
840
964
  'WARNING: Failed to access the Gurobi command line application');
841
965
  }
842
966
  }
967
+ // Check if scip(.exe) exists in its directory
968
+ let sp = path.join(scip_path, 'scip' + (PLATFORM.startsWith('win') ? '.exe' : ''));
969
+ const need_scip = !settings.solver || settings.preferred_solver === 'scip';
970
+ try {
971
+ fs.accessSync(sp, fs.constants.X_OK);
972
+ console.log('Path to SCIP:', sp);
973
+ if(need_scip) {
974
+ settings.solver = 'scip';
975
+ settings.solver_path = sp;
976
+ }
977
+ } catch(err) {
978
+ // Only report error if SCIP is needed
979
+ if(need_scip) {
980
+ console.log(err.message);
981
+ console.log('WARNING: SCIP application not found in', sp);
982
+ }
983
+ }
843
984
  // Check if lp_solve(.exe) exists in main directory
844
985
  const
845
986
  sp = path.join(WORKING_DIRECTORY,
@@ -882,10 +1023,13 @@ function createWorkspace() {
882
1023
  }
883
1024
  // Define the sub-directory paths
884
1025
  const ws = {
1026
+ autosave: path.join(SETTINGS.user_dir, 'autosave'),
885
1027
  channel: path.join(SETTINGS.user_dir, 'channel'),
886
1028
  callback: path.join(SETTINGS.user_dir, 'callback'),
1029
+ data: path.join(SETTINGS.user_dir, 'data'),
887
1030
  diagrams: path.join(SETTINGS.user_dir, 'diagrams'),
888
1031
  modules: path.join(SETTINGS.user_dir, 'modules'),
1032
+ reports: path.join(SETTINGS.user_dir, 'reports'),
889
1033
  solver_output: path.join(SETTINGS.user_dir, 'solver'),
890
1034
  };
891
1035
  // Create these sub-directories if not aready there
@@ -968,7 +1112,7 @@ PROMPTER.questionPrompt = (str) => {
968
1112
  // Initialize the Linny-R console components as global variables
969
1113
  global.UI = new Controller();
970
1114
  global.VM = new VirtualMachine();
971
- //global.REPOSITORY_BROWSER = new ConsoleRepositoryBrowser(), // still to re-code
1115
+ global.REPOSITORY_BROWSER = new ConsoleRepositoryBrowser();
972
1116
  global.FILE_MANAGER = new ConsoleFileManager();
973
1117
  global.DATASET_MANAGER = new DatasetManager();
974
1118
  global.CHART_MANAGER = new ChartManager();
@@ -990,8 +1134,19 @@ if(SETTINGS.model_path) {
990
1134
  MONITOR.show_log = SETTINGS.verbose;
991
1135
  VM.callback = () => {
992
1136
  const od = model.outputData;
993
- console.log(od[0]);
994
- console.log(od[1]);
1137
+ // Output data is two-string list [time series, statistics]
1138
+ if(SETTINGS.report) {
1139
+ // Output time series
1140
+ FILE_MANAGER.writeStringToFile(od[0],
1141
+ SETTINGS.report + '-series.txt');
1142
+ // Output statistics
1143
+ FILE_MANAGER.writeStringToFile(od[1],
1144
+ SETTINGS.report + '-stats.txt');
1145
+ } else {
1146
+ // Output strings to console
1147
+ console.log(od[0]);
1148
+ console.log(od[1]);
1149
+ }
995
1150
  VM.callback = null;
996
1151
  };
997
1152
  VM.solveModel();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linny-r",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Executable graphical language with WYSIWYG editor for MILP models",
5
5
  "main": "server.js",
6
6
  "scripts": {
package/server.js CHANGED
@@ -122,6 +122,8 @@ function checkNodeModule(name) {
122
122
  }
123
123
  }
124
124
 
125
+ // Currently, these external solvers are supported:
126
+ const SUPPORTED_SOLVERS = ['gurobi', 'scip', 'lp_solve'];
125
127
 
126
128
  // Load class MILPSolver
127
129
  const MILPSolver = require('./static/scripts/linny-r-milp.js');
@@ -847,20 +849,19 @@ function repoDelete(res, name, file) {
847
849
  // Dataset dialog
848
850
 
849
851
  function anyOSpath(p) {
850
- // Helper function that converts Unix path notation (with slashes) to
851
- // Windows notation if needed
852
- if(p.indexOf('/') < 0) return p;
853
- p = p.split('/');
852
+ // Helper function that converts any path notation to platform notation
853
+ // based on the predominant separator
854
+ const
855
+ s_parts = p.split('/'),
856
+ bs_parts = p.split('\\'),
857
+ parts = (s_parts.length > bs_parts.length ? s_parts : bs_parts);
854
858
  // On macOS machines, paths start with a slash, so first substring is empty
855
- if(p[0].length === 0) {
856
- // In that case, add the leading slash
857
- return '/' + path.join(...p);
858
- } else if(p[0].endsWith(':') && path.sep === '\\') {
859
+ if(parts[0].endsWith(':') && path.sep === '\\') {
859
860
  // On Windows machines, add a backslash after the disk (if specified)
860
- path[0] += path.sep;
861
+ parts[0] += path.sep;
861
862
  }
862
863
  // Reassemble path for the OS of this machine
863
- return path.join(...p);
864
+ return path.join(...parts);
864
865
  }
865
866
 
866
867
  function loadData(res, url) {
@@ -881,7 +882,11 @@ function loadData(res, url) {
881
882
  servePlainText(res, `ERROR: Invalid URL <tt>${url}</tt>`);
882
883
  }
883
884
  } else {
884
- const fp = anyOSpath(url);
885
+ let fp = anyOSpath(url);
886
+ if(!(fp.startsWith('/') || fp.startsWith('\\') || fp.indexOf(':\\') > 0)) {
887
+ // Relative path => add path to user/data directory
888
+ fp = path.join(WORKSPACE.data, fp);
889
+ }
885
890
  fs.readFile(fp, 'utf8', (err, data) => {
886
891
  if(err) {
887
892
  console.log(err);
@@ -1419,7 +1424,7 @@ function commandLineSettings() {
1419
1424
  settings.port = n;
1420
1425
  }
1421
1426
  } else if(av[0] === 'solver') {
1422
- if(av[1] !== 'gurobi' && av[1] !== 'lp_solve') {
1427
+ if(SUPPORTED_SOLVERS.indexOf(av[1]) < 0) {
1423
1428
  console.log(`WARNING: Unknown solver "${av[1]}"`);
1424
1429
  } else {
1425
1430
  settings.preferred_solver = av[1];
@@ -1452,6 +1457,7 @@ function commandLineSettings() {
1452
1457
  // Check whether MILP solver(s) and Inkscape have been installed
1453
1458
  const path_list = process.env.PATH.split(path.delimiter);
1454
1459
  let gurobi_path = '',
1460
+ scip_path = '',
1455
1461
  match,
1456
1462
  max_v = -1;
1457
1463
  for(let i = 0; i < path_list.length; i++) {
@@ -1460,6 +1466,8 @@ function commandLineSettings() {
1460
1466
  gurobi_path = path_list[i];
1461
1467
  max_v = parseInt(match[1]);
1462
1468
  }
1469
+ match = path_list[i].match(/[\/\\]scip[^\/\\]+[\/\\]bin/i);
1470
+ if(match) scip_path = path_list[i];
1463
1471
  match = path_list[i].match(/inkscape/i);
1464
1472
  if(match) settings.inkscape = path_list[i];
1465
1473
  }
@@ -1490,11 +1498,27 @@ function commandLineSettings() {
1490
1498
  'WARNING: Failed to access the Gurobi command line application');
1491
1499
  }
1492
1500
  }
1501
+ // Check if scip(.exe) exists in its directory
1502
+ let sp = path.join(scip_path, 'scip' + (PLATFORM.startsWith('win') ? '.exe' : ''));
1503
+ const need_scip = !settings.solver || settings.preferred_solver === 'scip';
1504
+ try {
1505
+ fs.accessSync(sp, fs.constants.X_OK);
1506
+ console.log('Path to SCIP:', sp);
1507
+ if(need_scip) {
1508
+ settings.solver = 'scip';
1509
+ settings.solver_path = sp;
1510
+ }
1511
+ } catch(err) {
1512
+ // Only report error if SCIP is needed
1513
+ if(need_scip) {
1514
+ console.log(err.message);
1515
+ console.log('WARNING: SCIP application not found in', sp);
1516
+ }
1517
+ }
1493
1518
  // Check if lp_solve(.exe) exists in working directory
1494
- const
1495
- sp = path.join(WORKING_DIRECTORY,
1496
- 'lp_solve' + (PLATFORM.startsWith('win') ? '.exe' : '')),
1497
- need_lps = !settings.solver || settings.preferred_solver === 'lp_solve';
1519
+ sp = path.join(WORKING_DIRECTORY,
1520
+ 'lp_solve' + (PLATFORM.startsWith('win') ? '.exe' : ''));
1521
+ const need_lps = !settings.solver || settings.preferred_solver === 'lp_solve';
1498
1522
  try {
1499
1523
  fs.accessSync(sp, fs.constants.X_OK);
1500
1524
  console.log('Path to LP_solve:', sp);
@@ -1568,6 +1592,7 @@ function createWorkspace() {
1568
1592
  autosave: path.join(SETTINGS.user_dir, 'autosave'),
1569
1593
  channel: path.join(SETTINGS.user_dir, 'channel'),
1570
1594
  callback: path.join(SETTINGS.user_dir, 'callback'),
1595
+ data: path.join(SETTINGS.user_dir, 'data'),
1571
1596
  diagrams: path.join(SETTINGS.user_dir, 'diagrams'),
1572
1597
  modules: path.join(SETTINGS.user_dir, 'modules'),
1573
1598
  solver_output: path.join(SETTINGS.user_dir, 'solver'),
Binary file
package/static/index.html CHANGED
@@ -1704,8 +1704,11 @@ NOTE: * and ? will be interpreted as wildcards"
1704
1704
  <!-- options are added by DATASET_MANAGER -->
1705
1705
  </select>
1706
1706
  <div id="series-remote">
1707
- <input id="series-url" type="text"
1708
- placeholder="(URL or path on local host)"> </div>
1707
+ <img id="series-clip" src="images/paperclip.png">
1708
+ <input id="series-url" type="text"
1709
+ placeholder="URL or path on local host"
1710
+ title="Path can be absolute or relative to (Linny-R)/user/data">
1711
+ </div>
1709
1712
  <div id="series-data-lbl">Series data:</div>
1710
1713
  <textarea id="series-data" autocomplete="off"
1711
1714
  autocorrect="off" autocapitalize="off" spellcheck="false">
@@ -2801,7 +2804,65 @@ where X can be one or several of these letters: ABCDELPQ">
2801
2804
  </div>
2802
2805
  </div>
2803
2806
  </div>
2804
-
2807
+
2808
+ <!-- the PASTE dialog prompts for a prefix and parameter bindings -->
2809
+ <div id="paste-modal" class="modal">
2810
+ <div id="paste-dlg" class="inp-dlg">
2811
+ <div class="dlg-title">
2812
+ Name conflict resolution strategy
2813
+ <img class="cancel-btn" src="images/cancel.png">
2814
+ <img class="ok-btn" src="images/ok.png">
2815
+ </div>
2816
+ <div id="paste-ftp" class="paste-option">
2817
+ <div id="paste-ftp-box" class="box checked"></div>
2818
+ <div class="paste-tactic">
2819
+ Change <span id="paste-from-prefix"></span>&hellip;
2820
+ to <span id="paste-to-prefix"></span>&hellip;
2821
+ </div>
2822
+ </div>
2823
+ <div id="paste-fta" class="paste-option">
2824
+ <div id="paste-fta-box" class="box checked"></div>
2825
+ <div class="paste-tactic">
2826
+ Change actor <span id="paste-from-actor"></span>
2827
+ to <span id="paste-to-actor"></span>
2828
+ </div>
2829
+ </div>
2830
+ <div class="paste-option">
2831
+ <div id="paste-a-box" class="box checked"></div>
2832
+ <div class="paste-tactic">
2833
+ <label>If no actor, add:</label>
2834
+ <input id="paste-actor" type="text"
2835
+ placeholder="(no actor)" autocomplete="off">
2836
+ </div>
2837
+ </div>
2838
+ <div class="paste-option">
2839
+ <div id="paste-p-box" class="box checked"></div>
2840
+ <div class="paste-tactic">
2841
+ <label>Add prefix:</label>
2842
+ <input id="paste-prefix" type="text"
2843
+ placeholder="(none)" autocomplete="off">
2844
+ </div>
2845
+ </div>
2846
+ <div class="paste-option">
2847
+ <div id="paste-inc-box" class="box checked"></div>
2848
+ <div class="paste-tactic">
2849
+ Auto-increment tail number
2850
+ </div>
2851
+ </div>
2852
+ <div class="paste-option">
2853
+ <div id="paste-near-box" class="box checked"></div>
2854
+ <div class="paste-tactic">
2855
+ Link to nearest eligible node
2856
+ </div>
2857
+ </div>
2858
+ <div style="font-weight: bold; margin:4px 2px 2px 2px">
2859
+ Mapping of nodes to link from/to:
2860
+ </div>
2861
+ <div id="paste-scroll-area">
2862
+ </div>
2863
+ </div>
2864
+ </div>
2865
+
2805
2866
  <!--- rotating Linny-R logo icon -->
2806
2867
  <div id="rotating-icon">
2807
2868
  <div>
@@ -2468,12 +2468,18 @@ td.equation-expression {
2468
2468
  #series-remote {
2469
2469
  position: absolute;
2470
2470
  top: 134px;
2471
- left: 3px;
2472
- width: calc(100% - 8px);
2471
+ left: 2px;
2472
+ width: calc(100% - 5px);
2473
+ }
2474
+
2475
+ #series-clip {
2476
+ height: 12px;
2477
+ width: 132x;
2478
+ vertical-align: middle;
2473
2479
  }
2474
2480
 
2475
2481
  #series-url {
2476
- width: 100%;
2482
+ width: calc(100% - 17px);
2477
2483
  font-size: 12px;
2478
2484
  }
2479
2485
 
@@ -4047,6 +4053,7 @@ span.sd-clear {
4047
4053
  height: 111px;
4048
4054
  }
4049
4055
 
4056
+ #paste-dlg,
4050
4057
  #include-dlg {
4051
4058
  width: 320px;
4052
4059
  height: min-content;
@@ -4072,6 +4079,7 @@ span.sd-clear {
4072
4079
  margin-left: 9px;
4073
4080
  }
4074
4081
 
4082
+ #paste-scroll-area,
4075
4083
  #include-scroll-area {
4076
4084
  margin: 2px;
4077
4085
  height: min-content;
@@ -4082,6 +4090,60 @@ span.sd-clear {
4082
4090
  overflow-y: auto;
4083
4091
  }
4084
4092
 
4093
+ div.paste-tactic {
4094
+ display: inline-block;
4095
+ vertical-align: top;
4096
+ padding-top: 3px;
4097
+ }
4098
+
4099
+ div.paste-select::before {
4100
+ content: ' \1F872';
4101
+ margin-right: 3px;
4102
+ }
4103
+
4104
+ div.paste-select {
4105
+ display: inline-block;
4106
+ font-size: 12px;
4107
+ max-width: calc(100% - 25px);
4108
+ margin: 2px;
4109
+ }
4110
+
4111
+ #paste-prefix,
4112
+ #paste-actor {
4113
+ width: 150px;
4114
+ font-size: 12px;
4115
+ height: 17px !important;
4116
+ margin-left: 1px;
4117
+ }
4118
+
4119
+ #paste-from-prefix,
4120
+ #paste-to-prefix,
4121
+ #paste-from-actor,
4122
+ #paste-to-actor {
4123
+ max-width: 220px;
4124
+ overflow: clip;
4125
+ white-space: nowrap;
4126
+ text-overflow: ellipsis;
4127
+ }
4128
+
4129
+ #paste-from-prefix {
4130
+ color: #6060c0;
4131
+ }
4132
+
4133
+ #paste-to-prefix {
4134
+ color: #0000c0;
4135
+ }
4136
+
4137
+ #paste-from-actor {
4138
+ font-style: italic;
4139
+ color: #8050a0;
4140
+ }
4141
+
4142
+ #paste-to-actor {
4143
+ font-style: italic;
4144
+ color: #600080;
4145
+ }
4146
+
4085
4147
  tr.i-param {
4086
4148
  font-style: italic;
4087
4149
  border: 1px solid Silver;
@@ -136,7 +136,7 @@ class Controller {
136
136
  ACTOR_PROPS: ['weight', 'comments'],
137
137
  CLUSTER_PROPS: ['comments', 'collapsed', 'ignore'],
138
138
  PROCESS_PROPS: ['comments', 'lower_bound', 'upper_bound', 'initial_level',
139
- 'pace', 'equal_bounds', 'level_to_zero', 'integer_level', 'collapsed'],
139
+ 'pace_expression', 'equal_bounds', 'level_to_zero', 'integer_level', 'collapsed'],
140
140
  PRODUCT_PROPS: ['comments', 'lower_bound', 'upper_bound', 'initial_level',
141
141
  'scale_unit', 'equal_bounds', 'price', 'is_source', 'is_sink', 'is_buffer',
142
142
  'is_data', 'integer_level', 'no_slack'],
@@ -316,6 +316,21 @@ class Controller {
316
316
  return pan;
317
317
  }
318
318
 
319
+ sharedPrefix(n1, n2) {
320
+ const
321
+ pan1 = this.prefixesAndName(n1),
322
+ pan2 = this.prefixesAndName(n2),
323
+ l = Math.min(pan1.length - 1, pan2.length - 1),
324
+ shared = [];
325
+ let i = 0;
326
+ while(i < l && ciCompare(pan1[i], pan2[i]) === 0) {
327
+ // NOTE: if identical except for case, prefer "Abc" over "aBc"
328
+ shared.push(pan1[i] < pan2[i] ? pan1[i] : pan2[i]);
329
+ i++;
330
+ }
331
+ return shared.join(this.PREFIXER);
332
+ }
333
+
319
334
  nameToID(name) {
320
335
  // Returns a name in lower case with link arrow replaced by three
321
336
  // underscores, constraint link arrow by four underscores, and spaces