mapshaper 0.7.72 → 0.7.75

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.
@@ -2577,7 +2577,7 @@
2577
2577
  return obj === Object(obj); // via underscore
2578
2578
  }
2579
2579
 
2580
- function clamp$5(val, min, max) {
2580
+ function clamp$7(val, min, max) {
2581
2581
  return val < min ? min : (val > max ? max : val);
2582
2582
  }
2583
2583
 
@@ -2595,7 +2595,7 @@
2595
2595
  }
2596
2596
 
2597
2597
  // Similar to isFinite() but does not coerce strings or other types
2598
- function isFiniteNumber(val) {
2598
+ function isFiniteNumber$1(val) {
2599
2599
  return isValidNumber(val) && val !== Infinity && val !== -Infinity;
2600
2600
  }
2601
2601
 
@@ -3076,7 +3076,7 @@
3076
3076
  return fmt;
3077
3077
  }
3078
3078
 
3079
- function shuffle(arr) {
3079
+ function shuffle$1(arr) {
3080
3080
  var tmp, i, j;
3081
3081
  for (i = arr.length - 1; i > 0; i--) {
3082
3082
  j = Math.floor(Math.random() * (i + 1));
@@ -3228,7 +3228,7 @@
3228
3228
  function findValueByRank(arr, rank) {
3229
3229
  if (!arr.length || rank < 1 || rank > arr.length) error$1("[findValueByRank()] invalid input");
3230
3230
 
3231
- rank = clamp$5(rank | 0, 1, arr.length);
3231
+ rank = clamp$7(rank | 0, 1, arr.length);
3232
3232
  var k = rank - 1, // conv. rank to array index
3233
3233
  n = arr.length,
3234
3234
  l = 0,
@@ -3602,7 +3602,7 @@
3602
3602
  // self-import and the resulting Rollup circular-dependency warning.
3603
3603
  var utils = {
3604
3604
  addThousandsSep, addslashes, arrayToIndex,
3605
- clamp: clamp$5, cleanNumericString, contains, copyElements, countValues, createBuffer,
3605
+ clamp: clamp$7, cleanNumericString, contains, copyElements, countValues, createBuffer,
3606
3606
  defaults, difference,
3607
3607
  endsWith, every, expandoBuffer, extend, extendBuffer,
3608
3608
  find, findMedian, findQuantile, findRankByValue, findStringPrefix,
@@ -3613,7 +3613,7 @@
3613
3613
  getSortedIds, getUniqueName, groupBy,
3614
3614
  htmlEscape,
3615
3615
  indexOf, indexOn, inherit, initializeArray, intersection,
3616
- isArray, isArrayLike, isBoolean, isDate, isEven, isFiniteNumber, isFunction,
3616
+ isArray, isArrayLike, isBoolean, isDate, isEven, isFiniteNumber: isFiniteNumber$1, isFunction,
3617
3617
  isInteger, isNonNegNumber, isNumber, isObject, isOdd, isPromise, isString,
3618
3618
  isValidNumber,
3619
3619
  lpad, ltrim,
@@ -3624,7 +3624,7 @@
3624
3624
  quicksort, quicksortPartition,
3625
3625
  range, regexEscape, reorderArray: reorderArray$1, repeat, repeatString,
3626
3626
  replaceArray, rpad, rtrim,
3627
- shuffle, some, sortArrayIndex, sortOn, splitLines, sum,
3627
+ shuffle: shuffle$1, some, sortArrayIndex, sortOn, splitLines, sum,
3628
3628
  toArray, toBuffer, trim: trim$1, trimQuotes,
3629
3629
  uniq, uniqifyNames,
3630
3630
  wildcardToRegExp
@@ -5177,7 +5177,7 @@
5177
5177
  var recipe = getRasterViewRecipe(grid, view && view.recipe);
5178
5178
  var stats = getRasterViewScalingStats(raster, recipe);
5179
5179
  var sourceRange = recipe.scaling == 'none' ? getPixelTypeRange(grid.pixelType) : null;
5180
- var displayRange = getDisplayRange(recipe.scaleRange);
5180
+ var displayRange = getDisplayRange$1(recipe.scaleRange);
5181
5181
  var offset = pixelId * grid.bands;
5182
5182
  var color = [0, 1, 2].map(function(band) {
5183
5183
  return scaleSample(grid.samples[offset + band], stats && stats[band], sourceRange, displayRange);
@@ -5204,7 +5204,7 @@
5204
5204
  var pixels = new Uint8ClampedArray(width * height * 4);
5205
5205
  var stats = statsArg || getScalingStats(samples, bands, noData, recipe);
5206
5206
  var sourceRange = recipe.scaling == 'none' ? getPixelTypeRange(grid.pixelType) : null;
5207
- var displayRange = getDisplayRange(recipe.scaleRange);
5207
+ var displayRange = getDisplayRange$1(recipe.scaleRange);
5208
5208
  var src, dest, val, isNoData, j, sample;
5209
5209
  for (var y = 0; y < height; y++) {
5210
5210
  for (var x = 0; x < width; x++) {
@@ -5642,7 +5642,7 @@
5642
5642
  return range;
5643
5643
  }
5644
5644
 
5645
- function getDisplayRange(scaleRange) {
5645
+ function getDisplayRange$1(scaleRange) {
5646
5646
  return [
5647
5647
  scaleRange[0] / 100 * 255,
5648
5648
  scaleRange[1] / 100 * 255
@@ -9527,8 +9527,11 @@
9527
9527
  var globals = {}; // share user-defined globals (job.defs) between runs
9528
9528
  var sharedVars = {}; // share -vars / -defaults templating scope between runs
9529
9529
 
9530
+ var activeSession = null; // see createCommandSession()
9531
+
9530
9532
  // expose this function, so other components can run commands (e.g. box tool)
9531
9533
  this.runMapshaperCommands = runMapshaperCommands;
9534
+ this.createCommandSession = createCommandSession;
9532
9535
 
9533
9536
  // Open the console (if closed) and run a command, as if the user had
9534
9537
  // typed it. Used by UI controls that surface console functionality, e.g.
@@ -9924,8 +9927,118 @@
9924
9927
  }
9925
9928
  }
9926
9929
 
9927
- function runMapshaperCommands(str, done) {
9930
+ // A command session is for a panel that applies its edits as they are made
9931
+ // (a live preview) but should leave one undo state and one history command
9932
+ // for the whole set: the commands run with session.run() share one undo
9933
+ // transaction, which keeps the state from before the first of them; each
9934
+ // one replaces the session's previous command in the session history; and
9935
+ // finish() adds the transaction to the undo history as one state.
9936
+ //
9937
+ // Nothing else may add an undo state while a session's is still to come, or
9938
+ // undoing that state would also take back the session's edits without
9939
+ // saying so. So another command finishes the session before it runs, and
9940
+ // Undo and Redo finish it before they act (Undo#addPendingCommit()). The
9941
+ // panel finishes it when it closes. A finished session can be run again,
9942
+ // which starts a new set.
9943
+ //
9944
+ // opts.label the undo state's label
9945
+ // opts.onCommit called as the state is added; may return {onUndo, onRedo},
9946
+ // called after the state is undone or redone, for GUI state
9947
+ // that goes with the edits
9948
+ function createCommandSession(optsArg) {
9949
+ var opts = optsArg || {};
9950
+ var busy = Promise.resolve();
9951
+ var queued = null;
9952
+ var unregisterCommit = null;
9953
+ var session = {
9954
+ tx: null,
9955
+ historyIds: [],
9956
+ flags: {},
9957
+ // Runs a command string. A command submitted while an earlier one is
9958
+ // still running waits for it, and replaces any other that is waiting,
9959
+ // so that a burst of edits runs the first and the last of them.
9960
+ run: function(cmd, done) {
9961
+ if (queued) {
9962
+ queued.cmd = cmd;
9963
+ queued.callbacks.push(done);
9964
+ return;
9965
+ }
9966
+ queued = {cmd: cmd, callbacks: [done]};
9967
+ busy = busy.then(function() {
9968
+ var job = queued;
9969
+ queued = null;
9970
+ return new Promise(function(resolve) {
9971
+ runMapshaperCommands(job.cmd, function(err, flags) {
9972
+ job.callbacks.forEach(function(cb) {
9973
+ if (cb) cb(err, flags);
9974
+ });
9975
+ resolve();
9976
+ }, {session: session});
9977
+ });
9978
+ });
9979
+ },
9980
+ finish: function() {
9981
+ return busy.then(commit);
9982
+ },
9983
+ isPending: function() {
9984
+ return activeSession == session;
9985
+ },
9986
+ // for runMapshaperCommands()
9987
+ activate: activate
9988
+ };
9989
+ return session;
9990
+
9991
+ function commit() {
9992
+ var tx = session.tx,
9993
+ historyIds = session.historyIds,
9994
+ flags = session.flags,
9995
+ callbacks;
9996
+ session.tx = null;
9997
+ session.historyIds = [];
9998
+ session.flags = {};
9999
+ if (activeSession == session) activeSession = null;
10000
+ if (unregisterCommit) unregisterCommit();
10001
+ unregisterCommit = null;
10002
+ if (!tx || !isCommandUndoEnabled()) return;
10003
+ callbacks = opts.onCommit ? opts.onCommit() || {} : {};
10004
+ return getStoredUndoHistory(gui).addTransaction(tx, {
10005
+ flags: flags,
10006
+ entryPrefix: 'command-session',
10007
+ onUndo: function() {
10008
+ gui.session.setCommandsActive(historyIds, false);
10009
+ if (callbacks.onUndo) callbacks.onUndo();
10010
+ },
10011
+ onRedo: function() {
10012
+ gui.session.setCommandsActive(historyIds, true);
10013
+ if (callbacks.onRedo) callbacks.onRedo();
10014
+ }
10015
+ }).catch(function(e) {
10016
+ console.error(e);
10017
+ consoleWarn('Undo state was not saved:', e.message || e);
10018
+ });
10019
+ }
10020
+
10021
+ function activate() {
10022
+ if (activeSession == session) return;
10023
+ activeSession = session;
10024
+ session.tx = createCommandUndoTransaction(opts.label || '');
10025
+ if (gui.undo && gui.undo.addPendingCommit) {
10026
+ unregisterCommit = gui.undo.addPendingCommit(session.finish);
10027
+ }
10028
+ }
10029
+
10030
+ }
10031
+
10032
+ function runMapshaperCommands(str, done, runOpts) {
10033
+ var session = runOpts && runOpts.session || null;
9928
10034
  var commands;
10035
+ if (activeSession && activeSession != session) {
10036
+ activeSession.finish().then(function() {
10037
+ runMapshaperCommands(str, done, runOpts);
10038
+ });
10039
+ return;
10040
+ }
10041
+ if (session) session.activate();
9929
10042
  try {
9930
10043
  commands = internal.parseConsoleCommands(str);
9931
10044
  // don't add info commands to console history
@@ -9936,7 +10049,7 @@
9936
10049
  return done(e, {});
9937
10050
  }
9938
10051
  if (commands.length === 0) return done();
9939
- applyParsedCommands(commands, str, function(err, flags) {
10052
+ applyParsedCommands(commands, str, session, function(err, flags) {
9940
10053
  if (flags) {
9941
10054
  model.updated(flags); // info commands do not return flags
9942
10055
  }
@@ -9962,13 +10075,13 @@
9962
10075
  });
9963
10076
  }
9964
10077
 
9965
- function applyParsedCommands(commands, commandString, done) {
10078
+ function applyParsedCommands(commands, commandString, session, done) {
9966
10079
  var active = model.getActiveLayer(),
9967
10080
  prevArcs = active?.dataset.arcs,
9968
10081
  prevTable = active?.layer.data,
9969
10082
  prevTableSize = prevTable ? prevTable.size() : 0,
9970
10083
  prevArcCount = prevArcs ? prevArcs.size() : 0,
9971
- undoTransaction = createCommandUndoTransaction(commandString),
10084
+ undoTransaction = session ? session.tx : createCommandUndoTransaction(commandString),
9972
10085
  job = new internal.Job(model),
9973
10086
  commandStart = Date.now();
9974
10087
 
@@ -10029,12 +10142,25 @@
10029
10142
  // the data.
10030
10143
  if (!err) {
10031
10144
  commandString = internal.standardizeConsoleCommands(commandString);
10145
+ if (session) {
10146
+ // the session's previous command is at the end of the history, and
10147
+ // an inactive tail is discarded by the next command added
10148
+ gui.session.setCommandsActive(session.historyIds, false);
10149
+ }
10032
10150
  historyIds.push(gui.session.consoleCommands(commandString));
10033
10151
  // kludge to terminate unclosed -if blocks
10034
10152
  if (commandString.includes('-if') && !commandString.includes('-endif')) {
10035
10153
  historyIds.push(gui.session.consoleCommands('-endif'));
10036
10154
  }
10037
10155
  }
10156
+ if (session) {
10157
+ if (!err) session.historyIds = historyIds;
10158
+ Object.assign(session.flags, flags);
10159
+ if (!undoTransaction) discardHistoryAfterUnrecordedCommand();
10160
+ logCommandTiming(commandString, commands, err, Date.now() - commandStart, null);
10161
+ done(err, flags);
10162
+ return;
10163
+ }
10038
10164
  addCommandUndoHistory(undoTransaction, err, flags, historyIds).then(function(undoTiming) {
10039
10165
  logCommandTiming(commandString, commands, err, Date.now() - commandStart, undoTiming);
10040
10166
  done(err, flags);
@@ -10707,8 +10833,6 @@
10707
10833
  var toggleBtn = null; // checkbox <input> for toggling layer selection
10708
10834
  var exportBtn = gui.container.findChild('.export-btn').addClass('disabled');
10709
10835
  var ofileName = gui.container.findChild('#ofile-name');
10710
- var frameInfo = menu.findChild('.export-frame-info').hide();
10711
- menu.findChild('.advanced-options').on('input', updateFrameInfo);
10712
10836
  new SimpleButton(menu.findChild('.close2-btn')).on('click', gui.clearMode);
10713
10837
 
10714
10838
  if (!GUI.exportIsSupported()) {
@@ -10777,7 +10901,6 @@
10777
10901
  formatPickedByUser = false;
10778
10902
  // initZipOption();
10779
10903
  initFormatMenu();
10780
- updateFrameInfo();
10781
10904
  updateExportCheckboxes();
10782
10905
  menu.show();
10783
10906
  }
@@ -10861,10 +10984,10 @@
10861
10984
  opts.history = snapshot;
10862
10985
  targets = addFrameTarget(targets);
10863
10986
  }
10864
- if (opts.format == 'svg' || opts.format == 'topojson') {
10987
+ if (opts.format == 'svg' || opts.format == 'html' || opts.format == 'topojson') {
10865
10988
  opts.gui_frame = getGuiFrameContext();
10866
10989
  }
10867
- if (opts.format == 'svg') {
10990
+ if (opts.format == 'svg' || opts.format == 'html') {
10868
10991
  targets = addFrameFurnitureTargets(targets, opts.gui_frame);
10869
10992
  }
10870
10993
  try {
@@ -11004,7 +11127,7 @@
11004
11127
  }
11005
11128
 
11006
11129
  function getExportFormats() {
11007
- var formats = ['shapefile', 'json', 'geojson', 'dsv', 'topojson', 'flatgeobuf', 'geopackage', 'geoparquet', 'kml', 'svg', internal.PACKAGE_EXT];
11130
+ var formats = ['shapefile', 'json', 'geojson', 'dsv', 'topojson', 'flatgeobuf', 'geopackage', 'geoparquet', 'svg', 'kml', 'html', internal.PACKAGE_EXT];
11008
11131
  // GeoTIFF is the one format here that only accepts raster layers, so it is
11009
11132
  // offered only when there is a raster to export.
11010
11133
  if (getExportFormatLayers().some(hasRaster)) formats.push('geotiff');
@@ -11052,13 +11175,11 @@
11052
11175
  // changes afterwards.
11053
11176
  formatPickedByUser = true;
11054
11177
  updateExportCheckboxes();
11055
- updateFrameInfo();
11056
11178
  }
11057
11179
 
11058
11180
  function setSelectedFormat(fmt) {
11059
11181
  var el = menu.findChild('.export-formats input[value="' + fmt + '"]');
11060
11182
  if (el) el.node().checked = true;
11061
- updateFrameInfo();
11062
11183
  }
11063
11184
 
11064
11185
  // Which formats apply depends on what is checked for export, so unchecking
@@ -11164,21 +11285,6 @@
11164
11285
  });
11165
11286
  return targets;
11166
11287
  }
11167
-
11168
- function updateFrameInfo() {
11169
- if (!frameInfo) return;
11170
- var context = getGuiFrameContext();
11171
- var format = getSelectedFormat();
11172
- var topoUsesPixels = format == 'topojson' &&
11173
- /\b(?:width|height)\s*=/.test(getExportOptsAsString());
11174
- if (context && (format == 'svg' || topoUsesPixels)) {
11175
- frameInfo.text('Map frame: ' +
11176
- internal.formatFrameSizeForDisplay(context.data)).show();
11177
- } else {
11178
- frameInfo.hide();
11179
- }
11180
- }
11181
-
11182
11288
  };
11183
11289
 
11184
11290
  function DomCache() {
@@ -11667,10 +11773,18 @@
11667
11773
  return retn;
11668
11774
  }
11669
11775
 
11776
+ // opts.session a command session from gui.console.createCommandSession(),
11777
+ // for an edit that is one of a set recorded as one undo state
11670
11778
  function runGuiEditCommand(gui, cmd, optsArg) {
11671
11779
  var opts = optsArg || {};
11672
11780
  if (!gui.console) return;
11673
- gui.console.runMapshaperCommands(cmd, function(err, flags) {
11781
+ if (opts.session) {
11782
+ opts.session.run(cmd, onDone);
11783
+ } else {
11784
+ gui.console.runMapshaperCommands(cmd, onDone);
11785
+ }
11786
+
11787
+ function onDone(err, flags) {
11674
11788
  if (err) {
11675
11789
  showPopupAlert(err.message || String(err), opts.title || 'Command error');
11676
11790
  if (opts.onError) opts.onError(err);
@@ -11680,7 +11794,7 @@
11680
11794
  if (opts.onDone) {
11681
11795
  opts.onDone(err, flags);
11682
11796
  }
11683
- });
11797
+ }
11684
11798
  }
11685
11799
 
11686
11800
  function LayerControl(gui) {
@@ -12928,19 +13042,28 @@
12928
13042
  return fields.sort();
12929
13043
  }
12930
13044
 
13045
+ // A -classify that assigns colors or values rewrites its output field in every
13046
+ // feature, so it supersedes an earlier one that wrote the same field -- unless
13047
+ // it reads that field, or writes a key file that would be lost.
12931
13048
  function getClassifyCullInfo(opts) {
12932
- if (opts.where || !classifyIsRandomFill(opts)) return null;
13049
+ var saveAs = opts.save_as || opts['save-as'] || '';
13050
+ var kind = opts.colors ? 'colors' : opts.values ? 'values' : '';
13051
+ if (opts.where || !kind || classifyWritesKey(opts)) return null;
13052
+ if ([opts.field, opts.index_field].some(function(field) {
13053
+ return field && (saveAs ? field == saveAs : ['fill', 'stroke', 'class'].includes(field));
13054
+ })) return null;
12933
13055
  return {
12934
13056
  type: 'classify',
12935
13057
  target: getTargetKey(opts),
12936
13058
  ids: getIdsKey(opts),
12937
- key: 'random-fill'
13059
+ key: kind + ':' + saveAs
12938
13060
  };
12939
13061
  }
12940
13062
 
12941
- function classifyIsRandomFill(opts) {
12942
- return opts.colors == 'random' && opts.method == 'non-adjacent' &&
12943
- !opts.field && !opts.values && !opts['save-as'] && !opts.save_as;
13063
+ function classifyWritesKey(opts) {
13064
+ return Object.keys(opts).some(function(name) {
13065
+ return name == 'key' || name.indexOf('key_') === 0 || name.indexOf('key-') === 0;
13066
+ });
12944
13067
  }
12945
13068
 
12946
13069
  function getTargetKey(opts) {
@@ -13250,6 +13373,8 @@
13250
13373
 
13251
13374
  function Undo(gui) {
13252
13375
  var history, offset, stashedUndo, editSession;
13376
+ var pendingCommits = [];
13377
+ var self = this;
13253
13378
  editSession = createEditSessionUndo();
13254
13379
  reset();
13255
13380
 
@@ -13475,6 +13600,29 @@
13475
13600
  addHistoryState(undo, redo, cleanup, opts);
13476
13601
  };
13477
13602
 
13603
+ // An edit whose history state has not been added yet -- a panel session
13604
+ // that records one state when it ends -- registers a function that adds it.
13605
+ // Undo and redo run it, and wait for it, before they act; otherwise they
13606
+ // would take back the state before the edit and leave the edit in place.
13607
+ // Returns a function that unregisters it.
13608
+ this.addPendingCommit = function(commit) {
13609
+ pendingCommits.push(commit);
13610
+ return function() {
13611
+ var i = pendingCommits.indexOf(commit);
13612
+ if (i > -1) pendingCommits.splice(i, 1);
13613
+ };
13614
+ };
13615
+
13616
+ function flushPendingCommits() {
13617
+ var commits = pendingCommits.splice(0);
13618
+ if (commits.length === 0) return null;
13619
+ return Promise.all(commits.map(function(commit) {
13620
+ return Promise.resolve(commit()).catch(function(e) {
13621
+ console.error(e);
13622
+ });
13623
+ }));
13624
+ }
13625
+
13478
13626
  this.evictOldestHistoryState = function(opts) {
13479
13627
  return evictOldestHistoryState(opts || {});
13480
13628
  };
@@ -13545,6 +13693,8 @@
13545
13693
  }
13546
13694
 
13547
13695
  this.undo = function() {
13696
+ var pending = flushPendingCommits();
13697
+ if (pending) return pending.then(function() { return self.undo(); });
13548
13698
  // firing even if history is empty
13549
13699
  // (because this event may trigger a new history state)
13550
13700
  gui.dispatchEvent('undo_redo_pre', {type: 'undo'});
@@ -13558,6 +13708,8 @@
13558
13708
  };
13559
13709
 
13560
13710
  this.redo = function() {
13711
+ var pending = flushPendingCommits();
13712
+ if (pending) return pending.then(function() { return self.redo(); });
13561
13713
  gui.dispatchEvent('undo_redo_pre', {type: 'redo'});
13562
13714
  if (offset <= 0) return;
13563
13715
  offset--;
@@ -14726,16 +14878,16 @@
14726
14878
  }
14727
14879
 
14728
14880
  // The label tool is offered where a label would join the active layer (see
14729
- // menus above), but it can act on any target once it is open: a label goes
14730
- // into a label layer created beside a layer that cannot hold one. So
14731
- // selecting a polygon layer while the tool is open must not close it -- the
14732
- // next label will start a label layer of its own.
14881
+ // menus above), and it stays open only there: selecting a layer that cannot
14882
+ // hold labels closes it, as selecting another kind of layer closes the line
14883
+ // and polygon tools. Left open, its panel went on styling a layer that has
14884
+ // no labels, and the next label would have started a label layer of its own
14885
+ // beside whatever had been selected. With no layer at all the tool stays,
14886
+ // since it creates the layer it needs.
14733
14887
  function labelModeIsAvailable() {
14734
14888
  var o = gui.model.getActiveLayer();
14735
14889
  if (_editMode != 'label') return false;
14736
- if (!o || !o.layer) return true; // the tool creates the layer it needs
14737
- // A table is the one target with no map to click on.
14738
- return internal.layerHasRaster(o.layer) || !!o.layer.geometry_type;
14890
+ return !o || !o.layer || labelWouldJoin(o.layer);
14739
14891
  }
14740
14892
 
14741
14893
  function labelStyleModeIsAvailable() {
@@ -15846,6 +15998,175 @@
15846
15998
  return Math.round(val * 100) / 100;
15847
15999
  }
15848
16000
 
16001
+ // Reads a color typed or pasted into a color field, and returns it as a
16002
+ // six-digit hex value (#rrggbb), or null if it isn't a color. Accepts what a
16003
+ // user is likely to have copied from somewhere else: hex with or without the
16004
+ // "#" and with 3, 4, 6 or 8 digits, CSS color names, rgb() and hsl() in
16005
+ // either the comma or the space-separated syntax, and oklch(). Alpha is
16006
+ // accepted and dropped, because the field holds an opaque color. An oklch()
16007
+ // color outside the sRGB gamut loses chroma, keeping its hue and lightness.
16008
+ function parseColorInput(str) {
16009
+ var s = String(str || '').trim().toLowerCase();
16010
+ var rgb;
16011
+ if (!s) return null;
16012
+ if (/^([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(s)) {
16013
+ s = '#' + s;
16014
+ }
16015
+ if (s[0] == '#') {
16016
+ rgb = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(s) ? internal.parseHexColor(s) : null;
16017
+ } else if (/^rgba?\(/.test(s)) {
16018
+ rgb = parseRgbFunction(s);
16019
+ } else if (/^hsla?\(/.test(s)) {
16020
+ rgb = parseHslFunction(s);
16021
+ } else if (/^oklch\(/.test(s)) {
16022
+ rgb = parseOklchFunction(s);
16023
+ } else if (/^[a-z]+$/.test(s)) {
16024
+ rgb = internal.parseColor(s);
16025
+ }
16026
+ return rgb ? formatHex(rgb) : null;
16027
+ }
16028
+
16029
+ // The formats a color field can show a color in
16030
+ var colorInputFormats = [
16031
+ {name: 'hex', label: 'HEX'},
16032
+ {name: 'rgb', label: 'RGB'},
16033
+ {name: 'hsl', label: 'HSL'},
16034
+ {name: 'oklch', label: 'OKLCH'}
16035
+ ];
16036
+
16037
+ // Shows a #rrggbb color in one of colorInputFormats, in a form that
16038
+ // parseColorInput() reads back.
16039
+ function formatColorInput(hex, format) {
16040
+ var rgb = internal.parseHexColor(hex);
16041
+ var hsl, lch;
16042
+ if (!rgb) return hex;
16043
+ if (format == 'rgb') {
16044
+ return 'rgb(' + [rgb.r, rgb.g, rgb.b].join(', ') + ')';
16045
+ }
16046
+ if (format == 'hsl') {
16047
+ hsl = rgbToHsl(rgb);
16048
+ return 'hsl(' + round$2(hsl.h, 1) + ', ' + round$2(hsl.s * 100, 1) + '%, ' +
16049
+ round$2(hsl.l * 100, 1) + '%)';
16050
+ }
16051
+ if (format == 'oklch') {
16052
+ lch = internal.oklabToOklch(internal.rgbToOklab(rgb));
16053
+ // a gray has no hue
16054
+ return 'oklch(' + lch.l.toFixed(4) + ' ' + lch.c.toFixed(4) + ' ' +
16055
+ (lch.c < 0.00005 ? 0 : round$2(lch.h, 2)) + ')';
16056
+ }
16057
+ return internal.formatColor({r: rgb.r, g: rgb.g, b: rgb.b, a: 1});
16058
+ }
16059
+
16060
+ function getFunctionArgs(s) {
16061
+ var match = /^[a-z]+\(([^)]*)\)$/.exec(s);
16062
+ var args;
16063
+ if (!match) return null;
16064
+ args = match[1].split(/[\s,/]+/).filter(Boolean);
16065
+ return args.length == 3 || args.length == 4 ? args : null;
16066
+ }
16067
+
16068
+ function parseRgbFunction(s) {
16069
+ var args = getFunctionArgs(s);
16070
+ var channels;
16071
+ if (!args) return null;
16072
+ channels = args.slice(0, 3).map(function(arg) {
16073
+ var val = parseFloat(arg);
16074
+ if (!/^-?[0-9.]+%?$/.test(arg) || !isFinite(val)) return NaN;
16075
+ return clamp$6(/%$/.test(arg) ? val / 100 * 255 : val, 0, 255);
16076
+ });
16077
+ if (channels.some(isNaN)) return null;
16078
+ return {r: channels[0], g: channels[1], b: channels[2]};
16079
+ }
16080
+
16081
+ function parseHslFunction(s) {
16082
+ var args = getFunctionArgs(s);
16083
+ var h, sat, light;
16084
+ if (!args) return null;
16085
+ h = parseHue(args[0]);
16086
+ sat = parsePercent(args[1]);
16087
+ light = parsePercent(args[2]);
16088
+ if (isNaN(h) || isNaN(sat) || isNaN(light)) return null;
16089
+ return hslToRgb(h, sat, light);
16090
+ }
16091
+
16092
+ // L is 0-1 or a percent; C is a number, or a percent of 0.4, as in CSS
16093
+ function parseOklchFunction(s) {
16094
+ var args = getFunctionArgs(s);
16095
+ var l, c, h;
16096
+ if (!args) return null;
16097
+ l = parseNumberOrPercent(args[0], 1);
16098
+ c = parseNumberOrPercent(args[1], 0.4);
16099
+ h = args[2] == 'none' ? 0 : parseHue(args[2]);
16100
+ if (isNaN(l) || isNaN(c) || isNaN(h)) return null;
16101
+ return internal.oklchToRgb({l: clamp$6(l, 0, 1), c: Math.max(c, 0), h: h});
16102
+ }
16103
+
16104
+ function parseNumberOrPercent(arg, pctScale) {
16105
+ if (arg == 'none') return 0;
16106
+ if (!/^-?[0-9.]+%?$/.test(arg) || !isFinite(parseFloat(arg))) return NaN;
16107
+ return /%$/.test(arg) ? parseFloat(arg) / 100 * pctScale : parseFloat(arg);
16108
+ }
16109
+
16110
+ // degrees, from a number or an angle with a CSS unit
16111
+ function parseHue(arg) {
16112
+ var match = /^(-?[0-9.]+)(deg|rad|grad|turn)?$/.exec(arg);
16113
+ var val, unit;
16114
+ if (!match) return NaN;
16115
+ val = parseFloat(match[1]);
16116
+ unit = match[2] || 'deg';
16117
+ if (unit == 'rad') val = val * 180 / Math.PI;
16118
+ if (unit == 'grad') val = val * 0.9;
16119
+ if (unit == 'turn') val = val * 360;
16120
+ return ((val % 360) + 360) % 360;
16121
+ }
16122
+
16123
+ // 0-1; the percent sign is optional, as in the space-separated syntax
16124
+ function parsePercent(arg) {
16125
+ if (!/^-?[0-9.]+%?$/.test(arg)) return NaN;
16126
+ return clamp$6(parseFloat(arg), 0, 100) / 100;
16127
+ }
16128
+
16129
+ function hslToRgb(h, s, l) {
16130
+ var c = (1 - Math.abs(2 * l - 1)) * s;
16131
+ var x = c * (1 - Math.abs((h / 60) % 2 - 1));
16132
+ var m = l - c / 2;
16133
+ var rgb = h < 60 ? [c, x, 0] :
16134
+ h < 120 ? [x, c, 0] :
16135
+ h < 180 ? [0, c, x] :
16136
+ h < 240 ? [0, x, c] :
16137
+ h < 300 ? [x, 0, c] : [c, 0, x];
16138
+ return {r: (rgb[0] + m) * 255, g: (rgb[1] + m) * 255, b: (rgb[2] + m) * 255};
16139
+ }
16140
+
16141
+ function rgbToHsl(rgb) {
16142
+ var r = rgb.r / 255, g = rgb.g / 255, b = rgb.b / 255;
16143
+ var max = Math.max(r, g, b), min = Math.min(r, g, b);
16144
+ var d = max - min;
16145
+ var l = (max + min) / 2;
16146
+ var h = 0, s = 0;
16147
+ if (d > 0) {
16148
+ s = d / (1 - Math.abs(2 * l - 1));
16149
+ if (max == r) h = ((g - b) / d + 6) % 6;
16150
+ else if (max == g) h = (b - r) / d + 2;
16151
+ else h = (r - g) / d + 4;
16152
+ h *= 60;
16153
+ }
16154
+ return {h: h, s: s, l: l};
16155
+ }
16156
+
16157
+ function round$2(val, decimals) {
16158
+ var k = Math.pow(10, decimals);
16159
+ return Math.round(val * k) / k;
16160
+ }
16161
+
16162
+ function formatHex(rgb) {
16163
+ return internal.formatColor({r: rgb.r, g: rgb.g, b: rgb.b, a: 1});
16164
+ }
16165
+
16166
+ function clamp$6(val, min, max) {
16167
+ return Math.max(min, Math.min(max, val));
16168
+ }
16169
+
15849
16170
  var grayscaleColorPresets = [
15850
16171
  '#000000', '#111111', '#222222', '#333333',
15851
16172
  '#444444', '#555555', '#666666', '#777777',
@@ -15880,17 +16201,23 @@
15880
16201
  // this holds at most one entry.
15881
16202
  var openPickers = [];
15882
16203
 
16204
+ // The format color fields show colors in, chosen with the picker's tabs. It
16205
+ // is shared by every picker, so it carries over from one to the next.
16206
+ var colorFieldFormat = 'hex';
16207
+
15883
16208
  function closeOpenPickers(except) {
15884
16209
  openPickers.slice().forEach(function(picker) {
15885
16210
  if (picker !== except) picker.hide();
15886
16211
  });
15887
16212
  }
15888
16213
 
16214
+ // opts.presetRows rows of preset colors; [] for none
16215
+ // opts.onPreview(hex), opts.onChange(hex), opts.onHide()
15889
16216
  function ColorPicker(parent, opts) {
15890
16217
  opts = opts || {};
15891
16218
  var self = this;
15892
16219
  var colorPicker = El('div').addClass('label-color-picker').appendTo(parent).hide();
15893
- var sbCanvas, hueCanvas, sbMarker, hueMarker, pickerHsbInputs;
16220
+ var sbCanvas, hueCanvas, sbMarker, hueMarker, colorInput, formatTabs;
15894
16221
  var pickerColor = {h: 0, s: 0, b: 0};
15895
16222
  // The hex value the picker was set to, until it is moved off it. HSB is
15896
16223
  // held in bytes, which cannot represent every RGB colour, so a colour that
@@ -15907,18 +16234,30 @@
15907
16234
  } else {
15908
16235
  closeOpenPickers(self);
15909
16236
  colorPicker.show();
16237
+ setPanelRaised(true);
15910
16238
  positionPicker();
15911
16239
  drawColorPicker();
16240
+ updatePickerFields();
15912
16241
  if (openPickers.indexOf(self) == -1) openPickers.push(self);
15913
16242
  }
15914
16243
  };
15915
16244
 
15916
16245
  this.hide = function() {
16246
+ var wasVisible = colorPicker.visible();
15917
16247
  colorPicker.hide();
16248
+ setPanelRaised(false);
15918
16249
  var i = openPickers.indexOf(self);
15919
16250
  if (i > -1) openPickers.splice(i, 1);
16251
+ if (wasVisible && opts.onHide) opts.onHide();
15920
16252
  };
15921
16253
 
16254
+ // The picker's z-index only places it within its panel, which is a stacking
16255
+ // context of its own (see .label-style-panel.color-picker-open).
16256
+ function setPanelRaised(raised) {
16257
+ var panel = colorPicker.node().closest('.label-style-panel');
16258
+ if (panel) panel.classList.toggle('color-picker-open', raised);
16259
+ }
16260
+
15922
16261
  this.visible = function() {
15923
16262
  return colorPicker.visible();
15924
16263
  };
@@ -15960,14 +16299,14 @@
15960
16299
  var hueWrap = El('div').addClass('label-color-canvas-wrap').appendTo(colorPicker);
15961
16300
  hueCanvas = El('canvas').attr('width', '256').attr('height', '18').appendTo(hueWrap);
15962
16301
  hueMarker = makePickerMarker().appendTo(hueWrap);
15963
- renderPresetRows(colorPicker);
15964
- pickerHsbInputs = {};
15965
- var hsbRow = El('div').addClass('label-color-picker-fields').appendTo(colorPicker);
15966
- addPickerNumberInput(hsbRow, 'h', 'H');
15967
- addPickerNumberInput(hsbRow, 's', 'S');
15968
- addPickerNumberInput(hsbRow, 'b', 'B');
15969
- El('button').appendTo(hsbRow).text('Close').on('click', function() {
15970
- colorPicker.hide();
16302
+ if (presetRows.length > 0) renderPresetRows(colorPicker);
16303
+ // a field for a typed or pasted color, with tabs for the format it shows
16304
+ // colors in
16305
+ addFormatTabs(colorPicker);
16306
+ var fieldRow = El('div').addClass('label-color-picker-fields').appendTo(colorPicker);
16307
+ addColorInput(fieldRow);
16308
+ El('button').appendTo(fieldRow).text('Close').on('click', function() {
16309
+ self.hide();
15971
16310
  });
15972
16311
  sbCanvas.on('mousedown', function(e) {
15973
16312
  startCanvasDrag(e, updateSbFromEvent);
@@ -16002,24 +16341,48 @@
16002
16341
  commitPickerColor();
16003
16342
  }
16004
16343
 
16005
- function addPickerNumberInput(row, name, label) {
16006
- var wrapper = El('label').appendTo(row);
16007
- El('span').appendTo(wrapper).text(label);
16008
- pickerHsbInputs[name] = El('input')
16009
- .attr('type', 'text')
16010
- .appendTo(wrapper)
16011
- .on('change', function() {
16012
- var h = parseNumberField(pickerHsbInputs.h.node().value);
16013
- var s = parseNumberField(pickerHsbInputs.s.node().value);
16014
- var b = parseNumberField(pickerHsbInputs.b.node().value);
16015
- if (!isFinite(h) || !isFinite(s) || !isFinite(b)) return;
16016
- setPickerColor({
16017
- h: degreesToByte(h),
16018
- s: pctToByte(s),
16019
- b: pctToByte(b)
16344
+ function addFormatTabs(parent) {
16345
+ var row = El('div').addClass('label-color-format-tabs').attr('role', 'group')
16346
+ .attr('aria-label', 'Color format').appendTo(parent);
16347
+ formatTabs = colorInputFormats.map(function(format) {
16348
+ return El('button').addClass('label-color-format-tab').attr('type', 'button')
16349
+ .attr('data-format', format.name).text(format.label).appendTo(row)
16350
+ .on('click', function() {
16351
+ colorFieldFormat = format.name;
16352
+ updatePickerFields();
16020
16353
  });
16021
- commitPickerColor();
16354
+ });
16355
+ }
16356
+
16357
+ // A color is applied when it is pasted, or when the field is left (Enter
16358
+ // leaves it, in the style panels). Whatever was accepted is shown in the
16359
+ // chosen format; something that isn't a color puts the field back.
16360
+ function addColorInput(row) {
16361
+ colorInput = El('input').attr('type', 'text').addClass('label-color-picker-input')
16362
+ .attr('aria-label', 'Color: hex, name, rgb(), hsl() or oklch()')
16363
+ .attr('spellcheck', 'false')
16364
+ .appendTo(row)
16365
+ .on('change', applyColorInput)
16366
+ .on('paste', function() {
16367
+ setTimeout(applyColorInput, 0);
16022
16368
  });
16369
+ colorInput.node().addEventListener('keydown', function(e) {
16370
+ if (e.key == 'Escape') updatePickerFields();
16371
+ });
16372
+ }
16373
+
16374
+ function applyColorInput() {
16375
+ var hex = parseColorInput(colorInput.node().value);
16376
+ if (!hex) {
16377
+ updatePickerFields();
16378
+ return;
16379
+ }
16380
+ if (hex == getPickerHex()) {
16381
+ updatePickerFields();
16382
+ return;
16383
+ }
16384
+ setPickerColor(hexToHsb(hex), hex);
16385
+ commitPickerColor();
16023
16386
  }
16024
16387
 
16025
16388
  function startCanvasDrag(e, update) {
@@ -16063,17 +16426,17 @@
16063
16426
  function getCanvasPoint(canvas, evt) {
16064
16427
  var rect = canvas.getBoundingClientRect();
16065
16428
  return {
16066
- x: clamp$4(Math.round((evt.clientX - rect.left) / rect.width * (canvas.width - 1)), 0, canvas.width - 1),
16067
- y: clamp$4(Math.round((evt.clientY - rect.top) / rect.height * (canvas.height - 1)), 0, canvas.height - 1)
16429
+ x: clamp$5(Math.round((evt.clientX - rect.left) / rect.width * (canvas.width - 1)), 0, canvas.width - 1),
16430
+ y: clamp$5(Math.round((evt.clientY - rect.top) / rect.height * (canvas.height - 1)), 0, canvas.height - 1)
16068
16431
  };
16069
16432
  }
16070
16433
 
16071
16434
  // @hex: the exact colour, when @hsb was made from one
16072
16435
  function setPickerColor(hsb, hex) {
16073
16436
  pickerColor = {
16074
- h: clamp$4(Math.round(hsb.h), 0, 255),
16075
- s: clamp$4(Math.round(hsb.s), 0, 255),
16076
- b: clamp$4(Math.round(hsb.b), 0, 255)
16437
+ h: clamp$5(Math.round(hsb.h), 0, 255),
16438
+ s: clamp$5(Math.round(hsb.s), 0, 255),
16439
+ b: clamp$5(Math.round(hsb.b), 0, 255)
16077
16440
  };
16078
16441
  pickerHex = hex || null;
16079
16442
  drawColorPicker();
@@ -16082,9 +16445,11 @@
16082
16445
  }
16083
16446
 
16084
16447
  function updatePickerFields() {
16085
- pickerHsbInputs.h.node().value = byteToDegrees(pickerColor.h) + '°';
16086
- pickerHsbInputs.s.node().value = byteToPct(pickerColor.s) + '%';
16087
- pickerHsbInputs.b.node().value = byteToPct(pickerColor.b) + '%';
16448
+ colorInput.node().value = formatColorInput(getPickerHex(), colorFieldFormat);
16449
+ formatTabs.forEach(function(tab) {
16450
+ var selected = tab.node().getAttribute('data-format') == colorFieldFormat;
16451
+ tab.classed('selected', selected).attr('aria-pressed', String(selected));
16452
+ });
16088
16453
  }
16089
16454
 
16090
16455
  function drawColorPicker() {
@@ -16210,27 +16575,7 @@
16210
16575
  };
16211
16576
  }
16212
16577
 
16213
- function byteToPct(val) {
16214
- return Math.round(val / 255 * 100);
16215
- }
16216
-
16217
- function pctToByte(val) {
16218
- return Math.round(clamp$4(val, 0, 100) / 100 * 255);
16219
- }
16220
-
16221
- function byteToDegrees(val) {
16222
- return Math.round(val / 255 * 360);
16223
- }
16224
-
16225
- function degreesToByte(val) {
16226
- return Math.round(clamp$4(val, 0, 360) / 360 * 255);
16227
- }
16228
-
16229
- function parseNumberField(str) {
16230
- return Number(String(str).replace(/[°%]/g, '').trim());
16231
- }
16232
-
16233
- function clamp$4(val, min, max) {
16578
+ function clamp$5(val, min, max) {
16234
16579
  return isFinite(val) ? Math.max(min, Math.min(max, val)) : min;
16235
16580
  }
16236
16581
 
@@ -20681,8 +21026,2011 @@
20681
21026
  return val === undefined || val === null || String(val).trim() === '';
20682
21027
  }
20683
21028
 
21029
+ // The state behind the color scheme panel, as plain objects and functions
21030
+ // that return new ones. See docs/development/color-scheme-panel-design.md
21031
+ //
21032
+ // A sequential scheme is either a preset (a named d3 ramp, possibly reversed)
21033
+ // or a custom ramp defined by pins ([{t, color}], see src/color/color-ramps.mjs).
21034
+ // Editing a tile of a preset turns it into a custom ramp, pinned at the
21035
+ // preset's two ends and at the edited tile.
21036
+ //
21037
+ // A categorical scheme is a palette of swatches, either a d3 categorical
21038
+ // scheme (in its own order, or rearranged) or a custom list, of which the
21039
+ // first n are used. Its 'categorical' method gives each unique value of the
21040
+ // field a swatch, taking them in turn (so there are never more swatches in
21041
+ // use than values); 'non-adjacent' has no field, and colors neighboring
21042
+ // polygons differently. Editing a swatch of a preset turns it into a custom
21043
+ // list.
21044
+ //
21045
+ // A diverging scheme is a ramp of 2K+1 tiles: K tiles on either side of a
21046
+ // center color, where K is the number of classes on the longer side of the
21047
+ // pivot. The shorter side uses the tiles nearest the center, and the center
21048
+ // tile colors the pivot class (if there is one). How many classes each side
21049
+ // has depends on the data, so the scheme keeps the layout that -classify
21050
+ // will find (see updateDivergingLayout()). A custom diverging ramp is pinned
21051
+ // at its ends and its center.
21052
+ //
21053
+ // A continuous sequential or diverging scheme gives unclassed colors: its
21054
+ // tiles are color stops, at the data's min, the class breaks and its max,
21055
+ // with colors interpolated between them. Each side of a diverging scheme is
21056
+ // a ramp of its own, a tile longer than its classes (so K is the longer
21057
+ // side's classes + 1); the center tile is used only by a pivot class, which
21058
+ // is a flat band of the center color.
21059
+ //
21060
+ // scheme: {
21061
+ // type: 'sequential', 'diverging' or 'categorical',
21062
+ // field, method, n, (n: the number of colors, or for
21063
+ // diverging schemes, of classes:
21064
+ // the total, or per side)
21065
+ // preset: name or null, reversed: boolean, (presets)
21066
+ // range: {start, end, base} or null (presets: the part of the ramp
21067
+ // the tiles are taken from, 0-1
21068
+ // in the preset's own direction;
21069
+ // see getSchemeRange())
21070
+ // pins: [{t, color}] or null, (custom sequential ramps)
21071
+ // vibrance, longHue (custom sequential ramps, see
21072
+ // getOklchInterpolator())
21073
+ // order: [...] or null (categorical presets: indexes
21074
+ // of the preset's colors, as
21075
+ // rearranged)
21076
+ // swatches: [...] or null (custom categorical lists)
21077
+ // continuous: boolean (sequential and diverging)
21078
+ // pivot: 'auto', 'median', 'mean' or a number,
21079
+ // neutral, continuousNeutral: boolean (a pivot class, for classed
21080
+ // and continuous schemes; see
21081
+ // getSchemeNeutral())
21082
+ // split: 'size' or 'count' (diverging: n is the total
21083
+ // number of classes, split so
21084
+ // they're the same size on both
21085
+ // sides, or the number per side)
21086
+ // layout: {pivot, neutral, below, above, breaks} or null
21087
+ // (diverging: the classes the
21088
+ // data gives, see
21089
+ // getDivergingLayout())
21090
+ // nullColor the color of features with no data (both types; not
21091
+ // used by non-adjacent schemes)
21092
+ // colors: [...] the colors that were applied, for checking a layer later
21093
+ // fillHash continuous schemes: a fingerprint of the fills they gave
21094
+ // the layer (interpolated fills aren't the scheme's colors)
21095
+ // }
21096
+
21097
+ var schemeTypes = [
21098
+ {name: 'sequential', label: 'Sequential'},
21099
+ {name: 'diverging', label: 'Diverging'},
21100
+ {name: 'categorical', label: 'Categorical'}
21101
+ ];
21102
+
21103
+ var classifyMethods = [
21104
+ {name: 'quantile', label: 'Quantile'},
21105
+ {name: 'equal-interval', label: 'Equal interval'},
21106
+ {name: 'nice', label: 'Nice breaks'},
21107
+ {name: 'hybrid', label: 'Hybrid'}
21108
+ ];
21109
+
21110
+ var pivotOptions = [
21111
+ {name: 'auto', label: 'Auto (0 or median)'},
21112
+ {name: 'median', label: 'Median'},
21113
+ {name: 'mean', label: 'Mean'},
21114
+ {name: 'value', label: 'Value'}
21115
+ ];
21116
+
21117
+ var divergingSplits = [
21118
+ {name: 'size', label: 'Same class size'},
21119
+ {name: 'count', label: 'Same number of classes'}
21120
+ ];
21121
+
21122
+ var categoricalMethods = [
21123
+ {name: 'categorical', label: 'Categories'},
21124
+ {name: 'non-adjacent', label: 'Non-adjacent'}
21125
+ ];
21126
+
21127
+ // -classify's default null value for colors, as hex
21128
+ var defaultNullColor = '#eeeeee';
21129
+
21130
+ var minSchemeColors = 2;
21131
+ var maxSchemeColors = 12;
21132
+ var maxCategoricalColors = 20;
21133
+ var defaultCategoricalPreset = 'batlowS';
21134
+ var defaultNonAdjacentColors = 5;
21135
+ var defaultDivergingPreset = 'RdBu';
21136
+ var defaultDivergingClasses = 7;
21137
+ var defaultDivergingSideClasses = 3;
21138
+ var maxDivergingSideClasses = 6;
21139
+ // colors for growing a custom categorical list past the palette it came from
21140
+ var extraSwatchSource = 'Tableau20';
21141
+ // vibrance is OKLCH chroma added to the colors of each interpolated segment
21142
+ // (-classify's vibrance= is the same, scaled to 0-1)
21143
+ var defaultVibrance = 0;
21144
+ var maxVibrance = internal.MAX_VIBRANCE_CHROMA;
21145
+ // the default sequential ramp: cream to blue, through green and teal
21146
+ var defaultEnds = ['#fff8da', '#2d4d8e'];
21147
+ // Cyclic ramps start and end on the same hue, which reads as a category
21148
+ // rather than an order.
21149
+ var excludedPresets = ['Rainbow', 'Sinebow'];
21150
+
21151
+ // Layers keep the scheme that colored them, so that the panel can reopen it
21152
+ // and the style panel can show it. Undo and redo of a scheme edit put back
21153
+ // the scheme that went with the colors (see the panel's command session).
21154
+ var layerSchemes = new WeakMap();
21155
+
21156
+ function getSequentialPresetNames() {
21157
+ return flattenGroups(getPresetGroups('sequential'));
21158
+ }
21159
+
21160
+ function getDivergingPresetNames() {
21161
+ return flattenGroups(getPresetGroups('diverging'));
21162
+ }
21163
+
21164
+ function getCategoricalPresetNames() {
21165
+ return flattenGroups(getPresetGroups('categorical'));
21166
+ }
21167
+
21168
+ // The presets of a scheme type, by source, for the palette menu:
21169
+ // [{source, names}]. Sequential presets include -classify's multi-hue
21170
+ // ('rainbow') schemes.
21171
+ function getPresetGroups(type) {
21172
+ var types = type == 'sequential' ? ['sequential', 'rainbow'] : [type];
21173
+ return internal.getColorSchemeGroups(types).map(function(group) {
21174
+ return {source: group.source, names: group.names.filter(function(name) {
21175
+ return !excludedPresets.includes(name);
21176
+ })};
21177
+ }).filter(function(group) {
21178
+ return group.names.length > 0;
21179
+ });
21180
+ }
21181
+
21182
+ function flattenGroups(groups) {
21183
+ return groups.reduce(function(memo, group) { return memo.concat(group.names); }, []);
21184
+ }
21185
+
21186
+ // The scheme a panel tab starts with on a layer
21187
+ function getDefaultSchemeOfType(type, lyr) {
21188
+ if (type == 'categorical') return getDefaultCategoricalScheme(lyr);
21189
+ if (type == 'diverging') return getDefaultDivergingScheme(lyr);
21190
+ return getDefaultScheme(getNumericFields(lyr)[0]);
21191
+ }
21192
+
21193
+ function getDefaultDivergingScheme(lyr) {
21194
+ return updateDivergingLayout({
21195
+ type: 'diverging',
21196
+ field: getNumericFields(lyr)[0] || null,
21197
+ method: 'quantile',
21198
+ n: defaultDivergingClasses,
21199
+ split: 'size',
21200
+ pivot: 'auto',
21201
+ neutral: true,
21202
+ preset: defaultDivergingPreset,
21203
+ reversed: false,
21204
+ pins: null,
21205
+ vibrance: defaultVibrance
21206
+ }, lyr);
21207
+ }
21208
+
21209
+ // The layout of a diverging scheme's classes on the layer's data, as
21210
+ // -classify finds it; null if there are no data to classify. Done after
21211
+ // every change that can move the classes (the field, method, pivot, pivot
21212
+ // class, number of classes), and when the panel opens, since the data may
21213
+ // have changed.
21214
+ function updateDivergingLayout(scheme, lyr) {
21215
+ if (scheme.type != 'diverging') return scheme;
21216
+ return Object.assign({}, scheme, {layout: getDivergingLayout(scheme, lyr)});
21217
+ }
21218
+
21219
+ function getDivergingLayout(scheme, lyr) {
21220
+ var values = getAscendingValues(lyr, scheme.field);
21221
+ if (values.length === 0) return null;
21222
+ var neutral = getSchemeNeutral(scheme);
21223
+ try {
21224
+ return internal.getDivergingLayout(values, scheme.method, {
21225
+ pivot: scheme.pivot,
21226
+ continuous: !!scheme.continuous,
21227
+ pivot_class: neutral,
21228
+ no_pivot_class: !neutral,
21229
+ classes: getDivergingClassesOption(scheme)
21230
+ });
21231
+ } catch(e) {
21232
+ return null;
21233
+ }
21234
+ }
21235
+
21236
+ // Whether a diverging scheme has a pivot class. Classed and continuous
21237
+ // schemes each keep their own setting, so that switching between them keeps
21238
+ // both: on by default for classes, off for continuous colors.
21239
+ function getSchemeNeutral(scheme) {
21240
+ return scheme.continuous ? !!scheme.continuousNeutral : scheme.neutral !== false;
21241
+ }
21242
+
21243
+ function isContinuousScheme(scheme) {
21244
+ return scheme.type != 'categorical' && !!scheme.continuous;
21245
+ }
21246
+
21247
+ // A sequential ramp starts with at least this many stops, since -classify
21248
+ // interpolates straight between them, so vibrance and the long hue path
21249
+ // show only at the stops.
21250
+ var minContinuousStartColors = 5;
21251
+
21252
+ function setSchemeContinuous(scheme, continuous) {
21253
+ var next = Object.assign({}, scheme, {continuous: !!continuous});
21254
+ if (continuous && next.type == 'sequential' && next.n < minContinuousStartColors) {
21255
+ next.n = minContinuousStartColors;
21256
+ }
21257
+ return next;
21258
+ }
21259
+
21260
+ function getAscendingValues(lyr, field) {
21261
+ if (!lyr || !lyr.data || !field || !lyr.data.fieldExists(field)) return [];
21262
+ return lyr.data.getRecords().map(function(rec) {
21263
+ return rec ? rec[field] : undefined;
21264
+ }).filter(isFiniteNumber).sort(function(a, b) { return a - b; });
21265
+ }
21266
+
21267
+ // -classify's classes= for a diverging scheme
21268
+ function getDivergingClassesOption(scheme) {
21269
+ return scheme.split == 'count' ? [scheme.n, scheme.n] : scheme.n;
21270
+ }
21271
+
21272
+ // The number of tiles of a ramp: for a diverging scheme, the center tile and
21273
+ // enough on each side for the longer side's classes (or a continuous
21274
+ // side's stops)
21275
+ function getRampSize(scheme) {
21276
+ var layout = scheme.layout;
21277
+ var k;
21278
+ if (scheme.type != 'diverging') return scheme.n;
21279
+ k = layout ? Math.max(layout.below, layout.above) :
21280
+ scheme.split == 'count' ? scheme.n : Math.floor(scheme.n / 2);
21281
+ k = scheme.continuous ? k + 1 : Math.max(k, 1);
21282
+ return k * 2 + 1;
21283
+ }
21284
+
21285
+ // The numbers of tiles that each side of a diverging scheme uses
21286
+ function getSideTiles(scheme) {
21287
+ var layout = scheme.layout;
21288
+ if (!scheme.continuous) return [layout.below, layout.above];
21289
+ return [internal.getContinuousSideStops(layout.below), internal.getContinuousSideStops(layout.above)];
21290
+ }
21291
+
21292
+ // One boolean per tile of a diverging scheme: whether a class (or a color
21293
+ // stop) uses it
21294
+ function getDivergingTileUse(scheme) {
21295
+ var size = getRampSize(scheme);
21296
+ var k = (size - 1) / 2;
21297
+ var layout = scheme.layout;
21298
+ var sides = layout ? getSideTiles(scheme) : [0, 0];
21299
+ var use = [];
21300
+ for (var i=0; i<size; i++) {
21301
+ use.push(!!layout && (i < k ? i >= k - sides[0] : i > k ? i <= k + sides[1] : !!layout.neutral));
21302
+ }
21303
+ return use;
21304
+ }
21305
+
21306
+ // The data value at each tile of a continuous scheme: {value, min, max}
21307
+ // (min and max: the value is the data's min or max), or for a diverging
21308
+ // scheme's center tile, the pivot class's range: {range}. Tiles that no stop
21309
+ // uses are null. Returns null if there are no data.
21310
+ function getContinuousTileStops(scheme, lyr) {
21311
+ var values, breaks, layout, stops, k, tiles;
21312
+ if (scheme.type == 'sequential') {
21313
+ values = getAscendingValues(lyr, scheme.field);
21314
+ if (values.length === 0) return null;
21315
+ try {
21316
+ breaks = internal.getSequentialBreaks(values, scheme.method, scheme.n - 2);
21317
+ } catch(e) {
21318
+ return null;
21319
+ }
21320
+ return markDataRange([values[0]].concat(breaks, [values[values.length - 1]]), values);
21321
+ }
21322
+ layout = scheme.layout;
21323
+ if (!layout) return null;
21324
+ stops = internal.getDivergingStops(layout);
21325
+ k = (getRampSize(scheme) - 1) / 2;
21326
+ tiles = new Array(k * 2 + 1).fill(null);
21327
+ markDataRange(stops.below, layout.dataRange).forEach(function(stop, i) {
21328
+ tiles[k - stops.below.length + i] = stop;
21329
+ });
21330
+ if (layout.neutral) tiles[k] = {range: layout.neutral};
21331
+ markDataRange(stops.above, layout.dataRange).forEach(function(stop, i) {
21332
+ tiles[k + 1 + i] = stop;
21333
+ });
21334
+ return tiles;
21335
+ }
21336
+
21337
+ // range: ascending values, of which the first is the min and the last the max
21338
+ function markDataRange(stops, range) {
21339
+ var min = range[0], max = range[range.length - 1];
21340
+ return stops.map(function(val) {
21341
+ return {value: val, min: val == min, max: val == max};
21342
+ });
21343
+ }
21344
+
21345
+ // The gradients to draw over the tiles of a continuous scheme, in order:
21346
+ // [{start, end, flatStart, flatEnd, samples: [{pos, color}]}], with positions
21347
+ // in tiles (0 is the center of the first tile, 1 of the second...). A
21348
+ // gradient runs from stop to stop, and holds its end colors over the outer
21349
+ // halves of its end tiles (flatStart, flatEnd) -- except where the data ends
21350
+ // between two stops (the shorter side of an equal-interval or nice
21351
+ // diverging scheme). A pivot class is a gradient of one color, over its
21352
+ // tile. The samples are of the interpolation -classify does between the
21353
+ // stops (samplesPerTile to each pair).
21354
+ function getContinuousSegments(scheme, samplesPerTile) {
21355
+ var colors = getSchemeColors(scheme);
21356
+ var layout = scheme.layout;
21357
+ var steps = samplesPerTile || 6;
21358
+ var segments = [];
21359
+ var stops, k, first, last;
21360
+ if (!isContinuousScheme(scheme)) return [];
21361
+ if (scheme.type == 'sequential') {
21362
+ return [makeSegment(colors, 0, 0, colors.length - 1, steps)];
21363
+ }
21364
+ if (!layout) return [];
21365
+ stops = internal.getDivergingStops(layout);
21366
+ k = (colors.length - 1) / 2;
21367
+ if (stops.below.length > 0) {
21368
+ first = k - stops.below.length;
21369
+ segments.push(makeSegment(colors.slice(first, k), first,
21370
+ first + internal.getStopPosition(stops.below, layout.dataRange[0]), k - 1, steps));
21371
+ }
21372
+ if (layout.neutral) {
21373
+ segments.push(makeSegment([colors[k]], k, k, k, steps));
21374
+ }
21375
+ if (stops.above.length > 0) {
21376
+ last = k + stops.above.length;
21377
+ segments.push(makeSegment(colors.slice(k + 1, last + 1), k + 1,
21378
+ k + 1, k + 1 + internal.getStopPosition(stops.above, layout.dataRange[1]), steps));
21379
+ }
21380
+ return segments;
21381
+ }
21382
+
21383
+ // colors: the stops' colors, the first at tile first
21384
+ // start, end: where the gradient starts and ends, in tiles
21385
+ function makeSegment(colors, first, start, end, steps) {
21386
+ var last = first + colors.length - 1;
21387
+ var getColor = colors.length > 1 ?
21388
+ internal.getInterpolatedValueGetter(colors, null, {interpolation: 'oklch'}) : null;
21389
+ var samples = [];
21390
+ var sample = function(pos) {
21391
+ samples.push({pos: pos, color: getColor ? toHex(getColor(pos - first)) : colors[0]});
21392
+ };
21393
+ sample(start);
21394
+ for (var i=Math.floor(start * steps) + 1; i / steps < end; i++) {
21395
+ sample(i / steps);
21396
+ }
21397
+ if (end > start) sample(end);
21398
+ return {start: start, end: end, flatStart: start == first, flatEnd: end == last, samples: samples};
21399
+ }
21400
+
21401
+ // The data range of each class of a diverging layout, in class order:
21402
+ // [low, high), with -Infinity and Infinity at the outer ends
21403
+ function getDivergingClassRanges(layout) {
21404
+ return getBreakRanges(layout ? layout.breaks : []);
21405
+ }
21406
+
21407
+ // The data range of each class of a sequential scheme, as -classify finds
21408
+ // them (see getDivergingClassRanges()), or null if there are no data
21409
+ function getSequentialClassRanges(scheme, lyr) {
21410
+ var values = getAscendingValues(lyr, scheme.field);
21411
+ if (values.length === 0) return null;
21412
+ try {
21413
+ return getBreakRanges(internal.getSequentialBreaks(values, scheme.method, scheme.n - 1));
21414
+ } catch(e) {
21415
+ return null;
21416
+ }
21417
+ }
21418
+
21419
+ function getBreakRanges(breaks) {
21420
+ var ranges = [];
21421
+ for (var i=0; i<=breaks.length; i++) {
21422
+ ranges.push([i > 0 ? breaks[i - 1] : -Infinity, i < breaks.length ? breaks[i] : Infinity]);
21423
+ }
21424
+ return ranges;
21425
+ }
21426
+
21427
+ // How the data falls around the pivot of a diverging scheme:
21428
+ // {pivot, below, above} (features below the pivot, and at or above it)
21429
+ function getPivotSummary(scheme, lyr) {
21430
+ var values = getAscendingValues(lyr, scheme.field);
21431
+ var pivot = scheme.layout ? scheme.layout.pivot : null;
21432
+ if (pivot === null) return null;
21433
+ return {
21434
+ pivot: pivot,
21435
+ below: values.filter(function(val) { return val < pivot; }).length,
21436
+ above: values.filter(function(val) { return val >= pivot; }).length
21437
+ };
21438
+ }
21439
+
21440
+ // pivot: 'auto', 'median', 'mean' or a number
21441
+ function setSchemePivot(scheme, pivot) {
21442
+ return Object.assign({}, scheme, {pivot: pivot});
21443
+ }
21444
+
21445
+ // sets the pivot class of the scheme's mode (see getSchemeNeutral())
21446
+ function setSchemeNeutral(scheme, neutral) {
21447
+ return Object.assign({}, scheme, scheme.continuous ? {continuousNeutral: !!neutral} : {neutral: !!neutral});
21448
+ }
21449
+
21450
+ // Switching between a total and a number per side starts over with the
21451
+ // default number
21452
+ function setSchemeSplit(scheme, split) {
21453
+ if (scheme.split == split) return scheme;
21454
+ return Object.assign({}, scheme, {
21455
+ split: split,
21456
+ n: split == 'count' ? defaultDivergingSideClasses : defaultDivergingClasses
21457
+ });
21458
+ }
21459
+
21460
+ // By the first text field, or else the first field that can be classified
21461
+ // by value, or else non-adjacent
21462
+ function getDefaultCategoricalScheme(lyr) {
21463
+ var fields = getCategoryFields(lyr);
21464
+ var records = lyr && lyr.data ? lyr.data.getRecords() : [];
21465
+ var field = fields.find(function(name) {
21466
+ return internal.getColumnType(name, records) == 'string';
21467
+ }) || fields[0] || null;
21468
+ var scheme = {
21469
+ type: 'categorical',
21470
+ field: field,
21471
+ method: field ? 'categorical' : 'non-adjacent',
21472
+ n: defaultNonAdjacentColors,
21473
+ preset: defaultCategoricalPreset,
21474
+ order: null,
21475
+ swatches: null
21476
+ };
21477
+ return field ? setSchemeField(scheme, field, getCategories(lyr, field).length) : scheme;
21478
+ }
21479
+
21480
+ function getMinSchemeColors(scheme) {
21481
+ return scheme.type == 'diverging' && scheme.split == 'count' ? 1 : minSchemeColors;
21482
+ }
21483
+
21484
+ function getSchemeMethods(scheme) {
21485
+ return scheme.type == 'categorical' ? categoricalMethods : classifyMethods;
21486
+ }
21487
+
21488
+ // count: the number of categories, for the categorical method
21489
+ function getMaxSchemeColors(scheme, count) {
21490
+ var max = maxSchemeColors;
21491
+ if (scheme.type == 'diverging' && scheme.split == 'count') return maxDivergingSideClasses;
21492
+ if (scheme.type != 'categorical') return max;
21493
+ max = scheme.preset ? internal.getCategoricalColors(scheme.preset).length : maxCategoricalColors;
21494
+ if (scheme.method == 'categorical' && count >= 0) {
21495
+ max = Math.min(max, Math.max(count, minSchemeColors));
21496
+ }
21497
+ return max;
21498
+ }
21499
+
21500
+ function clampColorCount(scheme, n, count) {
21501
+ var min = getMinSchemeColors(scheme);
21502
+ return clamp$4(Math.round(n) || min, min, getMaxSchemeColors(scheme, count));
21503
+ }
21504
+
21505
+ // A new field for a categorical scheme gets a swatch for each of its values,
21506
+ // up to as many as the palette has.
21507
+ // count: the number of categories in the field
21508
+ function setSchemeField(scheme, field, count) {
21509
+ var next = Object.assign({}, scheme, {field: field});
21510
+ if (next.type == 'categorical' && next.method == 'categorical') {
21511
+ next.n = clampColorCount(next, count, count);
21512
+ }
21513
+ return next;
21514
+ }
21515
+
21516
+ // field, count: the field to use, and its number of categories, when a
21517
+ // categorical scheme changes to the categorical method
21518
+ function setSchemeMethod(scheme, method, field, count) {
21519
+ var next = Object.assign({}, scheme, {method: method});
21520
+ if (next.type != 'categorical') return next;
21521
+ if (method == 'categorical') {
21522
+ return setSchemeField(next, next.field || field, count);
21523
+ }
21524
+ return setTileCount(next, defaultNonAdjacentColors);
21525
+ }
21526
+
21527
+ // Fields that a categorical scheme can classify: text and numbers. The
21528
+ // fill field is left out, since the scheme writes over it.
21529
+ function getCategoryFields(lyr) {
21530
+ var records = lyr && lyr.data ? lyr.data.getRecords() : [];
21531
+ if (!lyr || !lyr.data) return [];
21532
+ return lyr.data.getFields().filter(function(field) {
21533
+ var type = internal.getColumnType(field, records);
21534
+ return field != 'fill' && (type == 'string' || type == 'number');
21535
+ });
21536
+ }
21537
+
21538
+ // The values of a field, in the order -classify gives them swatches. Empty
21539
+ // values are left out, as -classify leaves them out: they are no data.
21540
+ function getCategories(lyr, field) {
21541
+ if (!lyr || !lyr.data || !field || !lyr.data.fieldExists(field)) return [];
21542
+ return internal.getUniqFieldValues(lyr.data.getRecords(), field).filter(isCategoryValue);
21543
+ }
21544
+
21545
+ // The same test as -classify's (0 is a value; null, undefined, '' and NaN
21546
+ // are not)
21547
+ function isCategoryValue(val) {
21548
+ return !!val || val === 0;
21549
+ }
21550
+
21551
+ // How many features the scheme gives the no-data color
21552
+ function getNoDataCount(lyr, scheme) {
21553
+ var records, test;
21554
+ if (!lyr || !lyr.data || !scheme.field || scheme.method == 'non-adjacent' ||
21555
+ !lyr.data.fieldExists(scheme.field)) return 0;
21556
+ records = lyr.data.getRecords();
21557
+ test = scheme.type == 'categorical' ? isCategoryValue : isFiniteNumber;
21558
+ return records.filter(function(rec) {
21559
+ return !test(rec ? rec[scheme.field] : undefined);
21560
+ }).length;
21561
+ }
21562
+
21563
+ function isFiniteNumber(val) {
21564
+ return typeof val == 'number' && isFinite(val);
21565
+ }
21566
+
21567
+ function getSchemeNullColor(scheme) {
21568
+ return scheme.nullColor || defaultNullColor;
21569
+ }
21570
+
21571
+ // An empty or unreadable color is the default
21572
+ function setSchemeNullColor(scheme, color) {
21573
+ var rgb = color ? internal.parseColor(color) : null;
21574
+ return Object.assign({}, scheme, {nullColor: rgb ? internal.formatColor(rgb) : defaultNullColor});
21575
+ }
21576
+
21577
+ // The categories each swatch colors, as -classify assigns them
21578
+ function getSwatchCategories(categories, n) {
21579
+ var groups = [];
21580
+ for (var i=0; i<n; i++) groups.push([]);
21581
+ categories.forEach(function(val, i) {
21582
+ groups[i % n].push(val);
21583
+ });
21584
+ return groups;
21585
+ }
21586
+
21587
+ function getDefaultScheme(field) {
21588
+ return {
21589
+ type: 'sequential',
21590
+ field: field || null,
21591
+ method: 'quantile',
21592
+ n: 5,
21593
+ preset: null,
21594
+ reversed: false,
21595
+ pins: [{t: 0, color: defaultEnds[0]}, {t: 1, color: defaultEnds[1]}],
21596
+ vibrance: defaultVibrance
21597
+ };
21598
+ }
21599
+
21600
+ function getSchemeColors(scheme) {
21601
+ return getSchemeTiles(scheme).map(function(tile) {
21602
+ return tile.color;
21603
+ });
21604
+ }
21605
+
21606
+ // One object per tile: {color, pinned, adjusted, ...}, where adjusted means
21607
+ // an interpolated color was moved to fit the sRGB gamut (see resolveRampTiles())
21608
+ function getSchemeTiles(scheme) {
21609
+ var colors;
21610
+ if (scheme.type == 'categorical') {
21611
+ return getCategoricalPalette(scheme).slice(0, scheme.n).map(function(color) {
21612
+ return {color: color, pinned: false, adjusted: false};
21613
+ });
21614
+ }
21615
+ if (scheme.preset) {
21616
+ colors = scheme.range ? getPresetSection(scheme, getRampSize(scheme), scheme.range) :
21617
+ getPresetColors(scheme.preset, getRampSize(scheme));
21618
+ if (scheme.reversed) colors.reverse();
21619
+ return colors.map(function(color) {
21620
+ return {color: color, pinned: false, adjusted: false};
21621
+ });
21622
+ }
21623
+ return internal.resolveRampTiles(scheme.pins, getRampSize(scheme), {
21624
+ vibrance: getSchemeVibrance(scheme),
21625
+ hue: scheme.longHue && scheme.type != 'diverging' ? 'longer' : 'shorter'
21626
+ });
21627
+ }
21628
+
21629
+ function getSchemeVibrance(scheme) {
21630
+ return scheme.vibrance >= 0 ? scheme.vibrance : defaultVibrance;
21631
+ }
21632
+
21633
+ function setSchemeVibrance(scheme, vibrance) {
21634
+ vibrance = clamp$4(+vibrance || 0, 0, maxVibrance);
21635
+ return Object.assign({}, scheme, {vibrance: vibrance});
21636
+ }
21637
+
21638
+ // longHue: hues go the long way around the color wheel between pinned colors
21639
+ function setSchemeLongHue(scheme, longHue) {
21640
+ return Object.assign({}, scheme, {longHue: !!longHue});
21641
+ }
21642
+
21643
+ function clamp$4(val, min, max) {
21644
+ return Math.min(Math.max(val, min), max);
21645
+ }
21646
+
21647
+ function getPresetColors(name, n) {
21648
+ return internal.getColorRamp(name, n).map(toHex);
21649
+ }
21650
+
21651
+ // The part of a sequential or diverging preset that its tiles are taken
21652
+ // from: {start, end, base}, 0-1 in the preset's own direction (before
21653
+ // reversing). A diverging range is the same on both sides of the center.
21654
+ // base is the number of tiles when the range was first narrowed: a preset
21655
+ // with a hand-picked set of that size (ColorBrewer's) is interpolated
21656
+ // between the set's colors, so that removing an end tile leaves the others
21657
+ // as they were.
21658
+ var minRangeSpan = 0.1;
21659
+
21660
+ function getSchemeRange(scheme) {
21661
+ return scheme.range || {start: 0, end: 1, base: getRampSize(scheme)};
21662
+ }
21663
+
21664
+ function hasSchemeRange(scheme) {
21665
+ return !!scheme.preset && scheme.type != 'categorical';
21666
+ }
21667
+
21668
+ // The range as positions along the tiles, left to right: [left, right]
21669
+ function getDisplayRange(scheme) {
21670
+ var range = getSchemeRange(scheme);
21671
+ return scheme.reversed ? [1 - range.end, 1 - range.start] : [range.start, range.end];
21672
+ }
21673
+
21674
+ // Moves one end of the range, given as a position along the tiles (0-1,
21675
+ // left to right); a diverging range moves at both ends.
21676
+ // side: 'left' or 'right'
21677
+ function setSchemeRangeEnd(scheme, side, pos) {
21678
+ var display = getDisplayRange(scheme);
21679
+ var left = display[0], right = display[1];
21680
+ pos = clamp$4(+pos || 0, 0, 1);
21681
+ if (scheme.type == 'diverging') {
21682
+ left = side == 'left' ? pos : 1 - pos;
21683
+ left = clamp$4(left, 0, (1 - minRangeSpan) / 2);
21684
+ right = 1 - left;
21685
+ } else if (side == 'left') {
21686
+ left = Math.min(pos, right - minRangeSpan);
21687
+ } else {
21688
+ right = Math.max(pos, left + minRangeSpan);
21689
+ }
21690
+ return setDisplayRange(scheme, left, right);
21691
+ }
21692
+
21693
+ function resetSchemeRange(scheme) {
21694
+ return Object.assign({}, scheme, {range: null});
21695
+ }
21696
+
21697
+ // Takes the tile at one end of a sequential preset away, keeping the colors
21698
+ // of the others: the range ends where the next tile was.
21699
+ // side: 'left' or 'right'
21700
+ function removeEndTile(scheme, side) {
21701
+ var display, step;
21702
+ if (!canRemoveEndTile(scheme)) return scheme;
21703
+ display = getDisplayRange(scheme);
21704
+ step = (display[1] - display[0]) / (scheme.n - 1);
21705
+ if (side == 'left') display[0] += step;
21706
+ else display[1] -= step;
21707
+ return Object.assign(setDisplayRange(scheme, display[0], display[1]), {n: scheme.n - 1});
21708
+ }
21709
+
21710
+ function canRemoveEndTile(scheme) {
21711
+ return hasSchemeRange(scheme) && scheme.type == 'sequential' &&
21712
+ scheme.n > getMinSchemeColors(scheme);
21713
+ }
21714
+
21715
+ function setDisplayRange(scheme, left, right) {
21716
+ var base = getSchemeRange(scheme).base;
21717
+ var start = scheme.reversed ? 1 - right : left;
21718
+ var end = scheme.reversed ? 1 - left : right;
21719
+ if (start <= 0 && end >= 1) return resetSchemeRange(scheme);
21720
+ return Object.assign({}, scheme, {range: {start: Math.max(start, 0), end: Math.min(end, 1), base: base}});
21721
+ }
21722
+
21723
+ // The whole of a preset, left to right, as the range's strip shows it
21724
+ function getRangeStripColors(scheme, n) {
21725
+ var colors = getPresetSection(scheme, n, {start: 0, end: 1, base: getSchemeRange(scheme).base});
21726
+ return scheme.reversed ? colors.reverse() : colors;
21727
+ }
21728
+
21729
+ function getPresetSection(scheme, n, range) {
21730
+ return internal.getColorRampSection(scheme.preset, n, range.start, range.end, range.base).map(toHex);
21731
+ }
21732
+
21733
+ // All the swatches of a categorical scheme, in order: the first n are used
21734
+ function getCategoricalPalette(scheme) {
21735
+ var colors;
21736
+ if (!scheme.preset) return scheme.swatches.concat();
21737
+ colors = getCategoricalPresetColors(scheme.preset);
21738
+ if (!scheme.order) return colors;
21739
+ return scheme.order.map(function(i) { return colors[i]; });
21740
+ }
21741
+
21742
+ function getCategoricalPresetColors(name) {
21743
+ return internal.getCategoricalColors(name).map(toHex);
21744
+ }
21745
+
21746
+ // A list of n colors, starting with the given ones and going on with colors
21747
+ // they don't have yet
21748
+ function extendSwatches(colors, n) {
21749
+ var swatches = colors.slice(0, n);
21750
+ getCategoricalPresetColors(extraSwatchSource).forEach(function(color) {
21751
+ if (swatches.length < n && !swatches.includes(color)) {
21752
+ swatches.push(color);
21753
+ }
21754
+ });
21755
+ return internal.wrapColors(swatches, n);
21756
+ }
21757
+
21758
+ // One boolean per tile: whether the user set its color.
21759
+ function getPinnedTiles(scheme) {
21760
+ if (scheme.preset || scheme.type == 'categorical') {
21761
+ return new Array(getRampSize(scheme)).fill(false);
21762
+ }
21763
+ return internal.getPinnedSlots(scheme.pins, getRampSize(scheme)).map(function(pinId) {
21764
+ return pinId > -1;
21765
+ });
21766
+ }
21767
+
21768
+ // count: the number of categories, for a categorical scheme
21769
+ function choosePreset(scheme, name, count) {
21770
+ var next;
21771
+ if (scheme.type == 'categorical') {
21772
+ next = Object.assign({}, scheme, {preset: name, order: null, swatches: null});
21773
+ next.n = clampColorCount(next, next.n, count);
21774
+ return next;
21775
+ }
21776
+ return Object.assign({}, scheme, {preset: name, reversed: false, pins: null, range: null});
21777
+ }
21778
+
21779
+ // A preset as a custom ramp, pinned at its ends, for editing.
21780
+ function makeSchemeCustom(scheme) {
21781
+ var colors;
21782
+ if (!scheme.preset) return scheme;
21783
+ if (scheme.type == 'categorical') {
21784
+ return Object.assign({}, scheme, {preset: null, order: null, swatches: getCategoricalPalette(scheme)});
21785
+ }
21786
+ colors = getSchemeColors(scheme);
21787
+ return Object.assign({}, scheme, {
21788
+ preset: null,
21789
+ reversed: false,
21790
+ range: null,
21791
+ pins: scheme.type == 'diverging' ?
21792
+ [{t: 0, color: colors[0]}, {t: 0.5, color: colors[(colors.length - 1) / 2]},
21793
+ {t: 1, color: colors[colors.length - 1]}] :
21794
+ [{t: 0, color: colors[0]}, {t: 1, color: colors[colors.length - 1]}]
21795
+ });
21796
+ }
21797
+
21798
+ // The center tile of a diverging ramp, which stays pinned
21799
+ function getCenterTile(scheme) {
21800
+ return scheme.type == 'diverging' ? (getRampSize(scheme) - 1) / 2 : -1;
21801
+ }
21802
+
21803
+ // The color to edit for tile i: a pinned tile's own color, which vibrance
21804
+ // may have raised in the ramp, or else the tile's color
21805
+ function getTileEditColor(scheme, i) {
21806
+ var slot = scheme.preset || scheme.type == 'categorical' ? -1 :
21807
+ internal.getPinnedSlots(scheme.pins, getRampSize(scheme))[i];
21808
+ if (slot > -1) return toHex(scheme.pins[slot].color);
21809
+ return getSchemeColors(scheme)[i];
21810
+ }
21811
+
21812
+ function setTileColor(scheme, i, color) {
21813
+ var custom = makeSchemeCustom(scheme);
21814
+ var swatches;
21815
+ if (custom.type == 'categorical') {
21816
+ swatches = custom.swatches.concat();
21817
+ swatches[i] = toHex(color);
21818
+ return Object.assign({}, custom, {swatches: swatches});
21819
+ }
21820
+ return Object.assign({}, custom, {
21821
+ pins: internal.setRampPin(custom.pins, getRampSize(custom), i, toHex(color))
21822
+ });
21823
+ }
21824
+
21825
+ function clearTileColor(scheme, i) {
21826
+ if (scheme.preset || scheme.type == 'categorical' || i == getCenterTile(scheme)) return scheme;
21827
+ return Object.assign({}, scheme, {
21828
+ pins: internal.clearRampPin(scheme.pins, getRampSize(scheme), i)
21829
+ });
21830
+ }
21831
+
21832
+ // A custom categorical list grows to have at least n swatches.
21833
+ // count: the number of categories, for a categorical scheme
21834
+ function setTileCount(scheme, n, count) {
21835
+ var next = Object.assign({}, scheme, {n: clampColorCount(scheme, n, count)});
21836
+ if (next.type == 'categorical' && !next.preset && next.swatches.length < next.n) {
21837
+ next.swatches = extendSwatches(next.swatches, next.n);
21838
+ }
21839
+ return next;
21840
+ }
21841
+
21842
+ // Moves a categorical swatch to a new place in the palette. Moving one
21843
+ // into the swatches in use pushes the last of them out of use, and moving one
21844
+ // out of use brings the first unused one in.
21845
+ // to: the swatch's index after the move
21846
+ function moveSwatch(scheme, from, to) {
21847
+ return rearrangeSwatches(scheme, function(items) {
21848
+ var item = items.splice(from, 1)[0];
21849
+ items.splice(to, 0, item);
21850
+ return items;
21851
+ });
21852
+ }
21853
+
21854
+ // Shuffles all of a categorical palette's swatches, in use or not, into a
21855
+ // new order.
21856
+ // random: a function like Math.random (for testing)
21857
+ function shuffleScheme(scheme, random) {
21858
+ var rand = random || Math.random;
21859
+ return rearrangeSwatches(scheme, function(items) {
21860
+ var shuffled, tries = 0;
21861
+ do {
21862
+ shuffled = shuffle(items.concat(), rand);
21863
+ } while (items.length > 1 && ++tries < 10 && shuffled.every(function(item, i) {
21864
+ return item == items[i];
21865
+ }));
21866
+ return shuffled;
21867
+ });
21868
+ }
21869
+
21870
+ function shuffle(arr, rand) {
21871
+ var j, tmp;
21872
+ for (var i=arr.length - 1; i>0; i--) {
21873
+ j = Math.floor(rand() * (i + 1));
21874
+ tmp = arr[i];
21875
+ arr[i] = arr[j];
21876
+ arr[j] = tmp;
21877
+ }
21878
+ return arr;
21879
+ }
21880
+
21881
+ // Presets are rearranged by index, so that they keep their name
21882
+ function rearrangeSwatches(scheme, rearrange) {
21883
+ var order;
21884
+ if (scheme.type != 'categorical') return scheme;
21885
+ if (!scheme.preset) {
21886
+ return Object.assign({}, scheme, {swatches: rearrange(scheme.swatches.concat())});
21887
+ }
21888
+ order = scheme.order || getCategoricalPresetColors(scheme.preset).map(function(c, i) { return i; });
21889
+ return Object.assign({}, scheme, {order: rearrange(order.concat())});
21890
+ }
21891
+
21892
+ function reverseScheme(scheme) {
21893
+ if (scheme.type == 'categorical') return scheme;
21894
+ if (scheme.preset) {
21895
+ return Object.assign({}, scheme, {reversed: !scheme.reversed});
21896
+ }
21897
+ return Object.assign({}, scheme, {
21898
+ pins: scheme.pins.map(function(pin) {
21899
+ return {t: 1 - pin.t, color: pin.color};
21900
+ }).reverse()
21901
+ });
21902
+ }
21903
+
21904
+ // The colors to give -classify: no more than there are categories, for the
21905
+ // categorical method. A diverging scheme gives all its tiles, from which
21906
+ // -classify takes the same ones the classes use here (see
21907
+ // getDivergingClassValues()).
21908
+ // count: the number of categories
21909
+ function getAppliedColors(scheme, count) {
21910
+ var colors = getSchemeColors(scheme);
21911
+ if (scheme.type == 'categorical' && scheme.method == 'categorical' && count > 0) {
21912
+ colors = colors.slice(0, count);
21913
+ }
21914
+ return colors;
21915
+ }
21916
+
21917
+ // opts.target a -target value, when the layer is not the active one
21918
+ function formatSchemeCommand(scheme, colors, opts) {
21919
+ var parts = ['-classify'];
21920
+ if (scheme.method != 'non-adjacent') {
21921
+ parts.push('field=' + quoteCommandValue(scheme.field));
21922
+ }
21923
+ parts.push('method=' + scheme.method);
21924
+ if (scheme.type == 'diverging') {
21925
+ parts.push('pivot=' + scheme.pivot, 'classes=' + getDivergingClassesOption(scheme));
21926
+ // continuous output has no pivot class by default, classes have one
21927
+ if (!getSchemeNeutral(scheme)) parts.push('no-pivot-class');
21928
+ else if (scheme.continuous) parts.push('pivot-class');
21929
+ } else if (isContinuousScheme(scheme)) {
21930
+ parts.push('classes=' + (scheme.n - 1));
21931
+ }
21932
+ if (isContinuousScheme(scheme)) {
21933
+ // the tiles already have their vibrance, so no vibrance=
21934
+ parts.push('continuous', 'interpolation=oklch');
21935
+ }
21936
+ parts.push('colors=' + colors.join(','));
21937
+ if (scheme.method != 'non-adjacent' && getSchemeNullColor(scheme) != defaultNullColor) {
21938
+ parts.push('null-value=' + getSchemeNullColor(scheme));
21939
+ }
21940
+ if (opts && opts.target) {
21941
+ parts.push('target=' + opts.target);
21942
+ }
21943
+ return parts.join(' ');
21944
+ }
21945
+
21946
+ function getNumericFields(lyr) {
21947
+ var records = lyr && lyr.data ? lyr.data.getRecords() : [];
21948
+ if (!lyr || !lyr.data) return [];
21949
+ return lyr.data.getFields().filter(function(field) {
21950
+ return internal.getColumnType(field, records) == 'number';
21951
+ });
21952
+ }
21953
+
21954
+ function setLayerScheme(lyr, scheme) {
21955
+ if (!lyr) return;
21956
+ if (scheme) {
21957
+ layerSchemes.set(lyr, scheme);
21958
+ } else {
21959
+ layerSchemes.delete(lyr);
21960
+ }
21961
+ }
21962
+
21963
+ // The scheme that colored a layer, or null if it has none, or if its fills
21964
+ // have since been changed some other way.
21965
+ function getLayerScheme(lyr) {
21966
+ var scheme = lyr ? layerSchemes.get(lyr) : null;
21967
+ if (!scheme || !schemeMatchesLayer(scheme, lyr)) return null;
21968
+ return scheme;
21969
+ }
21970
+
21971
+ // Checked from the data rather than kept in step with every edit, so that
21972
+ // anything that changes the fills -- a -style command, a typed fill color,
21973
+ // an undo past the scheme -- retires it.
21974
+ function schemeMatchesLayer(scheme, lyr) {
21975
+ var records, colors, val;
21976
+ if (!lyr || !lyr.data || !scheme.colors) return false;
21977
+ if (scheme.method != 'non-adjacent' && !lyr.data.fieldExists(scheme.field)) return false;
21978
+ if (isContinuousScheme(scheme)) {
21979
+ return !!scheme.fillHash && scheme.fillHash == getFillFingerprint(lyr);
21980
+ }
21981
+ colors = scheme.colors.map(function(c) { return c.toLowerCase(); });
21982
+ records = lyr.data.getRecords();
21983
+ for (var i=0; i<records.length; i++) {
21984
+ val = records[i] && records[i].fill;
21985
+ if (!val || (!colors.includes(String(val).toLowerCase()) && !isNullFill(val, scheme))) {
21986
+ return false;
21987
+ }
21988
+ }
21989
+ return true;
21990
+ }
21991
+
21992
+ // A scheme as applied to a layer, with what's needed to check it later
21993
+ // colors: the colors given to -classify
21994
+ function getAppliedScheme(scheme, colors, lyr) {
21995
+ var applied = Object.assign({}, scheme, {colors: colors});
21996
+ if (isContinuousScheme(scheme)) {
21997
+ applied.fillHash = getFillFingerprint(lyr);
21998
+ } else {
21999
+ delete applied.fillHash;
22000
+ }
22001
+ return applied;
22002
+ }
22003
+
22004
+ // A hash of a layer's fill values (FNV-1a, 32 bits)
22005
+ function getFillFingerprint(lyr) {
22006
+ var records = lyr && lyr.data ? lyr.data.getRecords() : [];
22007
+ var hash = 0x811c9dc5;
22008
+ var str;
22009
+ for (var i=0; i<records.length; i++) {
22010
+ str = String(records[i] ? records[i].fill : '') + '\n';
22011
+ for (var j=0; j<str.length; j++) {
22012
+ hash ^= str.charCodeAt(j);
22013
+ hash = Math.imul(hash, 0x01000193);
22014
+ }
22015
+ }
22016
+ return records.length + ':' + (hash >>> 0).toString(16);
22017
+ }
22018
+
22019
+ function isNullFill(val, scheme) {
22020
+ var color = getSchemeNullColor(scheme);
22021
+ val = String(val).toLowerCase();
22022
+ // -classify writes its default as #eee
22023
+ return val == color || color == defaultNullColor && val == '#eee';
22024
+ }
22025
+
22026
+ function toHex(color) {
22027
+ return internal.formatColor(internal.parseColor(color));
22028
+ }
22029
+
22030
+ // A popup beside the polygon style panel for coloring a layer's fills by a
22031
+ // data field, with -classify. Edits are applied as they are made, in a command
22032
+ // session, so that the whole time the panel is open on a layer is one undo
22033
+ // state and one command in the session history.
22034
+ // See docs/development/color-scheme-panel-design.md
22035
+ //
22036
+ // opts.getExtraCommands() commands to run after -classify, in the same step
22037
+ // (the style panel's pattern backgrounds follow the
22038
+ // fills)
22039
+ // opts.getSelectionCount() features selected in the style panel
22040
+ // opts.onUpdate() the scheme or the fills changed
22041
+ // opts.onClose() the panel was closed
22042
+ var reverseIcon = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" ' +
22043
+ 'stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">' +
22044
+ '<path d="M2 4.5h9.5M9 2l2.5 2.5L9 7"/><path d="M12 9.5H2.5M5 7L2.5 9.5 5 12"/></svg>';
22045
+ var shuffleIcon = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" ' +
22046
+ 'stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">' +
22047
+ '<path d="M1.5 3.5h2.2c1.4 0 2.3.7 3 2l1.6 3c.7 1.3 1.6 2 3 2h1.2"/>' +
22048
+ '<path d="M1.5 10.5h2.2c1.2 0 2-.5 2.6-1.4M8.2 4.9c.6-.9 1.4-1.4 2.6-1.4h1.7"/>' +
22049
+ '<path d="M11 1.5l1.7 2-1.7 2M11 8.5l1.7 2-1.7 2"/></svg>';
22050
+ var longHueIcon = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" ' +
22051
+ 'stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">' +
22052
+ '<path d="M11.5 4.6A5 5 0 1 0 12 7"/><path d="M12 1.8v3h-3"/></svg>';
22053
+ var continuousIcon = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none">' +
22054
+ '<defs><linearGradient id="color-scheme-continuous-icon">' +
22055
+ '<stop offset="0" stop-color="currentColor" stop-opacity="0"/>' +
22056
+ '<stop offset="1" stop-color="currentColor"/></linearGradient></defs>' +
22057
+ '<rect x="1.5" y="3.5" width="11" height="7" rx="1.5" fill="url(#color-scheme-continuous-icon)" ' +
22058
+ 'stroke="currentColor" stroke-width="1.2"/></svg>';
22059
+
22060
+ function ColorSchemePanel(gui, opts) {
22061
+ var parent = gui.container.findChild('.mshp-main-map');
22062
+ var panel = El('div').addClass('label-style-panel color-scheme-panel').appendTo(parent).hide();
22063
+ var session = null; // made on first use: the console is made after the panel
22064
+ var targetLayer = null;
22065
+ var scheme = null;
22066
+ var sessionLayer = null; // the layer the session's edits are to
22067
+ var baseScheme = null; // its scheme when they began
22068
+ var selectedTile = -1;
22069
+ // the scheme last used on each tab while the panel is open, for switching back
22070
+ var tabSchemes = {};
22071
+ var tabs = {};
22072
+ var paletteBtn, paletteMenu, countField, tileRow, gradientEl, tilesEl, picker, fieldRow, fieldSelect, methodSelect,
22073
+ noFieldsNote, selectionNote, controlsEl, vibranceRow, vibranceInput,
22074
+ longHueBtn, continuousBtn, reverseBtn, shuffleBtn, dropMarker, nullControl, nullCount, countLabel,
22075
+ rangeEl, rangeStrip, rangeShades, rangeHandles,
22076
+ divergingEl, pivotSelect, pivotInput, pivotNote, neutralToggle, splitSelect;
22077
+ // set while a tile is being dragged, so that letting go isn't a click
22078
+ var tileDrag = null;
22079
+
22080
+ initPanel();
22081
+
22082
+ gui.on('undo_redo_post', function() {
22083
+ var restored;
22084
+ if (!targetLayer) return;
22085
+ restored = getLayerScheme(targetLayer);
22086
+ if (restored) {
22087
+ scheme = updateDivergingLayout(restored, targetLayer);
22088
+ render();
22089
+ } else {
22090
+ close();
22091
+ }
22092
+ });
22093
+
22094
+ this.open = function(lyr) {
22095
+ var fields;
22096
+ if (targetLayer && targetLayer != lyr) close();
22097
+ targetLayer = lyr;
22098
+ selectedTile = -1;
22099
+ tabSchemes = {};
22100
+ scheme = getLayerScheme(lyr);
22101
+ panel.show();
22102
+ if (scheme) {
22103
+ scheme = updateDivergingLayout(scheme, lyr);
22104
+ } else {
22105
+ fields = getNumericFields(lyr);
22106
+ scheme = getDefaultSchemeOfType(fields.length > 0 ? 'sequential' : 'categorical', lyr);
22107
+ if (canApply(scheme)) apply();
22108
+ }
22109
+ render();
22110
+ };
22111
+
22112
+ this.close = close;
22113
+
22114
+ this.isOpen = function() {
22115
+ return !!targetLayer;
22116
+ };
22117
+
22118
+ function close() {
22119
+ var wasOpen = !!targetLayer;
22120
+ picker.hide();
22121
+ nullControl.picker.hide();
22122
+ hidePaletteMenu();
22123
+ panel.hide();
22124
+ targetLayer = null;
22125
+ scheme = null;
22126
+ if (session) session.finish();
22127
+ if (wasOpen && opts.onClose) opts.onClose();
22128
+ }
22129
+
22130
+ function getSession() {
22131
+ if (!session && gui.console && gui.console.createCommandSession) {
22132
+ session = gui.console.createCommandSession({label: 'Color scheme', onCommit: onCommit});
22133
+ }
22134
+ return session;
22135
+ }
22136
+
22137
+ function initPanel() {
22138
+ claimFieldKeys(panel.node(), {
22139
+ revert: render,
22140
+ release: releaseFocus
22141
+ });
22142
+ panel.node().addEventListener('click', function(e) {
22143
+ if (isTextInput(document.activeElement) || opensAMenu(e.target)) return;
22144
+ releaseFocus();
22145
+ });
22146
+
22147
+ var header = El('div').addClass('label-style-panel-title').appendTo(panel);
22148
+ El('span').appendTo(header).text('Color palettes');
22149
+ El('button').addClass('label-style-close').appendTo(header).text('×').on('click', function() {
22150
+ close();
22151
+ opts.onUpdate();
22152
+ });
22153
+
22154
+ var tabRow = El('div').addClass('label-style-row color-scheme-tabs').attr('role', 'tablist').appendTo(panel);
22155
+ schemeTypes.forEach(function(type) {
22156
+ tabs[type.name] = El('button').addClass('color-scheme-tab').attr('type', 'button')
22157
+ .attr('role', 'tab').text(type.label).appendTo(tabRow)
22158
+ .on('click', function() {
22159
+ switchType(type.name);
22160
+ });
22161
+ });
22162
+
22163
+ noFieldsNote = El('div').addClass('label-style-row color-scheme-note').appendTo(panel)
22164
+ .text('This layer has no numeric fields to classify.');
22165
+ controlsEl = El('div').appendTo(panel);
22166
+
22167
+ fieldRow = El('div').addClass('label-style-row color-scheme-select-row').appendTo(controlsEl);
22168
+ El('span').appendTo(fieldRow).text('Field');
22169
+ fieldSelect = El('select').attr('aria-label', 'Data field').appendTo(fieldRow).on('change', function() {
22170
+ var field = fieldSelect.node().value;
22171
+ changeScheme(setSchemeField(scheme, field, getCategoryCount(field)));
22172
+ });
22173
+
22174
+ var methodRow = El('div').addClass('label-style-row color-scheme-select-row').appendTo(controlsEl);
22175
+ El('span').appendTo(methodRow).text('Method');
22176
+ methodSelect = El('select').attr('aria-label', 'Classification method').appendTo(methodRow).on('change', function() {
22177
+ var field = scheme.field || getCategoryFields(targetLayer)[0];
22178
+ changeScheme(setSchemeMethod(scheme, methodSelect.node().value, field, getCategoryCount(field)));
22179
+ });
22180
+
22181
+ initDivergingControls();
22182
+
22183
+ var paletteRow = El('div').addClass('label-style-row label-split-row').appendTo(controlsEl);
22184
+ var paletteCell = El('div').addClass('label-split-cell color-scheme-palette-cell').appendTo(paletteRow);
22185
+ var countCell = El('div').addClass('label-split-cell').appendTo(paletteRow);
22186
+ El('span').appendTo(paletteCell).text('Palette');
22187
+ paletteBtn = El('div').addClass('color-scheme-palette-btn').attr('role', 'button')
22188
+ .attr('aria-label', 'Palette').appendTo(paletteCell).on('click', togglePaletteMenu);
22189
+ paletteMenu = El('div').addClass('color-scheme-palette-menu').appendTo(paletteCell).hide();
22190
+ countLabel = El('span').appendTo(countCell).text('Colors');
22191
+ // the scheme sets the limits (see getMinSchemeColors(), getMaxSchemeColors())
22192
+ countField = new SizeField(countCell, {
22193
+ min: 1,
22194
+ max: maxCategoricalColors,
22195
+ step: 1,
22196
+ bigStep: 2,
22197
+ decimals: 0,
22198
+ title: 'Number of classes',
22199
+ onSet: function(n) {
22200
+ changeScheme(setTileCount(scheme, n, getCategoryCount()));
22201
+ },
22202
+ onStep: function(delta) {
22203
+ changeScheme(setTileCount(scheme, scheme.n + (delta > 0 ? 1 : -1), getCategoryCount()));
22204
+ },
22205
+ onDone: releaseFocus
22206
+ });
22207
+
22208
+ tileRow = El('div').addClass('color-scheme-tile-row').appendTo(controlsEl);
22209
+ gradientEl = El('div').addClass('color-scheme-gradient').appendTo(tileRow);
22210
+ tilesEl = El('div').addClass('color-scheme-tiles').appendTo(tileRow);
22211
+ picker = new ColorPicker(tileRow, {
22212
+ presetRows: [],
22213
+ onPreview: function(hex) {
22214
+ var tile = getTileCells()[selectedTile];
22215
+ if (tile) tile.querySelector('.color-scheme-tile').style.backgroundColor = hex;
22216
+ },
22217
+ onChange: function(hex) {
22218
+ if (selectedTile > -1) changeScheme(setTileColor(scheme, selectedTile, hex));
22219
+ },
22220
+ // the highlight says which tile the picker is editing
22221
+ onHide: function() {
22222
+ selectedTile = -1;
22223
+ render();
22224
+ }
22225
+ });
22226
+
22227
+ // the slider works in thousandths of OKLCH chroma
22228
+ vibranceRow = addSliderRow('Vibrance', 'Makes all the colors more vivid',
22229
+ 0, Math.round(maxVibrance * 1000), 5, function(val) {
22230
+ changeScheme(setSchemeVibrance(scheme, val / 1000));
22231
+ });
22232
+ vibranceInput = vibranceRow.findChild('input');
22233
+ initRangeControl(vibranceRow);
22234
+ longHueBtn = addIconButton(vibranceRow, 'Go the long way around the color wheel', longHueIcon, function() {
22235
+ changeScheme(setSchemeLongHue(scheme, !scheme.longHue));
22236
+ }).addClass('long-hue-btn');
22237
+ continuousBtn = addIconButton(vibranceRow, 'Continuous colors (unclassed)', continuousIcon, function() {
22238
+ changeScheme(setSchemeContinuous(scheme, !scheme.continuous));
22239
+ }).addClass('continuous-btn');
22240
+ reverseBtn = addIconButton(vibranceRow, 'Reverse the colors', reverseIcon, function() {
22241
+ changeScheme(reverseScheme(scheme));
22242
+ });
22243
+ shuffleBtn = addIconButton(vibranceRow, 'Shuffle the colors', shuffleIcon, function() {
22244
+ changeScheme(shuffleScheme(scheme));
22245
+ });
22246
+ // the color of features with no data, and how many there are
22247
+ nullControl = makeColorRow(controlsEl, {
22248
+ label: 'No data',
22249
+ noOpacity: true,
22250
+ onColor: function(color) {
22251
+ changeScheme(setSchemeNullColor(scheme, color));
22252
+ }
22253
+ });
22254
+ nullControl.row.addClass('color-scheme-null-row');
22255
+ nullControl.chit.on('click', function() {
22256
+ picker.hide();
22257
+ });
22258
+ nullCount = El('span').addClass('color-scheme-note color-scheme-null-count').appendTo(nullControl.aside);
22259
+
22260
+ selectionNote = El('div').addClass('label-style-row color-scheme-note').appendTo(controlsEl)
22261
+ .text('Colors apply to every feature in the layer, not only the selected ones.');
22262
+
22263
+ document.addEventListener('mousedown', function(e) {
22264
+ if (paletteMenu.visible() && !paletteMenu.node().contains(e.target) &&
22265
+ !paletteBtn.node().contains(e.target)) {
22266
+ hidePaletteMenu();
22267
+ }
22268
+ });
22269
+ }
22270
+
22271
+ // The pivot, the pivot class and how the classes are split between the
22272
+ // sides
22273
+ function initDivergingControls() {
22274
+ divergingEl = El('div').appendTo(controlsEl);
22275
+ var pivotRow = El('div').addClass('label-style-row label-split-row').appendTo(divergingEl);
22276
+ var pivotCell = El('div').addClass('label-split-cell color-scheme-select-row').appendTo(pivotRow);
22277
+ var valueCell = El('div').addClass('label-split-cell color-scheme-select-row').appendTo(pivotRow);
22278
+ El('span').appendTo(pivotCell).text('Pivot');
22279
+ pivotSelect = El('select').attr('aria-label', 'Pivot').appendTo(pivotCell).on('change', function() {
22280
+ var name = pivotSelect.node().value;
22281
+ var current = scheme.layout ? scheme.layout.pivot : 0;
22282
+ changeScheme(setSchemePivot(scheme, name == 'value' ? current : name));
22283
+ });
22284
+ pivotOptions.forEach(function(item) {
22285
+ El('option').attr('value', item.name).text(item.label).appendTo(pivotSelect);
22286
+ });
22287
+ El('span').appendTo(valueCell).text('Value');
22288
+ pivotInput = El('input').attr('type', 'text').attr('aria-label', 'Pivot value')
22289
+ .addClass('color-scheme-pivot-input').appendTo(valueCell)
22290
+ .on('change', function() {
22291
+ var val = parseFloat(pivotInput.node().value);
22292
+ if (isFinite(val)) {
22293
+ changeScheme(setSchemePivot(scheme, val));
22294
+ } else {
22295
+ render();
22296
+ }
22297
+ })
22298
+ .on('keydown', function(e) {
22299
+ if (e.key == 'Enter') pivotInput.node().blur();
22300
+ });
22301
+ pivotNote = El('div').addClass('label-style-row color-scheme-note').appendTo(divergingEl);
22302
+
22303
+ var optionRow = El('div').addClass('label-style-row label-split-row').appendTo(divergingEl);
22304
+ var splitCell = El('div').addClass('label-split-cell color-scheme-select-row').appendTo(optionRow);
22305
+ var neutralCell = El('div').addClass('label-split-cell color-scheme-select-row').appendTo(optionRow);
22306
+ var neutralLabel = El('span').addClass('color-scheme-hint-label').appendTo(neutralCell).text('Pivot class')
22307
+ .attr('data-tooltip', 'A class around the pivot, in the center color')
22308
+ .on('mouseenter', function() { keepTooltipInWindow(neutralLabel.node()); });
22309
+ var neutralLine = El('div').addClass('color-scheme-toggle-line').appendTo(neutralCell);
22310
+ neutralToggle = makePanelToggle(neutralLine, {
22311
+ title: 'Pivot class',
22312
+ onChange: function(on) {
22313
+ changeScheme(setSchemeNeutral(scheme, on));
22314
+ }
22315
+ });
22316
+ El('span').appendTo(splitCell).text('Sides');
22317
+ splitSelect = El('select').attr('aria-label', 'Classes on each side').appendTo(splitCell).on('change', function() {
22318
+ changeScheme(setSchemeSplit(scheme, splitSelect.node().value));
22319
+ });
22320
+ divergingSplits.forEach(function(item) {
22321
+ El('option').attr('value', item.name).text(item.label).appendTo(splitSelect);
22322
+ });
22323
+ }
22324
+
22325
+ function renderDivergingControls() {
22326
+ var diverging = scheme.type == 'diverging';
22327
+ var pivot = scheme.pivot;
22328
+ var option = typeof pivot == 'number' ? 'value' : pivot;
22329
+ var summary;
22330
+ divergingEl.classed('hidden', !diverging);
22331
+ if (!diverging) return;
22332
+ pivotSelect.node().value = option;
22333
+ pivotInput.node().disabled = option != 'value';
22334
+ if (document.activeElement != pivotInput.node()) {
22335
+ pivotInput.node().value = scheme.layout ? formatNumber(scheme.layout.pivot) : '';
22336
+ }
22337
+ summary = getPivotSummary(scheme, targetLayer);
22338
+ pivotNote.text(summary ? formatCount(summary.below) + ' below the pivot, ' +
22339
+ summary.above + ' at or above' : 'No numeric data to classify.');
22340
+ neutralToggle.setState(getSchemeNeutral(scheme) ? 'on' : 'off');
22341
+ splitSelect.node().value = scheme.split;
22342
+ }
22343
+
22344
+ function formatCount(n) {
22345
+ return n + (n == 1 ? ' feature' : ' features');
22346
+ }
22347
+
22348
+ function formatNumber(val) {
22349
+ return String(+val.toPrecision(6));
22350
+ }
22351
+
22352
+ function render() {
22353
+ var categorical, fields, colors, tiles;
22354
+ if (!targetLayer || !scheme) return;
22355
+ categorical = scheme.type == 'categorical';
22356
+ Object.keys(tabs).forEach(function(type) {
22357
+ tabs[type].classed('selected', type == scheme.type)
22358
+ .attr('aria-selected', type == scheme.type ? 'true' : 'false');
22359
+ });
22360
+ fields = categorical ? getCategoryFields(targetLayer) : getNumericFields(targetLayer);
22361
+ noFieldsNote.classed('hidden', categorical || fields.length > 0);
22362
+ controlsEl.classed('hidden', !categorical && fields.length === 0);
22363
+ if (!categorical && fields.length === 0) return;
22364
+ renderFieldOptions(fields);
22365
+ fieldRow.classed('hidden', scheme.method == 'non-adjacent');
22366
+ renderMethodOptions(fields);
22367
+ renderDivergingControls();
22368
+ countLabel.text(scheme.type != 'diverging' ? 'Colors' : scheme.split == 'count' ? 'Per side' : 'Classes');
22369
+ countField.setValue(String(scheme.n));
22370
+ tiles = getSchemeTiles(scheme);
22371
+ colors = tiles.map(function(tile) { return tile.color; });
22372
+ renderPaletteButton(colors);
22373
+ renderTiles(tiles);
22374
+ renderVibrance();
22375
+ renderNullColor();
22376
+ selectionNote.classed('hidden', !(opts.getSelectionCount() > 0));
22377
+ }
22378
+
22379
+ function renderNullColor() {
22380
+ var count = getNoDataCount(targetLayer, scheme);
22381
+ nullControl.row.classed('hidden', scheme.method == 'non-adjacent');
22382
+ if (scheme.method == 'non-adjacent') nullControl.picker.hide();
22383
+ nullControl.showColor(getSchemeNullColor(scheme));
22384
+ nullCount.text(count === 0 ? 'None in this layer' : formatCount(count));
22385
+ }
22386
+
22387
+ function renderMethodOptions(fields) {
22388
+ methodSelect.empty();
22389
+ getSchemeMethods(scheme).forEach(function(item) {
22390
+ var opt = El('option').attr('value', item.name).text(item.label).appendTo(methodSelect);
22391
+ // classifying by category needs a field
22392
+ if (item.name == 'categorical' && fields.length === 0) opt.attr('disabled', true);
22393
+ });
22394
+ methodSelect.node().value = scheme.method;
22395
+ }
22396
+
22397
+ // Each tab keeps its own scheme while the panel is open, and starts from a
22398
+ // default the first time
22399
+ function switchType(type) {
22400
+ var next;
22401
+ if (type == scheme.type) return;
22402
+ tabSchemes[scheme.type] = scheme;
22403
+ next = tabSchemes[type] || getDefaultSchemeOfType(type, targetLayer);
22404
+ // one no-data color for both tabs
22405
+ next = updateDivergingLayout(Object.assign({}, next, {nullColor: scheme.nullColor}), targetLayer);
22406
+ picker.hide();
22407
+ selectedTile = -1;
22408
+ scheme = next;
22409
+ if (canApply(scheme)) apply();
22410
+ render();
22411
+ }
22412
+
22413
+ // a diverging scheme needs data that its classes can divide
22414
+ function canApply(s) {
22415
+ if (s.type == 'diverging' && !s.layout) return false;
22416
+ return s.method == 'non-adjacent' || !!s.field;
22417
+ }
22418
+
22419
+ // The number of categories in a field (the scheme's, by default), for a
22420
+ // categorical scheme
22421
+ function getCategoryCount(field) {
22422
+ if (scheme.type != 'categorical') return -1;
22423
+ return getCategories(targetLayer, field || scheme.field).length;
22424
+ }
22425
+
22426
+ function addSliderRow(label, tooltip, min, max, step, onInput) {
22427
+ var row = El('div').addClass('label-style-row color-scheme-slider-row').appendTo(controlsEl);
22428
+ var labelEl = El('span').addClass('color-scheme-slider-label').attr('data-tooltip', tooltip)
22429
+ .appendTo(row).text(label)
22430
+ .on('mouseenter', function() { keepTooltipInWindow(labelEl.node()); });
22431
+ var input = El('input').attr('type', 'range').attr('min', min).attr('max', max).attr('step', step)
22432
+ .attr('aria-label', label).appendTo(row)
22433
+ .on('input', function() {
22434
+ showSliderFill(input);
22435
+ onInput(+input.node().value);
22436
+ })
22437
+ .on('change', releaseFocus);
22438
+ return row;
22439
+ }
22440
+
22441
+ function addIconButton(parent, label, icon, onClick) {
22442
+ var btn = El('button').addClass('color-scheme-icon-btn').attr('type', 'button')
22443
+ .attr('aria-label', label).attr('data-tooltip', label).html(icon).appendTo(parent)
22444
+ .on('click', onClick)
22445
+ .on('mouseenter', function() { keepTooltipInWindow(btn.node()); });
22446
+ return btn;
22447
+ }
22448
+
22449
+ // The part of a preset that the tiles are taken from: a strip of the whole
22450
+ // ramp, shaded outside the part in use, with a handle at each end of it
22451
+ function initRangeControl(row) {
22452
+ var label = El('span').addClass('color-scheme-hint-label color-scheme-range-part')
22453
+ .attr('data-tooltip', 'Use part of the color ramp\n(double-click the strip to use all of it)')
22454
+ .appendTo(row).text('Range')
22455
+ .on('mouseenter', function() { keepTooltipInWindow(label.node()); });
22456
+ rangeEl = El('div').addClass('color-scheme-range color-scheme-range-part').appendTo(row)
22457
+ .on('pointerdown', startRangeDrag)
22458
+ .on('dblclick', function() {
22459
+ changeScheme(resetSchemeRange(scheme));
22460
+ });
22461
+ rangeStrip = El('div').addClass('color-scheme-range-strip').appendTo(rangeEl);
22462
+ rangeShades = ['left', 'right'].map(function(side) {
22463
+ return El('div').addClass('color-scheme-range-shade ' + side).appendTo(rangeEl);
22464
+ });
22465
+ rangeHandles = ['left', 'right'].map(function(side) {
22466
+ return El('div').addClass('color-scheme-range-handle').attr('role', 'slider').attr('tabindex', '0')
22467
+ .attr('aria-label', side == 'left' ? 'Start of range' : 'End of range')
22468
+ .attr('aria-valuemin', '0').attr('aria-valuemax', '100')
22469
+ .appendTo(rangeEl)
22470
+ .on('keydown', function(e) {
22471
+ onRangeKey(e, side);
22472
+ });
22473
+ });
22474
+ }
22475
+
22476
+ function renderRange() {
22477
+ var display, colors;
22478
+ if (!hasSchemeRange(scheme)) return;
22479
+ display = getDisplayRange(scheme);
22480
+ colors = getRangeStripColors(scheme, 33);
22481
+ rangeStrip.css('background-image', 'linear-gradient(to right, ' + colors.join(', ') + ')');
22482
+ rangeShades[0].css('width', pct(display[0]));
22483
+ rangeShades[1].css('width', pct(1 - display[1]));
22484
+ rangeHandles.forEach(function(handle, i) {
22485
+ handle.css('left', pct(display[i])).attr('aria-valuenow', String(Math.round(display[i] * 100)));
22486
+ });
22487
+ }
22488
+
22489
+ function pct(val) {
22490
+ return round(val * 100) + '%';
22491
+ }
22492
+
22493
+ // Dragging a handle (or pressing the strip, which takes the nearer handle
22494
+ // there) moves that end of the range
22495
+ function startRangeDrag(e) {
22496
+ var box = rangeStrip.node().getBoundingClientRect();
22497
+ var display = getDisplayRange(scheme);
22498
+ var pos = getPos(e);
22499
+ var side = e.target == rangeHandles[0].node() ? 'left' :
22500
+ e.target == rangeHandles[1].node() ? 'right' :
22501
+ Math.abs(pos - display[0]) <= Math.abs(pos - display[1]) ? 'left' : 'right';
22502
+ if (e.button !== 0 || !(box.width > 0)) return;
22503
+ e.preventDefault();
22504
+ picker.hide();
22505
+ move(e);
22506
+ document.addEventListener('pointermove', move);
22507
+ document.addEventListener('pointerup', finish);
22508
+ document.addEventListener('pointercancel', finish);
22509
+
22510
+ // in steps of half a percent, so that a drag runs fewer commands
22511
+ function getPos(e) {
22512
+ return Math.round((e.clientX - box.left) / box.width * 200) / 200;
22513
+ }
22514
+
22515
+ function move(e) {
22516
+ var next = setSchemeRangeEnd(scheme, side, getPos(e));
22517
+ if (getDisplayRange(next).join() != getDisplayRange(scheme).join()) changeScheme(next);
22518
+ }
22519
+
22520
+ function finish() {
22521
+ document.removeEventListener('pointermove', move);
22522
+ document.removeEventListener('pointerup', finish);
22523
+ document.removeEventListener('pointercancel', finish);
22524
+ }
22525
+ }
22526
+
22527
+ function onRangeKey(e, side) {
22528
+ var step = e.shiftKey ? 0.05 : 0.01;
22529
+ var display = getDisplayRange(scheme);
22530
+ var pos = side == 'left' ? display[0] : display[1];
22531
+ if (e.key == 'ArrowLeft' || e.key == 'ArrowDown') {
22532
+ pos -= step;
22533
+ } else if (e.key == 'ArrowRight' || e.key == 'ArrowUp') {
22534
+ pos += step;
22535
+ } else {
22536
+ return;
22537
+ }
22538
+ e.preventDefault();
22539
+ e.stopPropagation();
22540
+ changeScheme(setSchemeRangeEnd(scheme, side, pos));
22541
+ }
22542
+
22543
+ // Presets and categorical schemes have no interpolated tiles for vibrance
22544
+ // or the hue path to change; the row keeps only the reverse button for them,
22545
+ // and for sequential and diverging presets, the range.
22546
+ function renderVibrance() {
22547
+ var vibrance = getSchemeVibrance(scheme);
22548
+ setSliderValue(vibranceInput, Math.round(vibrance * 1000));
22549
+ vibranceRow.classed('preset', !!scheme.preset || scheme.type == 'categorical');
22550
+ vibranceRow.classed('ranged', hasSchemeRange(scheme));
22551
+ renderRange();
22552
+ reverseBtn.classed('hidden', scheme.type == 'categorical');
22553
+ shuffleBtn.classed('hidden', scheme.type != 'categorical');
22554
+ // each half of a diverging ramp runs from a low-chroma center, where the
22555
+ // hue path makes little difference
22556
+ longHueBtn.classed('hidden', scheme.type == 'diverging')
22557
+ .classed('selected', !!scheme.longHue)
22558
+ .attr('aria-pressed', scheme.longHue ? 'true' : 'false');
22559
+ continuousBtn.classed('hidden', scheme.type == 'categorical')
22560
+ .classed('selected', isContinuousScheme(scheme))
22561
+ .attr('aria-pressed', isContinuousScheme(scheme) ? 'true' : 'false');
22562
+ }
22563
+
22564
+ function setSliderValue(input, val) {
22565
+ if (input.node().value != val) input.node().value = val;
22566
+ showSliderFill(input);
22567
+ }
22568
+
22569
+ // the track is filled up to the center of the handle, which travels the
22570
+ // track's width less its 12px box (see page.css)
22571
+ function showSliderFill(input) {
22572
+ var el = input.node();
22573
+ var min = +el.min, max = +el.max;
22574
+ var fill = max > min ? (el.value - min) / (max - min) : 0;
22575
+ el.style.setProperty('--fill', 'calc(6px + (100% - 12px) * ' + round(fill * 1000) / 1000 + ')');
22576
+ }
22577
+
22578
+ function renderFieldOptions(fields) {
22579
+ fieldSelect.empty();
22580
+ fields.forEach(function(field) {
22581
+ El('option').attr('value', field).text(field).appendTo(fieldSelect);
22582
+ });
22583
+ fieldSelect.node().value = scheme.field || '';
22584
+ }
22585
+
22586
+ function renderPaletteButton(colors) {
22587
+ paletteBtn.empty();
22588
+ El('div').addClass('color-scheme-strip').appendTo(paletteBtn)
22589
+ .css('background-image', isContinuousScheme(scheme) ?
22590
+ getGradientStripBackground(scheme) : getStripBackground(colors));
22591
+ El('span').addClass('color-scheme-palette-name').appendTo(paletteBtn)
22592
+ .text(scheme.preset || 'Custom');
22593
+ }
22594
+
22595
+ function renderTiles(tiles) {
22596
+ var categorical = scheme.type == 'categorical';
22597
+ var diverging = scheme.type == 'diverging';
22598
+ var continuous = isContinuousScheme(scheme);
22599
+ var used = tiles.length;
22600
+ var tileUse = diverging ? getDivergingTileUse(scheme) : null;
22601
+ var center = getCenterTile(scheme);
22602
+ var classLabels = categorical ? null : continuous ? getStopLabels() : getClassLabels(tileUse);
22603
+ var groups = categorical && scheme.method == 'categorical' ?
22604
+ getSwatchCategories(getCategories(targetLayer, scheme.field), used) : null;
22605
+ // the end tiles of a sequential preset can be taken away
22606
+ var removable = canRemoveEndTile(scheme);
22607
+ var n, perRow;
22608
+ // a categorical scheme shows its whole palette, and marks the swatches
22609
+ // in use
22610
+ if (categorical) {
22611
+ tiles = getCategoricalPalette(scheme).map(function(color) {
22612
+ return {color: color, pinned: false, adjusted: false};
22613
+ });
22614
+ }
22615
+ n = tiles.length;
22616
+ // the stops of a continuous ramp stay in one row, under the gradient
22617
+ perRow = n > 12 && !continuous ? Math.ceil(n / 2) : n;
22618
+ tilesEl.empty();
22619
+ tilesEl.classed('two-rows', perRow < n);
22620
+ tilesEl.classed('continuous', continuous);
22621
+ tilesEl.node().style.setProperty('--tiles-per-row', perRow);
22622
+ dropMarker = El('div').addClass('color-scheme-drop-marker').appendTo(tilesEl).hide();
22623
+ tiles.forEach(function(tile, i) {
22624
+ var color = tile.color;
22625
+ var inUse = tileUse ? tileUse[i] : !categorical || i < used;
22626
+ var cell = El('div').addClass('color-scheme-tile-cell').appendTo(tilesEl);
22627
+ var tileEl = El('div').addClass('color-scheme-tile').attr('role', 'button')
22628
+ .attr('aria-label', 'Color ' + (i + 1) + ': ' + color + (inUse ? '' : ' (not used)'))
22629
+ .classed('selected', i == selectedTile)
22630
+ .css('background-color', color)
22631
+ .appendTo(cell)
22632
+ .on('click', function() {
22633
+ if (tileDrag && tileDrag.moved) return;
22634
+ selectTile(i, color);
22635
+ });
22636
+ if (categorical) {
22637
+ tileEl.on('pointerdown', function(e) {
22638
+ startTileDrag(e, i);
22639
+ });
22640
+ }
22641
+ if (removable && (i === 0 || i == n - 1)) {
22642
+ El('button').addClass('color-scheme-tile-remove').attr('type', 'button')
22643
+ .attr('aria-label', 'Remove this color').text('×').appendTo(tileEl)
22644
+ .on('click', function(e) {
22645
+ e.stopPropagation();
22646
+ changeScheme(removeEndTile(scheme, i === 0 ? 'left' : 'right'));
22647
+ });
22648
+ }
22649
+ if (groups && inUse || classLabels && classLabels[i]) {
22650
+ tileEl.attr('data-tooltip', groups ? formatCategories(groups[i]) : classLabels[i])
22651
+ .on('mouseenter', function() { keepTooltipInWindow(tileEl.node()); });
22652
+ }
22653
+ if (diverging && !continuous) {
22654
+ // the bar under the tiles that classes use; a gap at the center
22655
+ // when there is no pivot class (continuous ramps have the gradient)
22656
+ El('div').addClass('color-scheme-bar').appendTo(cell)
22657
+ .classed('used', inUse)
22658
+ .classed('joined', inUse && i < n - 1 && tileUse[i + 1]);
22659
+ }
22660
+ var pin = El('div').addClass('color-scheme-pin').appendTo(cell)
22661
+ .on('mouseenter', function() { keepTooltipInWindow(pin.node()); });
22662
+ var end = i === 0 || i == n - 1 || i == center;
22663
+ if (categorical) {
22664
+ // a bar under the swatches in use, unbroken to the end of each row
22665
+ pin.classed('used', inUse)
22666
+ .classed('joined', inUse && i < used - 1 && (i + 1) % perRow > 0);
22667
+ // a pinned tile between the ends needs its dot for unpinning, even if
22668
+ // vibrance was clipped
22669
+ } else if (tile.pinned && !end) {
22670
+ pin.addClass('pinned').attr('role', 'button').attr('aria-label', 'Unpin this color')
22671
+ .attr('data-tooltip', 'Unpin this color')
22672
+ .on('click', function() {
22673
+ changeScheme(clearTileColor(scheme, i));
22674
+ });
22675
+ } else if (tile.adjusted) {
22676
+ pin.addClass('adjusted').attr('data-tooltip', describeAdjustment(tile));
22677
+ }
22678
+ });
22679
+ renderGradient(continuous ? getContinuousSegments(scheme) : null, n);
22680
+ }
22681
+
22682
+ // The gradient over a continuous ramp's tiles, each stop over the center
22683
+ // of its tile; one bar per side of a diverging ramp, and one for a pivot
22684
+ // class
22685
+ function renderGradient(segments, n) {
22686
+ var width = tilesEl.node().clientWidth;
22687
+ var gap = 3;
22688
+ var tileWidth = (width - (n - 1) * gap) / n;
22689
+ var center = function(pos) { return pos * (tileWidth + gap) + tileWidth / 2; };
22690
+ gradientEl.empty();
22691
+ gradientEl.classed('hidden', !segments);
22692
+ if (!segments || !(width > 0)) return;
22693
+ segments.forEach(function(seg) {
22694
+ var left = seg.flatStart ? center(seg.start) - tileWidth / 2 : center(seg.start);
22695
+ var right = seg.flatEnd ? center(seg.end) + tileWidth / 2 : center(seg.end);
22696
+ var stops = seg.samples.map(function(sample) {
22697
+ return sample.color + ' ' + round(center(sample.pos) - left) + 'px';
22698
+ });
22699
+ if (stops.length == 1) stops.push(stops[0]);
22700
+ El('div').addClass('color-scheme-gradient-segment').appendTo(gradientEl).css({
22701
+ left: round(left) + 'px',
22702
+ width: round(right - left) + 'px',
22703
+ 'background-image': 'linear-gradient(to right, ' + stops.join(', ') + ')'
22704
+ });
22705
+ });
22706
+ }
22707
+
22708
+ function round(val) {
22709
+ return Math.round(val * 10) / 10;
22710
+ }
22711
+
22712
+ // The data value at each stop of a continuous ramp, and the range of a
22713
+ // pivot class
22714
+ function getStopLabels() {
22715
+ var stops = getContinuousTileStops(scheme, targetLayer) || [];
22716
+ return stops.map(function(stop) {
22717
+ if (!stop) return null;
22718
+ if (stop.range) return formatClassRange(stop.range);
22719
+ return formatNumber(stop.value) + (stop.min ? ' (min)' : stop.max ? ' (max)' : '');
22720
+ });
22721
+ }
22722
+
22723
+ // The data range of each tile's class, for the tiles that classes use
22724
+ // (sequential tiles are all used, one class each)
22725
+ function getClassLabels(tileUse) {
22726
+ var ranges, j = 0;
22727
+ if (scheme.type == 'sequential') {
22728
+ ranges = getSequentialClassRanges(scheme, targetLayer) || [];
22729
+ return ranges.map(formatClassRange);
22730
+ }
22731
+ ranges = getDivergingClassRanges(scheme.layout);
22732
+ return tileUse.map(function(used) {
22733
+ return used ? formatClassRange(ranges[j++]) : null;
22734
+ });
22735
+ }
22736
+
22737
+ function formatClassRange(range) {
22738
+ if (!range) return null;
22739
+ if (range[0] == -Infinity && range[1] == Infinity) return 'All values';
22740
+ if (range[0] == -Infinity) return 'Below ' + formatNumber(range[1]);
22741
+ if (range[1] == Infinity) return formatNumber(range[0]) + ' and above';
22742
+ return formatNumber(range[0]) + ' to ' + formatNumber(range[1]);
22743
+ }
22744
+
22745
+ // Dragging a categorical swatch moves it to where it's dropped, which may
22746
+ // be into or out of the swatches in use
22747
+ function startTileDrag(e, from) {
22748
+ var x0 = e.clientX, y0 = e.clientY;
22749
+ var drag = tileDrag = {from: from, moved: false, before: -1};
22750
+ if (e.button !== 0) return;
22751
+ document.addEventListener('pointermove', onMove);
22752
+ document.addEventListener('pointerup', onUp);
22753
+ document.addEventListener('pointercancel', onCancel);
22754
+
22755
+ function onMove(e) {
22756
+ if (!drag.moved && Math.abs(e.clientX - x0) + Math.abs(e.clientY - y0) < 5) return;
22757
+ if (!drag.moved) {
22758
+ drag.moved = true;
22759
+ tilesEl.addClass('dragging');
22760
+ getTileCells()[from].classList.add('drag-source');
22761
+ }
22762
+ drag.before = findDropPlace(e.clientX, e.clientY);
22763
+ showDropMarker(drag.before);
22764
+ }
22765
+
22766
+ function onUp() {
22767
+ var to = drag.before > from ? drag.before - 1 : drag.before;
22768
+ finish();
22769
+ if (drag.moved && drag.before > -1 && to != from) {
22770
+ picker.hide();
22771
+ selectedTile = -1;
22772
+ changeScheme(moveSwatch(scheme, from, to));
22773
+ }
22774
+ // the click that follows a drag is ignored, then dragging is over
22775
+ setTimeout(function() {
22776
+ if (tileDrag == drag) tileDrag = null;
22777
+ }, 0);
22778
+ }
22779
+
22780
+ function onCancel() {
22781
+ finish();
22782
+ tileDrag = null;
22783
+ }
22784
+
22785
+ function finish() {
22786
+ document.removeEventListener('pointermove', onMove);
22787
+ document.removeEventListener('pointerup', onUp);
22788
+ document.removeEventListener('pointercancel', onCancel);
22789
+ tilesEl.removeClass('dragging');
22790
+ getTileCells().forEach(function(cell) { cell.classList.remove('drag-source'); });
22791
+ dropMarker.hide();
22792
+ }
22793
+ }
22794
+
22795
+ function getTileCells() {
22796
+ return Array.from(tilesEl.node().querySelectorAll('.color-scheme-tile-cell'));
22797
+ }
22798
+
22799
+ // The index of the tile that a swatch dropped at (x, y) goes in front of
22800
+ // (the number of tiles, after the last), on the row nearest the pointer
22801
+ function findDropPlace(x, y) {
22802
+ var rects = getTileCells().map(function(cell) {
22803
+ return cell.querySelector('.color-scheme-tile').getBoundingClientRect();
22804
+ });
22805
+ var best = -1, bestDist = Infinity;
22806
+ rects.forEach(function(rect, i) {
22807
+ var dy = y < rect.top ? rect.top - y : y > rect.bottom ? y - rect.bottom : 0;
22808
+ var dx = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0;
22809
+ var dist = dy * 1000 + dx;
22810
+ if (dist < bestDist) {
22811
+ bestDist = dist;
22812
+ best = x < (rect.left + rect.right) / 2 ? i : i + 1;
22813
+ }
22814
+ });
22815
+ return best;
22816
+ }
22817
+
22818
+ // A line in the gap where the swatch would go
22819
+ function showDropMarker(before) {
22820
+ var cells = getTileCells();
22821
+ var box = tilesEl.node().getBoundingClientRect();
22822
+ var ref = cells[Math.min(before, cells.length - 1)].querySelector('.color-scheme-tile');
22823
+ var rect = ref.getBoundingClientRect();
22824
+ var left = before < cells.length ? rect.left - 2 : rect.right + 1;
22825
+ dropMarker.css({
22826
+ left: (left - box.left) + 'px',
22827
+ top: (rect.top - box.top) + 'px',
22828
+ height: rect.height + 'px'
22829
+ }).show();
22830
+ }
22831
+
22832
+ // A tooltip is centered under its pin or button, unless that would run it past the
22833
+ // edge of the window
22834
+ function keepTooltipInWindow(el) {
22835
+ var margin = 4;
22836
+ el.style.removeProperty('--tooltip-shift');
22837
+ if (!el.hasAttribute('data-tooltip')) return;
22838
+ var width = parseFloat(getComputedStyle(el, '::after').width) || 0;
22839
+ var rect = el.getBoundingClientRect();
22840
+ var center = rect.left + rect.width / 2;
22841
+ var left = center - width / 2;
22842
+ var right = center + width / 2;
22843
+ var maxRight = document.documentElement.clientWidth - margin;
22844
+ var shift = 0;
22845
+ if (right > maxRight) shift = maxRight - right;
22846
+ else if (left < margin) shift = margin - left;
22847
+ if (shift) el.style.setProperty('--tooltip-shift', shift + 'px');
22848
+ }
22849
+
22850
+ function formatCategories(values) {
22851
+ // "and 1 more" would take a line that the value itself can have
22852
+ var max = values.length == 5 ? 5 : 4;
22853
+ var labels = values.slice(0, max).map(function(val) {
22854
+ return val === '' || val === null || val === undefined ? '(no value)' : String(val);
22855
+ });
22856
+ if (values.length > max) labels.push('and ' + (values.length - max) + ' more');
22857
+ return labels.join('\n');
22858
+ }
22859
+
22860
+ function describeAdjustment(tile) {
22861
+ var parts = [];
22862
+ var dl = tile.l - tile.ideal.l;
22863
+ var dc = tile.c - tile.ideal.c;
22864
+ if (Math.abs(dl) >= 0.0005) parts.push('lightness ' + formatShift(dl));
22865
+ if (Math.abs(dc) >= 0.0005) parts.push('chroma ' + formatShift(dc));
22866
+ return 'Shifted to fit the sRGB gamut:\n' + parts.join(', ');
22867
+ }
22868
+
22869
+ function formatShift(val) {
22870
+ return (val > 0 ? '+' : '') + val.toFixed(3);
22871
+ }
22872
+
22873
+ function selectTile(i, color) {
22874
+ nullControl.picker.hide();
22875
+ if (selectedTile == i && picker.visible()) {
22876
+ picker.hide();
22877
+ } else {
22878
+ selectedTile = i;
22879
+ picker.setColor(getTileEditColor(scheme, i) || color);
22880
+ if (!picker.visible()) picker.toggle();
22881
+ }
22882
+ render();
22883
+ }
22884
+
22885
+ function togglePaletteMenu() {
22886
+ if (paletteMenu.visible()) {
22887
+ hidePaletteMenu();
22888
+ } else {
22889
+ renderPaletteMenu();
22890
+ paletteMenu.show();
22891
+ }
22892
+ }
22893
+
22894
+ function hidePaletteMenu() {
22895
+ paletteMenu.hide();
22896
+ }
22897
+
22898
+ function renderPaletteMenu() {
22899
+ var categorical = scheme.type == 'categorical';
22900
+ paletteMenu.empty();
22901
+ addPaletteItem('Custom', getSchemeColors(makeSchemeCustom(scheme)), !scheme.preset, function() {
22902
+ changeScheme(makeSchemeCustom(scheme));
22903
+ });
22904
+ getPresetGroups(scheme.type).forEach(function(group) {
22905
+ El('div').addClass('color-scheme-palette-heading').appendTo(paletteMenu).text(group.source);
22906
+ group.names.forEach(function(name) {
22907
+ var colors = categorical ? getCategoricalPresetColors(name) : getPresetColors(name, 7);
22908
+ addPaletteItem(name, colors, scheme.preset == name, function() {
22909
+ changeScheme(choosePreset(scheme, name, getCategoryCount()));
22910
+ });
22911
+ });
22912
+ });
22913
+ }
22914
+
22915
+ function addPaletteItem(name, colors, selected, onSelect) {
22916
+ var item = El('div').addClass('color-scheme-palette-item').attr('role', 'button')
22917
+ .classed('selected', selected).appendTo(paletteMenu)
22918
+ .on('click', function() {
22919
+ hidePaletteMenu();
22920
+ onSelect();
22921
+ });
22922
+ El('div').addClass('color-scheme-strip').appendTo(item)
22923
+ .css('background-image', getStripBackground(colors));
22924
+ El('span').appendTo(item).text(name);
22925
+ }
22926
+
22927
+ function changeScheme(next) {
22928
+ var prevSize = getSchemeTiles(scheme).length;
22929
+ scheme = updateDivergingLayout(next, targetLayer);
22930
+ if (getSchemeTiles(scheme).length != prevSize) {
22931
+ picker.hide();
22932
+ selectedTile = -1;
22933
+ }
22934
+ apply();
22935
+ render();
22936
+ }
22937
+
22938
+ function apply() {
22939
+ var lyr = targetLayer;
22940
+ var applying = scheme;
22941
+ var colors = getAppliedColors(scheme, getCategoryCount());
22942
+ var cmd, extra;
22943
+ if (!lyr || !canApply(scheme) || !gui.console) return;
22944
+ getSession();
22945
+ if (!session || !session.isPending()) {
22946
+ sessionLayer = lyr;
22947
+ baseScheme = getLayerScheme(lyr);
22948
+ }
22949
+ cmd = formatSchemeCommand(scheme, colors, {target: getTargetOption(lyr)});
22950
+ extra = opts.getExtraCommands ? opts.getExtraCommands() : '';
22951
+ if (extra) cmd += ' ' + extra;
22952
+ runGuiEditCommand(gui, cmd, {
22953
+ title: 'Color scheme',
22954
+ session: session,
22955
+ onDone: function(err) {
22956
+ if (!err) setLayerScheme(lyr, getAppliedScheme(applying, colors, lyr));
22957
+ opts.onUpdate();
22958
+ }
22959
+ });
22960
+ }
22961
+
22962
+ // The scheme on each side of the session's undo state, to go with the fills
22963
+ // that undo and redo put back.
22964
+ function onCommit() {
22965
+ var lyr = sessionLayer;
22966
+ var before = baseScheme;
22967
+ var after = lyr ? getLayerScheme(lyr) : null;
22968
+ if (!lyr) return null;
22969
+ return {
22970
+ onUndo: function() {
22971
+ setLayerScheme(lyr, before);
22972
+ },
22973
+ onRedo: function() {
22974
+ setLayerScheme(lyr, after);
22975
+ }
22976
+ };
22977
+ }
22978
+
22979
+ function getTargetOption(lyr) {
22980
+ var active = gui.model.getActiveLayer();
22981
+ if (active && active.layer == lyr) return '';
22982
+ return internal.formatOptionValue(internal.getLayerTargetId(gui.model, lyr));
22983
+ }
22984
+
22985
+ function releaseFocus() {
22986
+ releasePanelFocus(panel.node());
22987
+ }
22988
+ }
22989
+
22990
+ // A scheme's colors as a CSS background: blocks, or for a continuous scheme,
22991
+ // its gradients, side by side
22992
+ function getSchemeStripBackground(scheme) {
22993
+ return isContinuousScheme(scheme) ? getGradientStripBackground(scheme) : getStripBackground(scheme.colors);
22994
+ }
22995
+
22996
+ // Each gradient takes room for its length, from its first stop to its last
22997
+ // (see getContinuousSegments()), without the flat ends the panel's bar has
22998
+ // over its end tiles; a pivot class takes a tile's width. Hard edges between
22999
+ // them.
23000
+ function getGradientStripBackground(scheme) {
23001
+ var segments = getContinuousSegments(scheme);
23002
+ var lengths = segments.map(function(seg) {
23003
+ return seg.end > seg.start ? seg.end - seg.start : 1;
23004
+ });
23005
+ var total = lengths.reduce(function(memo, len) { return memo + len; }, 0);
23006
+ var offset = 0;
23007
+ var stops = [];
23008
+ var pct = function(pos) { return (Math.round(pos / total * 10000) / 100) + '%'; };
23009
+ if (!(total > 0)) return 'none';
23010
+ segments.forEach(function(seg, i) {
23011
+ var first = seg.samples[0].color;
23012
+ var last = seg.samples[seg.samples.length - 1].color;
23013
+ stops.push(first + ' ' + pct(offset));
23014
+ seg.samples.forEach(function(sample) {
23015
+ stops.push(sample.color + ' ' + pct(offset + sample.pos - seg.start));
23016
+ });
23017
+ offset += lengths[i];
23018
+ stops.push(last + ' ' + pct(offset));
23019
+ });
23020
+ return 'linear-gradient(to right, ' + stops.join(', ') + ')';
23021
+ }
23022
+
23023
+ // A row of hard-edged color blocks, as a CSS background
23024
+ function getStripBackground(colors) {
23025
+ var n = colors.length;
23026
+ var stops = colors.map(function(color, i) {
23027
+ return color + ' ' + (i / n * 100) + '%, ' + color + ' ' + ((i + 1) / n * 100) + '%';
23028
+ });
23029
+ return 'linear-gradient(to right, ' + stops.join(', ') + ')';
23030
+ }
23031
+
20684
23032
  var savedStylesKey = 'layer_style_presets';
20685
- var styleFields = ['stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'line-start', 'line-end', 'line-end-size', 'fill', 'fill-opacity', 'fill-pattern'].concat(internal.svg.glowFields);
23033
+ var styleFields = ['stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'stroke-linecap', 'line-start', 'line-end', 'line-end-size', 'line-fade', 'fill', 'fill-opacity', 'fill-pattern'].concat(internal.svg.glowFields);
20686
23034
  var arrowShapes = [{
20687
23035
  name: 'arrow',
20688
23036
  title: 'solid arrowheads'
@@ -20713,14 +23061,28 @@
20713
23061
  label: 'Both',
20714
23062
  title: 'arrowheads at both ends of the line'
20715
23063
  }];
23064
+ var lineCaps = [{
23065
+ name: 'round',
23066
+ label: 'Round',
23067
+ title: 'round line caps'
23068
+ }, {
23069
+ name: 'butt',
23070
+ label: 'Butt',
23071
+ title: 'flat line caps that end at the line\'s endpoints'
23072
+ }, {
23073
+ name: 'square',
23074
+ label: 'Square',
23075
+ title: 'flat line caps that extend past the line\'s endpoints'
23076
+ }];
20716
23077
 
20717
23078
  function LayerStyleTool(gui) {
20718
23079
  var parent = gui.container.findChild('.mshp-main-map');
20719
23080
  var panel = El('div').addClass('label-style-panel layer-style-panel rollover').appendTo(parent).hide();
20720
- var title, editingStatus, clearLink, strokeControl, fillControl, strokeWidthField, dashControl, arrowControl, randomFillBtn, presetControl, patternControl, glowControl, hit;
23081
+ var title, editingStatus, clearLink, strokeControl, fillControl, strokeWidthField, capControl, dashControl, arrowControl, presetControl, patternControl, glowControl, hit;
23082
+ var schemeBtn, schemeStrip, schemePanel;
20721
23083
  var targetLayer = null;
20722
23084
  // What the arrowhead switch turns on, for lines that have no heads
20723
- var lastArrow = {shape: 'arrow', position: 'end'};
23085
+ var lastArrow = {shape: 'arrow', position: 'end', fade: 0};
20724
23086
 
20725
23087
  initPanel();
20726
23088
  hit = gui.map.getHitControl && gui.map.getHitControl();
@@ -20771,6 +23133,7 @@
20771
23133
  }
20772
23134
 
20773
23135
  function turnOff() {
23136
+ schemePanel.close();
20774
23137
  panel.hide();
20775
23138
  strokeControl.picker.hide();
20776
23139
  fillControl.picker.hide();
@@ -20809,9 +23172,11 @@
20809
23172
  strokeControl = addColorControl(panel, 'Stroke', 'stroke', '#000000');
20810
23173
  strokeWidthField = addStrokeWidthControl(strokeControl.aside);
20811
23174
  fillControl = addColorControl(panel, 'Fill', 'fill', '');
20812
- randomFillBtn = makePanelActionButton(fillControl.aside, 'Random fill', applyRandomFillColors)
20813
- .addClass('layer-random-fill-btn');
20814
- dashControl = addDashArrayControl(panel);
23175
+ addColorSchemeControl();
23176
+ var lineRow = El('div').addClass('label-style-row label-split-row layer-line-row').appendTo(panel);
23177
+ capControl = addLineCapControl(lineRow);
23178
+ dashControl = addDashArrayControl(lineRow);
23179
+ dashControl.row = lineRow;
20815
23180
  arrowControl = addArrowControl(panel);
20816
23181
 
20817
23182
  var buttonRow = El('div').addClass('label-style-row label-panel-button-row').appendTo(panel);
@@ -20870,6 +23235,74 @@
20870
23235
  return control;
20871
23236
  }
20872
23237
 
23238
+ // Fills colored by a data field. While a scheme is applied, its colors take
23239
+ // the place of the fill's swatch and hex value, which have no one value to
23240
+ // show; the opacity still applies. Clicking them reopens the scheme.
23241
+ function addColorSchemeControl() {
23242
+ var box = fillControl.input.parent();
23243
+ schemeStrip = El('div').addClass('layer-scheme-strip').attr('role', 'button')
23244
+ .attr('aria-label', 'Edit color scheme');
23245
+ box.node().insertBefore(schemeStrip.node(), fillControl.input.node());
23246
+ schemeStrip.on('click', openColorSchemePanel);
23247
+ El('div').addClass('layer-scheme-colors').appendTo(schemeStrip);
23248
+ El('div').addClass('layer-scheme-remove').attr('role', 'button')
23249
+ .attr('aria-label', 'Remove color scheme').attr('title', 'Remove color scheme')
23250
+ .text('×').appendTo(schemeStrip)
23251
+ .on('click', function(e) {
23252
+ e.stopPropagation();
23253
+ removeColorScheme();
23254
+ });
23255
+ schemeBtn = makePanelActionButton(fillControl.aside, 'Palettes', openColorSchemePanel)
23256
+ .addClass('layer-palettes-btn');
23257
+ schemePanel = new ColorSchemePanel(gui, {
23258
+ getExtraCommands: function() {
23259
+ if (!targetLayer) return '';
23260
+ return formatStyleEditCommands(patternControl.getRefillExpressionEdits(getAllFeatureIds(targetLayer)));
23261
+ },
23262
+ getSelectionCount: function() {
23263
+ return getSelectionIds().length;
23264
+ },
23265
+ onUpdate: function() {
23266
+ if (panel.visible()) updateControls();
23267
+ },
23268
+ onClose: function() {
23269
+ panel.removeClass('replaced');
23270
+ }
23271
+ });
23272
+ }
23273
+
23274
+ function openColorSchemePanel() {
23275
+ syncTargetLayer();
23276
+ if (!targetLayer || targetLayer.geometry_type != 'polygon') return;
23277
+ fillControl.picker.hide();
23278
+ strokeControl.picker.hide();
23279
+ patternControl.hidePicker();
23280
+ // The scheme panel takes this panel's place until it closes
23281
+ panel.addClass('replaced');
23282
+ schemePanel.open(targetLayer);
23283
+ }
23284
+
23285
+ // Back to a single fill, unset, as when the fill field is emptied
23286
+ function removeColorScheme() {
23287
+ schemePanel.close();
23288
+ applyColorControlStyle(fillControl, '');
23289
+ }
23290
+
23291
+ function updateColorSchemeControl() {
23292
+ var isPolygon = targetLayer && targetLayer.geometry_type == 'polygon';
23293
+ var scheme = isPolygon ? getLayerScheme(targetLayer) : null;
23294
+ schemeBtn.classed('hidden', !isPolygon);
23295
+ fillControl.input.parent().classed('has-scheme', !!scheme);
23296
+ schemeBtn.classed('selected', schemePanel.isOpen());
23297
+ if (scheme) {
23298
+ schemeStrip.findChild('.layer-scheme-colors')
23299
+ .css('background-image', getSchemeStripBackground(scheme));
23300
+ schemeStrip.attr('title', scheme.method == 'non-adjacent' ?
23301
+ 'Neighbors colored differently' : 'Colored by ' + scheme.field);
23302
+ fillControl.picker.hide();
23303
+ }
23304
+ }
23305
+
20873
23306
  // In the narrow column beside the stroke's colour. Stepping runs up a ladder
20874
23307
  // of widths rather than by a fixed amount, because the useful ones are close
20875
23308
  // together at the hairline end and far apart above 2px.
@@ -20889,12 +23322,60 @@
20889
23322
  });
20890
23323
  }
20891
23324
 
20892
- // A literal stroke-dasharray, in the wide column so that it lines up with
20893
- // the stroke's colour above it. A blank field makes the line solid again.
20894
- function addDashArrayControl(parent) {
20895
- var row = El('div').addClass('label-style-row label-split-row layer-dash-row').appendTo(parent);
20896
- var cell = El('div').addClass('label-split-cell').appendTo(row);
20897
- El('div').addClass('label-split-cell').appendTo(row);
23325
+ // Round is the default, except on a dashed line, which the renderers give
23326
+ // butt caps unless it has a cap of its own.
23327
+ function addLineCapControl(row) {
23328
+ var cell = El('div').addClass('label-split-cell label-align-row').appendTo(row);
23329
+ var control = {btns: {}};
23330
+ El('span').appendTo(cell).text('Caps');
23331
+ var group = El('div').addClass('label-btn-group layer-cap-buttons').appendTo(cell);
23332
+ lineCaps.forEach(function(item) {
23333
+ control.btns[item.name] = makePanelButton(group, item.label, function() {
23334
+ applyLineCap(item.name);
23335
+ }).attr('data-line-cap', item.name).attr('aria-label', item.title);
23336
+ });
23337
+ return control;
23338
+ }
23339
+
23340
+ function getDefaultLineCap(rec) {
23341
+ return rec && rec['stroke-dasharray'] ? 'butt' : 'round';
23342
+ }
23343
+
23344
+ function getLineCap(rec) {
23345
+ return rec && rec['stroke-linecap'] || getDefaultLineCap(rec);
23346
+ }
23347
+
23348
+ // A cap that is the line's default is unset rather than stored, so that a
23349
+ // layer left at Round gets no stroke-linecap column.
23350
+ function applyLineCap(cap) {
23351
+ var records = getTargetRecords();
23352
+ var edits = [];
23353
+ getTargetIds().forEach(function(id) {
23354
+ var rec = records[id] || {};
23355
+ var value = cap == getDefaultLineCap(rec) ? '' : cap;
23356
+ if (value != (rec['stroke-linecap'] || '')) {
23357
+ edits.push({id: id, styles: [['stroke-linecap', value]]});
23358
+ }
23359
+ });
23360
+ runStyleEdits(edits);
23361
+ }
23362
+
23363
+ function updateLineCapControl() {
23364
+ var records = getTargetRecords();
23365
+ var cap;
23366
+ getTargetIds().forEach(function(id, i) {
23367
+ var val = getLineCap(records[id]);
23368
+ cap = i === 0 || val == cap ? val : '';
23369
+ });
23370
+ lineCaps.forEach(function(item) {
23371
+ capControl.btns[item.name].classed('selected', item.name == cap);
23372
+ });
23373
+ }
23374
+
23375
+ // A literal stroke-dasharray, in the narrow column beside the caps. A blank
23376
+ // field makes the line solid again.
23377
+ function addDashArrayControl(row) {
23378
+ var cell = El('div').addClass('label-split-cell layer-dash-cell').appendTo(row);
20898
23379
  var caption = El('div').addClass('label-style-row-label').appendTo(cell).text('Dashes');
20899
23380
  makeFieldTip(caption,
20900
23381
  'Dash and gap lengths in pixels, separated\n' +
@@ -20909,13 +23390,14 @@
20909
23390
  }
20910
23391
  applyDashArrayStyle(value);
20911
23392
  });
20912
- return {row: row, input: input};
23393
+ return {input: input};
20913
23394
  }
20914
23395
 
20915
23396
  // A switch in the heading says whether the lines have arrowheads; the rows
20916
23397
  // under it are the head's shape with its size beside it, then which ends
20917
- // get it. A line has one shape for both ends, which is all the panel sets,
20918
- // though -style can give the two ends different ones.
23398
+ // get it with the line's fade beside that. A line has one shape for both
23399
+ // ends, which is all the panel sets, though -style can give the two ends
23400
+ // different ones.
20919
23401
  function addArrowControl(parent) {
20920
23402
  var section = makePanelSection(parent, 'Arrowheads');
20921
23403
  var toggle = makePanelToggle(section.findChild('.label-style-section-title'), {
@@ -20928,8 +23410,8 @@
20928
23410
  var sizeCell = El('div').addClass('label-split-cell label-spacing-row layer-arrow-size-row').appendTo(shapeRow);
20929
23411
  var posRow = El('div').addClass('label-style-row label-split-row').appendTo(section);
20930
23412
  var posCell = El('div').addClass('label-split-cell label-align-row').appendTo(posRow);
23413
+ var fadeCell = El('div').addClass('label-split-cell layer-arrow-fade-cell').appendTo(posRow);
20931
23414
  var control = {section: section, toggle: toggle, shapeBtns: {}, posBtns: {}};
20932
- El('div').addClass('label-split-cell').appendTo(posRow);
20933
23415
  El('span').appendTo(shapeCell).text('Shape');
20934
23416
  var shapeGroup = El('div').addClass('label-btn-group label-callout-buttons layer-arrow-shape-buttons').appendTo(shapeCell);
20935
23417
  arrowShapes.forEach(function(item) {
@@ -20958,6 +23440,22 @@
20958
23440
  onStep: nudgeArrowSize,
20959
23441
  onDone: releaseFocus
20960
23442
  });
23443
+ var fadeCaption = El('div').addClass('label-style-row-label').appendTo(fadeCell).text('Fade %');
23444
+ makeFieldTip(fadeCaption,
23445
+ 'How much of the line fades in from its\n' +
23446
+ 'tail, as a percent of its length. The fade\n' +
23447
+ 'is a straight gradient, so it follows\n' +
23448
+ 'straight and gently curved lines best.');
23449
+ control.fadeField = new SizeField(fadeCell, {
23450
+ title: 'Percent of the line that fades in from its tail',
23451
+ min: 0,
23452
+ max: 100,
23453
+ step: 5,
23454
+ bigStep: 25,
23455
+ onSet: applyArrowFade,
23456
+ onStep: nudgeArrowFade,
23457
+ onDone: releaseFocus
23458
+ });
20961
23459
  return control;
20962
23460
  }
20963
23461
 
@@ -20972,10 +23470,11 @@
20972
23470
  return {shape: shape, position: position};
20973
23471
  }
20974
23472
 
20975
- // Switching on gives the lines that have no heads the last shape and ends
20976
- // the section showed, so that off and on again is a round trip; the lines
20977
- // that have heads keep theirs. Switching off leaves line-end-size alone,
20978
- // for the same reason.
23473
+ // Switching on gives the lines that have no heads the last shape, ends and
23474
+ // fade the section showed, so that off and on again is a round trip; the
23475
+ // lines that have heads keep theirs. Switching off leaves line-end-size
23476
+ // alone, for the same reason, but removes the fade, which would otherwise
23477
+ // go on showing with its control hidden.
20979
23478
  function setArrowsOn(on) {
20980
23479
  applyArrowEdits(function(info) {
20981
23480
  return on ? fillArrowInfo(info) : {shape: 'none', position: ''};
@@ -21007,7 +23506,8 @@
21007
23506
  var edits = [];
21008
23507
  getTargetIds().forEach(function(id) {
21009
23508
  var rec = records[id] || {};
21010
- var next = getNext(getArrowInfo(rec));
23509
+ var info = getArrowInfo(rec);
23510
+ var next = getNext(info);
21011
23511
  var on = next.shape != 'none';
21012
23512
  var styles = [
21013
23513
  ['line-start', on && next.position != 'end' ? next.shape : ''],
@@ -21016,6 +23516,11 @@
21016
23516
  // no need to remove what is not there
21017
23517
  return style[1] || rec[style[0]];
21018
23518
  });
23519
+ if (!on && rec['line-fade']) {
23520
+ styles.push(['line-fade', '']);
23521
+ } else if (on && info.shape == 'none' && lastArrow.fade > 0 && !rec['line-fade']) {
23522
+ styles.push(['line-fade', lastArrow.fade]);
23523
+ }
21019
23524
  if (on && addStroke) styles.push(['stroke', strokeControl.defaultColor]);
21020
23525
  if (styles.length > 0) edits.push({id: id, styles: styles});
21021
23526
  });
@@ -21037,6 +23542,34 @@
21037
23542
  applyArrowSize(Math.max(1, Math.round(size + delta)));
21038
23543
  }
21039
23544
 
23545
+ // The field is a percent; line-fade is a share of the line, 0-1. No fade
23546
+ // is unset rather than stored as 0.
23547
+ function applyArrowFade(pct) {
23548
+ var ids = getArrowTargetIds();
23549
+ var value = pct > 0 ? Math.round(pct * 10) / 1000 : '';
23550
+ if (ids.length === 0) return;
23551
+ runStyleEdits(ids.map(function(id) {
23552
+ return {id: id, styles: [['line-fade', value]]};
23553
+ }));
23554
+ }
23555
+
23556
+ function nudgeArrowFade(delta) {
23557
+ var shown = getArrowFadeShown(getArrowTargetIds());
23558
+ if (shown.value === '') return;
23559
+ applyArrowFade(Math.max(0, Math.min(100, Math.round(shown.value + delta))));
23560
+ }
23561
+
23562
+ function getArrowFadeShown(ids) {
23563
+ var records = getTargetRecords();
23564
+ var value = '', pct;
23565
+ for (var i=0; i<ids.length; i++) {
23566
+ pct = Math.round(internal.svg.parseLineFade(records[ids[i]] && records[ids[i]]['line-fade']) * 1000) / 10;
23567
+ if (i > 0 && pct !== value) return {value: '', mixed: true};
23568
+ value = pct;
23569
+ }
23570
+ return {value: value, mixed: false};
23571
+ }
23572
+
21040
23573
  function getArrowTargetIds() {
21041
23574
  var records = getTargetRecords();
21042
23575
  return getTargetIds().filter(function(id) {
@@ -21097,6 +23630,11 @@
21097
23630
  arrowControl.sizeField.setValue(shown.value);
21098
23631
  arrowControl.sizeField.setPlaceholder(shown.mixed ? 'mixed' : '');
21099
23632
  arrowControl.sizeField.setDisabled(arrowIds.length === 0);
23633
+ var fade = arrowIds.length > 0 ? getArrowFadeShown(arrowIds) : {value: '', mixed: false};
23634
+ if (fade.value !== '') lastArrow.fade = fade.value / 100;
23635
+ arrowControl.fadeField.setValue(fade.value);
23636
+ arrowControl.fadeField.setPlaceholder(fade.mixed ? 'mixed' : '');
23637
+ arrowControl.fadeField.setDisabled(arrowIds.length === 0);
21100
23638
  }
21101
23639
 
21102
23640
  function getTargetRecords() {
@@ -21122,8 +23660,11 @@
21122
23660
  updateColorControl(fillControl);
21123
23661
  updateStrokeWidthControl();
21124
23662
  updateDashArrayControl();
21125
- if (geom == 'polyline') updateArrowControl();
21126
- randomFillBtn.classed('hidden', geom != 'polygon');
23663
+ if (geom == 'polyline') {
23664
+ updateLineCapControl();
23665
+ updateArrowControl();
23666
+ }
23667
+ updateColorSchemeControl();
21127
23668
  patternControl.section.classed('hidden', geom != 'polygon');
21128
23669
  if (geom == 'polygon') patternControl.update();
21129
23670
  glowControl.section.classed('hidden', geom != 'polygon');
@@ -21260,6 +23801,8 @@
21260
23801
  }
21261
23802
  }
21262
23803
 
23804
+ // No button runs this for now; the Random fill button is waiting for a new
23805
+ // place in the panel.
21263
23806
  // Patterns keep their backgrounds in step with the fills, and the new fills
21264
23807
  // are not known until -classify has chosen them, so the patterns are
21265
23808
  // rewritten by an expression that reads each feature's fill when it runs.
@@ -21304,6 +23847,7 @@
21304
23847
  addStyleValue(style, 'stroke-opacity', parseOpacityValue(strokeControl.opacity.node().value));
21305
23848
  if (targetLayer && targetLayer.geometry_type == 'polyline') {
21306
23849
  addStyleValue(style, 'stroke-dasharray', getControlValue(dashControl.input));
23850
+ addStyleValue(style, 'stroke-linecap', getCommonStyleValue('stroke-linecap'));
21307
23851
  addArrowStyleValues(style);
21308
23852
  }
21309
23853
  if (targetLayer && targetLayer.geometry_type == 'polygon') {
@@ -21318,7 +23862,7 @@
21318
23862
  // Only when every target agrees, as with the other controls; a size is
21319
23863
  // saved only if the lines have their own.
21320
23864
  function addArrowStyleValues(style) {
21321
- ['line-start', 'line-end', 'line-end-size'].forEach(function(field) {
23865
+ ['line-start', 'line-end', 'line-end-size', 'line-fade'].forEach(function(field) {
21322
23866
  addStyleValue(style, field, getCommonStyleValue(field));
21323
23867
  });
21324
23868
  }
@@ -21439,6 +23983,7 @@
21439
23983
  function syncTargetLayer() {
21440
23984
  var lyr = getActiveLayer();
21441
23985
  if (lyr == targetLayer) return;
23986
+ schemePanel.close();
21442
23987
  if (layerCanBeStyled(lyr)) {
21443
23988
  targetLayer = lyr;
21444
23989
  if (hit) hit.clearSelection();
@@ -23972,9 +26517,20 @@
23972
26517
  _self.dispatchEvent('leave');
23973
26518
  }
23974
26519
 
26520
+ // mouseover and mouseout can arrive after a quick move has already pressed
26521
+ // and released the button somewhere else -- a click on a panel over the map
26522
+ // then looked like a click on the map. A press or release is over the area
26523
+ // if it lands on the area itself.
26524
+ function updateHover(e) {
26525
+ var over = !!e.target && element.contains(e.target);
26526
+ if (over && !_isOver) onAreaEnter();
26527
+ else if (!over && _isOver) onAreaOut();
26528
+ }
26529
+
23975
26530
  function onMouseUp(e) {
23976
- var evt = procMouseEvent(e),
23977
- elapsed, dx, dy;
26531
+ var evt, elapsed, dx, dy;
26532
+ updateHover(e);
26533
+ evt = procMouseEvent(e);
23978
26534
  _self.dispatchEvent('mouseup', evt);
23979
26535
  if (_dragging) {
23980
26536
  stopDragging(evt);
@@ -23996,6 +26552,7 @@
23996
26552
  }
23997
26553
 
23998
26554
  function onMouseDown(e) {
26555
+ updateHover(e);
23999
26556
  if (e.button != 2 && e.which != 3) { // ignore right-click
24000
26557
  _downEvt = procMouseEvent(e);
24001
26558
  }
@@ -31455,6 +34012,7 @@
31455
34012
  style.lineStart = rec && rec['line-start'];
31456
34013
  style.lineEnd = rec && rec['line-end'];
31457
34014
  style.lineEndSize = rec && rec['line-end-size'];
34015
+ style.lineFade = rec && rec['line-fade'];
31458
34016
  }
31459
34017
  if (hasGlows) {
31460
34018
  style.outerGlow = layerOuterGlow ? null : internal.svg.getPolygonGlow(rec, 'outer');
@@ -32819,9 +35377,10 @@
32819
35377
  continue;
32820
35378
  }
32821
35379
  // A line with arrowheads is drawn on its own, trimmed to meet its heads;
32822
- // see svg-line-arrows.mjs. Only lines styled with line-start or line-end
32823
- // get here, so the batch below is untouched for everything else.
32824
- if (drawStyle.lineStart || drawStyle.lineEnd) {
35380
+ // see svg-line-arrows.mjs. Only lines styled with line-start, line-end
35381
+ // or line-fade get here, so the batch below is untouched for
35382
+ // everything else.
35383
+ if (drawStyle.lineStart || drawStyle.lineEnd || drawStyle.lineFade) {
32825
35384
  if (!arrowPencil) arrowPencil = getArrowLinePencil(arcs, _ext, lineScale);
32826
35385
  if (arrowPencil(shp, _ctx, drawStyle, startPath, draw)) continue;
32827
35386
  }
@@ -33269,14 +35828,17 @@
33269
35828
  };
33270
35829
  }
33271
35830
 
33272
- // Returns a function that draws one line feature with its arrowheads, and
33273
- // returns false if the feature has none to draw (a style of 'none', or no
33274
- // stroke), for the caller to draw it the usual way.
35831
+ // Returns a function that draws one line feature with its arrowheads and fade,
35832
+ // and returns false if the feature has neither to draw (a style of 'none', or
35833
+ // no stroke), for the caller to draw it the usual way.
33275
35834
  //
33276
35835
  // Heads are sized by the same factor as the line width, so that a preview
33277
35836
  // matches the exported SVG. Far enough in that paths are clipped to the view
33278
35837
  // (see protectIterForDrawing()), the line is drawn clipped and untrimmed and
33279
- // only the heads are added, since a clipped path's ends are not its ends.
35838
+ // only the heads are added, since a clipped path's ends are not its ends. The
35839
+ // fade is placed by the whole line, so it is unaffected by the clipping.
35840
+ //
35841
+ // A faded part is stroked on its own, with its own gradient, as in SVG.
33280
35842
  function getArrowLinePencil(arcs, ext, lineScale) {
33281
35843
  var t = getScaledTransform(ext);
33282
35844
  var iter = new internal.ShapeIter(arcs);
@@ -33284,10 +35846,13 @@
33284
35846
  var clipped = ext.scale() > 100;
33285
35847
  return function(shp, ctx, style, startPath, draw) {
33286
35848
  var opts = internal.svg.makeLineArrowOpts(style.lineStart, style.lineEnd,
33287
- style.lineEndSize, style.strokeWidth, strokeScale);
35849
+ style.lineEndSize, style.strokeWidth, strokeScale, style.lineFade);
35850
+ var fadeColors = opts.fade > 0 ? internal.svg.getLineFadeColors(style.strokeColor) : null;
33288
35851
  var heads = [];
35852
+ var plain = [];
35853
+ var faded = [];
33289
35854
  var i, coords, shape;
33290
- if (opts.start == 'none' && opts.end == 'none' || !(style.strokeWidth > 0)) {
35855
+ if (opts.start == 'none' && opts.end == 'none' && !fadeColors || !(style.strokeWidth > 0)) {
33291
35856
  return false;
33292
35857
  }
33293
35858
  startPath(ctx, style);
@@ -33296,15 +35861,38 @@
33296
35861
  if (coords.length < 2) continue;
33297
35862
  shape = internal.svg.getLineArrowShape(coords, opts);
33298
35863
  heads = heads.concat(shape.heads);
33299
- if (!clipped) traceCoords(shape.coords, ctx);
35864
+ if (shape.fade && fadeColors) {
35865
+ faded.push({path: shp[i], shape: shape});
35866
+ } else if (clipped) {
35867
+ plain.push(shp[i]);
35868
+ } else {
35869
+ traceCoords(shape.coords, ctx);
35870
+ }
33300
35871
  }
33301
- if (clipped) draw(shp, ctx, style);
35872
+ if (clipped && plain.length > 0) draw(plain, ctx, style);
33302
35873
  endPath(ctx, style);
35874
+ for (i=0; i<faded.length; i++) {
35875
+ startPath(ctx, style);
35876
+ ctx.strokeStyle = getLineFadeGradient(ctx, faded[i].shape.fade, fadeColors);
35877
+ if (clipped) {
35878
+ draw([faded[i].path], ctx, style);
35879
+ } else {
35880
+ traceCoords(faded[i].shape.coords, ctx);
35881
+ }
35882
+ endPath(ctx, style);
35883
+ }
33303
35884
  drawArrowHeads(heads, ctx, style, opts.width);
33304
35885
  return true;
33305
35886
  };
33306
35887
  }
33307
35888
 
35889
+ function getLineFadeGradient(ctx, axis, colors) {
35890
+ var gradient = ctx.createLinearGradient(axis.from[0], axis.from[1], axis.to[0], axis.to[1]);
35891
+ gradient.addColorStop(0, colors.transparent);
35892
+ gradient.addColorStop(1, colors.solid);
35893
+ return gradient;
35894
+ }
35895
+
33308
35896
  function getPixelCoords(iter, path, t) {
33309
35897
  var coords = [];
33310
35898
  var x, y, prev;
@@ -33437,7 +36025,7 @@
33437
36025
  ctx.lineWidth = strokeWidth * strokeScale;
33438
36026
  ctx.strokeStyle = style.strokeColor;
33439
36027
  if (style.lineDash){
33440
- ctx.lineCap = 'butt';
36028
+ ctx.lineCap = style.lineCap || 'butt';
33441
36029
  ctx.setLineDash(scaleCanvasLineDash(style.lineDash, strokeScale));
33442
36030
  }
33443
36031
  if (style.miterLimit) {
@@ -36034,8 +38622,9 @@
36034
38622
  var marginField = toolbar.addTextField('Margin', {
36035
38623
  classname: 'frame-toolbar-margin-input',
36036
38624
  placeholder: '0',
36037
- tooltip: 'Padding added when fitting: 20px, 1cm, ' +
36038
- "or a percentage of the frame's width and height"
38625
+ tooltip: 'Padding added when fitting: 2%, 20px, 1cm, or ' +
38626
+ 'one to four values in CSS order (top right bottom left). ' +
38627
+ "A percentage is of the frame's width on every side."
36039
38628
  });
36040
38629
  toolbar.addSeparator();
36041
38630
  toolbar.addTextButton('Done', {
@@ -36285,7 +38874,7 @@
36285
38874
  return internal.getLayerTargetId(gui.model, o.layer);
36286
38875
  }).join(','))
36287
38876
  ];
36288
- if (margin) parts.push('offset=' + quoteCommandValue(margin));
38877
+ if (margin) parts.push('margin=' + quoteCommandValue(margin));
36289
38878
  if (!lockSize) parts.push('fix-scale');
36290
38879
  parts.push(getTargetOption(target));
36291
38880
  runGuiEditCommand(gui, parts.join(' '), {
@@ -36298,7 +38887,7 @@
36298
38887
  // cleared so the fit that just ran matches what the toolbar shows.
36299
38888
  function getMargin() {
36300
38889
  var value = marginField.getValue();
36301
- if (parseFloat(value) > 0) return value;
38890
+ if (hasNonZeroMargin(value)) return value;
36302
38891
  if (value) marginField.setValue('');
36303
38892
  return '';
36304
38893
  }
@@ -36349,8 +38938,10 @@
36349
38938
  marginInput.node().value = '2%';
36350
38939
  makeFieldTip(fitRow,
36351
38940
  'Space around the layers: 2%, 20px, 1cm.\n' +
36352
- "A percentage is of the frame's width on the\n" +
36353
- 'side and of its height on top and bottom.');
38941
+ "A percentage is of the frame's width, on\n" +
38942
+ 'every side. Use two to four values for\n' +
38943
+ 'uneven margins, in CSS order: 4% 2%, or\n' +
38944
+ '8% 2% 2% 2% (top right bottom left).');
36354
38945
 
36355
38946
  var drawRow = El('div').addClass('frame-create-option-row').appendTo(section);
36356
38947
  addButton(drawRow, 'Draw on the map', function() {
@@ -36382,7 +38973,7 @@
36382
38973
 
36383
38974
  function getMargin() {
36384
38975
  var value = marginInput.node().value.trim();
36385
- return parseFloat(value) > 0 ? value : '';
38976
+ return hasNonZeroMargin(value) ? value : '';
36386
38977
  }
36387
38978
  }
36388
38979
 
@@ -36462,7 +39053,7 @@
36462
39053
  });
36463
39054
  var parts = ['-frame', 'width=' + quoteCommandValue(width)];
36464
39055
  if (aspect) parts.push('aspect-ratio=' + aspect);
36465
- if (margin) parts.push('offset=' + quoteCommandValue(margin));
39056
+ if (margin) parts.push('margin=' + quoteCommandValue(margin));
36466
39057
  parts.push('name=frame');
36467
39058
  parts.push('target=' + internal.formatOptionValue(ids.join(',')));
36468
39059
  runCreateCommand(parts.join(' '));
@@ -36541,6 +39132,14 @@
36541
39132
  }
36542
39133
  }
36543
39134
 
39135
+ // A margin field's value is passed to the command when any of its one to four
39136
+ // values is positive, e.g. '0 2%'; a blank or all-zero margin is dropped.
39137
+ function hasNonZeroMargin(value) {
39138
+ return String(value || '').split(/[\s,]+/).some(function(str) {
39139
+ return parseFloat(str) > 0;
39140
+ });
39141
+ }
39142
+
36544
39143
  function addButton(parent, label, action) {
36545
39144
  El('div').addClass('btn dialog-btn').appendTo(parent)
36546
39145
  .text(label).on('click', action);