mapshaper 0.7.72 → 0.7.74

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
@@ -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,1787 @@
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
+ // pins: [{t, color}] or null, (custom sequential ramps)
21067
+ // vibrance, longHue (custom sequential ramps, see
21068
+ // getOklchInterpolator())
21069
+ // order: [...] or null (categorical presets: indexes
21070
+ // of the preset's colors, as
21071
+ // rearranged)
21072
+ // swatches: [...] or null (custom categorical lists)
21073
+ // continuous: boolean (sequential and diverging)
21074
+ // pivot: 'auto', 'median', 'mean' or a number,
21075
+ // neutral, continuousNeutral: boolean (a pivot class, for classed
21076
+ // and continuous schemes; see
21077
+ // getSchemeNeutral())
21078
+ // split: 'size' or 'count' (diverging: n is the total
21079
+ // number of classes, split so
21080
+ // they're the same size on both
21081
+ // sides, or the number per side)
21082
+ // layout: {pivot, neutral, below, above, breaks} or null
21083
+ // (diverging: the classes the
21084
+ // data gives, see
21085
+ // getDivergingLayout())
21086
+ // nullColor the color of features with no data (both types; not
21087
+ // used by non-adjacent schemes)
21088
+ // colors: [...] the colors that were applied, for checking a layer later
21089
+ // fillHash continuous schemes: a fingerprint of the fills they gave
21090
+ // the layer (interpolated fills aren't the scheme's colors)
21091
+ // }
21092
+
21093
+ var schemeTypes = [
21094
+ {name: 'sequential', label: 'Sequential'},
21095
+ {name: 'diverging', label: 'Diverging'},
21096
+ {name: 'categorical', label: 'Categorical'}
21097
+ ];
21098
+
21099
+ var classifyMethods = [
21100
+ {name: 'quantile', label: 'Quantile'},
21101
+ {name: 'equal-interval', label: 'Equal interval'},
21102
+ {name: 'nice', label: 'Nice breaks'},
21103
+ {name: 'hybrid', label: 'Hybrid'}
21104
+ ];
21105
+
21106
+ var pivotOptions = [
21107
+ {name: 'auto', label: 'Auto (0 or median)'},
21108
+ {name: 'median', label: 'Median'},
21109
+ {name: 'mean', label: 'Mean'},
21110
+ {name: 'value', label: 'Value'}
21111
+ ];
21112
+
21113
+ var divergingSplits = [
21114
+ {name: 'size', label: 'Same class size'},
21115
+ {name: 'count', label: 'Same number of classes'}
21116
+ ];
21117
+
21118
+ var categoricalMethods = [
21119
+ {name: 'categorical', label: 'Categories'},
21120
+ {name: 'non-adjacent', label: 'Non-adjacent'}
21121
+ ];
21122
+
21123
+ // -classify's default null value for colors, as hex
21124
+ var defaultNullColor = '#eeeeee';
21125
+
21126
+ var minSchemeColors = 2;
21127
+ var maxSchemeColors = 12;
21128
+ var maxCategoricalColors = 20;
21129
+ var defaultCategoricalPreset = 'Tableau10';
21130
+ var defaultNonAdjacentColors = 5;
21131
+ var defaultDivergingPreset = 'RdBu';
21132
+ var defaultDivergingClasses = 7;
21133
+ var defaultDivergingSideClasses = 3;
21134
+ var maxDivergingSideClasses = 6;
21135
+ // colors for growing a custom categorical list past the palette it came from
21136
+ var extraSwatchSource = 'Tableau20';
21137
+ // vibrance is OKLCH chroma added to the colors of each interpolated segment
21138
+ // (-classify's vibrance= is the same, scaled to 0-1)
21139
+ var defaultVibrance = 0;
21140
+ var maxVibrance = internal.MAX_VIBRANCE_CHROMA;
21141
+ var defaultEnds = ['#344a72', '#f0d26b'];
21142
+ // Cyclic ramps start and end on the same hue, which reads as a category
21143
+ // rather than an order.
21144
+ var excludedPresets = ['Rainbow', 'Sinebow'];
21145
+
21146
+ // Layers keep the scheme that colored them, so that the panel can reopen it
21147
+ // and the style panel can show it. Undo and redo of a scheme edit put back
21148
+ // the scheme that went with the colors (see the panel's command session).
21149
+ var layerSchemes = new WeakMap();
21150
+
21151
+ function getSequentialPresetNames() {
21152
+ return internal.getColorSchemeNames('sequential')
21153
+ .concat(internal.getColorSchemeNames('rainbow'))
21154
+ .filter(function(name) {
21155
+ return !excludedPresets.includes(name);
21156
+ });
21157
+ }
21158
+
21159
+ function getDivergingPresetNames() {
21160
+ return internal.getColorSchemeNames('diverging');
21161
+ }
21162
+
21163
+ function getCategoricalPresetNames() {
21164
+ return internal.getColorSchemeNames('categorical');
21165
+ }
21166
+
21167
+ // The scheme a panel tab starts with on a layer
21168
+ function getDefaultSchemeOfType(type, lyr) {
21169
+ if (type == 'categorical') return getDefaultCategoricalScheme(lyr);
21170
+ if (type == 'diverging') return getDefaultDivergingScheme(lyr);
21171
+ return getDefaultScheme(getNumericFields(lyr)[0]);
21172
+ }
21173
+
21174
+ function getDefaultDivergingScheme(lyr) {
21175
+ return updateDivergingLayout({
21176
+ type: 'diverging',
21177
+ field: getNumericFields(lyr)[0] || null,
21178
+ method: 'quantile',
21179
+ n: defaultDivergingClasses,
21180
+ split: 'size',
21181
+ pivot: 'auto',
21182
+ neutral: true,
21183
+ preset: defaultDivergingPreset,
21184
+ reversed: false,
21185
+ pins: null,
21186
+ vibrance: defaultVibrance
21187
+ }, lyr);
21188
+ }
21189
+
21190
+ // The layout of a diverging scheme's classes on the layer's data, as
21191
+ // -classify finds it; null if there are no data to classify. Done after
21192
+ // every change that can move the classes (the field, method, pivot, pivot
21193
+ // class, number of classes), and when the panel opens, since the data may
21194
+ // have changed.
21195
+ function updateDivergingLayout(scheme, lyr) {
21196
+ if (scheme.type != 'diverging') return scheme;
21197
+ return Object.assign({}, scheme, {layout: getDivergingLayout(scheme, lyr)});
21198
+ }
21199
+
21200
+ function getDivergingLayout(scheme, lyr) {
21201
+ var values = getAscendingValues(lyr, scheme.field);
21202
+ if (values.length === 0) return null;
21203
+ var neutral = getSchemeNeutral(scheme);
21204
+ try {
21205
+ return internal.getDivergingLayout(values, scheme.method, {
21206
+ pivot: scheme.pivot,
21207
+ continuous: !!scheme.continuous,
21208
+ pivot_class: neutral,
21209
+ no_pivot_class: !neutral,
21210
+ classes: getDivergingClassesOption(scheme)
21211
+ });
21212
+ } catch(e) {
21213
+ return null;
21214
+ }
21215
+ }
21216
+
21217
+ // Whether a diverging scheme has a pivot class. Classed and continuous
21218
+ // schemes each keep their own setting, so that switching between them keeps
21219
+ // both: on by default for classes, off for continuous colors.
21220
+ function getSchemeNeutral(scheme) {
21221
+ return scheme.continuous ? !!scheme.continuousNeutral : scheme.neutral !== false;
21222
+ }
21223
+
21224
+ function isContinuousScheme(scheme) {
21225
+ return scheme.type != 'categorical' && !!scheme.continuous;
21226
+ }
21227
+
21228
+ // A sequential ramp starts with at least this many stops, since -classify
21229
+ // interpolates straight between them, so vibrance and the long hue path
21230
+ // show only at the stops.
21231
+ var minContinuousStartColors = 5;
21232
+
21233
+ function setSchemeContinuous(scheme, continuous) {
21234
+ var next = Object.assign({}, scheme, {continuous: !!continuous});
21235
+ if (continuous && next.type == 'sequential' && next.n < minContinuousStartColors) {
21236
+ next.n = minContinuousStartColors;
21237
+ }
21238
+ return next;
21239
+ }
21240
+
21241
+ function getAscendingValues(lyr, field) {
21242
+ if (!lyr || !lyr.data || !field || !lyr.data.fieldExists(field)) return [];
21243
+ return lyr.data.getRecords().map(function(rec) {
21244
+ return rec ? rec[field] : undefined;
21245
+ }).filter(isFiniteNumber).sort(function(a, b) { return a - b; });
21246
+ }
21247
+
21248
+ // -classify's classes= for a diverging scheme
21249
+ function getDivergingClassesOption(scheme) {
21250
+ return scheme.split == 'count' ? [scheme.n, scheme.n] : scheme.n;
21251
+ }
21252
+
21253
+ // The number of tiles of a ramp: for a diverging scheme, the center tile and
21254
+ // enough on each side for the longer side's classes (or a continuous
21255
+ // side's stops)
21256
+ function getRampSize(scheme) {
21257
+ var layout = scheme.layout;
21258
+ var k;
21259
+ if (scheme.type != 'diverging') return scheme.n;
21260
+ k = layout ? Math.max(layout.below, layout.above) :
21261
+ scheme.split == 'count' ? scheme.n : Math.floor(scheme.n / 2);
21262
+ k = scheme.continuous ? k + 1 : Math.max(k, 1);
21263
+ return k * 2 + 1;
21264
+ }
21265
+
21266
+ // The numbers of tiles that each side of a diverging scheme uses
21267
+ function getSideTiles(scheme) {
21268
+ var layout = scheme.layout;
21269
+ if (!scheme.continuous) return [layout.below, layout.above];
21270
+ return [internal.getContinuousSideStops(layout.below), internal.getContinuousSideStops(layout.above)];
21271
+ }
21272
+
21273
+ // One boolean per tile of a diverging scheme: whether a class (or a color
21274
+ // stop) uses it
21275
+ function getDivergingTileUse(scheme) {
21276
+ var size = getRampSize(scheme);
21277
+ var k = (size - 1) / 2;
21278
+ var layout = scheme.layout;
21279
+ var sides = layout ? getSideTiles(scheme) : [0, 0];
21280
+ var use = [];
21281
+ for (var i=0; i<size; i++) {
21282
+ use.push(!!layout && (i < k ? i >= k - sides[0] : i > k ? i <= k + sides[1] : !!layout.neutral));
21283
+ }
21284
+ return use;
21285
+ }
21286
+
21287
+ // The data value at each tile of a continuous scheme: {value, min, max}
21288
+ // (min and max: the value is the data's min or max), or for a diverging
21289
+ // scheme's center tile, the pivot class's range: {range}. Tiles that no stop
21290
+ // uses are null. Returns null if there are no data.
21291
+ function getContinuousTileStops(scheme, lyr) {
21292
+ var values, breaks, layout, stops, k, tiles;
21293
+ if (scheme.type == 'sequential') {
21294
+ values = getAscendingValues(lyr, scheme.field);
21295
+ if (values.length === 0) return null;
21296
+ try {
21297
+ breaks = internal.getSequentialBreaks(values, scheme.method, scheme.n - 2);
21298
+ } catch(e) {
21299
+ return null;
21300
+ }
21301
+ return markDataRange([values[0]].concat(breaks, [values[values.length - 1]]), values);
21302
+ }
21303
+ layout = scheme.layout;
21304
+ if (!layout) return null;
21305
+ stops = internal.getDivergingStops(layout);
21306
+ k = (getRampSize(scheme) - 1) / 2;
21307
+ tiles = new Array(k * 2 + 1).fill(null);
21308
+ markDataRange(stops.below, layout.dataRange).forEach(function(stop, i) {
21309
+ tiles[k - stops.below.length + i] = stop;
21310
+ });
21311
+ if (layout.neutral) tiles[k] = {range: layout.neutral};
21312
+ markDataRange(stops.above, layout.dataRange).forEach(function(stop, i) {
21313
+ tiles[k + 1 + i] = stop;
21314
+ });
21315
+ return tiles;
21316
+ }
21317
+
21318
+ // range: ascending values, of which the first is the min and the last the max
21319
+ function markDataRange(stops, range) {
21320
+ var min = range[0], max = range[range.length - 1];
21321
+ return stops.map(function(val) {
21322
+ return {value: val, min: val == min, max: val == max};
21323
+ });
21324
+ }
21325
+
21326
+ // The gradients to draw over the tiles of a continuous scheme, in order:
21327
+ // [{start, end, flatStart, flatEnd, samples: [{pos, color}]}], with positions
21328
+ // in tiles (0 is the center of the first tile, 1 of the second...). A
21329
+ // gradient runs from stop to stop, and holds its end colors over the outer
21330
+ // halves of its end tiles (flatStart, flatEnd) -- except where the data ends
21331
+ // between two stops (the shorter side of an equal-interval or nice
21332
+ // diverging scheme). A pivot class is a gradient of one color, over its
21333
+ // tile. The samples are of the interpolation -classify does between the
21334
+ // stops (samplesPerTile to each pair).
21335
+ function getContinuousSegments(scheme, samplesPerTile) {
21336
+ var colors = getSchemeColors(scheme);
21337
+ var layout = scheme.layout;
21338
+ var steps = samplesPerTile || 6;
21339
+ var segments = [];
21340
+ var stops, k, first, last;
21341
+ if (!isContinuousScheme(scheme)) return [];
21342
+ if (scheme.type == 'sequential') {
21343
+ return [makeSegment(colors, 0, 0, colors.length - 1, steps)];
21344
+ }
21345
+ if (!layout) return [];
21346
+ stops = internal.getDivergingStops(layout);
21347
+ k = (colors.length - 1) / 2;
21348
+ if (stops.below.length > 0) {
21349
+ first = k - stops.below.length;
21350
+ segments.push(makeSegment(colors.slice(first, k), first,
21351
+ first + internal.getStopPosition(stops.below, layout.dataRange[0]), k - 1, steps));
21352
+ }
21353
+ if (layout.neutral) {
21354
+ segments.push(makeSegment([colors[k]], k, k, k, steps));
21355
+ }
21356
+ if (stops.above.length > 0) {
21357
+ last = k + stops.above.length;
21358
+ segments.push(makeSegment(colors.slice(k + 1, last + 1), k + 1,
21359
+ k + 1, k + 1 + internal.getStopPosition(stops.above, layout.dataRange[1]), steps));
21360
+ }
21361
+ return segments;
21362
+ }
21363
+
21364
+ // colors: the stops' colors, the first at tile first
21365
+ // start, end: where the gradient starts and ends, in tiles
21366
+ function makeSegment(colors, first, start, end, steps) {
21367
+ var last = first + colors.length - 1;
21368
+ var getColor = colors.length > 1 ?
21369
+ internal.getInterpolatedValueGetter(colors, null, {interpolation: 'oklch'}) : null;
21370
+ var samples = [];
21371
+ var sample = function(pos) {
21372
+ samples.push({pos: pos, color: getColor ? toHex(getColor(pos - first)) : colors[0]});
21373
+ };
21374
+ sample(start);
21375
+ for (var i=Math.floor(start * steps) + 1; i / steps < end; i++) {
21376
+ sample(i / steps);
21377
+ }
21378
+ if (end > start) sample(end);
21379
+ return {start: start, end: end, flatStart: start == first, flatEnd: end == last, samples: samples};
21380
+ }
21381
+
21382
+ // The data range of each class of a diverging layout, in class order:
21383
+ // [low, high), with -Infinity and Infinity at the outer ends
21384
+ function getDivergingClassRanges(layout) {
21385
+ return getBreakRanges(layout ? layout.breaks : []);
21386
+ }
21387
+
21388
+ // The data range of each class of a sequential scheme, as -classify finds
21389
+ // them (see getDivergingClassRanges()), or null if there are no data
21390
+ function getSequentialClassRanges(scheme, lyr) {
21391
+ var values = getAscendingValues(lyr, scheme.field);
21392
+ if (values.length === 0) return null;
21393
+ try {
21394
+ return getBreakRanges(internal.getSequentialBreaks(values, scheme.method, scheme.n - 1));
21395
+ } catch(e) {
21396
+ return null;
21397
+ }
21398
+ }
21399
+
21400
+ function getBreakRanges(breaks) {
21401
+ var ranges = [];
21402
+ for (var i=0; i<=breaks.length; i++) {
21403
+ ranges.push([i > 0 ? breaks[i - 1] : -Infinity, i < breaks.length ? breaks[i] : Infinity]);
21404
+ }
21405
+ return ranges;
21406
+ }
21407
+
21408
+ // How the data falls around the pivot of a diverging scheme:
21409
+ // {pivot, below, above} (features below the pivot, and at or above it)
21410
+ function getPivotSummary(scheme, lyr) {
21411
+ var values = getAscendingValues(lyr, scheme.field);
21412
+ var pivot = scheme.layout ? scheme.layout.pivot : null;
21413
+ if (pivot === null) return null;
21414
+ return {
21415
+ pivot: pivot,
21416
+ below: values.filter(function(val) { return val < pivot; }).length,
21417
+ above: values.filter(function(val) { return val >= pivot; }).length
21418
+ };
21419
+ }
21420
+
21421
+ // pivot: 'auto', 'median', 'mean' or a number
21422
+ function setSchemePivot(scheme, pivot) {
21423
+ return Object.assign({}, scheme, {pivot: pivot});
21424
+ }
21425
+
21426
+ // sets the pivot class of the scheme's mode (see getSchemeNeutral())
21427
+ function setSchemeNeutral(scheme, neutral) {
21428
+ return Object.assign({}, scheme, scheme.continuous ? {continuousNeutral: !!neutral} : {neutral: !!neutral});
21429
+ }
21430
+
21431
+ // Switching between a total and a number per side starts over with the
21432
+ // default number
21433
+ function setSchemeSplit(scheme, split) {
21434
+ if (scheme.split == split) return scheme;
21435
+ return Object.assign({}, scheme, {
21436
+ split: split,
21437
+ n: split == 'count' ? defaultDivergingSideClasses : defaultDivergingClasses
21438
+ });
21439
+ }
21440
+
21441
+ // By the first text field, or else the first field that can be classified
21442
+ // by value, or else non-adjacent
21443
+ function getDefaultCategoricalScheme(lyr) {
21444
+ var fields = getCategoryFields(lyr);
21445
+ var records = lyr && lyr.data ? lyr.data.getRecords() : [];
21446
+ var field = fields.find(function(name) {
21447
+ return internal.getColumnType(name, records) == 'string';
21448
+ }) || fields[0] || null;
21449
+ var scheme = {
21450
+ type: 'categorical',
21451
+ field: field,
21452
+ method: field ? 'categorical' : 'non-adjacent',
21453
+ n: defaultNonAdjacentColors,
21454
+ preset: defaultCategoricalPreset,
21455
+ order: null,
21456
+ swatches: null
21457
+ };
21458
+ return field ? setSchemeField(scheme, field, getCategories(lyr, field).length) : scheme;
21459
+ }
21460
+
21461
+ function getMinSchemeColors(scheme) {
21462
+ return scheme.type == 'diverging' && scheme.split == 'count' ? 1 : minSchemeColors;
21463
+ }
21464
+
21465
+ function getSchemeMethods(scheme) {
21466
+ return scheme.type == 'categorical' ? categoricalMethods : classifyMethods;
21467
+ }
21468
+
21469
+ // count: the number of categories, for the categorical method
21470
+ function getMaxSchemeColors(scheme, count) {
21471
+ var max = maxSchemeColors;
21472
+ if (scheme.type == 'diverging' && scheme.split == 'count') return maxDivergingSideClasses;
21473
+ if (scheme.type != 'categorical') return max;
21474
+ max = scheme.preset ? internal.getCategoricalColors(scheme.preset).length : maxCategoricalColors;
21475
+ if (scheme.method == 'categorical' && count >= 0) {
21476
+ max = Math.min(max, Math.max(count, minSchemeColors));
21477
+ }
21478
+ return max;
21479
+ }
21480
+
21481
+ function clampColorCount(scheme, n, count) {
21482
+ var min = getMinSchemeColors(scheme);
21483
+ return clamp$4(Math.round(n) || min, min, getMaxSchemeColors(scheme, count));
21484
+ }
21485
+
21486
+ // A new field for a categorical scheme gets a swatch for each of its values,
21487
+ // up to as many as the palette has.
21488
+ // count: the number of categories in the field
21489
+ function setSchemeField(scheme, field, count) {
21490
+ var next = Object.assign({}, scheme, {field: field});
21491
+ if (next.type == 'categorical' && next.method == 'categorical') {
21492
+ next.n = clampColorCount(next, count, count);
21493
+ }
21494
+ return next;
21495
+ }
21496
+
21497
+ // field, count: the field to use, and its number of categories, when a
21498
+ // categorical scheme changes to the categorical method
21499
+ function setSchemeMethod(scheme, method, field, count) {
21500
+ var next = Object.assign({}, scheme, {method: method});
21501
+ if (next.type != 'categorical') return next;
21502
+ if (method == 'categorical') {
21503
+ return setSchemeField(next, next.field || field, count);
21504
+ }
21505
+ return setTileCount(next, defaultNonAdjacentColors);
21506
+ }
21507
+
21508
+ // Fields that a categorical scheme can classify: text and numbers. The
21509
+ // fill field is left out, since the scheme writes over it.
21510
+ function getCategoryFields(lyr) {
21511
+ var records = lyr && lyr.data ? lyr.data.getRecords() : [];
21512
+ if (!lyr || !lyr.data) return [];
21513
+ return lyr.data.getFields().filter(function(field) {
21514
+ var type = internal.getColumnType(field, records);
21515
+ return field != 'fill' && (type == 'string' || type == 'number');
21516
+ });
21517
+ }
21518
+
21519
+ // The values of a field, in the order -classify gives them swatches. Empty
21520
+ // values are left out, as -classify leaves them out: they are no data.
21521
+ function getCategories(lyr, field) {
21522
+ if (!lyr || !lyr.data || !field || !lyr.data.fieldExists(field)) return [];
21523
+ return internal.getUniqFieldValues(lyr.data.getRecords(), field).filter(isCategoryValue);
21524
+ }
21525
+
21526
+ // The same test as -classify's (0 is a value; null, undefined, '' and NaN
21527
+ // are not)
21528
+ function isCategoryValue(val) {
21529
+ return !!val || val === 0;
21530
+ }
21531
+
21532
+ // How many features the scheme gives the no-data color
21533
+ function getNoDataCount(lyr, scheme) {
21534
+ var records, test;
21535
+ if (!lyr || !lyr.data || !scheme.field || scheme.method == 'non-adjacent' ||
21536
+ !lyr.data.fieldExists(scheme.field)) return 0;
21537
+ records = lyr.data.getRecords();
21538
+ test = scheme.type == 'categorical' ? isCategoryValue : isFiniteNumber;
21539
+ return records.filter(function(rec) {
21540
+ return !test(rec ? rec[scheme.field] : undefined);
21541
+ }).length;
21542
+ }
21543
+
21544
+ function isFiniteNumber(val) {
21545
+ return typeof val == 'number' && isFinite(val);
21546
+ }
21547
+
21548
+ function getSchemeNullColor(scheme) {
21549
+ return scheme.nullColor || defaultNullColor;
21550
+ }
21551
+
21552
+ // An empty or unreadable color is the default
21553
+ function setSchemeNullColor(scheme, color) {
21554
+ var rgb = color ? internal.parseColor(color) : null;
21555
+ return Object.assign({}, scheme, {nullColor: rgb ? internal.formatColor(rgb) : defaultNullColor});
21556
+ }
21557
+
21558
+ // The categories each swatch colors, as -classify assigns them
21559
+ function getSwatchCategories(categories, n) {
21560
+ var groups = [];
21561
+ for (var i=0; i<n; i++) groups.push([]);
21562
+ categories.forEach(function(val, i) {
21563
+ groups[i % n].push(val);
21564
+ });
21565
+ return groups;
21566
+ }
21567
+
21568
+ function getDefaultScheme(field) {
21569
+ return {
21570
+ type: 'sequential',
21571
+ field: field || null,
21572
+ method: 'quantile',
21573
+ n: 5,
21574
+ preset: null,
21575
+ reversed: false,
21576
+ pins: [{t: 0, color: defaultEnds[0]}, {t: 1, color: defaultEnds[1]}],
21577
+ vibrance: defaultVibrance
21578
+ };
21579
+ }
21580
+
21581
+ function getSchemeColors(scheme) {
21582
+ return getSchemeTiles(scheme).map(function(tile) {
21583
+ return tile.color;
21584
+ });
21585
+ }
21586
+
21587
+ // One object per tile: {color, pinned, adjusted, ...}, where adjusted means
21588
+ // an interpolated color was moved to fit the sRGB gamut (see resolveRampTiles())
21589
+ function getSchemeTiles(scheme) {
21590
+ var colors;
21591
+ if (scheme.type == 'categorical') {
21592
+ return getCategoricalPalette(scheme).slice(0, scheme.n).map(function(color) {
21593
+ return {color: color, pinned: false, adjusted: false};
21594
+ });
21595
+ }
21596
+ if (scheme.preset) {
21597
+ colors = getPresetColors(scheme.preset, getRampSize(scheme));
21598
+ if (scheme.reversed) colors.reverse();
21599
+ return colors.map(function(color) {
21600
+ return {color: color, pinned: false, adjusted: false};
21601
+ });
21602
+ }
21603
+ return internal.resolveRampTiles(scheme.pins, getRampSize(scheme), {
21604
+ vibrance: getSchemeVibrance(scheme),
21605
+ hue: scheme.longHue && scheme.type != 'diverging' ? 'longer' : 'shorter'
21606
+ });
21607
+ }
21608
+
21609
+ function getSchemeVibrance(scheme) {
21610
+ return scheme.vibrance >= 0 ? scheme.vibrance : defaultVibrance;
21611
+ }
21612
+
21613
+ function setSchemeVibrance(scheme, vibrance) {
21614
+ vibrance = clamp$4(+vibrance || 0, 0, maxVibrance);
21615
+ return Object.assign({}, scheme, {vibrance: vibrance});
21616
+ }
21617
+
21618
+ // longHue: hues go the long way around the color wheel between pinned colors
21619
+ function setSchemeLongHue(scheme, longHue) {
21620
+ return Object.assign({}, scheme, {longHue: !!longHue});
21621
+ }
21622
+
21623
+ function clamp$4(val, min, max) {
21624
+ return Math.min(Math.max(val, min), max);
21625
+ }
21626
+
21627
+ function getPresetColors(name, n) {
21628
+ return internal.getColorRamp(name, n).map(toHex);
21629
+ }
21630
+
21631
+ // All the swatches of a categorical scheme, in order: the first n are used
21632
+ function getCategoricalPalette(scheme) {
21633
+ var colors;
21634
+ if (!scheme.preset) return scheme.swatches.concat();
21635
+ colors = getCategoricalPresetColors(scheme.preset);
21636
+ if (!scheme.order) return colors;
21637
+ return scheme.order.map(function(i) { return colors[i]; });
21638
+ }
21639
+
21640
+ function getCategoricalPresetColors(name) {
21641
+ return internal.getCategoricalColors(name).map(toHex);
21642
+ }
21643
+
21644
+ // A list of n colors, starting with the given ones and going on with colors
21645
+ // they don't have yet
21646
+ function extendSwatches(colors, n) {
21647
+ var swatches = colors.slice(0, n);
21648
+ getCategoricalPresetColors(extraSwatchSource).forEach(function(color) {
21649
+ if (swatches.length < n && !swatches.includes(color)) {
21650
+ swatches.push(color);
21651
+ }
21652
+ });
21653
+ return internal.wrapColors(swatches, n);
21654
+ }
21655
+
21656
+ // One boolean per tile: whether the user set its color.
21657
+ function getPinnedTiles(scheme) {
21658
+ if (scheme.preset || scheme.type == 'categorical') {
21659
+ return new Array(getRampSize(scheme)).fill(false);
21660
+ }
21661
+ return internal.getPinnedSlots(scheme.pins, getRampSize(scheme)).map(function(pinId) {
21662
+ return pinId > -1;
21663
+ });
21664
+ }
21665
+
21666
+ // count: the number of categories, for a categorical scheme
21667
+ function choosePreset(scheme, name, count) {
21668
+ var next;
21669
+ if (scheme.type == 'categorical') {
21670
+ next = Object.assign({}, scheme, {preset: name, order: null, swatches: null});
21671
+ next.n = clampColorCount(next, next.n, count);
21672
+ return next;
21673
+ }
21674
+ return Object.assign({}, scheme, {preset: name, reversed: false, pins: null});
21675
+ }
21676
+
21677
+ // A preset as a custom ramp, pinned at its ends, for editing.
21678
+ function makeSchemeCustom(scheme) {
21679
+ var colors;
21680
+ if (!scheme.preset) return scheme;
21681
+ if (scheme.type == 'categorical') {
21682
+ return Object.assign({}, scheme, {preset: null, order: null, swatches: getCategoricalPalette(scheme)});
21683
+ }
21684
+ colors = getSchemeColors(scheme);
21685
+ return Object.assign({}, scheme, {
21686
+ preset: null,
21687
+ reversed: false,
21688
+ pins: scheme.type == 'diverging' ?
21689
+ [{t: 0, color: colors[0]}, {t: 0.5, color: colors[(colors.length - 1) / 2]},
21690
+ {t: 1, color: colors[colors.length - 1]}] :
21691
+ [{t: 0, color: colors[0]}, {t: 1, color: colors[colors.length - 1]}]
21692
+ });
21693
+ }
21694
+
21695
+ // The center tile of a diverging ramp, which stays pinned
21696
+ function getCenterTile(scheme) {
21697
+ return scheme.type == 'diverging' ? (getRampSize(scheme) - 1) / 2 : -1;
21698
+ }
21699
+
21700
+ // The color to edit for tile i: a pinned tile's own color, which vibrance
21701
+ // may have raised in the ramp, or else the tile's color
21702
+ function getTileEditColor(scheme, i) {
21703
+ var slot = scheme.preset || scheme.type == 'categorical' ? -1 :
21704
+ internal.getPinnedSlots(scheme.pins, getRampSize(scheme))[i];
21705
+ if (slot > -1) return toHex(scheme.pins[slot].color);
21706
+ return getSchemeColors(scheme)[i];
21707
+ }
21708
+
21709
+ function setTileColor(scheme, i, color) {
21710
+ var custom = makeSchemeCustom(scheme);
21711
+ var swatches;
21712
+ if (custom.type == 'categorical') {
21713
+ swatches = custom.swatches.concat();
21714
+ swatches[i] = toHex(color);
21715
+ return Object.assign({}, custom, {swatches: swatches});
21716
+ }
21717
+ return Object.assign({}, custom, {
21718
+ pins: internal.setRampPin(custom.pins, getRampSize(custom), i, toHex(color))
21719
+ });
21720
+ }
21721
+
21722
+ function clearTileColor(scheme, i) {
21723
+ if (scheme.preset || scheme.type == 'categorical' || i == getCenterTile(scheme)) return scheme;
21724
+ return Object.assign({}, scheme, {
21725
+ pins: internal.clearRampPin(scheme.pins, getRampSize(scheme), i)
21726
+ });
21727
+ }
21728
+
21729
+ // A custom categorical list grows to have at least n swatches.
21730
+ // count: the number of categories, for a categorical scheme
21731
+ function setTileCount(scheme, n, count) {
21732
+ var next = Object.assign({}, scheme, {n: clampColorCount(scheme, n, count)});
21733
+ if (next.type == 'categorical' && !next.preset && next.swatches.length < next.n) {
21734
+ next.swatches = extendSwatches(next.swatches, next.n);
21735
+ }
21736
+ return next;
21737
+ }
21738
+
21739
+ // Moves a categorical swatch to a new place in the palette. Moving one
21740
+ // into the swatches in use pushes the last of them out of use, and moving one
21741
+ // out of use brings the first unused one in.
21742
+ // to: the swatch's index after the move
21743
+ function moveSwatch(scheme, from, to) {
21744
+ return rearrangeSwatches(scheme, function(items) {
21745
+ var item = items.splice(from, 1)[0];
21746
+ items.splice(to, 0, item);
21747
+ return items;
21748
+ });
21749
+ }
21750
+
21751
+ // Shuffles all of a categorical palette's swatches, in use or not, into a
21752
+ // new order.
21753
+ // random: a function like Math.random (for testing)
21754
+ function shuffleScheme(scheme, random) {
21755
+ var rand = random || Math.random;
21756
+ return rearrangeSwatches(scheme, function(items) {
21757
+ var shuffled, tries = 0;
21758
+ do {
21759
+ shuffled = shuffle(items.concat(), rand);
21760
+ } while (items.length > 1 && ++tries < 10 && shuffled.every(function(item, i) {
21761
+ return item == items[i];
21762
+ }));
21763
+ return shuffled;
21764
+ });
21765
+ }
21766
+
21767
+ function shuffle(arr, rand) {
21768
+ var j, tmp;
21769
+ for (var i=arr.length - 1; i>0; i--) {
21770
+ j = Math.floor(rand() * (i + 1));
21771
+ tmp = arr[i];
21772
+ arr[i] = arr[j];
21773
+ arr[j] = tmp;
21774
+ }
21775
+ return arr;
21776
+ }
21777
+
21778
+ // Presets are rearranged by index, so that they keep their name
21779
+ function rearrangeSwatches(scheme, rearrange) {
21780
+ var order;
21781
+ if (scheme.type != 'categorical') return scheme;
21782
+ if (!scheme.preset) {
21783
+ return Object.assign({}, scheme, {swatches: rearrange(scheme.swatches.concat())});
21784
+ }
21785
+ order = scheme.order || getCategoricalPresetColors(scheme.preset).map(function(c, i) { return i; });
21786
+ return Object.assign({}, scheme, {order: rearrange(order.concat())});
21787
+ }
21788
+
21789
+ function reverseScheme(scheme) {
21790
+ if (scheme.type == 'categorical') return scheme;
21791
+ if (scheme.preset) {
21792
+ return Object.assign({}, scheme, {reversed: !scheme.reversed});
21793
+ }
21794
+ return Object.assign({}, scheme, {
21795
+ pins: scheme.pins.map(function(pin) {
21796
+ return {t: 1 - pin.t, color: pin.color};
21797
+ }).reverse()
21798
+ });
21799
+ }
21800
+
21801
+ // The colors to give -classify: no more than there are categories, for the
21802
+ // categorical method. A diverging scheme gives all its tiles, from which
21803
+ // -classify takes the same ones the classes use here (see
21804
+ // getDivergingClassValues()).
21805
+ // count: the number of categories
21806
+ function getAppliedColors(scheme, count) {
21807
+ var colors = getSchemeColors(scheme);
21808
+ if (scheme.type == 'categorical' && scheme.method == 'categorical' && count > 0) {
21809
+ colors = colors.slice(0, count);
21810
+ }
21811
+ return colors;
21812
+ }
21813
+
21814
+ // opts.target a -target value, when the layer is not the active one
21815
+ function formatSchemeCommand(scheme, colors, opts) {
21816
+ var parts = ['-classify'];
21817
+ if (scheme.method != 'non-adjacent') {
21818
+ parts.push('field=' + quoteCommandValue(scheme.field));
21819
+ }
21820
+ parts.push('method=' + scheme.method);
21821
+ if (scheme.type == 'diverging') {
21822
+ parts.push('pivot=' + scheme.pivot, 'classes=' + getDivergingClassesOption(scheme));
21823
+ // continuous output has no pivot class by default, classes have one
21824
+ if (!getSchemeNeutral(scheme)) parts.push('no-pivot-class');
21825
+ else if (scheme.continuous) parts.push('pivot-class');
21826
+ } else if (isContinuousScheme(scheme)) {
21827
+ parts.push('classes=' + (scheme.n - 1));
21828
+ }
21829
+ if (isContinuousScheme(scheme)) {
21830
+ // the tiles already have their vibrance, so no vibrance=
21831
+ parts.push('continuous', 'interpolation=oklch');
21832
+ }
21833
+ parts.push('colors=' + colors.join(','));
21834
+ if (scheme.method != 'non-adjacent' && getSchemeNullColor(scheme) != defaultNullColor) {
21835
+ parts.push('null-value=' + getSchemeNullColor(scheme));
21836
+ }
21837
+ if (opts && opts.target) {
21838
+ parts.push('target=' + opts.target);
21839
+ }
21840
+ return parts.join(' ');
21841
+ }
21842
+
21843
+ function getNumericFields(lyr) {
21844
+ var records = lyr && lyr.data ? lyr.data.getRecords() : [];
21845
+ if (!lyr || !lyr.data) return [];
21846
+ return lyr.data.getFields().filter(function(field) {
21847
+ return internal.getColumnType(field, records) == 'number';
21848
+ });
21849
+ }
21850
+
21851
+ function setLayerScheme(lyr, scheme) {
21852
+ if (!lyr) return;
21853
+ if (scheme) {
21854
+ layerSchemes.set(lyr, scheme);
21855
+ } else {
21856
+ layerSchemes.delete(lyr);
21857
+ }
21858
+ }
21859
+
21860
+ // The scheme that colored a layer, or null if it has none, or if its fills
21861
+ // have since been changed some other way.
21862
+ function getLayerScheme(lyr) {
21863
+ var scheme = lyr ? layerSchemes.get(lyr) : null;
21864
+ if (!scheme || !schemeMatchesLayer(scheme, lyr)) return null;
21865
+ return scheme;
21866
+ }
21867
+
21868
+ // Checked from the data rather than kept in step with every edit, so that
21869
+ // anything that changes the fills -- a -style command, a typed fill color,
21870
+ // an undo past the scheme -- retires it.
21871
+ function schemeMatchesLayer(scheme, lyr) {
21872
+ var records, colors, val;
21873
+ if (!lyr || !lyr.data || !scheme.colors) return false;
21874
+ if (scheme.method != 'non-adjacent' && !lyr.data.fieldExists(scheme.field)) return false;
21875
+ if (isContinuousScheme(scheme)) {
21876
+ return !!scheme.fillHash && scheme.fillHash == getFillFingerprint(lyr);
21877
+ }
21878
+ colors = scheme.colors.map(function(c) { return c.toLowerCase(); });
21879
+ records = lyr.data.getRecords();
21880
+ for (var i=0; i<records.length; i++) {
21881
+ val = records[i] && records[i].fill;
21882
+ if (!val || (!colors.includes(String(val).toLowerCase()) && !isNullFill(val, scheme))) {
21883
+ return false;
21884
+ }
21885
+ }
21886
+ return true;
21887
+ }
21888
+
21889
+ // A scheme as applied to a layer, with what's needed to check it later
21890
+ // colors: the colors given to -classify
21891
+ function getAppliedScheme(scheme, colors, lyr) {
21892
+ var applied = Object.assign({}, scheme, {colors: colors});
21893
+ if (isContinuousScheme(scheme)) {
21894
+ applied.fillHash = getFillFingerprint(lyr);
21895
+ } else {
21896
+ delete applied.fillHash;
21897
+ }
21898
+ return applied;
21899
+ }
21900
+
21901
+ // A hash of a layer's fill values (FNV-1a, 32 bits)
21902
+ function getFillFingerprint(lyr) {
21903
+ var records = lyr && lyr.data ? lyr.data.getRecords() : [];
21904
+ var hash = 0x811c9dc5;
21905
+ var str;
21906
+ for (var i=0; i<records.length; i++) {
21907
+ str = String(records[i] ? records[i].fill : '') + '\n';
21908
+ for (var j=0; j<str.length; j++) {
21909
+ hash ^= str.charCodeAt(j);
21910
+ hash = Math.imul(hash, 0x01000193);
21911
+ }
21912
+ }
21913
+ return records.length + ':' + (hash >>> 0).toString(16);
21914
+ }
21915
+
21916
+ function isNullFill(val, scheme) {
21917
+ var color = getSchemeNullColor(scheme);
21918
+ val = String(val).toLowerCase();
21919
+ // -classify writes its default as #eee
21920
+ return val == color || color == defaultNullColor && val == '#eee';
21921
+ }
21922
+
21923
+ function toHex(color) {
21924
+ return internal.formatColor(internal.parseColor(color));
21925
+ }
21926
+
21927
+ // A popup beside the polygon style panel for coloring a layer's fills by a
21928
+ // data field, with -classify. Edits are applied as they are made, in a command
21929
+ // session, so that the whole time the panel is open on a layer is one undo
21930
+ // state and one command in the session history.
21931
+ // See docs/development/color-scheme-panel-design.md
21932
+ //
21933
+ // opts.getExtraCommands() commands to run after -classify, in the same step
21934
+ // (the style panel's pattern backgrounds follow the
21935
+ // fills)
21936
+ // opts.getSelectionCount() features selected in the style panel
21937
+ // opts.onUpdate() the scheme or the fills changed
21938
+ // opts.onClose() the panel was closed
21939
+ var reverseIcon = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" ' +
21940
+ 'stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">' +
21941
+ '<path d="M2 4.5h9.5M9 2l2.5 2.5L9 7"/><path d="M12 9.5H2.5M5 7L2.5 9.5 5 12"/></svg>';
21942
+ var shuffleIcon = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" ' +
21943
+ 'stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">' +
21944
+ '<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"/>' +
21945
+ '<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"/>' +
21946
+ '<path d="M11 1.5l1.7 2-1.7 2M11 8.5l1.7 2-1.7 2"/></svg>';
21947
+ var longHueIcon = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" ' +
21948
+ 'stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">' +
21949
+ '<path d="M11.5 4.6A5 5 0 1 0 12 7"/><path d="M12 1.8v3h-3"/></svg>';
21950
+ var continuousIcon = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none">' +
21951
+ '<defs><linearGradient id="color-scheme-continuous-icon">' +
21952
+ '<stop offset="0" stop-color="currentColor" stop-opacity="0"/>' +
21953
+ '<stop offset="1" stop-color="currentColor"/></linearGradient></defs>' +
21954
+ '<rect x="1.5" y="3.5" width="11" height="7" rx="1.5" fill="url(#color-scheme-continuous-icon)" ' +
21955
+ 'stroke="currentColor" stroke-width="1.2"/></svg>';
21956
+
21957
+ function ColorSchemePanel(gui, opts) {
21958
+ var parent = gui.container.findChild('.mshp-main-map');
21959
+ var panel = El('div').addClass('label-style-panel color-scheme-panel').appendTo(parent).hide();
21960
+ var session = null; // made on first use: the console is made after the panel
21961
+ var targetLayer = null;
21962
+ var scheme = null;
21963
+ var sessionLayer = null; // the layer the session's edits are to
21964
+ var baseScheme = null; // its scheme when they began
21965
+ var selectedTile = -1;
21966
+ // the scheme last used on each tab while the panel is open, for switching back
21967
+ var tabSchemes = {};
21968
+ var tabs = {};
21969
+ var paletteBtn, paletteMenu, countField, tileRow, gradientEl, tilesEl, picker, fieldRow, fieldSelect, methodSelect,
21970
+ noFieldsNote, selectionNote, controlsEl, vibranceRow, vibranceInput,
21971
+ longHueBtn, continuousBtn, reverseBtn, shuffleBtn, dropMarker, nullControl, nullCount, countLabel,
21972
+ divergingEl, pivotSelect, pivotInput, pivotNote, neutralToggle, splitSelect;
21973
+ // set while a tile is being dragged, so that letting go isn't a click
21974
+ var tileDrag = null;
21975
+
21976
+ initPanel();
21977
+
21978
+ gui.on('undo_redo_post', function() {
21979
+ var restored;
21980
+ if (!targetLayer) return;
21981
+ restored = getLayerScheme(targetLayer);
21982
+ if (restored) {
21983
+ scheme = updateDivergingLayout(restored, targetLayer);
21984
+ render();
21985
+ } else {
21986
+ close();
21987
+ }
21988
+ });
21989
+
21990
+ this.open = function(lyr) {
21991
+ var fields;
21992
+ if (targetLayer && targetLayer != lyr) close();
21993
+ targetLayer = lyr;
21994
+ selectedTile = -1;
21995
+ tabSchemes = {};
21996
+ scheme = getLayerScheme(lyr);
21997
+ panel.show();
21998
+ if (scheme) {
21999
+ scheme = updateDivergingLayout(scheme, lyr);
22000
+ } else {
22001
+ fields = getNumericFields(lyr);
22002
+ scheme = getDefaultSchemeOfType(fields.length > 0 ? 'sequential' : 'categorical', lyr);
22003
+ if (canApply(scheme)) apply();
22004
+ }
22005
+ render();
22006
+ };
22007
+
22008
+ this.close = close;
22009
+
22010
+ this.isOpen = function() {
22011
+ return !!targetLayer;
22012
+ };
22013
+
22014
+ function close() {
22015
+ var wasOpen = !!targetLayer;
22016
+ picker.hide();
22017
+ nullControl.picker.hide();
22018
+ hidePaletteMenu();
22019
+ panel.hide();
22020
+ targetLayer = null;
22021
+ scheme = null;
22022
+ if (session) session.finish();
22023
+ if (wasOpen && opts.onClose) opts.onClose();
22024
+ }
22025
+
22026
+ function getSession() {
22027
+ if (!session && gui.console && gui.console.createCommandSession) {
22028
+ session = gui.console.createCommandSession({label: 'Color scheme', onCommit: onCommit});
22029
+ }
22030
+ return session;
22031
+ }
22032
+
22033
+ function initPanel() {
22034
+ claimFieldKeys(panel.node(), {
22035
+ revert: render,
22036
+ release: releaseFocus
22037
+ });
22038
+ panel.node().addEventListener('click', function(e) {
22039
+ if (isTextInput(document.activeElement) || opensAMenu(e.target)) return;
22040
+ releaseFocus();
22041
+ });
22042
+
22043
+ var header = El('div').addClass('label-style-panel-title').appendTo(panel);
22044
+ El('span').appendTo(header).text('Color palettes');
22045
+ El('button').addClass('label-style-close').appendTo(header).text('×').on('click', function() {
22046
+ close();
22047
+ opts.onUpdate();
22048
+ });
22049
+
22050
+ var tabRow = El('div').addClass('label-style-row color-scheme-tabs').attr('role', 'tablist').appendTo(panel);
22051
+ schemeTypes.forEach(function(type) {
22052
+ tabs[type.name] = El('button').addClass('color-scheme-tab').attr('type', 'button')
22053
+ .attr('role', 'tab').text(type.label).appendTo(tabRow)
22054
+ .on('click', function() {
22055
+ switchType(type.name);
22056
+ });
22057
+ });
22058
+
22059
+ noFieldsNote = El('div').addClass('label-style-row color-scheme-note').appendTo(panel)
22060
+ .text('This layer has no numeric fields to classify.');
22061
+ controlsEl = El('div').appendTo(panel);
22062
+
22063
+ fieldRow = El('div').addClass('label-style-row color-scheme-select-row').appendTo(controlsEl);
22064
+ El('span').appendTo(fieldRow).text('Field');
22065
+ fieldSelect = El('select').attr('aria-label', 'Data field').appendTo(fieldRow).on('change', function() {
22066
+ var field = fieldSelect.node().value;
22067
+ changeScheme(setSchemeField(scheme, field, getCategoryCount(field)));
22068
+ });
22069
+
22070
+ var methodRow = El('div').addClass('label-style-row color-scheme-select-row').appendTo(controlsEl);
22071
+ El('span').appendTo(methodRow).text('Method');
22072
+ methodSelect = El('select').attr('aria-label', 'Classification method').appendTo(methodRow).on('change', function() {
22073
+ var field = scheme.field || getCategoryFields(targetLayer)[0];
22074
+ changeScheme(setSchemeMethod(scheme, methodSelect.node().value, field, getCategoryCount(field)));
22075
+ });
22076
+
22077
+ initDivergingControls();
22078
+
22079
+ var paletteRow = El('div').addClass('label-style-row label-split-row').appendTo(controlsEl);
22080
+ var paletteCell = El('div').addClass('label-split-cell color-scheme-palette-cell').appendTo(paletteRow);
22081
+ var countCell = El('div').addClass('label-split-cell').appendTo(paletteRow);
22082
+ El('span').appendTo(paletteCell).text('Palette');
22083
+ paletteBtn = El('div').addClass('color-scheme-palette-btn').attr('role', 'button')
22084
+ .attr('aria-label', 'Palette').appendTo(paletteCell).on('click', togglePaletteMenu);
22085
+ paletteMenu = El('div').addClass('color-scheme-palette-menu').appendTo(paletteCell).hide();
22086
+ countLabel = El('span').appendTo(countCell).text('Colors');
22087
+ // the scheme sets the limits (see getMinSchemeColors(), getMaxSchemeColors())
22088
+ countField = new SizeField(countCell, {
22089
+ min: 1,
22090
+ max: maxCategoricalColors,
22091
+ step: 1,
22092
+ bigStep: 2,
22093
+ decimals: 0,
22094
+ title: 'Number of classes',
22095
+ onSet: function(n) {
22096
+ changeScheme(setTileCount(scheme, n, getCategoryCount()));
22097
+ },
22098
+ onStep: function(delta) {
22099
+ changeScheme(setTileCount(scheme, scheme.n + (delta > 0 ? 1 : -1), getCategoryCount()));
22100
+ },
22101
+ onDone: releaseFocus
22102
+ });
22103
+
22104
+ tileRow = El('div').addClass('color-scheme-tile-row').appendTo(controlsEl);
22105
+ gradientEl = El('div').addClass('color-scheme-gradient').appendTo(tileRow);
22106
+ tilesEl = El('div').addClass('color-scheme-tiles').appendTo(tileRow);
22107
+ picker = new ColorPicker(tileRow, {
22108
+ presetRows: [],
22109
+ onPreview: function(hex) {
22110
+ var tile = getTileCells()[selectedTile];
22111
+ if (tile) tile.querySelector('.color-scheme-tile').style.backgroundColor = hex;
22112
+ },
22113
+ onChange: function(hex) {
22114
+ if (selectedTile > -1) changeScheme(setTileColor(scheme, selectedTile, hex));
22115
+ },
22116
+ // the highlight says which tile the picker is editing
22117
+ onHide: function() {
22118
+ selectedTile = -1;
22119
+ render();
22120
+ }
22121
+ });
22122
+
22123
+ // the slider works in thousandths of OKLCH chroma
22124
+ vibranceRow = addSliderRow('Vibrance', 'Makes all the colors more vivid',
22125
+ 0, Math.round(maxVibrance * 1000), 5, function(val) {
22126
+ changeScheme(setSchemeVibrance(scheme, val / 1000));
22127
+ });
22128
+ vibranceInput = vibranceRow.findChild('input');
22129
+ longHueBtn = addIconButton(vibranceRow, 'Go the long way around the color wheel', longHueIcon, function() {
22130
+ changeScheme(setSchemeLongHue(scheme, !scheme.longHue));
22131
+ }).addClass('long-hue-btn');
22132
+ continuousBtn = addIconButton(vibranceRow, 'Continuous colors (unclassed)', continuousIcon, function() {
22133
+ changeScheme(setSchemeContinuous(scheme, !scheme.continuous));
22134
+ }).addClass('continuous-btn');
22135
+ reverseBtn = addIconButton(vibranceRow, 'Reverse the colors', reverseIcon, function() {
22136
+ changeScheme(reverseScheme(scheme));
22137
+ });
22138
+ shuffleBtn = addIconButton(vibranceRow, 'Shuffle the colors', shuffleIcon, function() {
22139
+ changeScheme(shuffleScheme(scheme));
22140
+ });
22141
+ // the color of features with no data, and how many there are
22142
+ nullControl = makeColorRow(controlsEl, {
22143
+ label: 'No data',
22144
+ noOpacity: true,
22145
+ onColor: function(color) {
22146
+ changeScheme(setSchemeNullColor(scheme, color));
22147
+ }
22148
+ });
22149
+ nullControl.row.addClass('color-scheme-null-row');
22150
+ nullControl.chit.on('click', function() {
22151
+ picker.hide();
22152
+ });
22153
+ nullCount = El('span').addClass('color-scheme-note color-scheme-null-count').appendTo(nullControl.aside);
22154
+
22155
+ selectionNote = El('div').addClass('label-style-row color-scheme-note').appendTo(controlsEl)
22156
+ .text('Colors apply to every feature in the layer, not only the selected ones.');
22157
+
22158
+ document.addEventListener('mousedown', function(e) {
22159
+ if (paletteMenu.visible() && !paletteMenu.node().contains(e.target) &&
22160
+ !paletteBtn.node().contains(e.target)) {
22161
+ hidePaletteMenu();
22162
+ }
22163
+ });
22164
+ }
22165
+
22166
+ // The pivot, the pivot class and how the classes are split between the
22167
+ // sides
22168
+ function initDivergingControls() {
22169
+ divergingEl = El('div').appendTo(controlsEl);
22170
+ var pivotRow = El('div').addClass('label-style-row label-split-row').appendTo(divergingEl);
22171
+ var pivotCell = El('div').addClass('label-split-cell color-scheme-select-row').appendTo(pivotRow);
22172
+ var valueCell = El('div').addClass('label-split-cell color-scheme-select-row').appendTo(pivotRow);
22173
+ El('span').appendTo(pivotCell).text('Pivot');
22174
+ pivotSelect = El('select').attr('aria-label', 'Pivot').appendTo(pivotCell).on('change', function() {
22175
+ var name = pivotSelect.node().value;
22176
+ var current = scheme.layout ? scheme.layout.pivot : 0;
22177
+ changeScheme(setSchemePivot(scheme, name == 'value' ? current : name));
22178
+ });
22179
+ pivotOptions.forEach(function(item) {
22180
+ El('option').attr('value', item.name).text(item.label).appendTo(pivotSelect);
22181
+ });
22182
+ El('span').appendTo(valueCell).text('Value');
22183
+ pivotInput = El('input').attr('type', 'text').attr('aria-label', 'Pivot value')
22184
+ .addClass('color-scheme-pivot-input').appendTo(valueCell)
22185
+ .on('change', function() {
22186
+ var val = parseFloat(pivotInput.node().value);
22187
+ if (isFinite(val)) {
22188
+ changeScheme(setSchemePivot(scheme, val));
22189
+ } else {
22190
+ render();
22191
+ }
22192
+ })
22193
+ .on('keydown', function(e) {
22194
+ if (e.key == 'Enter') pivotInput.node().blur();
22195
+ });
22196
+ pivotNote = El('div').addClass('label-style-row color-scheme-note').appendTo(divergingEl);
22197
+
22198
+ var optionRow = El('div').addClass('label-style-row label-split-row').appendTo(divergingEl);
22199
+ var splitCell = El('div').addClass('label-split-cell color-scheme-select-row').appendTo(optionRow);
22200
+ var neutralCell = El('div').addClass('label-split-cell color-scheme-select-row').appendTo(optionRow);
22201
+ var neutralLabel = El('span').addClass('color-scheme-hint-label').appendTo(neutralCell).text('Pivot class')
22202
+ .attr('data-tooltip', 'A class around the pivot, in the center color')
22203
+ .on('mouseenter', function() { keepTooltipInWindow(neutralLabel.node()); });
22204
+ var neutralLine = El('div').addClass('color-scheme-toggle-line').appendTo(neutralCell);
22205
+ neutralToggle = makePanelToggle(neutralLine, {
22206
+ title: 'Pivot class',
22207
+ onChange: function(on) {
22208
+ changeScheme(setSchemeNeutral(scheme, on));
22209
+ }
22210
+ });
22211
+ El('span').appendTo(splitCell).text('Sides');
22212
+ splitSelect = El('select').attr('aria-label', 'Classes on each side').appendTo(splitCell).on('change', function() {
22213
+ changeScheme(setSchemeSplit(scheme, splitSelect.node().value));
22214
+ });
22215
+ divergingSplits.forEach(function(item) {
22216
+ El('option').attr('value', item.name).text(item.label).appendTo(splitSelect);
22217
+ });
22218
+ }
22219
+
22220
+ function renderDivergingControls() {
22221
+ var diverging = scheme.type == 'diverging';
22222
+ var pivot = scheme.pivot;
22223
+ var option = typeof pivot == 'number' ? 'value' : pivot;
22224
+ var summary;
22225
+ divergingEl.classed('hidden', !diverging);
22226
+ if (!diverging) return;
22227
+ pivotSelect.node().value = option;
22228
+ pivotInput.node().disabled = option != 'value';
22229
+ if (document.activeElement != pivotInput.node()) {
22230
+ pivotInput.node().value = scheme.layout ? formatNumber(scheme.layout.pivot) : '';
22231
+ }
22232
+ summary = getPivotSummary(scheme, targetLayer);
22233
+ pivotNote.text(summary ? formatCount(summary.below) + ' below the pivot, ' +
22234
+ summary.above + ' at or above' : 'No numeric data to classify.');
22235
+ neutralToggle.setState(getSchemeNeutral(scheme) ? 'on' : 'off');
22236
+ splitSelect.node().value = scheme.split;
22237
+ }
22238
+
22239
+ function formatCount(n) {
22240
+ return n + (n == 1 ? ' feature' : ' features');
22241
+ }
22242
+
22243
+ function formatNumber(val) {
22244
+ return String(+val.toPrecision(6));
22245
+ }
22246
+
22247
+ function render() {
22248
+ var categorical, fields, colors, tiles;
22249
+ if (!targetLayer || !scheme) return;
22250
+ categorical = scheme.type == 'categorical';
22251
+ Object.keys(tabs).forEach(function(type) {
22252
+ tabs[type].classed('selected', type == scheme.type)
22253
+ .attr('aria-selected', type == scheme.type ? 'true' : 'false');
22254
+ });
22255
+ fields = categorical ? getCategoryFields(targetLayer) : getNumericFields(targetLayer);
22256
+ noFieldsNote.classed('hidden', categorical || fields.length > 0);
22257
+ controlsEl.classed('hidden', !categorical && fields.length === 0);
22258
+ if (!categorical && fields.length === 0) return;
22259
+ renderFieldOptions(fields);
22260
+ fieldRow.classed('hidden', scheme.method == 'non-adjacent');
22261
+ renderMethodOptions(fields);
22262
+ renderDivergingControls();
22263
+ countLabel.text(scheme.type != 'diverging' ? 'Colors' : scheme.split == 'count' ? 'Per side' : 'Classes');
22264
+ countField.setValue(String(scheme.n));
22265
+ tiles = getSchemeTiles(scheme);
22266
+ colors = tiles.map(function(tile) { return tile.color; });
22267
+ renderPaletteButton(colors);
22268
+ renderTiles(tiles);
22269
+ renderVibrance();
22270
+ renderNullColor();
22271
+ selectionNote.classed('hidden', !(opts.getSelectionCount() > 0));
22272
+ }
22273
+
22274
+ function renderNullColor() {
22275
+ var count = getNoDataCount(targetLayer, scheme);
22276
+ nullControl.row.classed('hidden', scheme.method == 'non-adjacent');
22277
+ if (scheme.method == 'non-adjacent') nullControl.picker.hide();
22278
+ nullControl.showColor(getSchemeNullColor(scheme));
22279
+ nullCount.text(count === 0 ? 'None in this layer' : formatCount(count));
22280
+ }
22281
+
22282
+ function renderMethodOptions(fields) {
22283
+ methodSelect.empty();
22284
+ getSchemeMethods(scheme).forEach(function(item) {
22285
+ var opt = El('option').attr('value', item.name).text(item.label).appendTo(methodSelect);
22286
+ // classifying by category needs a field
22287
+ if (item.name == 'categorical' && fields.length === 0) opt.attr('disabled', true);
22288
+ });
22289
+ methodSelect.node().value = scheme.method;
22290
+ }
22291
+
22292
+ // Each tab keeps its own scheme while the panel is open, and starts from a
22293
+ // default the first time
22294
+ function switchType(type) {
22295
+ var next;
22296
+ if (type == scheme.type) return;
22297
+ tabSchemes[scheme.type] = scheme;
22298
+ next = tabSchemes[type] || getDefaultSchemeOfType(type, targetLayer);
22299
+ // one no-data color for both tabs
22300
+ next = updateDivergingLayout(Object.assign({}, next, {nullColor: scheme.nullColor}), targetLayer);
22301
+ picker.hide();
22302
+ selectedTile = -1;
22303
+ scheme = next;
22304
+ if (canApply(scheme)) apply();
22305
+ render();
22306
+ }
22307
+
22308
+ // a diverging scheme needs data that its classes can divide
22309
+ function canApply(s) {
22310
+ if (s.type == 'diverging' && !s.layout) return false;
22311
+ return s.method == 'non-adjacent' || !!s.field;
22312
+ }
22313
+
22314
+ // The number of categories in a field (the scheme's, by default), for a
22315
+ // categorical scheme
22316
+ function getCategoryCount(field) {
22317
+ if (scheme.type != 'categorical') return -1;
22318
+ return getCategories(targetLayer, field || scheme.field).length;
22319
+ }
22320
+
22321
+ function addSliderRow(label, tooltip, min, max, step, onInput) {
22322
+ var row = El('div').addClass('label-style-row color-scheme-slider-row').appendTo(controlsEl);
22323
+ var labelEl = El('span').addClass('color-scheme-slider-label').attr('data-tooltip', tooltip)
22324
+ .appendTo(row).text(label)
22325
+ .on('mouseenter', function() { keepTooltipInWindow(labelEl.node()); });
22326
+ var input = El('input').attr('type', 'range').attr('min', min).attr('max', max).attr('step', step)
22327
+ .attr('aria-label', label).appendTo(row)
22328
+ .on('input', function() {
22329
+ onInput(+input.node().value);
22330
+ })
22331
+ .on('change', releaseFocus);
22332
+ return row;
22333
+ }
22334
+
22335
+ function addIconButton(parent, label, icon, onClick) {
22336
+ var btn = El('button').addClass('color-scheme-icon-btn').attr('type', 'button')
22337
+ .attr('aria-label', label).attr('data-tooltip', label).html(icon).appendTo(parent)
22338
+ .on('click', onClick)
22339
+ .on('mouseenter', function() { keepTooltipInWindow(btn.node()); });
22340
+ return btn;
22341
+ }
22342
+
22343
+ // Presets and categorical schemes have no interpolated tiles for vibrance
22344
+ // or the hue path to change; the row keeps only the reverse button for them.
22345
+ function renderVibrance() {
22346
+ var vibrance = getSchemeVibrance(scheme);
22347
+ setSliderValue(vibranceInput, Math.round(vibrance * 1000));
22348
+ vibranceRow.classed('preset', !!scheme.preset || scheme.type == 'categorical');
22349
+ reverseBtn.classed('hidden', scheme.type == 'categorical');
22350
+ shuffleBtn.classed('hidden', scheme.type != 'categorical');
22351
+ // each half of a diverging ramp runs from a low-chroma center, where the
22352
+ // hue path makes little difference
22353
+ longHueBtn.classed('hidden', scheme.type == 'diverging')
22354
+ .classed('selected', !!scheme.longHue)
22355
+ .attr('aria-pressed', scheme.longHue ? 'true' : 'false');
22356
+ continuousBtn.classed('hidden', scheme.type == 'categorical')
22357
+ .classed('selected', isContinuousScheme(scheme))
22358
+ .attr('aria-pressed', isContinuousScheme(scheme) ? 'true' : 'false');
22359
+ }
22360
+
22361
+ function setSliderValue(input, val) {
22362
+ if (input.node().value != val) input.node().value = val;
22363
+ }
22364
+
22365
+ function renderFieldOptions(fields) {
22366
+ fieldSelect.empty();
22367
+ fields.forEach(function(field) {
22368
+ El('option').attr('value', field).text(field).appendTo(fieldSelect);
22369
+ });
22370
+ fieldSelect.node().value = scheme.field || '';
22371
+ }
22372
+
22373
+ function renderPaletteButton(colors) {
22374
+ paletteBtn.empty();
22375
+ El('div').addClass('color-scheme-strip').appendTo(paletteBtn)
22376
+ .css('background-image', isContinuousScheme(scheme) ?
22377
+ getGradientStripBackground(scheme) : getStripBackground(colors));
22378
+ El('span').addClass('color-scheme-palette-name').appendTo(paletteBtn)
22379
+ .text(scheme.preset || 'Custom');
22380
+ }
22381
+
22382
+ function renderTiles(tiles) {
22383
+ var categorical = scheme.type == 'categorical';
22384
+ var diverging = scheme.type == 'diverging';
22385
+ var continuous = isContinuousScheme(scheme);
22386
+ var used = tiles.length;
22387
+ var tileUse = diverging ? getDivergingTileUse(scheme) : null;
22388
+ var center = getCenterTile(scheme);
22389
+ var classLabels = categorical ? null : continuous ? getStopLabels() : getClassLabels(tileUse);
22390
+ var groups = categorical && scheme.method == 'categorical' ?
22391
+ getSwatchCategories(getCategories(targetLayer, scheme.field), used) : null;
22392
+ var n, perRow;
22393
+ // a categorical scheme shows its whole palette, and marks the swatches
22394
+ // in use
22395
+ if (categorical) {
22396
+ tiles = getCategoricalPalette(scheme).map(function(color) {
22397
+ return {color: color, pinned: false, adjusted: false};
22398
+ });
22399
+ }
22400
+ n = tiles.length;
22401
+ // the stops of a continuous ramp stay in one row, under the gradient
22402
+ perRow = n > 12 && !continuous ? Math.ceil(n / 2) : n;
22403
+ tilesEl.empty();
22404
+ tilesEl.classed('two-rows', perRow < n);
22405
+ tilesEl.classed('continuous', continuous);
22406
+ tilesEl.node().style.setProperty('--tiles-per-row', perRow);
22407
+ dropMarker = El('div').addClass('color-scheme-drop-marker').appendTo(tilesEl).hide();
22408
+ tiles.forEach(function(tile, i) {
22409
+ var color = tile.color;
22410
+ var inUse = tileUse ? tileUse[i] : !categorical || i < used;
22411
+ var cell = El('div').addClass('color-scheme-tile-cell').appendTo(tilesEl);
22412
+ var tileEl = El('div').addClass('color-scheme-tile').attr('role', 'button')
22413
+ .attr('aria-label', 'Color ' + (i + 1) + ': ' + color + (inUse ? '' : ' (not used)'))
22414
+ .classed('selected', i == selectedTile)
22415
+ .css('background-color', color)
22416
+ .appendTo(cell)
22417
+ .on('click', function() {
22418
+ if (tileDrag && tileDrag.moved) return;
22419
+ selectTile(i, color);
22420
+ });
22421
+ if (categorical) {
22422
+ tileEl.on('pointerdown', function(e) {
22423
+ startTileDrag(e, i);
22424
+ });
22425
+ }
22426
+ if (groups && inUse || classLabels && classLabels[i]) {
22427
+ tileEl.attr('data-tooltip', groups ? formatCategories(groups[i]) : classLabels[i])
22428
+ .on('mouseenter', function() { keepTooltipInWindow(tileEl.node()); });
22429
+ }
22430
+ if (diverging && !continuous) {
22431
+ // the bar under the tiles that classes use; a gap at the center
22432
+ // when there is no pivot class (continuous ramps have the gradient)
22433
+ El('div').addClass('color-scheme-bar').appendTo(cell)
22434
+ .classed('used', inUse)
22435
+ .classed('joined', inUse && i < n - 1 && tileUse[i + 1]);
22436
+ }
22437
+ var pin = El('div').addClass('color-scheme-pin').appendTo(cell)
22438
+ .on('mouseenter', function() { keepTooltipInWindow(pin.node()); });
22439
+ var end = i === 0 || i == n - 1 || i == center;
22440
+ if (categorical) {
22441
+ // a bar under the swatches in use, unbroken to the end of each row
22442
+ pin.classed('used', inUse)
22443
+ .classed('joined', inUse && i < used - 1 && (i + 1) % perRow > 0);
22444
+ // a pinned tile between the ends needs its dot for unpinning, even if
22445
+ // vibrance was clipped
22446
+ } else if (tile.pinned && !end) {
22447
+ pin.addClass('pinned').attr('role', 'button').attr('aria-label', 'Unpin this color')
22448
+ .attr('data-tooltip', 'Unpin this color')
22449
+ .on('click', function() {
22450
+ changeScheme(clearTileColor(scheme, i));
22451
+ });
22452
+ } else if (tile.adjusted) {
22453
+ pin.addClass('adjusted').attr('data-tooltip', describeAdjustment(tile));
22454
+ }
22455
+ });
22456
+ renderGradient(continuous ? getContinuousSegments(scheme) : null, n);
22457
+ }
22458
+
22459
+ // The gradient over a continuous ramp's tiles, each stop over the center
22460
+ // of its tile; one bar per side of a diverging ramp, and one for a pivot
22461
+ // class
22462
+ function renderGradient(segments, n) {
22463
+ var width = tilesEl.node().clientWidth;
22464
+ var gap = 3;
22465
+ var tileWidth = (width - (n - 1) * gap) / n;
22466
+ var center = function(pos) { return pos * (tileWidth + gap) + tileWidth / 2; };
22467
+ gradientEl.empty();
22468
+ gradientEl.classed('hidden', !segments);
22469
+ if (!segments || !(width > 0)) return;
22470
+ segments.forEach(function(seg) {
22471
+ var left = seg.flatStart ? center(seg.start) - tileWidth / 2 : center(seg.start);
22472
+ var right = seg.flatEnd ? center(seg.end) + tileWidth / 2 : center(seg.end);
22473
+ var stops = seg.samples.map(function(sample) {
22474
+ return sample.color + ' ' + round(center(sample.pos) - left) + 'px';
22475
+ });
22476
+ if (stops.length == 1) stops.push(stops[0]);
22477
+ El('div').addClass('color-scheme-gradient-segment').appendTo(gradientEl).css({
22478
+ left: round(left) + 'px',
22479
+ width: round(right - left) + 'px',
22480
+ 'background-image': 'linear-gradient(to right, ' + stops.join(', ') + ')'
22481
+ });
22482
+ });
22483
+ }
22484
+
22485
+ function round(val) {
22486
+ return Math.round(val * 10) / 10;
22487
+ }
22488
+
22489
+ // The data value at each stop of a continuous ramp, and the range of a
22490
+ // pivot class
22491
+ function getStopLabels() {
22492
+ var stops = getContinuousTileStops(scheme, targetLayer) || [];
22493
+ return stops.map(function(stop) {
22494
+ if (!stop) return null;
22495
+ if (stop.range) return formatClassRange(stop.range);
22496
+ return formatNumber(stop.value) + (stop.min ? ' (min)' : stop.max ? ' (max)' : '');
22497
+ });
22498
+ }
22499
+
22500
+ // The data range of each tile's class, for the tiles that classes use
22501
+ // (sequential tiles are all used, one class each)
22502
+ function getClassLabels(tileUse) {
22503
+ var ranges, j = 0;
22504
+ if (scheme.type == 'sequential') {
22505
+ ranges = getSequentialClassRanges(scheme, targetLayer) || [];
22506
+ return ranges.map(formatClassRange);
22507
+ }
22508
+ ranges = getDivergingClassRanges(scheme.layout);
22509
+ return tileUse.map(function(used) {
22510
+ return used ? formatClassRange(ranges[j++]) : null;
22511
+ });
22512
+ }
22513
+
22514
+ function formatClassRange(range) {
22515
+ if (!range) return null;
22516
+ if (range[0] == -Infinity && range[1] == Infinity) return 'All values';
22517
+ if (range[0] == -Infinity) return 'Below ' + formatNumber(range[1]);
22518
+ if (range[1] == Infinity) return formatNumber(range[0]) + ' and above';
22519
+ return formatNumber(range[0]) + ' to ' + formatNumber(range[1]);
22520
+ }
22521
+
22522
+ // Dragging a categorical swatch moves it to where it's dropped, which may
22523
+ // be into or out of the swatches in use
22524
+ function startTileDrag(e, from) {
22525
+ var x0 = e.clientX, y0 = e.clientY;
22526
+ var drag = tileDrag = {from: from, moved: false, before: -1};
22527
+ if (e.button !== 0) return;
22528
+ document.addEventListener('pointermove', onMove);
22529
+ document.addEventListener('pointerup', onUp);
22530
+ document.addEventListener('pointercancel', onCancel);
22531
+
22532
+ function onMove(e) {
22533
+ if (!drag.moved && Math.abs(e.clientX - x0) + Math.abs(e.clientY - y0) < 5) return;
22534
+ if (!drag.moved) {
22535
+ drag.moved = true;
22536
+ tilesEl.addClass('dragging');
22537
+ getTileCells()[from].classList.add('drag-source');
22538
+ }
22539
+ drag.before = findDropPlace(e.clientX, e.clientY);
22540
+ showDropMarker(drag.before);
22541
+ }
22542
+
22543
+ function onUp() {
22544
+ var to = drag.before > from ? drag.before - 1 : drag.before;
22545
+ finish();
22546
+ if (drag.moved && drag.before > -1 && to != from) {
22547
+ picker.hide();
22548
+ selectedTile = -1;
22549
+ changeScheme(moveSwatch(scheme, from, to));
22550
+ }
22551
+ // the click that follows a drag is ignored, then dragging is over
22552
+ setTimeout(function() {
22553
+ if (tileDrag == drag) tileDrag = null;
22554
+ }, 0);
22555
+ }
22556
+
22557
+ function onCancel() {
22558
+ finish();
22559
+ tileDrag = null;
22560
+ }
22561
+
22562
+ function finish() {
22563
+ document.removeEventListener('pointermove', onMove);
22564
+ document.removeEventListener('pointerup', onUp);
22565
+ document.removeEventListener('pointercancel', onCancel);
22566
+ tilesEl.removeClass('dragging');
22567
+ getTileCells().forEach(function(cell) { cell.classList.remove('drag-source'); });
22568
+ dropMarker.hide();
22569
+ }
22570
+ }
22571
+
22572
+ function getTileCells() {
22573
+ return Array.from(tilesEl.node().querySelectorAll('.color-scheme-tile-cell'));
22574
+ }
22575
+
22576
+ // The index of the tile that a swatch dropped at (x, y) goes in front of
22577
+ // (the number of tiles, after the last), on the row nearest the pointer
22578
+ function findDropPlace(x, y) {
22579
+ var rects = getTileCells().map(function(cell) {
22580
+ return cell.querySelector('.color-scheme-tile').getBoundingClientRect();
22581
+ });
22582
+ var best = -1, bestDist = Infinity;
22583
+ rects.forEach(function(rect, i) {
22584
+ var dy = y < rect.top ? rect.top - y : y > rect.bottom ? y - rect.bottom : 0;
22585
+ var dx = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0;
22586
+ var dist = dy * 1000 + dx;
22587
+ if (dist < bestDist) {
22588
+ bestDist = dist;
22589
+ best = x < (rect.left + rect.right) / 2 ? i : i + 1;
22590
+ }
22591
+ });
22592
+ return best;
22593
+ }
22594
+
22595
+ // A line in the gap where the swatch would go
22596
+ function showDropMarker(before) {
22597
+ var cells = getTileCells();
22598
+ var box = tilesEl.node().getBoundingClientRect();
22599
+ var ref = cells[Math.min(before, cells.length - 1)].querySelector('.color-scheme-tile');
22600
+ var rect = ref.getBoundingClientRect();
22601
+ var left = before < cells.length ? rect.left - 2 : rect.right + 1;
22602
+ dropMarker.css({
22603
+ left: (left - box.left) + 'px',
22604
+ top: (rect.top - box.top) + 'px',
22605
+ height: rect.height + 'px'
22606
+ }).show();
22607
+ }
22608
+
22609
+ // A tooltip is centered under its pin or button, unless that would run it past the
22610
+ // edge of the window
22611
+ function keepTooltipInWindow(el) {
22612
+ var margin = 4;
22613
+ el.style.removeProperty('--tooltip-shift');
22614
+ if (!el.hasAttribute('data-tooltip')) return;
22615
+ var width = parseFloat(getComputedStyle(el, '::after').width) || 0;
22616
+ var rect = el.getBoundingClientRect();
22617
+ var center = rect.left + rect.width / 2;
22618
+ var left = center - width / 2;
22619
+ var right = center + width / 2;
22620
+ var maxRight = document.documentElement.clientWidth - margin;
22621
+ var shift = 0;
22622
+ if (right > maxRight) shift = maxRight - right;
22623
+ else if (left < margin) shift = margin - left;
22624
+ if (shift) el.style.setProperty('--tooltip-shift', shift + 'px');
22625
+ }
22626
+
22627
+ function formatCategories(values) {
22628
+ // "and 1 more" would take a line that the value itself can have
22629
+ var max = values.length == 5 ? 5 : 4;
22630
+ var labels = values.slice(0, max).map(function(val) {
22631
+ return val === '' || val === null || val === undefined ? '(no value)' : String(val);
22632
+ });
22633
+ if (values.length > max) labels.push('and ' + (values.length - max) + ' more');
22634
+ return labels.join('\n');
22635
+ }
22636
+
22637
+ function describeAdjustment(tile) {
22638
+ var parts = [];
22639
+ var dl = tile.l - tile.ideal.l;
22640
+ var dc = tile.c - tile.ideal.c;
22641
+ if (Math.abs(dl) >= 0.0005) parts.push('lightness ' + formatShift(dl));
22642
+ if (Math.abs(dc) >= 0.0005) parts.push('chroma ' + formatShift(dc));
22643
+ return 'Shifted to fit the sRGB gamut:\n' + parts.join(', ');
22644
+ }
22645
+
22646
+ function formatShift(val) {
22647
+ return (val > 0 ? '+' : '') + val.toFixed(3);
22648
+ }
22649
+
22650
+ function selectTile(i, color) {
22651
+ nullControl.picker.hide();
22652
+ if (selectedTile == i && picker.visible()) {
22653
+ picker.hide();
22654
+ } else {
22655
+ selectedTile = i;
22656
+ picker.setColor(getTileEditColor(scheme, i) || color);
22657
+ if (!picker.visible()) picker.toggle();
22658
+ }
22659
+ render();
22660
+ }
22661
+
22662
+ function togglePaletteMenu() {
22663
+ if (paletteMenu.visible()) {
22664
+ hidePaletteMenu();
22665
+ } else {
22666
+ renderPaletteMenu();
22667
+ paletteMenu.show();
22668
+ }
22669
+ }
22670
+
22671
+ function hidePaletteMenu() {
22672
+ paletteMenu.hide();
22673
+ }
22674
+
22675
+ function renderPaletteMenu() {
22676
+ var categorical = scheme.type == 'categorical';
22677
+ var names = categorical ? getCategoricalPresetNames() :
22678
+ scheme.type == 'diverging' ? getDivergingPresetNames() : getSequentialPresetNames();
22679
+ paletteMenu.empty();
22680
+ addPaletteItem('Custom', getSchemeColors(makeSchemeCustom(scheme)), !scheme.preset, function() {
22681
+ changeScheme(makeSchemeCustom(scheme));
22682
+ });
22683
+ names.forEach(function(name) {
22684
+ var colors = categorical ? getCategoricalPresetColors(name) : getPresetColors(name, 7);
22685
+ addPaletteItem(name, colors, scheme.preset == name, function() {
22686
+ changeScheme(choosePreset(scheme, name, getCategoryCount()));
22687
+ });
22688
+ });
22689
+ }
22690
+
22691
+ function addPaletteItem(name, colors, selected, onSelect) {
22692
+ var item = El('div').addClass('color-scheme-palette-item').attr('role', 'button')
22693
+ .classed('selected', selected).appendTo(paletteMenu)
22694
+ .on('click', function() {
22695
+ hidePaletteMenu();
22696
+ onSelect();
22697
+ });
22698
+ El('div').addClass('color-scheme-strip').appendTo(item)
22699
+ .css('background-image', getStripBackground(colors));
22700
+ El('span').appendTo(item).text(name);
22701
+ }
22702
+
22703
+ function changeScheme(next) {
22704
+ var prevSize = getSchemeTiles(scheme).length;
22705
+ scheme = updateDivergingLayout(next, targetLayer);
22706
+ if (getSchemeTiles(scheme).length != prevSize) {
22707
+ picker.hide();
22708
+ selectedTile = -1;
22709
+ }
22710
+ apply();
22711
+ render();
22712
+ }
22713
+
22714
+ function apply() {
22715
+ var lyr = targetLayer;
22716
+ var applying = scheme;
22717
+ var colors = getAppliedColors(scheme, getCategoryCount());
22718
+ var cmd, extra;
22719
+ if (!lyr || !canApply(scheme) || !gui.console) return;
22720
+ getSession();
22721
+ if (!session || !session.isPending()) {
22722
+ sessionLayer = lyr;
22723
+ baseScheme = getLayerScheme(lyr);
22724
+ }
22725
+ cmd = formatSchemeCommand(scheme, colors, {target: getTargetOption(lyr)});
22726
+ extra = opts.getExtraCommands ? opts.getExtraCommands() : '';
22727
+ if (extra) cmd += ' ' + extra;
22728
+ runGuiEditCommand(gui, cmd, {
22729
+ title: 'Color scheme',
22730
+ session: session,
22731
+ onDone: function(err) {
22732
+ if (!err) setLayerScheme(lyr, getAppliedScheme(applying, colors, lyr));
22733
+ opts.onUpdate();
22734
+ }
22735
+ });
22736
+ }
22737
+
22738
+ // The scheme on each side of the session's undo state, to go with the fills
22739
+ // that undo and redo put back.
22740
+ function onCommit() {
22741
+ var lyr = sessionLayer;
22742
+ var before = baseScheme;
22743
+ var after = lyr ? getLayerScheme(lyr) : null;
22744
+ if (!lyr) return null;
22745
+ return {
22746
+ onUndo: function() {
22747
+ setLayerScheme(lyr, before);
22748
+ },
22749
+ onRedo: function() {
22750
+ setLayerScheme(lyr, after);
22751
+ }
22752
+ };
22753
+ }
22754
+
22755
+ function getTargetOption(lyr) {
22756
+ var active = gui.model.getActiveLayer();
22757
+ if (active && active.layer == lyr) return '';
22758
+ return internal.formatOptionValue(internal.getLayerTargetId(gui.model, lyr));
22759
+ }
22760
+
22761
+ function releaseFocus() {
22762
+ releasePanelFocus(panel.node());
22763
+ }
22764
+ }
22765
+
22766
+ // A scheme's colors as a CSS background: blocks, or for a continuous scheme,
22767
+ // its gradients, side by side
22768
+ function getSchemeStripBackground(scheme) {
22769
+ return isContinuousScheme(scheme) ? getGradientStripBackground(scheme) : getStripBackground(scheme.colors);
22770
+ }
22771
+
22772
+ // Each gradient takes room for its length, from its first stop to its last
22773
+ // (see getContinuousSegments()), without the flat ends the panel's bar has
22774
+ // over its end tiles; a pivot class takes a tile's width. Hard edges between
22775
+ // them.
22776
+ function getGradientStripBackground(scheme) {
22777
+ var segments = getContinuousSegments(scheme);
22778
+ var lengths = segments.map(function(seg) {
22779
+ return seg.end > seg.start ? seg.end - seg.start : 1;
22780
+ });
22781
+ var total = lengths.reduce(function(memo, len) { return memo + len; }, 0);
22782
+ var offset = 0;
22783
+ var stops = [];
22784
+ var pct = function(pos) { return (Math.round(pos / total * 10000) / 100) + '%'; };
22785
+ if (!(total > 0)) return 'none';
22786
+ segments.forEach(function(seg, i) {
22787
+ var first = seg.samples[0].color;
22788
+ var last = seg.samples[seg.samples.length - 1].color;
22789
+ stops.push(first + ' ' + pct(offset));
22790
+ seg.samples.forEach(function(sample) {
22791
+ stops.push(sample.color + ' ' + pct(offset + sample.pos - seg.start));
22792
+ });
22793
+ offset += lengths[i];
22794
+ stops.push(last + ' ' + pct(offset));
22795
+ });
22796
+ return 'linear-gradient(to right, ' + stops.join(', ') + ')';
22797
+ }
22798
+
22799
+ // A row of hard-edged color blocks, as a CSS background
22800
+ function getStripBackground(colors) {
22801
+ var n = colors.length;
22802
+ var stops = colors.map(function(color, i) {
22803
+ return color + ' ' + (i / n * 100) + '%, ' + color + ' ' + ((i + 1) / n * 100) + '%';
22804
+ });
22805
+ return 'linear-gradient(to right, ' + stops.join(', ') + ')';
22806
+ }
22807
+
20684
22808
  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);
22809
+ 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
22810
  var arrowShapes = [{
20687
22811
  name: 'arrow',
20688
22812
  title: 'solid arrowheads'
@@ -20713,14 +22837,28 @@
20713
22837
  label: 'Both',
20714
22838
  title: 'arrowheads at both ends of the line'
20715
22839
  }];
22840
+ var lineCaps = [{
22841
+ name: 'round',
22842
+ label: 'Round',
22843
+ title: 'round line caps'
22844
+ }, {
22845
+ name: 'butt',
22846
+ label: 'Butt',
22847
+ title: 'flat line caps that end at the line\'s endpoints'
22848
+ }, {
22849
+ name: 'square',
22850
+ label: 'Square',
22851
+ title: 'flat line caps that extend past the line\'s endpoints'
22852
+ }];
20716
22853
 
20717
22854
  function LayerStyleTool(gui) {
20718
22855
  var parent = gui.container.findChild('.mshp-main-map');
20719
22856
  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;
22857
+ var title, editingStatus, clearLink, strokeControl, fillControl, strokeWidthField, capControl, dashControl, arrowControl, presetControl, patternControl, glowControl, hit;
22858
+ var schemeBtn, schemeStrip, schemePanel;
20721
22859
  var targetLayer = null;
20722
22860
  // What the arrowhead switch turns on, for lines that have no heads
20723
- var lastArrow = {shape: 'arrow', position: 'end'};
22861
+ var lastArrow = {shape: 'arrow', position: 'end', fade: 0};
20724
22862
 
20725
22863
  initPanel();
20726
22864
  hit = gui.map.getHitControl && gui.map.getHitControl();
@@ -20771,6 +22909,7 @@
20771
22909
  }
20772
22910
 
20773
22911
  function turnOff() {
22912
+ schemePanel.close();
20774
22913
  panel.hide();
20775
22914
  strokeControl.picker.hide();
20776
22915
  fillControl.picker.hide();
@@ -20809,9 +22948,11 @@
20809
22948
  strokeControl = addColorControl(panel, 'Stroke', 'stroke', '#000000');
20810
22949
  strokeWidthField = addStrokeWidthControl(strokeControl.aside);
20811
22950
  fillControl = addColorControl(panel, 'Fill', 'fill', '');
20812
- randomFillBtn = makePanelActionButton(fillControl.aside, 'Random fill', applyRandomFillColors)
20813
- .addClass('layer-random-fill-btn');
20814
- dashControl = addDashArrayControl(panel);
22951
+ addColorSchemeControl();
22952
+ var lineRow = El('div').addClass('label-style-row label-split-row layer-line-row').appendTo(panel);
22953
+ capControl = addLineCapControl(lineRow);
22954
+ dashControl = addDashArrayControl(lineRow);
22955
+ dashControl.row = lineRow;
20815
22956
  arrowControl = addArrowControl(panel);
20816
22957
 
20817
22958
  var buttonRow = El('div').addClass('label-style-row label-panel-button-row').appendTo(panel);
@@ -20870,6 +23011,74 @@
20870
23011
  return control;
20871
23012
  }
20872
23013
 
23014
+ // Fills colored by a data field. While a scheme is applied, its colors take
23015
+ // the place of the fill's swatch and hex value, which have no one value to
23016
+ // show; the opacity still applies. Clicking them reopens the scheme.
23017
+ function addColorSchemeControl() {
23018
+ var box = fillControl.input.parent();
23019
+ schemeStrip = El('div').addClass('layer-scheme-strip').attr('role', 'button')
23020
+ .attr('aria-label', 'Edit color scheme');
23021
+ box.node().insertBefore(schemeStrip.node(), fillControl.input.node());
23022
+ schemeStrip.on('click', openColorSchemePanel);
23023
+ El('div').addClass('layer-scheme-colors').appendTo(schemeStrip);
23024
+ El('div').addClass('layer-scheme-remove').attr('role', 'button')
23025
+ .attr('aria-label', 'Remove color scheme').attr('title', 'Remove color scheme')
23026
+ .text('×').appendTo(schemeStrip)
23027
+ .on('click', function(e) {
23028
+ e.stopPropagation();
23029
+ removeColorScheme();
23030
+ });
23031
+ schemeBtn = makePanelActionButton(fillControl.aside, 'Palettes', openColorSchemePanel)
23032
+ .addClass('layer-palettes-btn');
23033
+ schemePanel = new ColorSchemePanel(gui, {
23034
+ getExtraCommands: function() {
23035
+ if (!targetLayer) return '';
23036
+ return formatStyleEditCommands(patternControl.getRefillExpressionEdits(getAllFeatureIds(targetLayer)));
23037
+ },
23038
+ getSelectionCount: function() {
23039
+ return getSelectionIds().length;
23040
+ },
23041
+ onUpdate: function() {
23042
+ if (panel.visible()) updateControls();
23043
+ },
23044
+ onClose: function() {
23045
+ panel.removeClass('replaced');
23046
+ }
23047
+ });
23048
+ }
23049
+
23050
+ function openColorSchemePanel() {
23051
+ syncTargetLayer();
23052
+ if (!targetLayer || targetLayer.geometry_type != 'polygon') return;
23053
+ fillControl.picker.hide();
23054
+ strokeControl.picker.hide();
23055
+ patternControl.hidePicker();
23056
+ // The scheme panel takes this panel's place until it closes
23057
+ panel.addClass('replaced');
23058
+ schemePanel.open(targetLayer);
23059
+ }
23060
+
23061
+ // Back to a single fill, unset, as when the fill field is emptied
23062
+ function removeColorScheme() {
23063
+ schemePanel.close();
23064
+ applyColorControlStyle(fillControl, '');
23065
+ }
23066
+
23067
+ function updateColorSchemeControl() {
23068
+ var isPolygon = targetLayer && targetLayer.geometry_type == 'polygon';
23069
+ var scheme = isPolygon ? getLayerScheme(targetLayer) : null;
23070
+ schemeBtn.classed('hidden', !isPolygon);
23071
+ fillControl.input.parent().classed('has-scheme', !!scheme);
23072
+ schemeBtn.classed('selected', schemePanel.isOpen());
23073
+ if (scheme) {
23074
+ schemeStrip.findChild('.layer-scheme-colors')
23075
+ .css('background-image', getSchemeStripBackground(scheme));
23076
+ schemeStrip.attr('title', scheme.method == 'non-adjacent' ?
23077
+ 'Neighbors colored differently' : 'Colored by ' + scheme.field);
23078
+ fillControl.picker.hide();
23079
+ }
23080
+ }
23081
+
20873
23082
  // In the narrow column beside the stroke's colour. Stepping runs up a ladder
20874
23083
  // of widths rather than by a fixed amount, because the useful ones are close
20875
23084
  // together at the hairline end and far apart above 2px.
@@ -20889,12 +23098,60 @@
20889
23098
  });
20890
23099
  }
20891
23100
 
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);
23101
+ // Round is the default, except on a dashed line, which the renderers give
23102
+ // butt caps unless it has a cap of its own.
23103
+ function addLineCapControl(row) {
23104
+ var cell = El('div').addClass('label-split-cell label-align-row').appendTo(row);
23105
+ var control = {btns: {}};
23106
+ El('span').appendTo(cell).text('Caps');
23107
+ var group = El('div').addClass('label-btn-group layer-cap-buttons').appendTo(cell);
23108
+ lineCaps.forEach(function(item) {
23109
+ control.btns[item.name] = makePanelButton(group, item.label, function() {
23110
+ applyLineCap(item.name);
23111
+ }).attr('data-line-cap', item.name).attr('aria-label', item.title);
23112
+ });
23113
+ return control;
23114
+ }
23115
+
23116
+ function getDefaultLineCap(rec) {
23117
+ return rec && rec['stroke-dasharray'] ? 'butt' : 'round';
23118
+ }
23119
+
23120
+ function getLineCap(rec) {
23121
+ return rec && rec['stroke-linecap'] || getDefaultLineCap(rec);
23122
+ }
23123
+
23124
+ // A cap that is the line's default is unset rather than stored, so that a
23125
+ // layer left at Round gets no stroke-linecap column.
23126
+ function applyLineCap(cap) {
23127
+ var records = getTargetRecords();
23128
+ var edits = [];
23129
+ getTargetIds().forEach(function(id) {
23130
+ var rec = records[id] || {};
23131
+ var value = cap == getDefaultLineCap(rec) ? '' : cap;
23132
+ if (value != (rec['stroke-linecap'] || '')) {
23133
+ edits.push({id: id, styles: [['stroke-linecap', value]]});
23134
+ }
23135
+ });
23136
+ runStyleEdits(edits);
23137
+ }
23138
+
23139
+ function updateLineCapControl() {
23140
+ var records = getTargetRecords();
23141
+ var cap;
23142
+ getTargetIds().forEach(function(id, i) {
23143
+ var val = getLineCap(records[id]);
23144
+ cap = i === 0 || val == cap ? val : '';
23145
+ });
23146
+ lineCaps.forEach(function(item) {
23147
+ capControl.btns[item.name].classed('selected', item.name == cap);
23148
+ });
23149
+ }
23150
+
23151
+ // A literal stroke-dasharray, in the narrow column beside the caps. A blank
23152
+ // field makes the line solid again.
23153
+ function addDashArrayControl(row) {
23154
+ var cell = El('div').addClass('label-split-cell layer-dash-cell').appendTo(row);
20898
23155
  var caption = El('div').addClass('label-style-row-label').appendTo(cell).text('Dashes');
20899
23156
  makeFieldTip(caption,
20900
23157
  'Dash and gap lengths in pixels, separated\n' +
@@ -20909,13 +23166,14 @@
20909
23166
  }
20910
23167
  applyDashArrayStyle(value);
20911
23168
  });
20912
- return {row: row, input: input};
23169
+ return {input: input};
20913
23170
  }
20914
23171
 
20915
23172
  // A switch in the heading says whether the lines have arrowheads; the rows
20916
23173
  // 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.
23174
+ // get it with the line's fade beside that. A line has one shape for both
23175
+ // ends, which is all the panel sets, though -style can give the two ends
23176
+ // different ones.
20919
23177
  function addArrowControl(parent) {
20920
23178
  var section = makePanelSection(parent, 'Arrowheads');
20921
23179
  var toggle = makePanelToggle(section.findChild('.label-style-section-title'), {
@@ -20928,8 +23186,8 @@
20928
23186
  var sizeCell = El('div').addClass('label-split-cell label-spacing-row layer-arrow-size-row').appendTo(shapeRow);
20929
23187
  var posRow = El('div').addClass('label-style-row label-split-row').appendTo(section);
20930
23188
  var posCell = El('div').addClass('label-split-cell label-align-row').appendTo(posRow);
23189
+ var fadeCell = El('div').addClass('label-split-cell layer-arrow-fade-cell').appendTo(posRow);
20931
23190
  var control = {section: section, toggle: toggle, shapeBtns: {}, posBtns: {}};
20932
- El('div').addClass('label-split-cell').appendTo(posRow);
20933
23191
  El('span').appendTo(shapeCell).text('Shape');
20934
23192
  var shapeGroup = El('div').addClass('label-btn-group label-callout-buttons layer-arrow-shape-buttons').appendTo(shapeCell);
20935
23193
  arrowShapes.forEach(function(item) {
@@ -20958,6 +23216,22 @@
20958
23216
  onStep: nudgeArrowSize,
20959
23217
  onDone: releaseFocus
20960
23218
  });
23219
+ var fadeCaption = El('div').addClass('label-style-row-label').appendTo(fadeCell).text('Fade %');
23220
+ makeFieldTip(fadeCaption,
23221
+ 'How much of the line fades in from its\n' +
23222
+ 'tail, as a percent of its length. The fade\n' +
23223
+ 'is a straight gradient, so it follows\n' +
23224
+ 'straight and gently curved lines best.');
23225
+ control.fadeField = new SizeField(fadeCell, {
23226
+ title: 'Percent of the line that fades in from its tail',
23227
+ min: 0,
23228
+ max: 100,
23229
+ step: 5,
23230
+ bigStep: 25,
23231
+ onSet: applyArrowFade,
23232
+ onStep: nudgeArrowFade,
23233
+ onDone: releaseFocus
23234
+ });
20961
23235
  return control;
20962
23236
  }
20963
23237
 
@@ -20972,10 +23246,11 @@
20972
23246
  return {shape: shape, position: position};
20973
23247
  }
20974
23248
 
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.
23249
+ // Switching on gives the lines that have no heads the last shape, ends and
23250
+ // fade the section showed, so that off and on again is a round trip; the
23251
+ // lines that have heads keep theirs. Switching off leaves line-end-size
23252
+ // alone, for the same reason, but removes the fade, which would otherwise
23253
+ // go on showing with its control hidden.
20979
23254
  function setArrowsOn(on) {
20980
23255
  applyArrowEdits(function(info) {
20981
23256
  return on ? fillArrowInfo(info) : {shape: 'none', position: ''};
@@ -21007,7 +23282,8 @@
21007
23282
  var edits = [];
21008
23283
  getTargetIds().forEach(function(id) {
21009
23284
  var rec = records[id] || {};
21010
- var next = getNext(getArrowInfo(rec));
23285
+ var info = getArrowInfo(rec);
23286
+ var next = getNext(info);
21011
23287
  var on = next.shape != 'none';
21012
23288
  var styles = [
21013
23289
  ['line-start', on && next.position != 'end' ? next.shape : ''],
@@ -21016,6 +23292,11 @@
21016
23292
  // no need to remove what is not there
21017
23293
  return style[1] || rec[style[0]];
21018
23294
  });
23295
+ if (!on && rec['line-fade']) {
23296
+ styles.push(['line-fade', '']);
23297
+ } else if (on && info.shape == 'none' && lastArrow.fade > 0 && !rec['line-fade']) {
23298
+ styles.push(['line-fade', lastArrow.fade]);
23299
+ }
21019
23300
  if (on && addStroke) styles.push(['stroke', strokeControl.defaultColor]);
21020
23301
  if (styles.length > 0) edits.push({id: id, styles: styles});
21021
23302
  });
@@ -21037,6 +23318,34 @@
21037
23318
  applyArrowSize(Math.max(1, Math.round(size + delta)));
21038
23319
  }
21039
23320
 
23321
+ // The field is a percent; line-fade is a share of the line, 0-1. No fade
23322
+ // is unset rather than stored as 0.
23323
+ function applyArrowFade(pct) {
23324
+ var ids = getArrowTargetIds();
23325
+ var value = pct > 0 ? Math.round(pct * 10) / 1000 : '';
23326
+ if (ids.length === 0) return;
23327
+ runStyleEdits(ids.map(function(id) {
23328
+ return {id: id, styles: [['line-fade', value]]};
23329
+ }));
23330
+ }
23331
+
23332
+ function nudgeArrowFade(delta) {
23333
+ var shown = getArrowFadeShown(getArrowTargetIds());
23334
+ if (shown.value === '') return;
23335
+ applyArrowFade(Math.max(0, Math.min(100, Math.round(shown.value + delta))));
23336
+ }
23337
+
23338
+ function getArrowFadeShown(ids) {
23339
+ var records = getTargetRecords();
23340
+ var value = '', pct;
23341
+ for (var i=0; i<ids.length; i++) {
23342
+ pct = Math.round(internal.svg.parseLineFade(records[ids[i]] && records[ids[i]]['line-fade']) * 1000) / 10;
23343
+ if (i > 0 && pct !== value) return {value: '', mixed: true};
23344
+ value = pct;
23345
+ }
23346
+ return {value: value, mixed: false};
23347
+ }
23348
+
21040
23349
  function getArrowTargetIds() {
21041
23350
  var records = getTargetRecords();
21042
23351
  return getTargetIds().filter(function(id) {
@@ -21097,6 +23406,11 @@
21097
23406
  arrowControl.sizeField.setValue(shown.value);
21098
23407
  arrowControl.sizeField.setPlaceholder(shown.mixed ? 'mixed' : '');
21099
23408
  arrowControl.sizeField.setDisabled(arrowIds.length === 0);
23409
+ var fade = arrowIds.length > 0 ? getArrowFadeShown(arrowIds) : {value: '', mixed: false};
23410
+ if (fade.value !== '') lastArrow.fade = fade.value / 100;
23411
+ arrowControl.fadeField.setValue(fade.value);
23412
+ arrowControl.fadeField.setPlaceholder(fade.mixed ? 'mixed' : '');
23413
+ arrowControl.fadeField.setDisabled(arrowIds.length === 0);
21100
23414
  }
21101
23415
 
21102
23416
  function getTargetRecords() {
@@ -21122,8 +23436,11 @@
21122
23436
  updateColorControl(fillControl);
21123
23437
  updateStrokeWidthControl();
21124
23438
  updateDashArrayControl();
21125
- if (geom == 'polyline') updateArrowControl();
21126
- randomFillBtn.classed('hidden', geom != 'polygon');
23439
+ if (geom == 'polyline') {
23440
+ updateLineCapControl();
23441
+ updateArrowControl();
23442
+ }
23443
+ updateColorSchemeControl();
21127
23444
  patternControl.section.classed('hidden', geom != 'polygon');
21128
23445
  if (geom == 'polygon') patternControl.update();
21129
23446
  glowControl.section.classed('hidden', geom != 'polygon');
@@ -21260,6 +23577,8 @@
21260
23577
  }
21261
23578
  }
21262
23579
 
23580
+ // No button runs this for now; the Random fill button is waiting for a new
23581
+ // place in the panel.
21263
23582
  // Patterns keep their backgrounds in step with the fills, and the new fills
21264
23583
  // are not known until -classify has chosen them, so the patterns are
21265
23584
  // rewritten by an expression that reads each feature's fill when it runs.
@@ -21304,6 +23623,7 @@
21304
23623
  addStyleValue(style, 'stroke-opacity', parseOpacityValue(strokeControl.opacity.node().value));
21305
23624
  if (targetLayer && targetLayer.geometry_type == 'polyline') {
21306
23625
  addStyleValue(style, 'stroke-dasharray', getControlValue(dashControl.input));
23626
+ addStyleValue(style, 'stroke-linecap', getCommonStyleValue('stroke-linecap'));
21307
23627
  addArrowStyleValues(style);
21308
23628
  }
21309
23629
  if (targetLayer && targetLayer.geometry_type == 'polygon') {
@@ -21318,7 +23638,7 @@
21318
23638
  // Only when every target agrees, as with the other controls; a size is
21319
23639
  // saved only if the lines have their own.
21320
23640
  function addArrowStyleValues(style) {
21321
- ['line-start', 'line-end', 'line-end-size'].forEach(function(field) {
23641
+ ['line-start', 'line-end', 'line-end-size', 'line-fade'].forEach(function(field) {
21322
23642
  addStyleValue(style, field, getCommonStyleValue(field));
21323
23643
  });
21324
23644
  }
@@ -21439,6 +23759,7 @@
21439
23759
  function syncTargetLayer() {
21440
23760
  var lyr = getActiveLayer();
21441
23761
  if (lyr == targetLayer) return;
23762
+ schemePanel.close();
21442
23763
  if (layerCanBeStyled(lyr)) {
21443
23764
  targetLayer = lyr;
21444
23765
  if (hit) hit.clearSelection();
@@ -31455,6 +33776,7 @@
31455
33776
  style.lineStart = rec && rec['line-start'];
31456
33777
  style.lineEnd = rec && rec['line-end'];
31457
33778
  style.lineEndSize = rec && rec['line-end-size'];
33779
+ style.lineFade = rec && rec['line-fade'];
31458
33780
  }
31459
33781
  if (hasGlows) {
31460
33782
  style.outerGlow = layerOuterGlow ? null : internal.svg.getPolygonGlow(rec, 'outer');
@@ -32819,9 +35141,10 @@
32819
35141
  continue;
32820
35142
  }
32821
35143
  // 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) {
35144
+ // see svg-line-arrows.mjs. Only lines styled with line-start, line-end
35145
+ // or line-fade get here, so the batch below is untouched for
35146
+ // everything else.
35147
+ if (drawStyle.lineStart || drawStyle.lineEnd || drawStyle.lineFade) {
32825
35148
  if (!arrowPencil) arrowPencil = getArrowLinePencil(arcs, _ext, lineScale);
32826
35149
  if (arrowPencil(shp, _ctx, drawStyle, startPath, draw)) continue;
32827
35150
  }
@@ -33269,14 +35592,17 @@
33269
35592
  };
33270
35593
  }
33271
35594
 
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.
35595
+ // Returns a function that draws one line feature with its arrowheads and fade,
35596
+ // and returns false if the feature has neither to draw (a style of 'none', or
35597
+ // no stroke), for the caller to draw it the usual way.
33275
35598
  //
33276
35599
  // Heads are sized by the same factor as the line width, so that a preview
33277
35600
  // matches the exported SVG. Far enough in that paths are clipped to the view
33278
35601
  // (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.
35602
+ // only the heads are added, since a clipped path's ends are not its ends. The
35603
+ // fade is placed by the whole line, so it is unaffected by the clipping.
35604
+ //
35605
+ // A faded part is stroked on its own, with its own gradient, as in SVG.
33280
35606
  function getArrowLinePencil(arcs, ext, lineScale) {
33281
35607
  var t = getScaledTransform(ext);
33282
35608
  var iter = new internal.ShapeIter(arcs);
@@ -33284,10 +35610,13 @@
33284
35610
  var clipped = ext.scale() > 100;
33285
35611
  return function(shp, ctx, style, startPath, draw) {
33286
35612
  var opts = internal.svg.makeLineArrowOpts(style.lineStart, style.lineEnd,
33287
- style.lineEndSize, style.strokeWidth, strokeScale);
35613
+ style.lineEndSize, style.strokeWidth, strokeScale, style.lineFade);
35614
+ var fadeColors = opts.fade > 0 ? internal.svg.getLineFadeColors(style.strokeColor) : null;
33288
35615
  var heads = [];
35616
+ var plain = [];
35617
+ var faded = [];
33289
35618
  var i, coords, shape;
33290
- if (opts.start == 'none' && opts.end == 'none' || !(style.strokeWidth > 0)) {
35619
+ if (opts.start == 'none' && opts.end == 'none' && !fadeColors || !(style.strokeWidth > 0)) {
33291
35620
  return false;
33292
35621
  }
33293
35622
  startPath(ctx, style);
@@ -33296,15 +35625,38 @@
33296
35625
  if (coords.length < 2) continue;
33297
35626
  shape = internal.svg.getLineArrowShape(coords, opts);
33298
35627
  heads = heads.concat(shape.heads);
33299
- if (!clipped) traceCoords(shape.coords, ctx);
35628
+ if (shape.fade && fadeColors) {
35629
+ faded.push({path: shp[i], shape: shape});
35630
+ } else if (clipped) {
35631
+ plain.push(shp[i]);
35632
+ } else {
35633
+ traceCoords(shape.coords, ctx);
35634
+ }
33300
35635
  }
33301
- if (clipped) draw(shp, ctx, style);
35636
+ if (clipped && plain.length > 0) draw(plain, ctx, style);
33302
35637
  endPath(ctx, style);
35638
+ for (i=0; i<faded.length; i++) {
35639
+ startPath(ctx, style);
35640
+ ctx.strokeStyle = getLineFadeGradient(ctx, faded[i].shape.fade, fadeColors);
35641
+ if (clipped) {
35642
+ draw([faded[i].path], ctx, style);
35643
+ } else {
35644
+ traceCoords(faded[i].shape.coords, ctx);
35645
+ }
35646
+ endPath(ctx, style);
35647
+ }
33303
35648
  drawArrowHeads(heads, ctx, style, opts.width);
33304
35649
  return true;
33305
35650
  };
33306
35651
  }
33307
35652
 
35653
+ function getLineFadeGradient(ctx, axis, colors) {
35654
+ var gradient = ctx.createLinearGradient(axis.from[0], axis.from[1], axis.to[0], axis.to[1]);
35655
+ gradient.addColorStop(0, colors.transparent);
35656
+ gradient.addColorStop(1, colors.solid);
35657
+ return gradient;
35658
+ }
35659
+
33308
35660
  function getPixelCoords(iter, path, t) {
33309
35661
  var coords = [];
33310
35662
  var x, y, prev;
@@ -33437,7 +35789,7 @@
33437
35789
  ctx.lineWidth = strokeWidth * strokeScale;
33438
35790
  ctx.strokeStyle = style.strokeColor;
33439
35791
  if (style.lineDash){
33440
- ctx.lineCap = 'butt';
35792
+ ctx.lineCap = style.lineCap || 'butt';
33441
35793
  ctx.setLineDash(scaleCanvasLineDash(style.lineDash, strokeScale));
33442
35794
  }
33443
35795
  if (style.miterLimit) {
@@ -36034,8 +38386,9 @@
36034
38386
  var marginField = toolbar.addTextField('Margin', {
36035
38387
  classname: 'frame-toolbar-margin-input',
36036
38388
  placeholder: '0',
36037
- tooltip: 'Padding added when fitting: 20px, 1cm, ' +
36038
- "or a percentage of the frame's width and height"
38389
+ tooltip: 'Padding added when fitting: 2%, 20px, 1cm, or ' +
38390
+ 'one to four values in CSS order (top right bottom left). ' +
38391
+ "A percentage is of the frame's width on every side."
36039
38392
  });
36040
38393
  toolbar.addSeparator();
36041
38394
  toolbar.addTextButton('Done', {
@@ -36285,7 +38638,7 @@
36285
38638
  return internal.getLayerTargetId(gui.model, o.layer);
36286
38639
  }).join(','))
36287
38640
  ];
36288
- if (margin) parts.push('offset=' + quoteCommandValue(margin));
38641
+ if (margin) parts.push('margin=' + quoteCommandValue(margin));
36289
38642
  if (!lockSize) parts.push('fix-scale');
36290
38643
  parts.push(getTargetOption(target));
36291
38644
  runGuiEditCommand(gui, parts.join(' '), {
@@ -36298,7 +38651,7 @@
36298
38651
  // cleared so the fit that just ran matches what the toolbar shows.
36299
38652
  function getMargin() {
36300
38653
  var value = marginField.getValue();
36301
- if (parseFloat(value) > 0) return value;
38654
+ if (hasNonZeroMargin(value)) return value;
36302
38655
  if (value) marginField.setValue('');
36303
38656
  return '';
36304
38657
  }
@@ -36349,8 +38702,10 @@
36349
38702
  marginInput.node().value = '2%';
36350
38703
  makeFieldTip(fitRow,
36351
38704
  '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.');
38705
+ "A percentage is of the frame's width, on\n" +
38706
+ 'every side. Use two to four values for\n' +
38707
+ 'uneven margins, in CSS order: 4% 2%, or\n' +
38708
+ '8% 2% 2% 2% (top right bottom left).');
36354
38709
 
36355
38710
  var drawRow = El('div').addClass('frame-create-option-row').appendTo(section);
36356
38711
  addButton(drawRow, 'Draw on the map', function() {
@@ -36382,7 +38737,7 @@
36382
38737
 
36383
38738
  function getMargin() {
36384
38739
  var value = marginInput.node().value.trim();
36385
- return parseFloat(value) > 0 ? value : '';
38740
+ return hasNonZeroMargin(value) ? value : '';
36386
38741
  }
36387
38742
  }
36388
38743
 
@@ -36462,7 +38817,7 @@
36462
38817
  });
36463
38818
  var parts = ['-frame', 'width=' + quoteCommandValue(width)];
36464
38819
  if (aspect) parts.push('aspect-ratio=' + aspect);
36465
- if (margin) parts.push('offset=' + quoteCommandValue(margin));
38820
+ if (margin) parts.push('margin=' + quoteCommandValue(margin));
36466
38821
  parts.push('name=frame');
36467
38822
  parts.push('target=' + internal.formatOptionValue(ids.join(',')));
36468
38823
  runCreateCommand(parts.join(' '));
@@ -36541,6 +38896,14 @@
36541
38896
  }
36542
38897
  }
36543
38898
 
38899
+ // A margin field's value is passed to the command when any of its one to four
38900
+ // values is positive, e.g. '0 2%'; a blank or all-zero margin is dropped.
38901
+ function hasNonZeroMargin(value) {
38902
+ return String(value || '').split(/[\s,]+/).some(function(str) {
38903
+ return parseFloat(str) > 0;
38904
+ });
38905
+ }
38906
+
36544
38907
  function addButton(parent, label, action) {
36545
38908
  El('div').addClass('btn dialog-btn').appendTo(parent)
36546
38909
  .text(label).on('click', action);