mapshaper 0.7.70 → 0.7.72

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.
@@ -3021,7 +3021,7 @@
3021
3021
  return str + repeatString(pad, size - str.length);
3022
3022
  }
3023
3023
 
3024
- function trim(str) {
3024
+ function trim$1(str) {
3025
3025
  return ltrim(rtrim(str));
3026
3026
  }
3027
3027
 
@@ -3625,7 +3625,7 @@
3625
3625
  range, regexEscape, reorderArray: reorderArray$1, repeat, repeatString,
3626
3626
  replaceArray, rpad, rtrim,
3627
3627
  shuffle, some, sortArrayIndex, sortOn, splitLines, sum,
3628
- toArray, toBuffer, trim, trimQuotes,
3628
+ toArray, toBuffer, trim: trim$1, trimQuotes,
3629
3629
  uniq, uniqifyNames,
3630
3630
  wildcardToRegExp
3631
3631
  };
@@ -8860,30 +8860,6 @@
8860
8860
  }
8861
8861
  }
8862
8862
 
8863
- // Test if adding point p to a sequence of points (in pixel coords)
8864
- // would result in a polyline that deviates from a straight line by
8865
- // more than a given number of pixels
8866
- //
8867
- function pointExceedsTolerance(p, points, tolerance) {
8868
- if (points.length < 2) return false;
8869
- var p1 = points[0], p2, dist, angle;
8870
- for (var i=1; i<points.length; i++) {
8871
- p2 = points[i];
8872
- dist = Math.sqrt(geom.pointSegDistSq(p2[0], p2[1], p1[0], p1[1], p[0], p[1]));
8873
- if (dist > tolerance) return true;
8874
- }
8875
- return false;
8876
- }
8877
-
8878
- function getAvgPoint(points) {
8879
- var x=0, y=0;
8880
- for (var i=0; i<points.length; i++) {
8881
- x += points[i][0];
8882
- y += points[i][1];
8883
- }
8884
- return [x/points.length, y/points.length];
8885
- }
8886
-
8887
8863
  function setZ(lyr, z) {
8888
8864
  lyr.gui.source.dataset.arcs.setRetainedInterval(z);
8889
8865
  if (isProjectedLayer(lyr)) {
@@ -13466,10 +13442,10 @@
13466
13442
 
13467
13443
  gui.on('path_extend', function(e) {
13468
13444
  var redo = function() {
13469
- gui.dispatchEvent('redo_path_extend', {p: e.p, shapes: e.shapes2});
13445
+ gui.dispatchEvent('redo_path_extend', {points: e.points, shapes: e.shapes2});
13470
13446
  };
13471
13447
  var undo = function() {
13472
- gui.dispatchEvent('undo_path_extend', {shapes: e.shapes1});
13448
+ gui.dispatchEvent('undo_path_extend', {points: e.points, shapes: e.shapes1});
13473
13449
  };
13474
13450
  addHistoryState(undo, redo);
13475
13451
  });
@@ -15630,6 +15606,11 @@
15630
15606
  return drawnFont || '';
15631
15607
  }
15632
15608
 
15609
+ // Whether @fontName is one of the fonts above and installed here.
15610
+ function isInstalledFont(fontName) {
15611
+ return getInstalledFontNames().indexOf(fontName) > -1;
15612
+ }
15613
+
15633
15614
  function getInstalledFontNames() {
15634
15615
  var names = [];
15635
15616
  getInstalledFonts().forEach(function(group) {
@@ -15911,6 +15892,11 @@
15911
15892
  var colorPicker = El('div').addClass('label-color-picker').appendTo(parent).hide();
15912
15893
  var sbCanvas, hueCanvas, sbMarker, hueMarker, pickerHsbInputs;
15913
15894
  var pickerColor = {h: 0, s: 0, b: 0};
15895
+ // The hex value the picker was set to, until it is moved off it. HSB is
15896
+ // held in bytes, which cannot represent every RGB colour, so a colour that
15897
+ // was typed or picked from a preset would otherwise come back as a
15898
+ // neighbour of itself (#ff8800 as #ff8a00).
15899
+ var pickerHex = null;
15914
15900
  var presetRows = opts.presetRows || [grayscaleColorPresets];
15915
15901
 
15916
15902
  init();
@@ -15939,13 +15925,15 @@
15939
15925
 
15940
15926
  this.setColor = function(color) {
15941
15927
  if (isHexColor(color)) {
15942
- setPickerColor(hexToHsb(color));
15928
+ setPickerColor(hexToHsb(color), color);
15943
15929
  }
15944
15930
  };
15945
15931
 
15946
- this.getColor = function() {
15947
- return hsbToHex(pickerColor);
15948
- };
15932
+ this.getColor = getPickerHex;
15933
+
15934
+ function getPickerHex() {
15935
+ return pickerHex || hsbToHex(pickerColor);
15936
+ }
15949
15937
 
15950
15938
  // The picker is placed in viewport coordinates (see .label-color-picker), so
15951
15939
  // it has to be put against the field it belongs to each time it opens. The
@@ -15998,7 +15986,7 @@
15998
15986
  var tile = El('div')
15999
15987
  .addClass('label-color-preset')
16000
15988
  .attr('role', 'button')
16001
- .attr('title', color)
15989
+ .attr('aria-label', color)
16002
15990
  .appendTo(rowEl)
16003
15991
  .css('background-color', color);
16004
15992
  if (row.length == 16 && i == 8) tile.addClass('label-color-preset-group-start');
@@ -16010,7 +15998,7 @@
16010
15998
  }
16011
15999
 
16012
16000
  function applyPreset(color) {
16013
- setPickerColor(hexToHsb(color));
16001
+ setPickerColor(hexToHsb(color), color);
16014
16002
  commitPickerColor();
16015
16003
  }
16016
16004
 
@@ -16080,15 +16068,17 @@
16080
16068
  };
16081
16069
  }
16082
16070
 
16083
- function setPickerColor(hsb) {
16071
+ // @hex: the exact colour, when @hsb was made from one
16072
+ function setPickerColor(hsb, hex) {
16084
16073
  pickerColor = {
16085
16074
  h: clamp$4(Math.round(hsb.h), 0, 255),
16086
16075
  s: clamp$4(Math.round(hsb.s), 0, 255),
16087
16076
  b: clamp$4(Math.round(hsb.b), 0, 255)
16088
16077
  };
16078
+ pickerHex = hex || null;
16089
16079
  drawColorPicker();
16090
16080
  updatePickerFields();
16091
- if (opts.onPreview) opts.onPreview(hsbToHex(pickerColor));
16081
+ if (opts.onPreview) opts.onPreview(getPickerHex());
16092
16082
  }
16093
16083
 
16094
16084
  function updatePickerFields() {
@@ -16144,7 +16134,7 @@
16144
16134
  }
16145
16135
 
16146
16136
  function commitPickerColor() {
16147
- if (opts.onChange) opts.onChange(hsbToHex(pickerColor));
16137
+ if (opts.onChange) opts.onChange(getPickerHex());
16148
16138
  }
16149
16139
 
16150
16140
  function getMarkerColor(rgb) {
@@ -16475,7 +16465,7 @@
16475
16465
  // in a menu that is only on screen while it is being used, and a
16476
16466
  // destructive action is better for waiting until intent is shown.
16477
16467
  El('div').addClass('label-saved-style-delete').attr('role', 'button')
16478
- .attr('title', 'Delete this ' + opts.styleLabel)
16468
+ .attr('aria-label', 'Delete this ' + opts.styleLabel)
16479
16469
  .appendTo(itemEl).html('&times;')
16480
16470
  .on('click', function() {
16481
16471
  deleteStyle(item);
@@ -16548,7 +16538,7 @@
16548
16538
  // Escape. Whether that means giving up the keyboard is the
16549
16539
  // caller's question: the field cannot know what else wants it.
16550
16540
  // decimals how finely a typed size is kept (default 1)
16551
- // title tooltip for the field
16541
+ // title the field's accessible name (not a tooltip)
16552
16542
  function SizeField(parent, opts) {
16553
16543
  var o = Object.assign({min: 1, max: 999, step: 1, bigStep: 10}, opts || {});
16554
16544
  var box = El('div').addClass('size-field').appendTo(parent);
@@ -16561,7 +16551,7 @@
16561
16551
  var dirty = false; // holds typing that has not been committed
16562
16552
  var disabled = false;
16563
16553
 
16564
- if (o.title) input.attr('title', o.title);
16554
+ if (o.title) input.attr('aria-label', o.title);
16565
16555
 
16566
16556
  // While the caret is in this field the keyboard belongs to it. Without this
16567
16557
  // the GUI's own handlers see the keystrokes: Escape would disarm the tool,
@@ -16865,14 +16855,15 @@
16865
16855
  // the one thing the third state exists to avoid saying.
16866
16856
  //
16867
16857
  // opts.onChange(on) the switch was clicked
16868
- // opts.title, opts.className
16858
+ // opts.title the switch's accessible name (not a tooltip)
16859
+ // opts.className
16869
16860
  function makePanelToggle(parent, opts) {
16870
16861
  var track = El('div').addClass('label-toggle').attr('role', 'checkbox').appendTo(parent);
16871
16862
  var state = 'off';
16872
16863
  if (opts.className) track.addClass(opts.className);
16873
16864
  var disabled = false;
16874
16865
  El('div').addClass('label-toggle-knob').appendTo(track);
16875
- if (opts.title) track.attr('title', opts.title);
16866
+ if (opts.title) track.attr('aria-label', opts.title);
16876
16867
  // A click on a mixed switch turns everything on. It is the convention, and
16877
16868
  // it is the reading that reaches a state the switch can describe: the next
16878
16869
  // click then turns everything off, so both are one click away.
@@ -16912,7 +16903,7 @@
16912
16903
  });
16913
16904
  }
16914
16905
 
16915
- // A button with a word on it rather than a glyph: Clear style, Random fills,
16906
+ // A button with a word on it rather than a glyph: Clear style, Random fill,
16916
16907
  // Create. Shaped like the panel's fields rather than like its 19px icon
16917
16908
  // buttons, because what it does is named rather than drawn, and it is as wide
16918
16909
  // as the name.
@@ -16957,7 +16948,7 @@
16957
16948
  // opacity qualifies the colour, and one border says so where two fields side
16958
16949
  // by side said they were separate settings. It also leaves the narrow column
16959
16950
  // of the row free for a field that needs it. The opacity has no caption; its
16960
- // percent sign and its tooltip say what it is.
16951
+ // percent sign says what it is.
16961
16952
  //
16962
16953
  // Returns the field's box; the opacity input is made by makeOpacityInput().
16963
16954
  function makeColorOpacityField(parent, chit, input, opacityOpts) {
@@ -16970,7 +16961,10 @@
16970
16961
  // split row. Fill and Stroke are each one of these.
16971
16962
  //
16972
16963
  // opts.label the caption over the field
16973
- // opts.onColor(hex) a colour was typed, picked or previewed to a finish
16964
+ // opts.onColor(hex) a colour was typed, picked or previewed to a finish;
16965
+ // '' when the field was emptied, which unsets a colour
16966
+ // that can be unset and restores the default of one
16967
+ // that cannot
16974
16968
  // opts.onOpacity(frac) a usable percentage was typed
16975
16969
  // opts.revert() one that was not, so put the row back as it was
16976
16970
  // opts.noOpacity a colour with no opacity of its own, like a pattern's,
@@ -17006,7 +17000,7 @@
17006
17000
  control.picker.toggle();
17007
17001
  });
17008
17002
  control.input = El('input').attr('type', 'text')
17009
- .attr('title', opts.label + ' color')
17003
+ .attr('aria-label', opts.label + ' color')
17010
17004
  .on('change', function() {
17011
17005
  var color = control.input.node().value.trim();
17012
17006
  if (isHexColor(color)) control.picker.setColor(color);
@@ -17042,7 +17036,7 @@
17042
17036
  // opts.revert() it was not, so put back what the field was showing
17043
17037
  function makeOpacityInput(parent, opts) {
17044
17038
  var input = El('input').attr('type', 'text').addClass('label-opacity-input')
17045
- .attr('title', 'Opacity, 0-100%')
17039
+ .attr('aria-label', 'Opacity, 0-100%')
17046
17040
  .appendTo(parent)
17047
17041
  .on('change', function() {
17048
17042
  var val = parseOpacityValue(input.node().value);
@@ -17238,6 +17232,36 @@
17238
17232
  return gui.state.label_text_session || null;
17239
17233
  }
17240
17234
 
17235
+ // The label tool's armed placement tool: 'anchor', 'block', 'path' or null.
17236
+ // The tool owns it; the panel reads it, because an armed tool with nothing
17237
+ // selected is what points the panel at new labels rather than at all of them.
17238
+ function setLabelToolArmed(gui, mode) {
17239
+ var val = mode || null;
17240
+ if ((gui.state.label_tool_armed || null) === val) return;
17241
+ gui.state.label_tool_armed = val;
17242
+ gui.dispatchEvent('label_tool_armed_change');
17243
+ }
17244
+
17245
+ function getLabelToolArmed(gui) {
17246
+ return gui.state.label_tool_armed || null;
17247
+ }
17248
+
17249
+ // The "Editing:" line at the top of the style panels, which says what a
17250
+ // change to the panel's controls will act on. One wording for every panel.
17251
+ //
17252
+ // o.selected how many features are selected
17253
+ // o.total how many features the layer has
17254
+ // o.newLabels (label panel) a placement tool is armed with nothing
17255
+ // selected, so the controls set the style of the next label
17256
+ // o.editingText (label panel) a label is open for typing
17257
+ function formatEditingStatus(o) {
17258
+ var what = o.editingText ? 'this label' :
17259
+ o.selected > 0 ? o.selected + ' selected' :
17260
+ o.newLabels ? 'new labels' :
17261
+ o.total > 0 ? 'all' : 'none (empty layer)';
17262
+ return 'Editing: ' + what;
17263
+ }
17264
+
17241
17265
  // Dragging an anchored label's text away from its anchor: what the drag
17242
17266
  // writes, and which of the nine positions it ends up nearest.
17243
17267
  //
@@ -17573,6 +17597,80 @@
17573
17597
  return wrapper;
17574
17598
  }
17575
17599
 
17600
+ // Default sizes and colours of the label tool's anchor symbols, which differ by
17601
+ // shape: a star or a ring at the size of a circle reads as smaller, and the NYT
17602
+ // star is a logo, drawn in its own red.
17603
+ var iconDefaults = {
17604
+ circle: {size: 5},
17605
+ square: {size: 5},
17606
+ star: {size: 8},
17607
+ ring: {size: 8},
17608
+ 'nyt-star': {size: 14, color: '#cc0000'}
17609
+ };
17610
+ var fallbackSize = 5;
17611
+
17612
+ function getDefaultIconSize(shape) {
17613
+ var o = iconDefaults[shape];
17614
+ return o ? o.size : fallbackSize;
17615
+ }
17616
+
17617
+ // '' for a shape drawn in whatever colour the label gives it.
17618
+ function getDefaultIconColor(shape) {
17619
+ var o = iconDefaults[shape];
17620
+ return o && o.color || '';
17621
+ }
17622
+
17623
+ // The icon-size and icon-color to write when labels whose symbol is @prevShape
17624
+ // ('' for none, or for a selection of mixed shapes) are given @shape, from the
17625
+ // @size and @color they share. A missing size is '' or 0, and a colour of ''
17626
+ // is none; null is a colour the labels do not agree on, which is left alone.
17627
+ //
17628
+ // A value that is the old shape's default follows the shape to its own
17629
+ // default, and a value the user chose is kept -- so switching shapes resizes a
17630
+ // symbol nobody sized, and never undoes a size somebody set. The colour is
17631
+ // returned only if it changes, as '' to remove it.
17632
+ function getIconShapeChange(shape, prevShape, size, color) {
17633
+ var out = {};
17634
+ var sizeVal = Number(size);
17635
+ var prevColor = getDefaultIconColor(prevShape);
17636
+ var nextColor = getDefaultIconColor(shape);
17637
+ if (!(sizeVal > 0) || prevShape && sizeVal == getDefaultIconSize(prevShape)) {
17638
+ out.size = getDefaultIconSize(shape);
17639
+ } else {
17640
+ out.size = sizeVal;
17641
+ }
17642
+ if (color !== null && sameColor$1(color, prevColor) && !sameColor$1(color, nextColor)) {
17643
+ out.color = nextColor;
17644
+ }
17645
+ return out;
17646
+ }
17647
+
17648
+ function sameColor$1(a, b) {
17649
+ return String(a || '').toLowerCase() == String(b || '').toLowerCase();
17650
+ }
17651
+
17652
+ // Features offered only to people at The New York Times, who are the ones with
17653
+ // its NYTFranklin typeface installed. This is a convenience, not access
17654
+ // control: anything gated here must still render for everyone, because a map
17655
+ // made with it can be opened anywhere.
17656
+ //
17657
+ // ?nyt=on or ?nyt=off in the page URL overrides the check, so that the gated
17658
+ // features can be tested (and hidden) on any machine.
17659
+ var NYT_FONT = 'NYTFranklin';
17660
+ var cached;
17661
+
17662
+ function isNytUser() {
17663
+ if (cached === undefined) cached = detectNytUser();
17664
+ return cached;
17665
+ }
17666
+
17667
+ function detectNytUser() {
17668
+ var flag = GUI.getUrlVars().nyt;
17669
+ if (flag == 'on' || flag === true) return true;
17670
+ if (flag == 'off' || flag === false) return false;
17671
+ return isInstalledFont(NYT_FONT);
17672
+ }
17673
+
17576
17674
  var fontField = 'font-family';
17577
17675
  var fontSizeField = 'font-size';
17578
17676
  var fontStyleField = 'font-style';
@@ -17605,7 +17703,6 @@
17605
17703
  var defaultFontWeight = '400';
17606
17704
  var defaultLabelColor = '#000000';
17607
17705
  var defaultIconColor = '#000000';
17608
- var defaultIconSize = 5;
17609
17706
  // The width a halo is switched on at: past the edge of the glyphs, so a
17610
17707
  // stroke of twice this.
17611
17708
  var defaultHaloWidth = 2;
@@ -17678,6 +17775,10 @@
17678
17775
  name: 'star'
17679
17776
  }, {
17680
17777
  name: 'ring'
17778
+ }, {
17779
+ name: 'nyt-star',
17780
+ title: 'NYT star',
17781
+ nytOnly: true
17681
17782
  }];
17682
17783
  var defaultIconShape = 'circle';
17683
17784
  // Alignment writes label-align rather than text-anchor, because text-anchor
@@ -17733,6 +17834,9 @@
17733
17834
  }, {
17734
17835
  name: 'open-arrow',
17735
17836
  title: 'open arrowhead at the anchor'
17837
+ }, {
17838
+ name: 'ring',
17839
+ title: 'ring around the anchor'
17736
17840
  }];
17737
17841
  // The anchor is at the lower left of the shapes and at the left of the ends,
17738
17842
  // which is where the markers go. The arrowheads are drawn to the renderer's
@@ -17743,14 +17847,16 @@
17743
17847
  elbow: '<path d="M3.5 12.5L8 4.5H13"></path>',
17744
17848
  curve: '<path d="M3.5 12.5Q4.5 4.5 12.5 4"></path>',
17745
17849
  none: '<path d="M3 8H13"></path>',
17746
- arrow: '<path d="M6.7 8H13"></path><path class="fill" d="M2.5 8L8.53 5.57V10.43Z"></path>',
17747
- 'open-arrow': '<path d="M3.2 8H13"></path><path d="M7.38 5.07L3.2 8L7.38 10.93"></path>'
17850
+ arrow: '<path d="M6.84 8H13"></path><path class="fill" d="M2.5 8L8.7 6.05V9.95Z"></path>',
17851
+ 'open-arrow': '<path d="M3.2 8H13"></path><path d="M7.38 5.07L3.2 8L7.38 10.93"></path>',
17852
+ ring: '<path d="M8.4 8H13"></path><circle cx="5.6" cy="8" r="2.8"></circle>'
17748
17853
  };
17749
17854
  var iconButtonSymbols = {
17750
17855
  circle: '<circle cx="8" cy="8" r="4.25"></circle>',
17751
17856
  square: '<rect x="4" y="4" width="8" height="8"></rect>',
17752
17857
  ring: '<circle cx="8" cy="8" r="3.8"></circle>',
17753
- star: '<path d="M8 3.2l1.18 2.92 3.14.22-2.42 2 .76 3.06L8 9.75 5.34 11.4l.76-3.06-2.42-2 3.14-.22L8 3.2z"></path>'
17858
+ star: '<path d="M8 3.2l1.18 2.92 3.14.22-2.42 2 .76 3.06L8 9.75 5.34 11.4l.76-3.06-2.42-2 3.14-.22L8 3.2z"></path>',
17859
+ 'nyt-star': '<polygon transform="scale(0.4)" points="19.98 5.72 23.15 11.76 29.45 9.15 27.99 15.82 34.5 17.86 29.1 22.03 32.76 27.78 25.95 27.5 25.06 34.27 20.01 29.67 14.98 34.28 14.08 27.52 7.26 27.82 10.92 22.06 5.5 17.9 12 15.84 10.53 9.18 16.84 11.77"></polygon>'
17754
17860
  };
17755
17861
 
17756
17862
  function LabelTool(gui) {
@@ -17765,6 +17871,7 @@
17765
17871
  // The shape the toggle turns back on, so that switching a symbol off and on
17766
17872
  // again does not silently change a star into a circle.
17767
17873
  var lastIconShape = defaultIconShape;
17874
+ var shownIconTypes = null;
17768
17875
  // Likewise the halo's width, which is what switching one off removes.
17769
17876
  var lastHaloWidth = defaultHaloWidth;
17770
17877
  // And the callout's shape.
@@ -17793,6 +17900,11 @@
17793
17900
  // a label opened for typing becomes what the controls act on
17794
17901
  if (panel.visible()) updateControls();
17795
17902
  });
17903
+ gui.on('label_tool_armed_change', function() {
17904
+ // arming a tool points an empty selection at new labels, and disarming
17905
+ // points it back at the whole layer
17906
+ if (panel.visible()) updateControls();
17907
+ });
17796
17908
  gui.on('label_position_mode_change', function() {
17797
17909
  // The tool sets the mode back to Fixed when it opens, and the toggle is
17798
17910
  // the only thing on screen that says which of the two is on.
@@ -17898,7 +18010,7 @@
17898
18010
  // number otherwise keep theirs, and so does a colour field, because the
17899
18011
  // field beside it has one and "Color" says the percentage is its opacity.
17900
18012
  var fontRow = El('div').addClass('label-style-row').appendTo(textSection);
17901
- fontSelect = El('select').attr('title', 'Font').appendTo(fontRow).on('change', function() {
18013
+ fontSelect = El('select').attr('aria-label', 'Font').appendTo(fontRow).on('change', function() {
17902
18014
  if (fontSelect.node().value) {
17903
18015
  applyFont(fontSelect.node().value);
17904
18016
  }
@@ -17906,7 +18018,7 @@
17906
18018
 
17907
18019
  var styleSizeRow = El('div').addClass('label-style-row label-split-row').appendTo(textSection);
17908
18020
  var fontStyleRow = El('div').addClass('label-split-cell label-font-style-row').appendTo(styleSizeRow);
17909
- fontStyleSelect = El('select').attr('title', 'Font style').appendTo(fontStyleRow).on('change', function() {
18021
+ fontStyleSelect = El('select').attr('aria-label', 'Font style').appendTo(fontStyleRow).on('change', function() {
17910
18022
  if (fontStyleSelect.node().value) {
17911
18023
  applyFontStyleVariant(fontStyleSelect.node().value);
17912
18024
  }
@@ -17932,7 +18044,7 @@
17932
18044
  var textColorCell = El('div').addClass('label-split-cell label-color-row label-text-color-row').appendTo(colorRow);
17933
18045
  El('span').appendTo(textColorCell).text('Color');
17934
18046
  colorChit = El('div').addClass('label-color-chit').attr('role', 'button');
17935
- colorInput = El('input').attr('type', 'text').attr('title', 'Text color');
18047
+ colorInput = El('input').attr('type', 'text').attr('aria-label', 'Text color');
17936
18048
  var textColorField = addColorOpacityField(textColorCell, colorChit, colorInput, applyLabelOpacity);
17937
18049
  colorFieldBox = textColorField.box;
17938
18050
  opacityInput = textColorField.opacity;
@@ -17942,12 +18054,9 @@
17942
18054
  });
17943
18055
  colorInput.on('change', function() {
17944
18056
  var color = colorInput.node().value.trim();
17945
- if (color) {
17946
- if (isHexColor(color)) {
17947
- colorPicker.setColor(color);
17948
- }
17949
- applyLabelColor(color);
17950
- }
18057
+ // An emptied field unsets the colour.
18058
+ if (isHexColor(color)) colorPicker.setColor(color);
18059
+ applyLabelColor(color);
17951
18060
  });
17952
18061
  colorPicker = initColorPicker(textColorCell, colorChit, colorInput, applyLabelColor);
17953
18062
 
@@ -17965,7 +18074,7 @@
17965
18074
  applyLabelAlign(item.name);
17966
18075
  })
17967
18076
  .attr('data-align', item.name)
17968
- .attr('title', item.title);
18077
+ .attr('aria-label', item.title);
17969
18078
  alignBtns[item.name] = btn;
17970
18079
  appendAlignButtonSymbol(btn, item.name);
17971
18080
  });
@@ -18004,7 +18113,7 @@
18004
18113
  var haloColorCell = El('div').addClass('label-split-cell label-color-row label-halo-color-row').appendTo(haloColorRow);
18005
18114
  El('span').appendTo(haloColorCell).text('Color');
18006
18115
  haloColorChit = El('div').addClass('label-color-chit').attr('role', 'button');
18007
- haloColorInput = El('input').attr('type', 'text').attr('title', 'Halo color');
18116
+ haloColorInput = El('input').attr('type', 'text').attr('aria-label', 'Halo color');
18008
18117
  var haloColorField = addColorOpacityField(haloColorCell, haloColorChit, haloColorInput, applyHaloOpacity);
18009
18118
  haloColorFieldBox = haloColorField.box;
18010
18119
  haloOpacityInput = haloColorField.opacity;
@@ -18014,12 +18123,8 @@
18014
18123
  });
18015
18124
  haloColorInput.on('change', function() {
18016
18125
  var color = haloColorInput.node().value.trim();
18017
- if (color) {
18018
- if (isHexColor(color)) {
18019
- haloColorPicker.setColor(color);
18020
- }
18021
- applyHaloColor(color);
18022
- }
18126
+ if (isHexColor(color)) haloColorPicker.setColor(color);
18127
+ applyHaloColor(color);
18023
18128
  });
18024
18129
  haloColorPicker = initColorPicker(haloColorCell, haloColorChit, haloColorInput, applyHaloColor);
18025
18130
 
@@ -18055,12 +18160,12 @@
18055
18160
  var iconRow = El('div').addClass('label-style-row label-icon-shapes-row').appendTo(iconSection);
18056
18161
  var iconGroup = iconGroupEl = El('div').addClass('label-btn-group label-icon-buttons').appendTo(iconRow);
18057
18162
  iconBtns = {};
18058
- iconTypes.forEach(function(icon) {
18163
+ getShownIconTypes().forEach(function(icon) {
18059
18164
  var btn = makePanelButton(iconGroup, '', function() {
18060
18165
  applyIcon(icon.name);
18061
18166
  })
18062
18167
  .attr('data-icon', icon.name)
18063
- .attr('title', icon.name);
18168
+ .attr('aria-label', icon.title || icon.name);
18064
18169
  iconBtns[icon.name] = btn;
18065
18170
  appendIconButtonSymbol(btn, icon.name);
18066
18171
  });
@@ -18069,7 +18174,7 @@
18069
18174
  var iconColorCell = El('div').addClass('label-split-cell label-color-row label-icon-color-row').appendTo(iconColorRow);
18070
18175
  El('span').appendTo(iconColorCell).text('Color');
18071
18176
  iconColorChit = El('div').addClass('label-color-chit').attr('role', 'button');
18072
- iconColorInput = El('input').attr('type', 'text').attr('title', 'Symbol color');
18177
+ iconColorInput = El('input').attr('type', 'text').attr('aria-label', 'Symbol color');
18073
18178
  var iconColorField = addColorOpacityField(iconColorCell, iconColorChit, iconColorInput, applyIconOpacity);
18074
18179
  iconColorFieldBox = iconColorField.box;
18075
18180
  iconOpacityInput = iconColorField.opacity;
@@ -18079,12 +18184,8 @@
18079
18184
  });
18080
18185
  iconColorInput.on('change', function() {
18081
18186
  var color = iconColorInput.node().value.trim();
18082
- if (color) {
18083
- if (isHexColor(color)) {
18084
- iconColorPicker.setColor(color);
18085
- }
18086
- applyIconColor(color);
18087
- }
18187
+ if (isHexColor(color)) iconColorPicker.setColor(color);
18188
+ applyIconColor(color);
18088
18189
  });
18089
18190
  iconColorPicker = initColorPicker(iconColorCell, iconColorChit, iconColorInput, applyIconColor);
18090
18191
 
@@ -18117,7 +18218,7 @@
18117
18218
  applyLabelPosition(pos);
18118
18219
  })
18119
18220
  .attr('data-position', pos)
18120
- .attr('title', pos);
18221
+ .attr('aria-label', pos);
18121
18222
  });
18122
18223
 
18123
18224
 
@@ -18127,7 +18228,7 @@
18127
18228
  setLabelPositionMode(gui, item.name, getPositionModeKind());
18128
18229
  })
18129
18230
  .attr('data-drag-mode', item.name)
18130
- .attr('title', item.title);
18231
+ .attr('aria-label', item.title);
18131
18232
  });
18132
18233
 
18133
18234
  presetControl = new StylePresetControl(panel, {
@@ -18198,7 +18299,7 @@
18198
18299
  var endSizeCell = El('div').addClass('label-split-cell label-spacing-row label-callout-end-size-row').appendTo(endRow);
18199
18300
  El('span').appendTo(endSizeCell).text('Size');
18200
18301
  calloutEndSizeInput = new SizeField(endSizeCell, {
18201
- title: 'Arrowhead size in px, the length of its sides',
18302
+ title: 'Marker size in px: an arrowhead\'s sides, or a ring\'s diameter',
18202
18303
  min: 1,
18203
18304
  max: 60,
18204
18305
  step: 1,
@@ -18212,7 +18313,7 @@
18212
18313
  var colorCell = El('div').addClass('label-split-cell label-color-row label-callout-color-row').appendTo(colorRow);
18213
18314
  El('span').appendTo(colorCell).text('Color');
18214
18315
  calloutColorChit = El('div').addClass('label-color-chit').attr('role', 'button');
18215
- calloutColorInput = El('input').attr('type', 'text').attr('title', 'Callout color');
18316
+ calloutColorInput = El('input').attr('type', 'text').attr('aria-label', 'Callout color');
18216
18317
  var calloutColorField = addColorOpacityField(colorCell, calloutColorChit, calloutColorInput, applyCalloutOpacity);
18217
18318
  calloutColorFieldBox = calloutColorField.box;
18218
18319
  calloutOpacityInput = calloutColorField.opacity;
@@ -18222,12 +18323,8 @@
18222
18323
  });
18223
18324
  calloutColorInput.on('change', function() {
18224
18325
  var color = calloutColorInput.node().value.trim();
18225
- if (color) {
18226
- if (isHexColor(color)) {
18227
- calloutColorPicker.setColor(color);
18228
- }
18229
- applyCalloutColor(color);
18230
- }
18326
+ if (isHexColor(color)) calloutColorPicker.setColor(color);
18327
+ applyCalloutColor(color);
18231
18328
  });
18232
18329
  calloutColorPicker = initColorPicker(colorCell, calloutColorChit, calloutColorInput, applyCalloutColor);
18233
18330
 
@@ -18236,7 +18333,7 @@
18236
18333
  var gapCell = El('div').addClass('label-split-cell label-spacing-row label-callout-gap-row').appendTo(colorRow);
18237
18334
  El('span').appendTo(gapCell).text('Gap');
18238
18335
  calloutGapInput = El('input').attr('type', 'text').addClass('label-measure-input')
18239
- .attr('title', 'Space between the callout and the anchor, in px')
18336
+ .attr('aria-label', 'Space between the callout and the anchor, in px')
18240
18337
  .attr('placeholder', 'auto')
18241
18338
  .attr('data-placeholder', 'auto')
18242
18339
  .appendTo(gapCell)
@@ -18248,7 +18345,7 @@
18248
18345
  function makeCalloutButton(parent, item, action) {
18249
18346
  var btn = makePanelButton(parent, '', action)
18250
18347
  .attr('data-callout', item.name)
18251
- .attr('title', item.title);
18348
+ .attr('aria-label', item.title);
18252
18349
  El('<svg class="label-callout-symbol" viewBox="0 0 16 16" aria-hidden="true">' +
18253
18350
  calloutButtonSymbols[item.name] + '</svg>').appendTo(btn);
18254
18351
  return btn;
@@ -18349,6 +18446,16 @@
18349
18446
  return !!(gui.interaction && gui.interaction.getMode() == labelMode);
18350
18447
  }
18351
18448
 
18449
+ // Whether an empty selection means the next label rather than every label:
18450
+ // only while a placement tool is armed, or a label not yet made is being
18451
+ // typed. With no tool armed the panel styles the whole layer, as the line
18452
+ // and polygon panels do.
18453
+ function editingNewLabels() {
18454
+ var session = getLabelTextSession(gui);
18455
+ if (!labelModeIsOn()) return false;
18456
+ return !!getLabelToolArmed(gui) || !!(session && session.id == -1);
18457
+ }
18458
+
18352
18459
  function updateVisibility() {
18353
18460
  var enabled = activeLayerHasLabels();
18354
18461
  textBtn.classed('disabled', !enabled);
@@ -18432,13 +18539,14 @@
18432
18539
  if (session) return [];
18433
18540
  ids = getSelectionIds();
18434
18541
  if (ids.length > 0) return ids;
18435
- return labelModeIsOn() ? [] : getAllLabelIds();
18542
+ return editingNewLabels() ? [] : getAllLabelIds();
18436
18543
  }
18437
18544
 
18438
- // With nothing selected the controls edit the style that new labels are given,
18439
- // so they stay live even when there is no label, and no layer, yet.
18545
+ // With a tool armed and nothing selected the controls edit the style that
18546
+ // new labels are given, so they stay live even when there is no label, and
18547
+ // no layer, yet. With no tool armed and no labels, there is nothing to edit.
18440
18548
  function controlsEnabled() {
18441
- return getTargetIds().length > 0 || labelModeIsOn();
18549
+ return getTargetIds().length > 0 || editingNewLabels();
18442
18550
  }
18443
18551
 
18444
18552
  function getAllLabelIds() {
@@ -18475,8 +18583,10 @@
18475
18583
  // rest of the selection has nothing to compare.
18476
18584
  var iconIds = getIconValueIds();
18477
18585
  var iconVal = getCommonValue(iconIds, iconField);
18478
- var iconSize = getShownValue(iconIds, iconSizeField, {useDefault: true, defaultValue: defaultIconSize});
18479
- var iconColor = getShownValue(iconIds, iconColorField, {useDefault: true, defaultValue: defaultIconColor});
18586
+ var iconShape = iconVal || lastIconShape;
18587
+ var iconSize = getShownValue(iconIds, iconSizeField, {useDefault: true, defaultValue: getDefaultIconSize(iconShape)});
18588
+ var iconColor = getShownValue(iconIds, iconColorField, {useDefault: true,
18589
+ defaultValue: getDefaultIconColor(iconShape) || defaultIconColor});
18480
18590
  var iconOpacity = getShownValue(iconIds, iconOpacityField, {useDefault: true, defaultValue: 1});
18481
18591
  var haloIds = getHaloValueIds();
18482
18592
  var haloWidth = getShownValue(haloIds, haloWidthField, {useDefault: true, defaultValue: lastHaloWidth});
@@ -18666,22 +18776,21 @@
18666
18776
  });
18667
18777
  }
18668
18778
 
18779
+ // What the controls will act on. With a tool armed an empty selection is the
18780
+ // next label rather than the whole layer, and saying "all" there is what
18781
+ // would make the panel untrustworthy.
18669
18782
  function updateEditingStatus(count, editingText) {
18670
- editingStatus.text('Editing: ' + describeTarget(count, editingText));
18783
+ editingStatus.text(formatEditingStatus({
18784
+ selected: count,
18785
+ total: getActiveLayer() ? internal.getFeatureCount(getActiveLayer()) : 0,
18786
+ newLabels: editingNewLabels(),
18787
+ editingText: editingText
18788
+ }));
18671
18789
  // "deselect" is for a selection the user made; a label being typed into is
18672
18790
  // left by clicking away or pressing Escape, not from here.
18673
18791
  clearLink.classed('hidden', count === 0 || editingText);
18674
18792
  }
18675
18793
 
18676
- // What the controls will act on. In label mode an empty selection is the next
18677
- // label rather than the whole layer, and saying "all" there is what would
18678
- // make the panel untrustworthy.
18679
- function describeTarget(count, editingText) {
18680
- if (editingText) return 'this label';
18681
- if (count > 0) return count + ' selected';
18682
- return labelModeIsOn() ? 'new labels' : 'all';
18683
- }
18684
-
18685
18794
  function updateFontSizeControls(shown) {
18686
18795
  fontSizeInput.setValue(shown.value || '');
18687
18796
  fontSizeInput.setPlaceholder(shown.mixed ? MIXED_TEXT : '');
@@ -18821,7 +18930,7 @@
18821
18930
  // border the buttons share fades with them -- a live border around dead
18822
18931
  // buttons is the one part of a disabled control that still looks usable.
18823
18932
  iconGroupEl.classed('disabled', off);
18824
- iconTypes.forEach(function(icon) {
18933
+ getShownIconTypes().forEach(function(icon) {
18825
18934
  iconBtns[icon.name].classed('selected', !off && icon.name == iconVal);
18826
18935
  setPanelButtonDisabled(iconBtns[icon.name], off);
18827
18936
  });
@@ -18890,7 +18999,7 @@
18890
18999
  var ids = getTargetIds();
18891
19000
  var table = getActiveTable();
18892
19001
  if (ids.length === 0) {
18893
- return internal.svg.labelHasHalo(labelModeIsOn() ? getNewLabelStyle(gui) : null) ? 'on' : 'off';
19002
+ return internal.svg.labelHasHalo(editingNewLabels() ? getNewLabelStyle(gui) : null) ? 'on' : 'off';
18894
19003
  }
18895
19004
  return getToggleState(ids.map(function(id) {
18896
19005
  return internal.svg.labelHasHalo(table && table.getRecordAt(id));
@@ -18950,7 +19059,7 @@
18950
19059
  var ids = getTargetIds();
18951
19060
  var table = getActiveTable();
18952
19061
  if (ids.length === 0) {
18953
- return internal.svg.labelHasCallout(labelModeIsOn() ? getNewLabelStyle(gui) : null) ? 'on' : 'off';
19062
+ return internal.svg.labelHasCallout(editingNewLabels() ? getNewLabelStyle(gui) : null) ? 'on' : 'off';
18954
19063
  }
18955
19064
  return getToggleState(ids.map(function(id) {
18956
19065
  return internal.svg.labelHasCallout(table && table.getRecordAt(id));
@@ -19092,7 +19201,7 @@
19092
19201
  function updateCalloutEndSizeControl(off) {
19093
19202
  var ids = getTargetIds().length > 0 ? getCalloutEndTargetIds() : [];
19094
19203
  var noMarker = getTargetIds().length > 0 ? ids.length === 0 :
19095
- getCalloutEnd(labelModeIsOn() ? getNewLabelStyle(gui) : null) == 'none';
19204
+ getCalloutEnd(editingNewLabels() ? getNewLabelStyle(gui) : null) == 'none';
19096
19205
  var shown = off || noMarker ? {value: '', mixed: false} : getCalloutEndSizeShown(ids);
19097
19206
  calloutEndSizeInput.setValue(shown.value);
19098
19207
  calloutEndSizeInput.setPlaceholder(shown.mixed ? MIXED_TEXT : '');
@@ -19131,7 +19240,7 @@
19131
19240
  var vals;
19132
19241
  if (!controlsEnabled()) return {value: '', mixed: false};
19133
19242
  if (ids.length === 0) {
19134
- return {value: fn(labelModeIsOn() ? getNewLabelStyle(gui) : null), mixed: false};
19243
+ return {value: fn(editingNewLabels() ? getNewLabelStyle(gui) : null), mixed: false};
19135
19244
  }
19136
19245
  vals = ids.map(function(id) {
19137
19246
  return fn(table && table.getRecordAt(id));
@@ -19207,7 +19316,7 @@
19207
19316
  var records = getActiveTable() && getActiveTable().getRecords();
19208
19317
  var value, val;
19209
19318
  if (ids.length === 0) {
19210
- return resolveAlignment(labelModeIsOn() ? getNewLabelStyle(gui) : null);
19319
+ return resolveAlignment(editingNewLabels() ? getNewLabelStyle(gui) : null);
19211
19320
  }
19212
19321
  for (var i = 0; i < ids.length; i++) {
19213
19322
  val = resolveAlignment(records && records[ids[i]]);
@@ -19227,7 +19336,7 @@
19227
19336
  // label will be created with, falling back to the same default an existing
19228
19337
  // label would show.
19229
19338
  function getNewLabelValue(field, opts) {
19230
- var style = labelModeIsOn() ? getNewLabelStyle(gui) : null;
19339
+ var style = editingNewLabels() ? getNewLabelStyle(gui) : null;
19231
19340
  var val = style ? style[field] : '';
19232
19341
  if (val || val === 0) return val;
19233
19342
  return opts && opts.useDefault ? opts.defaultValue : '';
@@ -19318,7 +19427,7 @@
19318
19427
  var records = getActiveTable() && getActiveTable().getRecords();
19319
19428
  var ids = getTargetIds();
19320
19429
  if (ids.length === 0) {
19321
- return !!(labelModeIsOn() && getNewLabelStyle(gui)[textAnchorField]);
19430
+ return !!(editingNewLabels() && getNewLabelStyle(gui)[textAnchorField]);
19322
19431
  }
19323
19432
  return ids.some(function(id) {
19324
19433
  var rec = records && records[id];
@@ -19420,12 +19529,23 @@
19420
19529
 
19421
19530
  function getSelectedIcon() {
19422
19531
  var out = '';
19423
- iconTypes.forEach(function(icon) {
19532
+ getShownIconTypes().forEach(function(icon) {
19424
19533
  if (iconBtns[icon.name].hasClass('selected')) out = icon.name;
19425
19534
  });
19426
19535
  return out;
19427
19536
  }
19428
19537
 
19538
+ // A label that already has an NYT star keeps it without the button: it is
19539
+ // drawn for everyone, and only choosing one is gated.
19540
+ function getShownIconTypes() {
19541
+ if (!shownIconTypes) {
19542
+ shownIconTypes = iconTypes.filter(function(icon) {
19543
+ return !icon.nytOnly || isNytUser();
19544
+ });
19545
+ }
19546
+ return shownIconTypes;
19547
+ }
19548
+
19429
19549
  function applyStyleObject(style) {
19430
19550
  var styles = [];
19431
19551
  savedStyleFields.forEach(function(field) {
@@ -19456,8 +19576,9 @@
19456
19576
  // The size the symbols in the selection already share, where they share
19457
19577
  // one: a shape applied to a mixed selection gives the labels that had no
19458
19578
  // symbol the size of the ones that did, rather than resetting them all to
19459
- // the default.
19460
- styles.push([iconSizeField, getNumericSize(getIconValueIds(), iconSizeField, defaultIconSize)]);
19579
+ // the default. A size or colour that was the old shape's default becomes
19580
+ // the new one's (see getIconShapeChange()).
19581
+ addIconShapeDefaults(styles, iconName, getIconValueIds());
19461
19582
  // The symbol's own opacity goes on with it, because the label's opacity
19462
19583
  // is applied to both elements: without this, text set to 50% would give
19463
19584
  // a half-faded symbol while the Icon section showed it at 100%.
@@ -19469,6 +19590,14 @@
19469
19590
  applyStyleValues(styles);
19470
19591
  }
19471
19592
 
19593
+ function addIconShapeDefaults(styles, iconName, ids) {
19594
+ var color = getCommonValueInfo(ids, iconColorField);
19595
+ var change = getIconShapeChange(iconName, getCommonValue(ids, iconField) || '',
19596
+ getCommonValue(ids, iconSizeField), color.mixed ? null : color.value || '');
19597
+ styles.push([iconSizeField, change.size]);
19598
+ if ('color' in change) styles.push([iconColorField, change.color]);
19599
+ }
19600
+
19472
19601
  // Switching a symbol on moves a label sitting at the centre out from under
19473
19602
  // it, in the same command, so that the pair is one undo step and the text
19474
19603
  // is never briefly drawn over the symbol it just asked for.
@@ -19509,7 +19638,9 @@
19509
19638
  }
19510
19639
 
19511
19640
  function nudgeIconSize(delta) {
19512
- var size = getNumericSize(getIconTargetIds(), iconSizeField, defaultIconSize);
19641
+ var ids = getIconTargetIds();
19642
+ var size = getNumericSize(ids, iconSizeField,
19643
+ getDefaultIconSize(getCommonValue(ids, iconField)));
19513
19644
  if (!controlsEnabled()) return;
19514
19645
  size = Math.max(1, size + delta);
19515
19646
  applyIconSize(size);
@@ -19836,13 +19967,13 @@
19836
19967
  // no fill gets a clear one, so that the pattern is drawn over whatever is
19837
19968
  // underneath.
19838
19969
  function getPatternBackground(fill) {
19839
- return isBlank$1(fill) ? 'none' : String(fill).trim();
19970
+ return isBlank$2(fill) ? 'none' : String(fill).trim();
19840
19971
  }
19841
19972
 
19842
19973
  // Whether the settings describe a pattern the renderer will draw. A hatch with
19843
19974
  // no gap would be a solid fill, and the parser refuses a stripe of no width.
19844
19975
  function isValidPatternControls(o) {
19845
- if (!o || patternTypes.indexOf(o.type) == -1 || isBlank$1(o.color)) return false;
19976
+ if (!o || patternTypes.indexOf(o.type) == -1 || isBlank$2(o.color)) return false;
19846
19977
  if (!(o.size > 0) || !isFinite(o.angle)) return false;
19847
19978
  return o.type == 'hatches' ? o.gap > 0 : o.gap >= 0;
19848
19979
  }
@@ -19936,7 +20067,7 @@
19936
20067
  return str == 'transparent' ? 'none' : str;
19937
20068
  }
19938
20069
 
19939
- function isBlank$1(val) {
20070
+ function isBlank$2(val) {
19940
20071
  return val === undefined || val === null || String(val).trim() === '';
19941
20072
  }
19942
20073
 
@@ -19953,7 +20084,7 @@
19953
20084
  // opts.revert() put the panel back as the data has it
19954
20085
  // opts.releaseFocus()
19955
20086
  function PatternFillControl(parent, opts) {
19956
- var section = makePanelSection(parent, 'Pattern');
20087
+ var section = makePanelSection(parent, 'Patterns');
19957
20088
  var shown = {type: 'none'};
19958
20089
  // What switching the pattern on applies when the features have none: the
19959
20090
  // last pattern the section showed, so that off and on again is a round trip.
@@ -19998,7 +20129,7 @@
19998
20129
  var code = rec && rec['fill-pattern'];
19999
20130
  var edit = {id: id, styles: styles};
20000
20131
  var refilled;
20001
- if (refill && !isBlank(code)) {
20132
+ if (refill && !isBlank$1(code)) {
20002
20133
  refilled = refillPattern(internal.parsePattern(code), rec.fill, fill);
20003
20134
  if (refilled) edit.styles = styles.concat([['fill-pattern', refilled]]);
20004
20135
  }
@@ -20015,7 +20146,7 @@
20015
20146
  ids.forEach(function(id) {
20016
20147
  var rec = records[id];
20017
20148
  var code = rec && rec['fill-pattern'];
20018
- var o = isBlank(code) ? null : getPatternControls(internal.parsePattern(code), rec.fill);
20149
+ var o = isBlank$1(code) ? null : getPatternControls(internal.parsePattern(code), rec.fill);
20019
20150
  if (o) edits.push({id: id, styles: [['fill-pattern', formatFillPatternExpression(o)]]});
20020
20151
  });
20021
20152
  return edits;
@@ -20028,7 +20159,7 @@
20028
20159
  onChange: setPatternOn
20029
20160
  });
20030
20161
  var typeRow = El('div').addClass('label-style-row layer-pattern-type-row').appendTo(section);
20031
- typeSelect = El('select').attr('title', 'Pattern type').appendTo(typeRow)
20162
+ typeSelect = El('select').attr('aria-label', 'Pattern type').appendTo(typeRow)
20032
20163
  .on('change', function() {
20033
20164
  selectType(typeSelect.node().value);
20034
20165
  });
@@ -20045,7 +20176,12 @@
20045
20176
  label: 'Color',
20046
20177
  noOpacity: true,
20047
20178
  onColor: function(color) {
20048
- if (color) applyChange({color: color});
20179
+ // A pattern has to have a colour, so an emptied field is its default.
20180
+ if (!color) {
20181
+ color = defaultPatternColor;
20182
+ colorControl.showColor(color);
20183
+ }
20184
+ applyChange({color: color});
20049
20185
  },
20050
20186
  revert: opts.revert
20051
20187
  });
@@ -20096,7 +20232,7 @@
20096
20232
  makeFieldTip(caption,
20097
20233
  'The fill-pattern syntax of the -style command.\n' +
20098
20234
  'See the command reference for details.');
20099
- codeInput = El('input').attr('type', 'text').attr('title', 'Pattern code').appendTo(customRow)
20235
+ codeInput = El('input').attr('type', 'text').attr('aria-label', 'Pattern code').appendTo(customRow)
20100
20236
  .on('change', function() {
20101
20237
  applyCode(codeInput.node().value.trim());
20102
20238
  });
@@ -20173,7 +20309,7 @@
20173
20309
 
20174
20310
  function hasPattern(id) {
20175
20311
  var rec = opts.getRecords()[id];
20176
- return !isBlank(rec && rec['fill-pattern']);
20312
+ return !isBlank$1(rec && rec['fill-pattern']);
20177
20313
  }
20178
20314
 
20179
20315
  function applyChange(change) {
@@ -20245,7 +20381,7 @@
20245
20381
 
20246
20382
  function readPatternState(rec) {
20247
20383
  var code = rec && rec['fill-pattern'];
20248
- if (isBlank(code)) return {type: 'none'};
20384
+ if (isBlank$1(code)) return {type: 'none'};
20249
20385
  return getPatternControls(internal.parsePattern(code), rec.fill) ||
20250
20386
  {type: 'custom', code: String(code)};
20251
20387
  }
@@ -20261,18 +20397,330 @@
20261
20397
  return undefined;
20262
20398
  }
20263
20399
 
20400
+ function isBlank$1(val) {
20401
+ return val === undefined || val === null || String(val).trim() === '';
20402
+ }
20403
+
20404
+ // The polygon panel's "Effects" section: an outer and an inner glow, each a
20405
+ // color, an opacity and a width. See svg-glow.mjs for how they are drawn.
20406
+ //
20407
+ // A glow is there when its color is set, so each glow is a color row with no
20408
+ // switch of its own: picking a color adds the glow, and emptying the color
20409
+ // field removes it. The section's switch opens the section, and switching it
20410
+ // off removes both glows and their settings.
20411
+ //
20412
+ // A width or opacity typed while no feature has the glow is kept, and goes
20413
+ // with the color when one is picked.
20414
+ //
20415
+ // opts.getRecords() the target layer's records
20416
+ // opts.getTargetIds() the features being styled
20417
+ // opts.applyEdits(edits, title) run per-feature edits: [{id, styles}]
20418
+ // opts.revert() put the panel back as the data has it
20419
+ // opts.releaseFocus()
20420
+
20421
+ var glowTypes = ['outer', 'inner'];
20422
+ var glowTitles = {outer: 'Outer glow', inner: 'Inner glow'};
20423
+
20424
+ function GlowEffectsControl(parent, opts) {
20425
+ var section = makePanelSection(parent, 'Effects');
20426
+ var rows = {};
20427
+ // Switched on, with no glow to show yet.
20428
+ var opened = false;
20429
+ var pending = {outer: {}, inner: {}};
20430
+ var toggle = makePanelToggle(section.findChild('.label-style-section-title'), {
20431
+ title: 'Add glow effects',
20432
+ className: 'layer-effects-toggle',
20433
+ onChange: function(on) {
20434
+ var edits;
20435
+ opened = on;
20436
+ pending = {outer: {}, inner: {}};
20437
+ edits = on ? [] : getEffectsOffEdits(opts.getRecords(), opts.getTargetIds());
20438
+ if (edits.length > 0) {
20439
+ opts.applyEdits(edits, 'Remove glow effects');
20440
+ } else {
20441
+ update();
20442
+ }
20443
+ }
20444
+ });
20445
+ glowTypes.forEach(function(type) {
20446
+ rows[type] = addGlowRow(type);
20447
+ });
20448
+
20449
+ this.section = section;
20450
+ this.update = update;
20451
+ this.hidePickers = hidePickers;
20452
+
20453
+ this.reset = function() {
20454
+ opened = false;
20455
+ pending = {outer: {}, inner: {}};
20456
+ hidePickers();
20457
+ };
20458
+
20459
+ // The glows every target has, for a saved style.
20460
+ this.addStyleValues = function(style) {
20461
+ var records = opts.getRecords();
20462
+ var ids = opts.getTargetIds();
20463
+ glowTypes.forEach(function(type) {
20464
+ if (getGlowState(records, ids, type) != 'on') return;
20465
+ ['color', 'width', 'opacity'].forEach(function(name) {
20466
+ var field = getGlowField(type, name);
20467
+ var val = getCommonValue(records, ids, field);
20468
+ if (val || val === 0) style[field] = val;
20469
+ });
20470
+ });
20471
+ };
20472
+
20473
+ function hidePickers() {
20474
+ rows.outer.picker.hide();
20475
+ rows.inner.picker.hide();
20476
+ }
20477
+
20478
+ function addGlowRow(type) {
20479
+ var o = makeColorRow(section, {
20480
+ label: glowTitles[type],
20481
+ onColor: function(color) {
20482
+ applyGlowColor(type, color);
20483
+ },
20484
+ onOpacity: function(value) {
20485
+ applyGlowValue(type, 'opacity', value);
20486
+ },
20487
+ revert: opts.revert
20488
+ });
20489
+ o.row.addClass('layer-glow-row layer-' + type + '-glow-row');
20490
+ El('span').appendTo(o.aside).text('Width');
20491
+ o.width = new SizeField(o.aside, {
20492
+ min: 0.5,
20493
+ max: 200,
20494
+ step: 1,
20495
+ bigStep: 5,
20496
+ decimals: 1,
20497
+ title: 'How far the glow reaches from the edge, in px',
20498
+ onSet: function(value) {
20499
+ applyGlowValue(type, 'width', value);
20500
+ },
20501
+ onStep: function(delta) {
20502
+ var shown = Number(o.width.getValue());
20503
+ if (!(shown > 0)) shown = internal.svg.DEFAULT_GLOW_WIDTH;
20504
+ applyGlowValue(type, 'width', Math.max(0.5, Math.round(shown + delta)));
20505
+ },
20506
+ onDone: opts.releaseFocus
20507
+ });
20508
+ return o;
20509
+ }
20510
+
20511
+ function applyGlowColor(type, color) {
20512
+ var edits = getGlowColorEdits(opts.getRecords(), opts.getTargetIds(), type, color, pending[type]);
20513
+ if (color) pending[type] = {};
20514
+ if (edits.length > 0) {
20515
+ opts.applyEdits(edits, color ? glowTitles[type] : 'Remove ' + glowTitles[type].toLowerCase());
20516
+ } else {
20517
+ update();
20518
+ }
20519
+ }
20520
+
20521
+ // A width or opacity goes to the features that have the glow, as the label
20522
+ // panel's halo settings go only to the labels that have a halo.
20523
+ function applyGlowValue(type, name, value) {
20524
+ var records = opts.getRecords();
20525
+ var ids = opts.getTargetIds().filter(function(id) {
20526
+ return hasGlow(records[id], type);
20527
+ });
20528
+ if (ids.length === 0) {
20529
+ pending[type][name] = value;
20530
+ update();
20531
+ return;
20532
+ }
20533
+ opts.applyEdits(ids.map(function(id) {
20534
+ return {id: id, styles: [[getGlowField(type, name), formatGlowValue(name, value)]]};
20535
+ }));
20536
+ }
20537
+
20538
+ function update() {
20539
+ var records = opts.getRecords();
20540
+ var ids = opts.getTargetIds();
20541
+ var state = getEffectsState(records, ids);
20542
+ if (state != 'off') opened = true;
20543
+ toggle.setState(state == 'off' && opened ? 'on' : state);
20544
+ section.classed('collapsed', state == 'off' && !opened);
20545
+ if (state == 'off' && !opened) hidePickers();
20546
+ glowTypes.forEach(function(type) {
20547
+ updateGlowRow(type, records, ids);
20548
+ });
20549
+ }
20550
+
20551
+ function updateGlowRow(type, records, ids) {
20552
+ var o = rows[type];
20553
+ var shown = getShownGlow(records, ids, type, pending[type]);
20554
+ o.showColor(shown.color);
20555
+ o.input.attr('placeholder', shown.mixed ? 'mixed' : '');
20556
+ o.chit.classed('mixed', shown.mixed);
20557
+ o.opacity.node().value = shown.opacity === '' ? '' : formatOpacityPct(shown.opacity);
20558
+ o.width.setValue(shown.width === '' ? '' : String(shown.width));
20559
+ o.width.setPlaceholder(shown.width === '' ? 'mixed' : '');
20560
+ }
20561
+ }
20562
+
20563
+ function getGlowField(type, name) {
20564
+ return type + '-glow-' + name;
20565
+ }
20566
+
20567
+ // In the panel, a feature has a glow when the glow's color is set.
20568
+ function hasGlow(rec, type) {
20569
+ return !!rec && !isBlank(rec[getGlowField(type, 'color')]);
20570
+ }
20571
+
20572
+ // 'on', 'off' or 'mixed', over the features @ids
20573
+ function getGlowState(records, ids, type) {
20574
+ var n = ids.filter(function(id) { return hasGlow(records[id], type); }).length;
20575
+ return n === 0 ? 'off' : n < ids.length ? 'mixed' : 'on';
20576
+ }
20577
+
20578
+ function getEffectsState(records, ids) {
20579
+ var n = ids.filter(function(id) {
20580
+ return hasGlow(records[id], 'outer') || hasGlow(records[id], 'inner');
20581
+ }).length;
20582
+ return n === 0 ? 'off' : n < ids.length ? 'mixed' : 'on';
20583
+ }
20584
+
20585
+ // Per-feature edits for a color typed or picked for one glow. A color adds the
20586
+ // glow to every feature, with the @pending width and opacity where a feature
20587
+ // has none of its own; an empty color removes the glow's color.
20588
+ function getGlowColorEdits(records, ids, type, color, pending) {
20589
+ var colorField = getGlowField(type, 'color');
20590
+ var edits = [];
20591
+ ids.forEach(function(id) {
20592
+ var rec = records[id] || {};
20593
+ var styles = [];
20594
+ if (!color) {
20595
+ if (!isBlank(rec[colorField])) styles.push([colorField, '']);
20596
+ } else {
20597
+ if (String(rec[colorField]).trim() != color) styles.push([colorField, color]);
20598
+ ['width', 'opacity'].forEach(function(name) {
20599
+ var field = getGlowField(type, name);
20600
+ if (pending && name in pending && isBlank(rec[field])) {
20601
+ var val = formatGlowValue(name, pending[name]);
20602
+ if (val !== '') styles.push([field, val]);
20603
+ }
20604
+ });
20605
+ }
20606
+ if (styles.length > 0) edits.push({id: id, styles: styles});
20607
+ });
20608
+ return edits;
20609
+ }
20610
+
20611
+ // Switching Effects off removes every glow setting, which mean nothing
20612
+ // without a color.
20613
+ function getEffectsOffEdits(records, ids) {
20614
+ var edits = [];
20615
+ ids.forEach(function(id) {
20616
+ var rec = records[id] || {};
20617
+ var styles = internal.svg.glowFields.filter(function(field) {
20618
+ return !isBlank(rec[field]);
20619
+ }).map(function(field) {
20620
+ return [field, ''];
20621
+ });
20622
+ if (styles.length > 0) edits.push({id: id, styles: styles});
20623
+ });
20624
+ return edits;
20625
+ }
20626
+
20627
+ // What a glow's row shows. The color is blank where no feature has the glow,
20628
+ // and blank and mixed where only some do or their colors differ. The width
20629
+ // and opacity are those the glowing features agree on, '' where they differ;
20630
+ // with no glowing features, they are the @pending ones, the default width and
20631
+ // no opacity.
20632
+ function getShownGlow(records, ids, type, pending) {
20633
+ var glowIds = ids.filter(function(id) { return hasGlow(records[id], type); });
20634
+ var defaultWidth = internal.svg.DEFAULT_GLOW_WIDTH;
20635
+ var color, mixed;
20636
+ pending = pending || {};
20637
+ if (glowIds.length === 0) {
20638
+ return {
20639
+ color: '',
20640
+ mixed: false,
20641
+ width: 'width' in pending ? pending.width : defaultWidth,
20642
+ opacity: 'opacity' in pending ? pending.opacity : ''
20643
+ };
20644
+ }
20645
+ color = getCommonValue(records, glowIds, getGlowField(type, 'color'), undefined, trim);
20646
+ mixed = glowIds.length < ids.length || color === '';
20647
+ return {
20648
+ color: mixed ? '' : color,
20649
+ mixed: mixed,
20650
+ width: getCommonValue(records, glowIds, getGlowField(type, 'width'), defaultWidth, Number),
20651
+ opacity: getCommonValue(records, glowIds, getGlowField(type, 'opacity'), 1, Number)
20652
+ };
20653
+ }
20654
+
20655
+ // Full opacity is the default, and stored as no opacity at all.
20656
+ function formatGlowValue(name, value) {
20657
+ return name == 'opacity' && value >= 1 ? '' : value;
20658
+ }
20659
+
20660
+ // The value of @field that the features @ids share, or '' if they differ.
20661
+ function getCommonValue(records, ids, field, defaultValue, convert) {
20662
+ var value, val;
20663
+ for (var i=0; i<ids.length; i++) {
20664
+ val = records[ids[i]] && records[ids[i]][field];
20665
+ if (isBlank(val)) val = defaultValue === undefined ? '' : defaultValue;
20666
+ if (convert && val !== '' && val !== null) val = convert(val);
20667
+ if (i === 0) {
20668
+ value = val;
20669
+ } else if (val !== value) {
20670
+ return '';
20671
+ }
20672
+ }
20673
+ return ids.length > 0 ? value : '';
20674
+ }
20675
+
20676
+ function trim(val) {
20677
+ return String(val).trim();
20678
+ }
20679
+
20264
20680
  function isBlank(val) {
20265
20681
  return val === undefined || val === null || String(val).trim() === '';
20266
20682
  }
20267
20683
 
20268
20684
  var savedStylesKey = 'layer_style_presets';
20269
- var styleFields = ['stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'fill', 'fill-opacity', 'fill-pattern'];
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);
20686
+ var arrowShapes = [{
20687
+ name: 'arrow',
20688
+ title: 'solid arrowheads'
20689
+ }, {
20690
+ name: 'open-arrow',
20691
+ title: 'open arrowheads'
20692
+ }, {
20693
+ name: 'dot',
20694
+ title: 'dots'
20695
+ }];
20696
+ // The arrows are the label callout's, which point left, the way a line's
20697
+ // start head does; the dot's left edge is where their tips are.
20698
+ var arrowButtonSymbols = {
20699
+ arrow: calloutButtonSymbols.arrow,
20700
+ 'open-arrow': calloutButtonSymbols['open-arrow'],
20701
+ dot: '<path d="M5 8H13"></path><circle class="fill" cx="5" cy="8" r="2.6"></circle>'
20702
+ };
20703
+ var arrowPositions = [{
20704
+ name: 'start',
20705
+ label: 'Start',
20706
+ title: 'arrowhead at the start of the line'
20707
+ }, {
20708
+ name: 'end',
20709
+ label: 'End',
20710
+ title: 'arrowhead at the end of the line'
20711
+ }, {
20712
+ name: 'both',
20713
+ label: 'Both',
20714
+ title: 'arrowheads at both ends of the line'
20715
+ }];
20270
20716
 
20271
20717
  function LayerStyleTool(gui) {
20272
20718
  var parent = gui.container.findChild('.mshp-main-map');
20273
20719
  var panel = El('div').addClass('label-style-panel layer-style-panel rollover').appendTo(parent).hide();
20274
- var title, editingStatus, clearLink, strokeControl, fillControl, strokeWidthField, dashControl, randomFillBtn, presetControl, patternControl, hit;
20720
+ var title, editingStatus, clearLink, strokeControl, fillControl, strokeWidthField, dashControl, arrowControl, randomFillBtn, presetControl, patternControl, glowControl, hit;
20275
20721
  var targetLayer = null;
20722
+ // What the arrowhead switch turns on, for lines that have no heads
20723
+ var lastArrow = {shape: 'arrow', position: 'end'};
20276
20724
 
20277
20725
  initPanel();
20278
20726
  hit = gui.map.getHitControl && gui.map.getHitControl();
@@ -20328,6 +20776,7 @@
20328
20776
  fillControl.picker.hide();
20329
20777
  patternControl.hidePicker();
20330
20778
  patternControl.reset();
20779
+ glowControl.reset();
20331
20780
  targetLayer = null;
20332
20781
  }
20333
20782
 
@@ -20357,13 +20806,15 @@
20357
20806
  editingStatus = El('span').addClass('label-editing-status').appendTo(editRow);
20358
20807
  clearLink = El('span').addClass('label-editing-clear colored-text').appendTo(editRow).text('deselect').on('click', clearSelection);
20359
20808
 
20360
- fillControl = addColorControl(panel, 'Fill', 'fill', '');
20361
20809
  strokeControl = addColorControl(panel, 'Stroke', 'stroke', '#000000');
20362
20810
  strokeWidthField = addStrokeWidthControl(strokeControl.aside);
20811
+ fillControl = addColorControl(panel, 'Fill', 'fill', '');
20812
+ randomFillBtn = makePanelActionButton(fillControl.aside, 'Random fill', applyRandomFillColors)
20813
+ .addClass('layer-random-fill-btn');
20363
20814
  dashControl = addDashArrayControl(panel);
20815
+ arrowControl = addArrowControl(panel);
20364
20816
 
20365
20817
  var buttonRow = El('div').addClass('label-style-row label-panel-button-row').appendTo(panel);
20366
- randomFillBtn = makePanelActionButton(buttonRow, 'Random fills', applyRandomFillColors);
20367
20818
  makePanelActionButton(buttonRow, 'Clear style', clearLayerStyle);
20368
20819
 
20369
20820
  patternControl = new PatternFillControl(panel, {
@@ -20376,6 +20827,14 @@
20376
20827
  releaseFocus: releaseFocus
20377
20828
  });
20378
20829
 
20830
+ glowControl = new GlowEffectsControl(panel, {
20831
+ getRecords: getTargetRecords,
20832
+ getTargetIds: getTargetIds,
20833
+ applyEdits: runStyleEdits,
20834
+ revert: updateControls,
20835
+ releaseFocus: releaseFocus
20836
+ });
20837
+
20379
20838
  presetControl = new StylePresetControl(panel, {
20380
20839
  storageKey: savedStylesKey,
20381
20840
  type: getStyleType,
@@ -20397,10 +20856,9 @@
20397
20856
  var control = makeColorRow(parent, {
20398
20857
  label: label,
20399
20858
  onColor: function(color) {
20400
- // Blanking the field is not a way to unset a colour: -style reads an
20401
- // empty value as "remove this", and a field left empty by a mistyped
20402
- // hex would then clear the layer rather than say nothing.
20403
- if (color) applyColorControlStyle(control, color);
20859
+ // An emptied field unsets the colour: -style reads an empty value as
20860
+ // "remove this".
20861
+ applyColorControlStyle(control, color);
20404
20862
  },
20405
20863
  onOpacity: function(value) {
20406
20864
  applyLayerStyle(field + '-opacity', value);
@@ -20454,6 +20912,197 @@
20454
20912
  return {row: row, input: input};
20455
20913
  }
20456
20914
 
20915
+ // A switch in the heading says whether the lines have arrowheads; the rows
20916
+ // 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.
20919
+ function addArrowControl(parent) {
20920
+ var section = makePanelSection(parent, 'Arrowheads');
20921
+ var toggle = makePanelToggle(section.findChild('.label-style-section-title'), {
20922
+ title: 'Add arrowheads',
20923
+ className: 'layer-arrow-toggle',
20924
+ onChange: setArrowsOn
20925
+ });
20926
+ var shapeRow = El('div').addClass('label-style-row label-split-row').appendTo(section);
20927
+ var shapeCell = El('div').addClass('label-split-cell label-align-row').appendTo(shapeRow);
20928
+ var sizeCell = El('div').addClass('label-split-cell label-spacing-row layer-arrow-size-row').appendTo(shapeRow);
20929
+ var posRow = El('div').addClass('label-style-row label-split-row').appendTo(section);
20930
+ var posCell = El('div').addClass('label-split-cell label-align-row').appendTo(posRow);
20931
+ var control = {section: section, toggle: toggle, shapeBtns: {}, posBtns: {}};
20932
+ El('div').addClass('label-split-cell').appendTo(posRow);
20933
+ El('span').appendTo(shapeCell).text('Shape');
20934
+ var shapeGroup = El('div').addClass('label-btn-group label-callout-buttons layer-arrow-shape-buttons').appendTo(shapeCell);
20935
+ arrowShapes.forEach(function(item) {
20936
+ var btn = makePanelButton(shapeGroup, '', function() {
20937
+ applyArrowShape(item.name);
20938
+ }).attr('data-arrow-shape', item.name).attr('aria-label', item.title);
20939
+ El('<svg class="label-callout-symbol" viewBox="0 0 16 16" aria-hidden="true">' +
20940
+ arrowButtonSymbols[item.name] + '</svg>').appendTo(btn);
20941
+ control.shapeBtns[item.name] = btn;
20942
+ });
20943
+ El('span').appendTo(posCell).text('Ends');
20944
+ var posGroup = El('div').addClass('label-btn-group layer-arrow-position-buttons').appendTo(posCell);
20945
+ arrowPositions.forEach(function(item) {
20946
+ control.posBtns[item.name] = makePanelButton(posGroup, item.label, function() {
20947
+ applyArrowPosition(item.name);
20948
+ }).attr('data-arrow-position', item.name).attr('aria-label', item.title);
20949
+ });
20950
+ El('span').appendTo(sizeCell).text('Size');
20951
+ control.sizeField = new SizeField(sizeCell, {
20952
+ title: 'Arrowhead size in px, the length of its sides or the diameter of a dot',
20953
+ min: 1,
20954
+ max: 60,
20955
+ step: 1,
20956
+ bigStep: 5,
20957
+ onSet: applyArrowSize,
20958
+ onStep: nudgeArrowSize,
20959
+ onDone: releaseFocus
20960
+ });
20961
+ return control;
20962
+ }
20963
+
20964
+ // {shape, position} for one record, 'none' and '' when it has no heads. A
20965
+ // line whose two ends have different shapes reports the start's.
20966
+ function getArrowInfo(rec) {
20967
+ var start = internal.svg.getLineEndType(rec || {}, 'line-start');
20968
+ var end = internal.svg.getLineEndType(rec || {}, 'line-end');
20969
+ var shape = start != 'none' ? start : end;
20970
+ var position = start != 'none' && end != 'none' ? 'both' :
20971
+ start != 'none' ? 'start' : end != 'none' ? 'end' : '';
20972
+ return {shape: shape, position: position};
20973
+ }
20974
+
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.
20979
+ function setArrowsOn(on) {
20980
+ applyArrowEdits(function(info) {
20981
+ return on ? fillArrowInfo(info) : {shape: 'none', position: ''};
20982
+ });
20983
+ }
20984
+
20985
+ // Each target keeps whichever of shape and position is not being set, so
20986
+ // that changing the shape of a selection whose lines point different ways
20987
+ // leaves them pointing those ways.
20988
+ function applyArrowShape(shape) {
20989
+ applyArrowEdits(function(info) {
20990
+ return {shape: shape, position: fillArrowInfo(info).position};
20991
+ });
20992
+ }
20993
+
20994
+ function applyArrowPosition(position) {
20995
+ applyArrowEdits(function(info) {
20996
+ return {shape: fillArrowInfo(info).shape, position: position};
20997
+ });
20998
+ }
20999
+
21000
+ function fillArrowInfo(info) {
21001
+ return info.shape == 'none' ? {shape: lastArrow.shape, position: lastArrow.position} : info;
21002
+ }
21003
+
21004
+ function applyArrowEdits(getNext) {
21005
+ var records = getTargetRecords();
21006
+ var addStroke = styleFieldIsUnsetForTargets('stroke');
21007
+ var edits = [];
21008
+ getTargetIds().forEach(function(id) {
21009
+ var rec = records[id] || {};
21010
+ var next = getNext(getArrowInfo(rec));
21011
+ var on = next.shape != 'none';
21012
+ var styles = [
21013
+ ['line-start', on && next.position != 'end' ? next.shape : ''],
21014
+ ['line-end', on && next.position != 'start' ? next.shape : '']
21015
+ ].filter(function(style) {
21016
+ // no need to remove what is not there
21017
+ return style[1] || rec[style[0]];
21018
+ });
21019
+ if (on && addStroke) styles.push(['stroke', strokeControl.defaultColor]);
21020
+ if (styles.length > 0) edits.push({id: id, styles: styles});
21021
+ });
21022
+ runStyleEdits(edits);
21023
+ }
21024
+
21025
+ function applyArrowSize(value) {
21026
+ var ids = getArrowTargetIds();
21027
+ if (ids.length === 0) return;
21028
+ runStyleEdits(ids.map(function(id) {
21029
+ return {id: id, styles: [['line-end-size', value]]};
21030
+ }));
21031
+ }
21032
+
21033
+ function nudgeArrowSize(delta) {
21034
+ var shown = getArrowSizeShown(getArrowTargetIds());
21035
+ var size = Number(shown.value);
21036
+ if (!isFinite(size) || shown.value === '') return;
21037
+ applyArrowSize(Math.max(1, Math.round(size + delta)));
21038
+ }
21039
+
21040
+ function getArrowTargetIds() {
21041
+ var records = getTargetRecords();
21042
+ return getTargetIds().filter(function(id) {
21043
+ return getArrowInfo(records[id]).shape != 'none';
21044
+ });
21045
+ }
21046
+
21047
+ // The size a head is drawn at, which is the one its line width gives it
21048
+ // when it has none of its own.
21049
+ function getArrowSizeShown(ids) {
21050
+ var records = getTargetRecords();
21051
+ var value = '', size;
21052
+ for (var i=0; i<ids.length; i++) {
21053
+ size = getArrowSize(records[ids[i]]);
21054
+ if (i > 0 && size !== value) return {value: '', mixed: true};
21055
+ value = size;
21056
+ }
21057
+ return {value: value, mixed: false};
21058
+ }
21059
+
21060
+ function getArrowSize(rec) {
21061
+ var opts = internal.svg.makeLineArrowOpts('arrow', '', rec && rec['line-end-size'],
21062
+ rec && rec['stroke-width'], 1);
21063
+ var size = getArrowInfo(rec).shape == 'dot' ? opts.dotSize : opts.size;
21064
+ return formatNumberValue(Math.round(size * 100) / 100);
21065
+ }
21066
+
21067
+ // The shape and ends shown are those of the lines with heads; lines
21068
+ // without are what the switch's mixed state is for.
21069
+ function updateArrowControl() {
21070
+ var records = getTargetRecords();
21071
+ var ids = getTargetIds();
21072
+ var arrowIds = getArrowTargetIds();
21073
+ var shape, position, info;
21074
+ for (var i=0; i<arrowIds.length; i++) {
21075
+ info = getArrowInfo(records[arrowIds[i]]);
21076
+ if (i === 0) {
21077
+ shape = info.shape;
21078
+ position = info.position;
21079
+ } else {
21080
+ if (info.shape != shape) shape = '';
21081
+ if (info.position != position) position = '';
21082
+ }
21083
+ }
21084
+ if (shape) lastArrow.shape = shape;
21085
+ if (position) lastArrow.position = position;
21086
+ var state = arrowIds.length === 0 ? 'off' :
21087
+ arrowIds.length < ids.length ? 'mixed' : 'on';
21088
+ arrowControl.toggle.setState(state);
21089
+ arrowControl.section.classed('collapsed', state == 'off');
21090
+ arrowShapes.forEach(function(item) {
21091
+ arrowControl.shapeBtns[item.name].classed('selected', item.name == shape);
21092
+ });
21093
+ arrowPositions.forEach(function(item) {
21094
+ arrowControl.posBtns[item.name].classed('selected', item.name == position);
21095
+ });
21096
+ var shown = arrowIds.length > 0 ? getArrowSizeShown(arrowIds) : {value: '', mixed: false};
21097
+ arrowControl.sizeField.setValue(shown.value);
21098
+ arrowControl.sizeField.setPlaceholder(shown.mixed ? 'mixed' : '');
21099
+ arrowControl.sizeField.setDisabled(arrowIds.length === 0);
21100
+ }
21101
+
21102
+ function getTargetRecords() {
21103
+ return targetLayer && targetLayer.data ? targetLayer.data.getRecords() : [];
21104
+ }
21105
+
20457
21106
  function releaseFocus() {
20458
21107
  releasePanelFocus(panel.node());
20459
21108
  }
@@ -20468,13 +21117,17 @@
20468
21117
  strokeControl.row.show();
20469
21118
  fillControl.row.classed('hidden', geom != 'polygon');
20470
21119
  dashControl.row.classed('hidden', geom != 'polyline');
21120
+ arrowControl.section.classed('hidden', geom != 'polyline');
20471
21121
  updateColorControl(strokeControl);
20472
21122
  updateColorControl(fillControl);
20473
21123
  updateStrokeWidthControl();
20474
21124
  updateDashArrayControl();
21125
+ if (geom == 'polyline') updateArrowControl();
20475
21126
  randomFillBtn.classed('hidden', geom != 'polygon');
20476
21127
  patternControl.section.classed('hidden', geom != 'polygon');
20477
21128
  if (geom == 'polygon') patternControl.update();
21129
+ glowControl.section.classed('hidden', geom != 'polygon');
21130
+ if (geom == 'polygon') glowControl.update();
20478
21131
  presetControl.render();
20479
21132
  updateSavedStyleControls();
20480
21133
  }
@@ -20535,7 +21188,7 @@
20535
21188
 
20536
21189
  function applyColorControlStyle(control, color) {
20537
21190
  var styles = [[control.field, color]];
20538
- if (control.field == 'stroke' && strokeWidthIsUnsetForTargets()) {
21191
+ if (color && control.field == 'stroke' && strokeWidthIsUnsetForTargets()) {
20539
21192
  styles.push(['stroke-width', 1]);
20540
21193
  }
20541
21194
  if (control.field == 'fill') {
@@ -20651,15 +21304,25 @@
20651
21304
  addStyleValue(style, 'stroke-opacity', parseOpacityValue(strokeControl.opacity.node().value));
20652
21305
  if (targetLayer && targetLayer.geometry_type == 'polyline') {
20653
21306
  addStyleValue(style, 'stroke-dasharray', getControlValue(dashControl.input));
21307
+ addArrowStyleValues(style);
20654
21308
  }
20655
21309
  if (targetLayer && targetLayer.geometry_type == 'polygon') {
20656
21310
  addStyleValue(style, 'fill', getControlValue(fillControl.input));
20657
21311
  addStyleValue(style, 'fill-opacity', parseOpacityValue(fillControl.opacity.node().value));
20658
21312
  addStyleValue(style, 'fill-pattern', patternControl.getCode(getControlValue(fillControl.input)));
21313
+ glowControl.addStyleValues(style);
20659
21314
  }
20660
21315
  return style;
20661
21316
  }
20662
21317
 
21318
+ // Only when every target agrees, as with the other controls; a size is
21319
+ // saved only if the lines have their own.
21320
+ function addArrowStyleValues(style) {
21321
+ ['line-start', 'line-end', 'line-end-size'].forEach(function(field) {
21322
+ addStyleValue(style, field, getCommonStyleValue(field));
21323
+ });
21324
+ }
21325
+
20663
21326
  function addStyleValue(style, field, value) {
20664
21327
  if (value || value === 0) {
20665
21328
  style[field] = value;
@@ -20759,9 +21422,13 @@
20759
21422
  updateControls();
20760
21423
  }
20761
21424
 
21425
+ // An empty layer has nothing for the controls to act on, so they are shown
21426
+ // disabled rather than left to do nothing.
20762
21427
  function updateEditingStatus(count) {
20763
- editingStatus.text(count > 0 ? 'Editing: ' + count + ' selected' : 'Editing: all');
21428
+ var total = targetLayer ? internal.getFeatureCount(targetLayer) : 0;
21429
+ editingStatus.text(formatEditingStatus({selected: count, total: total}));
20764
21430
  clearLink.classed('hidden', count === 0);
21431
+ panel.classed('no-targets', total === 0);
20765
21432
  }
20766
21433
 
20767
21434
  function getActiveLayer() {
@@ -20988,8 +21655,17 @@
20988
21655
  var control = makeColorRow(circlesSection, {
20989
21656
  label: label,
20990
21657
  // Every circle property is applied together, from what the controls are
20991
- // showing: the fields are one style, not five.
20992
- onColor: applyCircleStyles,
21658
+ // showing: the fields are one style, not five. An emptied colour field
21659
+ // unsets the colour, which applying the fields would leave alone.
21660
+ onColor: function(color) {
21661
+ if (color) {
21662
+ applyCircleStyles();
21663
+ } else if (!styleFieldIsUnset(field)) {
21664
+ runStyleCommand([field + '=' + quoteCommandValue('')], 'Remove circle ' + field);
21665
+ } else {
21666
+ updateControls();
21667
+ }
21668
+ },
20993
21669
  onOpacity: applyCircleStyles,
20994
21670
  revert: updateControls
20995
21671
  });
@@ -21339,9 +22015,13 @@
21339
22015
  updateControls();
21340
22016
  }
21341
22017
 
22018
+ // As in the line and polygon panels: an empty layer's controls are shown
22019
+ // disabled rather than left to do nothing.
21342
22020
  function updateEditingStatus(count) {
21343
- editingStatus.text(count > 0 ? 'Editing: ' + count + ' selected' : 'Editing: all');
22021
+ var total = targetLayer ? internal.getFeatureCount(targetLayer) : 0;
22022
+ editingStatus.text(formatEditingStatus({selected: count, total: total}));
21344
22023
  clearLink.classed('hidden', count === 0);
22024
+ panel.classed('no-targets', total === 0);
21345
22025
  }
21346
22026
 
21347
22027
  function setCircleColor(control, color) {
@@ -22931,6 +23611,7 @@
22931
23611
  // data.ids = [drawingId];
22932
23612
  // data.id = drawingId;
22933
23613
  data.ids = utils$1.uniq(data.ids.concat([drawingId]));
23614
+ data.drawing_id = drawingId;
22934
23615
  }
22935
23616
  if (pinnedOn) {
22936
23617
  data.pinned = true;
@@ -24858,12 +25539,7 @@
24858
25539
  styleOpts.interactionMode == 'edit_polygons' ||
24859
25540
  styleOpts.interactionMode == 'snip_lines') {
24860
25541
  // special overlay: shape editing mode
24861
- lyr = getOverlayLayer(activeLyr, hitData.ids);
24862
- lyr.gui.style = getLineEditingStyle(hitData);
24863
- if (activeLyr.geometry_type == 'polygon') {
24864
- lyr.gui.style.fillColor = hoverFill;
24865
- }
24866
- return [lyr];
25542
+ return getShapeEditingLayers(activeLyr, hitData);
24867
25543
  }
24868
25544
  layers = [];
24869
25545
  if (styleOpts.interactionMode == 'label') {
@@ -24998,15 +25674,50 @@
24998
25674
  };
24999
25675
  }
25000
25676
 
25677
+ // The path being drawn is shown without its vertices, which are only of use
25678
+ // for reshaping a completed path. The hover markers go on the last layer, so
25679
+ // they are drawn on top.
25680
+ function getShapeEditingLayers(activeLyr, hitData) {
25681
+ var drawingIds = hitData.drawing_id >= 0 ? [hitData.drawing_id] : [];
25682
+ var otherIds = utils$1.difference(hitData.ids || [], drawingIds);
25683
+ var layers = [], lyr;
25684
+ if (drawingIds.length > 0) {
25685
+ lyr = getShapeEditingLayer(activeLyr, hitData, drawingIds, false);
25686
+ // The end of a path being drawn has closely spaced vertices, and pixel
25687
+ // rounding would show as a staircase.
25688
+ lyr.gui.style.unroundedCoords = true;
25689
+ layers.push(lyr);
25690
+ }
25691
+ if (otherIds.length > 0 || layers.length === 0) {
25692
+ layers.push(getShapeEditingLayer(activeLyr, hitData, otherIds, true));
25693
+ }
25694
+ layers.forEach(function(lyr, i) {
25695
+ if (i < layers.length - 1) {
25696
+ lyr.gui.style.vertex_overlay = null;
25697
+ lyr.gui.style.pending_snip = null;
25698
+ }
25699
+ });
25700
+ return layers;
25701
+ }
25702
+
25703
+ function getShapeEditingLayer(activeLyr, hitData, ids, showVertices) {
25704
+ var lyr = getOverlayLayer(activeLyr, ids);
25705
+ lyr.gui.style = getLineEditingStyle(hitData, ids, showVertices);
25706
+ if (activeLyr.geometry_type == 'polygon') {
25707
+ lyr.gui.style.fillColor = hoverFill;
25708
+ }
25709
+ return lyr;
25710
+ }
25711
+
25001
25712
  // style for vertex edit mode
25002
- function getLineEditingStyle(o) {
25713
+ function getLineEditingStyle(o, ids, showVertices) {
25003
25714
  var isVertex = o.hit_type == 'vertex' || o.hit_type == 'disabled';
25004
25715
  return {
25005
- ids: o.ids,
25716
+ ids: ids,
25006
25717
  overlay: true,
25007
25718
  strokeColor: black,
25008
25719
  strokeWidth: 1.2,
25009
- vertices: true,
25720
+ vertices: showVertices,
25010
25721
  vertex_overlay_color: getVertexOverlayColor(o.hit_type),
25011
25722
  vertex_overlay_scale: isVertex ? 2.5 : 2,
25012
25723
  vertex_overlay: o.hit_coordinates || null,
@@ -25207,6 +25918,11 @@
25207
25918
  // pixel distance threshold for hovering near a vertex or segment midpoint
25208
25919
  var HOVER_THRESHOLD$1 = 10;
25209
25920
 
25921
+ // How far the pointer has to move from where the mouse was pressed before a
25922
+ // drag draws a stroke. This matches the distance under which gui-mouse treats
25923
+ // a press as a click, so a press that stays this close adds only a click vertex.
25924
+ var STROKE_START_DIST = 6;
25925
+
25210
25926
  function initLineEditing(gui, ext, hit) {
25211
25927
  var hoverVertexInfo;
25212
25928
  var prevVertexAddedEvent;
@@ -25216,7 +25932,8 @@
25216
25932
  var drawingId = -1; // feature id of path being drawn
25217
25933
  var sessionCount = 0;
25218
25934
  var alert;
25219
- var pencilPoints = [];
25935
+ var stroke = null; // freehand stroke in progress (see startStroke())
25936
+ var blockDrag = false; // a drag finished a path; ignore it until the mouse is released
25220
25937
  var _dragging = false;
25221
25938
 
25222
25939
  function active() {
@@ -25236,7 +25953,7 @@
25236
25953
  }
25237
25954
 
25238
25955
  function pencilIsActive() {
25239
- return active() && (cmdKeyDown() || pathDrawing()) && !vertexDragging() && !!pencilPoints;
25956
+ return active() && (cmdKeyDown() || pathDrawing()) && !vertexDragging();
25240
25957
  }
25241
25958
 
25242
25959
  function polygonMode() {
@@ -25273,19 +25990,32 @@
25273
25990
  fullRedraw();
25274
25991
  });
25275
25992
 
25993
+ // an undo during a stroke takes back the whole stroke
25994
+ gui.on('undo_redo_pre', function() {
25995
+ if (stroke) {
25996
+ finishStroke();
25997
+ blockDrag = true;
25998
+ }
25999
+ });
26000
+
25276
26001
  gui.on('undo_path_add', function(e) {
25277
26002
  deleteLastPath(hit.getHitTarget());
25278
26003
  clearDrawingInfo();
25279
26004
  });
25280
26005
 
26006
+ // e.points: the vertices that the edit added to the path
25281
26007
  gui.on('redo_path_extend', function(e) {
25282
26008
  var target = hit.getHitTarget();
25283
-
25284
- if (pathDrawing() && prevHoverEvent) {
25285
- updatePathEndpoint(e.p);
25286
- appendVertex$1(target, pixToDataCoords(prevHoverEvent.x, prevHoverEvent.y));
26009
+ var points = e.points;
26010
+ var last = points[points.length - 1];
26011
+ if (pathDrawing()) {
26012
+ // the vertex following the pointer becomes the first added vertex
26013
+ setVertexCoords(target, [target.gui.displayArcs.getPointCount() - 1], points[0]);
26014
+ points.slice(1).forEach(function(p) { appendVertex$1(target, p); });
26015
+ appendVertex$1(target, prevHoverEvent ? pixToDataCoords(prevHoverEvent.x, prevHoverEvent.y) : last);
26016
+ hit.triggerChangeEvent();
25287
26017
  } else {
25288
- appendVertex$1(target, e.p);
26018
+ points.forEach(function(p) { appendVertex$1(target, p); });
25289
26019
  }
25290
26020
  if (e.shapes) {
25291
26021
  replaceDrawnShapes(e.shapes);
@@ -25294,11 +26024,13 @@
25294
26024
 
25295
26025
  gui.on('undo_path_extend', function(e) {
25296
26026
  var target = hit.getHitTarget();
25297
- if (pathDrawing() && prevHoverEvent) {
26027
+ // while drawing, this also removes the vertex that follows the pointer,
26028
+ // and the path's new last vertex takes its place
26029
+ for (var i=0; i<e.points.length; i++) {
25298
26030
  deleteLastVertex(target);
26031
+ }
26032
+ if (pathDrawing() && prevHoverEvent) {
25299
26033
  updatePathEndpoint(pixToDataCoords(prevHoverEvent.x, prevHoverEvent.y));
25300
- } else {
25301
- deleteLastVertex(target);
25302
26034
  }
25303
26035
  if (e.shapes) {
25304
26036
  replaceDrawnShapes(e.shapes);
@@ -25418,60 +26150,170 @@
25418
26150
  });
25419
26151
 
25420
26152
  gui.map.getMouse().on('dragend', function(e) {
25421
- pencilPoints = []; // re-enable pencil after closing a path
26153
+ finishStroke();
26154
+ blockDrag = false;
25422
26155
  });
25423
26156
 
25424
26157
  gui.map.getMouse().on('drag', function(e) {
26158
+ if (blockDrag) {
26159
+ e.stopPropagation(); // don't pan until the mouse is released
26160
+ return;
26161
+ }
25425
26162
  if (!pencilIsActive()) {
25426
- if (!pencilPoints) {
25427
- // null points signals that a path was just completed -- block panning
25428
- e.stopPropagation();
25429
- }
25430
26163
  return;
25431
26164
  }
25432
26165
  if (gui.keyboard.spaceIsPressed()) {
25433
26166
  // pan if dragging with spacebar down
25434
- pencilPoints = []; // don't continue previous line after panning
26167
+ finishStroke();
25435
26168
  return;
25436
26169
  }
25437
26170
  e.stopPropagation(); // prevent panning
25438
- hoverVertexInfo = findPathStartInfo(e);
25439
- var xy = [e.x, e.y], xy2;
25440
- var p = pixToDataCoords(e.x, e.y);
25441
- var addedToPath = true;
25442
- if (!pathDrawing()) {
25443
- pencilPoints = [xy];
25444
- startNewPath(p);
25445
- } else if (pencilPoints.length == 0) {
25446
- // start pencil-drawing when a path is started
25447
- pencilPoints = [xy];
25448
- extendCurrentPath(p);
25449
- } else if (polygonMode() && hoverVertexInfo && pencilPoints.length > 2) {
25450
- // close path
25451
- p = hoverVertexInfo.point;
25452
- appendVertex$1(hit.getHitTarget(), p);
25453
- extendCurrentPath(p);
25454
- pencilPoints = null; // stop drawing
25455
- } else if (pencilPoints.length >= 2 && pointExceedsTolerance(xy, pencilPoints, 1.2)) {
25456
- xy2 = pencilPoints.pop();
25457
- p = pixToDataCoords(xy2[0], xy2[1]);
25458
- extendCurrentPath(p);
25459
- // kludgy way to get a smoother line (could be better)
25460
- // not this pencilPoints = [getAvgPoint(pencilPoints), xy2, xy];
25461
- // not this pencilPoints = pencilPoints.slice(-2).concat([xy2, xy]);
25462
- pencilPoints = [getAvgPoint(pencilPoints.slice(-3).concat([xy2])), xy2, xy];
26171
+ if (!stroke) {
26172
+ if (geom.distance2D(0, 0, e.dragX, e.dragY) < STROKE_START_DIST) return;
26173
+ startStroke(e);
26174
+ if (!stroke) return;
26175
+ }
26176
+ hoverVertexInfo = polygonMode() ? findPathStartInfo(e) : null;
26177
+ if (hoverVertexInfo && stroke.sampleCount > 1) {
26178
+ // the stroke has returned to the start of the path -- close it
26179
+ finishStroke(hoverVertexInfo);
26180
+ blockDrag = true;
25463
26181
  } else {
25464
- // skip this point, update the hover line
25465
- pencilPoints.push(xy);
25466
- updatePathEndpoint(p);
25467
- addedToPath = false;
25468
- }
25469
- if (addedToPath) {
25470
- //
25471
- prevVertexAddedEvent = e;
26182
+ addStrokeSample(e);
25472
26183
  }
25473
26184
  }, null, 3); // higher priority than hit control
25474
26185
 
26186
+ // A stroke is smoothed as it is drawn. Vertices are placed some way behind
26187
+ // the pointer, where later samples no longer change the curve, and they stay
26188
+ // where they are. Between the last of them and the pointer, the path shows
26189
+ // the pointer's own trace (see updateStrokeTail()).
26190
+ //
26191
+ // The first vertex of the stroke (the anchor) is either the start of a new
26192
+ // path or the vertex that was following the pointer when the mouse was
26193
+ // pressed. Vertices are added to the path as they are placed, but the edit
26194
+ // that records them is made when the stroke ends, so that an undo takes back
26195
+ // the whole stroke.
26196
+ function startStroke(e) {
26197
+ var target = hit.getHitTarget();
26198
+ var anchorCommitted = !pathDrawing();
26199
+ var n, anchor;
26200
+ if (anchorCommitted) {
26201
+ // the path_add edit records the anchor
26202
+ hoverVertexInfo = null;
26203
+ startNewPath(pixToDataCoords(e.x - e.dragX, e.y - e.dragY));
26204
+ } else if (polygonMode() && hoverVertexInfo?.type == 'vertex') {
26205
+ // pressing on a vertex closes the polygon, as a click does
26206
+ extendCurrentPath([getLastVertexCoords(target)], true);
26207
+ blockDrag = true;
26208
+ return;
26209
+ } else if (pointerIsOnLastVertex(target)) {
26210
+ // continue from the last vertex rather than doubling it
26211
+ anchorCommitted = true;
26212
+ } else {
26213
+ // leave the vertex under the pointer behind as the anchor, and add a new
26214
+ // one to follow the pointer
26215
+ appendVertex$1(target, getLastVertexCoords(target));
26216
+ }
26217
+ n = target.gui.displayArcs.getPointCount();
26218
+ anchor = target.gui.displayArcs.getVertex2(n - 2);
26219
+ stroke = {
26220
+ anchorCommitted: anchorCommitted,
26221
+ // data coords of the vertices before the pointer that no edit records yet
26222
+ points: anchorCommitted ? [] : [getVertexCoords(target, n - 2)],
26223
+ // fitted in display coords, which survive a zoom mid-stroke
26224
+ fitter: new internal.GaussianStrokeFitter(anchor, {pixelSize: ext.getPixelSize()}),
26225
+ previewCount: 0, // vertices between the placed vertices and the pointer
26226
+ sampleCount: 0,
26227
+ lastEvent: e
26228
+ };
26229
+ }
26230
+
26231
+ // Test if the vertex following the pointer is on the path's last vertex
26232
+ function pointerIsOnLastVertex(target) {
26233
+ var arcs = target.gui.displayArcs;
26234
+ var n = arcs.getPointCount();
26235
+ var a = arcs.getVertex2(n - 1), b = arcs.getVertex2(n - 2);
26236
+ return geom.distance2D(a[0], a[1], b[0], b[1]) / ext.getPixelSize() < 3;
26237
+ }
26238
+
26239
+ function addStrokeSample(e) {
26240
+ var s = stroke;
26241
+ var placed = s.fitter.addSample(ext.pixCoordsToMapCoords(e.x, e.y));
26242
+ s.sampleCount++;
26243
+ s.lastEvent = e;
26244
+ updateStrokeTail(s, placed, s.fitter.getPreview(), pixToDataCoords(e.x, e.y));
26245
+ }
26246
+
26247
+ // Rewrites the end of the path after the stroke's placed vertices: newly
26248
+ // placed vertices, then the preview of the part of the stroke that has not
26249
+ // been placed yet, then the vertex following the pointer. Existing vertices
26250
+ // are overwritten rather than removed and added again, because adding or
26251
+ // removing a vertex copies the coordinates of the whole layer.
26252
+ // placed, preview: display coords
26253
+ // hover: data coords
26254
+ function updateStrokeTail(s, placed, preview, hover) {
26255
+ var target = hit.getHitTarget();
26256
+ var fixed = toDataCoords(target, placed);
26257
+ var tail = fixed.concat(toDataCoords(target, preview), [hover]);
26258
+ var count = s.previewCount + 1; // vertices after the placed vertices
26259
+ var start = target.gui.displayArcs.getPointCount() - count;
26260
+ var i;
26261
+ for (; count > tail.length; count--) {
26262
+ deleteLastVertex(target);
26263
+ }
26264
+ for (i = 0; i < tail.length; i++) {
26265
+ if (i < count) {
26266
+ setVertexCoords(target, [start + i], tail[i]);
26267
+ } else {
26268
+ appendVertex$1(target, tail[i]);
26269
+ }
26270
+ }
26271
+ s.points = s.points.concat(fixed);
26272
+ s.previewCount = tail.length - fixed.length - 1;
26273
+ hit.triggerChangeEvent();
26274
+ }
26275
+
26276
+ function toDataCoords(target, points) {
26277
+ return points.map(function(p) {
26278
+ return translateDisplayPoint(target, p);
26279
+ });
26280
+ }
26281
+
26282
+ // Completes the stroke in progress up to the pointer, and records the
26283
+ // stroke's vertices as a single edit.
26284
+ // end: (optional) hover info of the vertex that the stroke has to end at
26285
+ function finishStroke(end) {
26286
+ var s = stroke;
26287
+ var target, rest, points, count;
26288
+ if (!s) return;
26289
+ stroke = null;
26290
+ target = hit.getHitTarget();
26291
+ rest = end ? s.fitter.addSample(end.displayPoint) : [];
26292
+ rest = toDataCoords(target, rest.concat(s.fitter.finish()));
26293
+ if (end && rest.length > 0) {
26294
+ rest[rest.length - 1] = end.point;
26295
+ }
26296
+ // remove the preview
26297
+ count = s.previewCount + 1;
26298
+ for (; count > 1; count--) {
26299
+ deleteLastVertex(target);
26300
+ }
26301
+ if (rest.length === 0 && s.points.length > 0) {
26302
+ // the last vertex in the path takes the place of the vertex following the pointer
26303
+ deleteLastVertex(target);
26304
+ rest = [s.points.pop()];
26305
+ } else if (rest.length > 0) {
26306
+ setVertexCoords(target, [target.gui.displayArcs.getPointCount() - 1], rest[0]);
26307
+ }
26308
+ points = s.points.concat(rest);
26309
+ if (points.length === 0 || !s.anchorCommitted && points.length == 1) {
26310
+ hit.triggerChangeEvent(); // the stroke never left its anchor
26311
+ return;
26312
+ }
26313
+ prevVertexAddedEvent = s.lastEvent;
26314
+ extendCurrentPath(points, !!end, s.points.length);
26315
+ }
26316
+
25475
26317
  hit.on('drag', function(e) {
25476
26318
  if (!vertexDragging() || pathDrawing()) {
25477
26319
  return;
@@ -25551,7 +26393,8 @@
25551
26393
  if (detectDoubleClick(e)) return; // ignore second click of a dblclick
25552
26394
  var p = pixToDataCoords(e.x, e.y);
25553
26395
  if (pathDrawing()) {
25554
- extendCurrentPath(hoverVertexInfo?.point || p);
26396
+ // finish the path if a vertex is selected (but not an interpolated point)
26397
+ extendCurrentPath([hoverVertexInfo?.point || p], hoverVertexInfo?.type == 'vertex');
25555
26398
  } else if (hoverVertexInfo?.type == 'interpolated') {
25556
26399
  // don't start new path if hovering along a segment -- this is
25557
26400
  // likely to be an attempt to add a new vertex, not start a new path
@@ -25651,6 +26494,11 @@
25651
26494
 
25652
26495
  function finishCurrentPath() {
25653
26496
  if (!pathDrawing()) return;
26497
+ if (stroke) {
26498
+ // finishing mid-drag (e.g. with the Enter key)
26499
+ finishStroke();
26500
+ blockDrag = true;
26501
+ }
25654
26502
  var target = hit.getHitTarget();
25655
26503
  if (getLastArcLength(target) <= 2) { // includes hover point
25656
26504
  // deleteLastPath(target);
@@ -25674,15 +26522,21 @@
25674
26522
  updateCursor();
25675
26523
  }
25676
26524
 
25677
- // p: [x, y] source data coordinates of new point on path
25678
- function extendCurrentPath(p) {
26525
+ // points: [x, y] source data coordinates of vertices to add to the path. The
26526
+ // vertex that follows the pointer becomes points[placed], so it must
26527
+ // already be there; the rest are appended after it.
26528
+ // finish: true if the path ends at an existing vertex (which closes a polygon)
26529
+ // placed: (optional) number of points already in the path, before the vertex
26530
+ // that follows the pointer
26531
+ function extendCurrentPath(points, finish, placed) {
25679
26532
  var target = hit.getHitTarget();
25680
26533
  var shapes1, shapes2;
25681
- // finish the path if a vertex is selected (but not an interpolated point)
25682
- var finish = hoverVertexInfo?.type == 'vertex';
25683
26534
  if (getLastArcLength(target) < 2) {
25684
26535
  stop$1('Defective path');
25685
26536
  }
26537
+ for (var i=(placed || 0) + 1; i<points.length; i++) {
26538
+ appendVertex$1(target, points[i]);
26539
+ }
25686
26540
  if (finish && polygonMode()) {
25687
26541
  shapes1 = target.shapes.slice(initialShapeCount);
25688
26542
  try {
@@ -25694,12 +26548,12 @@
25694
26548
  }
25695
26549
  if (shapes2) {
25696
26550
  replaceDrawnShapes(shapes2);
25697
- gui.dispatchEvent('path_extend', {target, p, shapes1, shapes2});
26551
+ gui.dispatchEvent('path_extend', {target, points, shapes1, shapes2});
25698
26552
  clearDrawingInfo();
25699
26553
  fullRedraw();
25700
26554
  } else {
25701
- appendVertex$1(target, p);
25702
- gui.dispatchEvent('path_extend', {target, p});
26555
+ appendVertex$1(target, points[points.length - 1]); // the new vertex following the pointer
26556
+ gui.dispatchEvent('path_extend', {target, points});
25703
26557
  hit.triggerChangeEvent(); // trigger overlay redraw
25704
26558
  }
25705
26559
  }
@@ -28765,6 +29619,7 @@
28765
29619
  selection.turnOff();
28766
29620
  abandonCurve();
28767
29621
  armed = null;
29622
+ setLabelToolArmed(gui, null);
28768
29623
  updateButtons();
28769
29624
  hideInstructions();
28770
29625
  drag = null;
@@ -28830,6 +29685,7 @@
28830
29685
  if (armed == mode) return;
28831
29686
  if (drawingCurve()) abandonCurve();
28832
29687
  armed = mode;
29688
+ setLabelToolArmed(gui, mode);
28833
29689
  updateButtons();
28834
29690
  showInstructions();
28835
29691
  }
@@ -30581,10 +31437,29 @@
30581
31437
  // array of field names of relevant svg display properties
30582
31438
  fields = getStyleFields(lyr).filter(function(f) {return f in styleIndex;}),
30583
31439
  records = lyr.data.getRecords();
31440
+ // Arrowheads are drawn by the canvas as shapes, not set as attributes, so
31441
+ // they are not among the attribute fields. Assigned on every call, like the
31442
+ // rest, because the style object is reused from one feature to the next.
31443
+ var arrowFields = getLineArrowFields(lyr);
31444
+ var hasStrokeFields = fields.includes('stroke') || fields.includes('stroke-width');
31445
+ // Glows are drawn from the record rather than set as attributes, like the
31446
+ // arrowheads. An outer glow that the whole layer shares is drawn once for the
31447
+ // layer (see svg-glow.mjs), so the shapes are then given none of their own.
31448
+ var hasGlows = layerHasGlowFields(lyr);
31449
+ var layerOuterGlow = hasGlows ? internal.svg.getLayerOuterGlow(lyr) : null;
30584
31450
 
30585
31451
  var styler = function(style, i) {
30586
31452
  var rec = records[i];
30587
31453
  var fname, val;
31454
+ if (arrowFields) {
31455
+ style.lineStart = rec && rec['line-start'];
31456
+ style.lineEnd = rec && rec['line-end'];
31457
+ style.lineEndSize = rec && rec['line-end-size'];
31458
+ }
31459
+ if (hasGlows) {
31460
+ style.outerGlow = layerOuterGlow ? null : internal.svg.getPolygonGlow(rec, 'outer');
31461
+ style.innerGlow = internal.svg.getPolygonGlow(rec, 'inner');
31462
+ }
30588
31463
  for (var j=0; j<fields.length; j++) {
30589
31464
  fname = fields[j];
30590
31465
  val = rec && rec[fname];
@@ -30606,6 +31481,18 @@
30606
31481
  }
30607
31482
  };
30608
31483
  var style = {styler: styler, type: 'styled'};
31484
+ if (hasGlows) {
31485
+ style.layerOuterGlow = layerOuterGlow;
31486
+ // How far past its shapes the layer draws, which is how far outside the
31487
+ // view a shape can be and still reach into it.
31488
+ style.glowReach = internal.svg.getMaxGlowWidth(records) * internal.svg.GLOW_REACH;
31489
+ }
31490
+ // A line layer styled with nothing but arrowheads is drawn the way SVG
31491
+ // export draws it, with the black 1px line the layer's group gives it.
31492
+ if (arrowFields && !hasStrokeFields) {
31493
+ style.strokeColor = 'black';
31494
+ style.strokeWidth = 1;
31495
+ }
30609
31496
  // use squares if radius is missing... (TODO: check behavior with labels, etc)
30610
31497
  if (lyr.geometry_type == 'point' && fields.includes('r') === false) {
30611
31498
  style.dotSize = 1;
@@ -30620,7 +31507,25 @@
30620
31507
  // return fields.indexOf('r') > -1; // require 'r' field for point symbols
30621
31508
  return fields.includes('fill') || fields.includes('r'); // support colored squares
30622
31509
  }
30623
- return utils$1.difference(fields, ['opacity', 'class']).length > 0;
31510
+ return utils$1.difference(fields, ['opacity', 'class']).length > 0 ||
31511
+ !!getLineArrowFields(lyr) || layerHasGlowFields(lyr);
31512
+ }
31513
+
31514
+ function layerHasGlowFields(lyr) {
31515
+ if (lyr.geometry_type != 'polygon' || !lyr.data) return false;
31516
+ return lyr.data.getFields().some(function(f) {
31517
+ return internal.svg.glowFields.includes(f);
31518
+ });
31519
+ }
31520
+
31521
+ // The arrowhead fields a line layer has, or null
31522
+ function getLineArrowFields(lyr) {
31523
+ var fields;
31524
+ if (lyr.geometry_type != 'polyline' || !lyr.data) return null;
31525
+ fields = lyr.data.getFields().filter(function(f) {
31526
+ return internal.svg.lineArrowFields.includes(f);
31527
+ });
31528
+ return fields.length > 0 ? fields : null;
30624
31529
  }
30625
31530
 
30626
31531
  function getStyleFields(lyr) {
@@ -31527,6 +32432,102 @@
31527
32432
  }
31528
32433
  }
31529
32434
 
32435
+ // Canvas rendering of polygon glows, matching the SVG filters in svg-glow.mjs.
32436
+ //
32437
+ // What glows is drawn first into a scratch canvas: one shape, or a whole layer
32438
+ // for a layer's outer glow. The glows are then made from that image's alpha,
32439
+ // the way a filter works from SourceAlpha:
32440
+ //
32441
+ // outer the image's shadow, with the image itself cut out of it
32442
+ // inner the shadow of the image's inverse, kept only inside the image
32443
+ //
32444
+ // A shadow is only drawn with the image that casts it, so the image is drawn
32445
+ // off the edge of the canvas and its shadow is offset back into place. Drawn
32446
+ // in place and clipped, the image leaks into its own antialiased edge.
32447
+ //
32448
+ // A shadow's shadowBlur is twice the standard deviation of its blur, and a
32449
+ // glow's width is twice the standard deviation of the filter's, so the two
32450
+ // are the same number. Shadows are drawn in device pixels whatever the
32451
+ // transform, and the scratch canvases are never transformed when one is.
32452
+
32453
+ var scratch = {};
32454
+
32455
+ function getScratchCanvas(name, w, h) {
32456
+ var o = scratch[name];
32457
+ if (!o) {
32458
+ o = scratch[name] = {canvas: document.createElement('canvas')};
32459
+ o.ctx = o.canvas.getContext('2d');
32460
+ }
32461
+ // Grown and never shrunk: resizing a canvas reallocates it, and there can
32462
+ // be a resize for every shape drawn.
32463
+ if (o.canvas.width < w || o.canvas.height < h) {
32464
+ o.canvas.width = Math.max(o.canvas.width, w);
32465
+ o.canvas.height = Math.max(o.canvas.height, h);
32466
+ }
32467
+ o.ctx.setTransform(1, 0, 0, 1, 0, 0);
32468
+ o.ctx.clearRect(0, 0, w, h);
32469
+ return o;
32470
+ }
32471
+
32472
+ // A canvas to draw what glows into, @w by @h device pixels. Separate from the
32473
+ // ones compositeGlows() uses, so a shape can be drawn into it while a layer is
32474
+ // being drawn into another.
32475
+ // @name: 'shape' or 'layer'
32476
+ function getGlowSourceCanvas(name, w, h) {
32477
+ return getScratchCanvas('source-' + name, w, h);
32478
+ }
32479
+
32480
+ // How far past a shape its glows reach, in device pixels.
32481
+ function getGlowMargin(outer, inner, pxScale) {
32482
+ var width = Math.max(outer ? outer.width : 0, inner ? inner.width : 0);
32483
+ return Math.ceil(width * pxScale * internal.svg.GLOW_REACH) + 2;
32484
+ }
32485
+
32486
+ // Draws @src (the top-left @w by @h pixels of a canvas) into @ctx at @x, @y,
32487
+ // with its glows: the outer one underneath and the inner one on top.
32488
+ // @pxScale: device pixels per px of glow width
32489
+ function compositeGlows(ctx, src, x, y, w, h, outer, inner, pxScale) {
32490
+ var glow, inverse;
32491
+ if (outer) {
32492
+ glow = getScratchCanvas('glow', w, h);
32493
+ drawShadow(glow.ctx, src, w, h, outer, pxScale);
32494
+ glow.ctx.globalCompositeOperation = 'destination-out';
32495
+ glow.ctx.drawImage(src, 0, 0, w, h, 0, 0, w, h);
32496
+ glow.ctx.globalCompositeOperation = 'source-over';
32497
+ ctx.drawImage(glow.canvas, 0, 0, w, h, x, y, w, h);
32498
+ }
32499
+ ctx.drawImage(src, 0, 0, w, h, x, y, w, h);
32500
+ if (inner) {
32501
+ inverse = getScratchCanvas('inverse', w, h);
32502
+ inverse.ctx.fillStyle = '#000';
32503
+ inverse.ctx.fillRect(0, 0, w, h);
32504
+ inverse.ctx.globalCompositeOperation = 'destination-out';
32505
+ inverse.ctx.drawImage(src, 0, 0, w, h, 0, 0, w, h);
32506
+ inverse.ctx.globalCompositeOperation = 'source-over';
32507
+ glow = getScratchCanvas('glow', w, h);
32508
+ drawShadow(glow.ctx, inverse.canvas, w, h, inner, pxScale);
32509
+ glow.ctx.globalCompositeOperation = 'destination-in';
32510
+ glow.ctx.drawImage(src, 0, 0, w, h, 0, 0, w, h);
32511
+ glow.ctx.globalCompositeOperation = 'source-over';
32512
+ ctx.drawImage(glow.canvas, 0, 0, w, h, x, y, w, h);
32513
+ }
32514
+ }
32515
+
32516
+ // The shadow of the top-left @w by @h pixels of @img, with no image. The glow's
32517
+ // opacity is globalAlpha rather than part of the shadow's color, which can be
32518
+ // any CSS color and would have to be parsed to take one.
32519
+ function drawShadow(ctx, img, w, h, glow, pxScale) {
32520
+ var offset = w + glow.width * pxScale * 4 + 10;
32521
+ ctx.save();
32522
+ ctx.globalAlpha = glow.opacity;
32523
+ ctx.shadowColor = glow.color;
32524
+ ctx.shadowBlur = glow.width * pxScale;
32525
+ ctx.shadowOffsetX = offset;
32526
+ ctx.shadowOffsetY = 0;
32527
+ ctx.drawImage(img, 0, 0, w, h, -offset, 0, w, h);
32528
+ ctx.restore();
32529
+ }
32530
+
31530
32531
  // TODO: consider moving this upstream
31531
32532
  function getArcsForRendering(lyr, ext) {
31532
32533
  var dataset = lyr.gui.source.dataset;
@@ -31577,9 +32578,9 @@
31577
32578
  }
31578
32579
  } else {
31579
32580
  arcs = getArcsForRendering(lyr, ext);
31580
- filter = getShapeFilter(arcs, layer.shapes, ext);
32581
+ filter = getShapeFilter(arcs, layer.shapes, ext, style.overlay ? 0 : style.glowReach);
31581
32582
  canv.drawStyledPaths(layer.shapes, arcs, style, filter);
31582
- if (style.vertices) {
32583
+ if (style.vertices || style.vertex_overlay || style.pending_snip) {
31583
32584
  canv.drawVertices(layer.shapes, arcs, style, filter);
31584
32585
  }
31585
32586
  }
@@ -31615,10 +32616,17 @@
31615
32616
  // view. The filter takes a shape index and tests the shape's bbox against
31616
32617
  // the viewport. At nearly full extent the test is a no-op, so we return
31617
32618
  // null to let the caller skip it entirely.
31618
- function getShapeFilter(arcs, shapes, ext) {
32619
+ // reach: how far past its shapes the layer draws, in px (e.g. a glow)
32620
+ function getShapeFilter(arcs, shapes, ext, reach) {
31619
32621
  if (ext.scale() < 1.1) return null;
31620
32622
  var view = ext.getBounds();
31621
32623
  var b = new Bounds$1();
32624
+ var pad;
32625
+ if (reach > 0) {
32626
+ pad = reach * (ext.getSymbolScale() || 1) * ext.getPixelSize();
32627
+ view = new Bounds$1(view.toArray());
32628
+ view.padBounds(pad, pad, pad, pad);
32629
+ }
31622
32630
  return function(i) {
31623
32631
  var shp = shapes[i];
31624
32632
  if (!shp) return false;
@@ -31726,6 +32734,8 @@
31726
32734
  };
31727
32735
  */
31728
32736
 
32737
+ // Draws a dot at each vertex (if style.vertices is set), and the markers of
32738
+ // style.vertex_overlay and style.pending_snip.
31729
32739
  _self.drawVertices = function(shapes, arcs, style, filter) {
31730
32740
  var iter = new internal.ShapeIter(arcs);
31731
32741
  var t = getScaledTransform(_ext);
@@ -31735,7 +32745,7 @@
31735
32745
  var i, j, p;
31736
32746
  _ctx.beginPath();
31737
32747
  _ctx.fillStyle = color;
31738
- for (i=0; i<shapes.length; i++) {
32748
+ for (i=0; style.vertices && i<shapes.length; i++) {
31739
32749
  var shp = shapes[i];
31740
32750
  if (!shp || filter && !filter(i)) continue;
31741
32751
  for (j=0; j<shp.length; j++) {
@@ -31773,50 +32783,143 @@
31773
32783
  _self.drawStyledPaths = function(shapes, arcs, style, filter) {
31774
32784
  var styleIndex = {};
31775
32785
  var batchSize = 100;
31776
- var startPath = getPathStart(_ext, getScaledLineScale(_ext, style));
32786
+ var lineScale = getScaledLineScale(_ext, style);
32787
+ var startPath = getPathStart(_ext, lineScale);
31777
32788
  var draw = getShapePencil(arcs, _ext);
32789
+ var arrowPencil = null;
31778
32790
  var key, item, shp;
31779
32791
  var styler = style.styler || null;
31780
32792
  var drawStyle = styler ? utils$1.defaults({}, style) : style;
31781
- for (var i=0; i<shapes.length; i++) {
31782
- shp = shapes[i];
31783
- if (!shp || filter && !filter(i)) continue;
31784
- if (styler) {
31785
- styler(drawStyle, i);
31786
- }
31787
- if (!drawStyle.batchOverlay && (drawStyle.overlay ||
31788
- drawStyle.opacity < 1 || drawStyle.fillOpacity < 1 ||
31789
- drawStyle.strokeOpacity < 1 || drawStyle.fillEffect)) {
31790
- // don't batch shapes with opacity, in case they overlap
31791
- drawPaths([shp], startPath, draw, drawStyle);
31792
- continue;
31793
- }
31794
- key = getStyleKey(drawStyle);
31795
- if (key in styleIndex === false) {
31796
- styleIndex[key] = {
31797
- style: utils$1.defaults({}, drawStyle),
31798
- shapes: []
31799
- };
32793
+ // Hover and selection overlays copy the layer's style; they do not glow.
32794
+ var glows = !style.overlay;
32795
+ var glowScale = getCanvasStrokeScale(GUI.getPixelRatio(), lineScale);
32796
+ var layerGlow = glows && style.layerOuterGlow ?
32797
+ startLayerGlow(style.layerOuterGlow, glowScale) : null;
32798
+ try {
32799
+ drawShapes();
32800
+ } finally {
32801
+ if (layerGlow) endLayerGlow(layerGlow);
32802
+ }
32803
+ if (layerGlow) compositeLayerGlow(layerGlow);
32804
+
32805
+ function drawShapes() {
32806
+ var glowPencil = null;
32807
+ for (var i=0; i<shapes.length; i++) {
32808
+ shp = shapes[i];
32809
+ if (!shp || filter && !filter(i)) continue;
32810
+ if (styler) {
32811
+ styler(drawStyle, i);
32812
+ }
32813
+ if (glows && (drawStyle.outerGlow || drawStyle.innerGlow)) {
32814
+ // Shapes before this one are drawn first, as they are in SVG, where
32815
+ // a shape's outer glow lies over the shapes that precede it.
32816
+ flushBatches();
32817
+ if (!glowPencil) glowPencil = getGlowShapePencil(arcs, startPath, draw, glowScale);
32818
+ glowPencil(shp, drawStyle);
32819
+ continue;
32820
+ }
32821
+ // 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) {
32825
+ if (!arrowPencil) arrowPencil = getArrowLinePencil(arcs, _ext, lineScale);
32826
+ if (arrowPencil(shp, _ctx, drawStyle, startPath, draw)) continue;
32827
+ }
32828
+ if (!drawStyle.batchOverlay && (drawStyle.overlay ||
32829
+ drawStyle.opacity < 1 || drawStyle.fillOpacity < 1 ||
32830
+ drawStyle.strokeOpacity < 1 || drawStyle.fillEffect)) {
32831
+ // don't batch shapes with opacity, in case they overlap
32832
+ drawPaths([shp], startPath, draw, drawStyle);
32833
+ continue;
32834
+ }
32835
+ key = getStyleKey(drawStyle);
32836
+ if (key in styleIndex === false) {
32837
+ styleIndex[key] = {
32838
+ style: utils$1.defaults({}, drawStyle),
32839
+ shapes: []
32840
+ };
32841
+ }
32842
+ item = styleIndex[key];
32843
+ item.shapes.push(shp);
32844
+ if (item.shapes.length >= batchSize) {
32845
+ drawPaths(item.shapes, startPath, draw, item.style);
32846
+ item.shapes = [];
32847
+ }
31800
32848
  }
31801
- item = styleIndex[key];
31802
- item.shapes.push(shp);
31803
- if (item.shapes.length >= batchSize) {
32849
+ flushBatches();
32850
+ }
32851
+
32852
+ function flushBatches() {
32853
+ Object.keys(styleIndex).forEach(function(key) {
32854
+ var item = styleIndex[key];
32855
+ if (item.shapes.length === 0) return;
31804
32856
  drawPaths(item.shapes, startPath, draw, item.style);
31805
32857
  item.shapes = [];
31806
- }
32858
+ });
31807
32859
  }
31808
- Object.keys(styleIndex).forEach(function(key) {
31809
- var item = styleIndex[key];
31810
- drawPaths(item.shapes, startPath, draw, item.style);
31811
- });
31812
32860
  };
31813
32861
 
31814
- function drawPaths(shapes, beginPath, drawShape, style) {
31815
- beginPath(_ctx, style);
32862
+ function drawPaths(shapes, beginPath, drawShape, style, ctx) {
32863
+ ctx = ctx || _ctx;
32864
+ beginPath(ctx, style);
31816
32865
  for (var i=0, n=shapes.length; i<n; i++) {
31817
- drawShape(shapes[i], _ctx, style);
32866
+ drawShape(shapes[i], ctx, style);
31818
32867
  }
31819
- endPath(_ctx, style);
32868
+ endPath(ctx, style);
32869
+ }
32870
+
32871
+ // A layer's own outer glow is made from the whole layer, so the layer is
32872
+ // drawn into a canvas of its own and then onto this one with its glow. The
32873
+ // canvas is larger than this one by the reach of the glow, so that shapes
32874
+ // just outside the view still cast theirs into it.
32875
+ function startLayerGlow(glow, pxScale) {
32876
+ var margin = getGlowMargin(glow, null, pxScale);
32877
+ var w = _canvas.width + margin * 2;
32878
+ var h = _canvas.height + margin * 2;
32879
+ var src = getGlowSourceCanvas('layer', w, h);
32880
+ var o = {glow: glow, pxScale: pxScale, margin: margin, w: w, h: h,
32881
+ src: src, ctx: _ctx};
32882
+ src.ctx.setTransform(1, 0, 0, 1, margin, margin);
32883
+ _ctx = src.ctx;
32884
+ return o;
32885
+ }
32886
+
32887
+ function endLayerGlow(o) {
32888
+ o.src.ctx.setTransform(1, 0, 0, 1, 0, 0);
32889
+ _ctx = o.ctx;
32890
+ }
32891
+
32892
+ function compositeLayerGlow(o) {
32893
+ compositeGlows(_ctx, o.src.canvas, -o.margin, -o.margin, o.w, o.h,
32894
+ o.glow, null, o.pxScale);
32895
+ }
32896
+
32897
+ // Draws one shape with its own glows, which are made from the shape alone.
32898
+ // The shape is drawn into a canvas the size of its bounding box and the
32899
+ // reach of its glows, cut down to the part that can be seen.
32900
+ function getGlowShapePencil(arcs, startPath, draw, pxScale) {
32901
+ var t = getScaledTransform(_ext);
32902
+ var b = new Bounds$1();
32903
+ return function(shp, style) {
32904
+ var outer = style.outerGlow, inner = style.innerGlow;
32905
+ var margin = getGlowMargin(outer, inner, pxScale) +
32906
+ Math.ceil((style.strokeWidth > 0 ? style.strokeWidth * pxScale : 0) / 2);
32907
+ var viewMargin = margin * 2;
32908
+ var x0, y0, x1, y1, src;
32909
+ b.empty();
32910
+ arcs.getMultiShapeBounds(shp, b);
32911
+ if (!b.hasBounds()) return;
32912
+ x0 = Math.floor(Math.max(b.xmin * t.mx + t.bx - margin, -viewMargin));
32913
+ x1 = Math.ceil(Math.min(b.xmax * t.mx + t.bx + margin, _canvas.width + viewMargin));
32914
+ y0 = Math.floor(Math.max(b.ymax * t.my + t.by - margin, -viewMargin));
32915
+ y1 = Math.ceil(Math.min(b.ymin * t.my + t.by + margin, _canvas.height + viewMargin));
32916
+ if (x1 <= x0 || y1 <= y0) return;
32917
+ src = getGlowSourceCanvas('shape', x1 - x0, y1 - y0);
32918
+ src.ctx.setTransform(1, 0, 0, 1, -x0, -y0);
32919
+ drawPaths([shp], startPath, draw, style, src.ctx);
32920
+ src.ctx.setTransform(1, 0, 0, 1, 0, 0);
32921
+ compositeGlows(_ctx, src.canvas, x0, y0, x1 - x0, y1 - y0, outer, inner, pxScale);
32922
+ };
31820
32923
  }
31821
32924
 
31822
32925
  _self.drawSquareDots = function(shapes, style) {
@@ -32138,6 +33241,10 @@
32138
33241
  return x + 0.5 | 0;
32139
33242
  }
32140
33243
 
33244
+ function noRound(x) {
33245
+ return x;
33246
+ }
33247
+
32141
33248
  function roundToHalfPix(x) {
32142
33249
  return (x * 2 | 0) / 2;
32143
33250
  }
@@ -32149,15 +33256,113 @@
32149
33256
  if (style.fillEffect) {
32150
33257
  ctx.fillStyle = getCanvasFillEffect(ctx, shp, arcs, ext, style);
32151
33258
  }
33259
+ // Rounding also drops vertices that land on the same pixel, which keeps
33260
+ // detailed shapes fast to draw, so only a style for a few short paths
33261
+ // should turn it off (see gui-overlay-styler.mjs).
33262
+ var round = style.unroundedCoords ? noRound : roundToPix;
32152
33263
  for (var i=0, n=shp ? shp.length : 0; i<n; i++) {
32153
33264
  iter.init(shp[i]);
32154
33265
  // 0.2 trades visible seams for performance
32155
33266
  // drawPath(protectIterForDrawing(iter, ext), t, ctx, 0.2);
32156
- drawPath2(protectIterForDrawing(iter, ext), t, ctx, roundToPix);
33267
+ drawPath2(protectIterForDrawing(iter, ext), t, ctx, round);
33268
+ }
33269
+ };
33270
+ }
33271
+
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.
33275
+ //
33276
+ // Heads are sized by the same factor as the line width, so that a preview
33277
+ // matches the exported SVG. Far enough in that paths are clipped to the view
33278
+ // (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.
33280
+ function getArrowLinePencil(arcs, ext, lineScale) {
33281
+ var t = getScaledTransform(ext);
33282
+ var iter = new internal.ShapeIter(arcs);
33283
+ var strokeScale = getCanvasStrokeScale(GUI.getPixelRatio(), lineScale);
33284
+ var clipped = ext.scale() > 100;
33285
+ return function(shp, ctx, style, startPath, draw) {
33286
+ var opts = internal.svg.makeLineArrowOpts(style.lineStart, style.lineEnd,
33287
+ style.lineEndSize, style.strokeWidth, strokeScale);
33288
+ var heads = [];
33289
+ var i, coords, shape;
33290
+ if (opts.start == 'none' && opts.end == 'none' || !(style.strokeWidth > 0)) {
33291
+ return false;
32157
33292
  }
33293
+ startPath(ctx, style);
33294
+ for (i=0; i<shp.length; i++) {
33295
+ coords = getPixelCoords(iter, shp[i], t);
33296
+ if (coords.length < 2) continue;
33297
+ shape = internal.svg.getLineArrowShape(coords, opts);
33298
+ heads = heads.concat(shape.heads);
33299
+ if (!clipped) traceCoords(shape.coords, ctx);
33300
+ }
33301
+ if (clipped) draw(shp, ctx, style);
33302
+ endPath(ctx, style);
33303
+ drawArrowHeads(heads, ctx, style, opts.width);
33304
+ return true;
32158
33305
  };
32159
33306
  }
32160
33307
 
33308
+ function getPixelCoords(iter, path, t) {
33309
+ var coords = [];
33310
+ var x, y, prev;
33311
+ iter.init(path);
33312
+ while (iter.hasNext()) {
33313
+ x = iter.x * t.mx + t.bx;
33314
+ y = iter.y * t.my + t.by;
33315
+ if (prev && prev[0] == x && prev[1] == y) continue;
33316
+ prev = [x, y];
33317
+ coords.push(prev);
33318
+ }
33319
+ return coords;
33320
+ }
33321
+
33322
+ function traceCoords(coords, ctx) {
33323
+ ctx.moveTo(coords[0][0], coords[0][1]);
33324
+ for (var i=1; i<coords.length; i++) {
33325
+ ctx.lineTo(coords[i][0], coords[i][1]);
33326
+ }
33327
+ }
33328
+
33329
+ // Solid heads and dots are filled with the line's colour; open ones are stroked with
33330
+ // its width, always whole and round-cornered, whatever the line's dashes and
33331
+ // caps.
33332
+ function drawArrowHeads(heads, ctx, style, width) {
33333
+ var alpha = (style.opacity >= 0 ? style.opacity : 1) *
33334
+ (style.strokeOpacity >= 0 ? style.strokeOpacity : 1);
33335
+ var head, p;
33336
+ if (heads.length === 0) return;
33337
+ ctx.globalAlpha = alpha;
33338
+ ctx.fillStyle = style.strokeColor;
33339
+ ctx.strokeStyle = style.strokeColor;
33340
+ ctx.lineWidth = width;
33341
+ ctx.lineCap = 'round';
33342
+ ctx.lineJoin = 'round';
33343
+ ctx.setLineDash([]);
33344
+ for (var i=0; i<heads.length; i++) {
33345
+ head = heads[i];
33346
+ ctx.beginPath();
33347
+ if (head.type == 'dot') {
33348
+ ctx.arc(head.center[0], head.center[1], head.radius, 0, Math.PI * 2);
33349
+ ctx.fill();
33350
+ continue;
33351
+ }
33352
+ p = head.points;
33353
+ ctx.moveTo(p[0][0], p[0][1]);
33354
+ ctx.lineTo(p[1][0], p[1][1]);
33355
+ ctx.lineTo(p[2][0], p[2][1]);
33356
+ if (head.type == 'arrow') {
33357
+ ctx.closePath();
33358
+ ctx.fill();
33359
+ } else {
33360
+ ctx.stroke();
33361
+ }
33362
+ }
33363
+ ctx.globalAlpha = 1;
33364
+ }
33365
+
32161
33366
  function protectIterForDrawing(iter, ext) {
32162
33367
  var bounds, k;
32163
33368
  if (ext.scale() > 100) {
@@ -35400,8 +36605,9 @@
35400
36605
  var appearance = makePanelSection(form, 'Appearance');
35401
36606
  backgroundControl = makeColorRow(appearance, {
35402
36607
  label: 'Background',
36608
+ // An emptied colour field unsets the colour.
35403
36609
  onColor: function(color) {
35404
- if (color) applyFrameStyle([['fill', color]]);
36610
+ applyFrameStyle([['fill', color]]);
35405
36611
  },
35406
36612
  onOpacity: function(value) {
35407
36613
  applyFrameStyle([['fill-opacity', value]]);
@@ -35411,9 +36617,8 @@
35411
36617
  neatlineControl = makeColorRow(appearance, {
35412
36618
  label: 'Neatline',
35413
36619
  onColor: function(color) {
35414
- if (!color) return;
35415
36620
  var styles = [['stroke', color]];
35416
- if (!getStyleValue('stroke-width')) styles.push(['stroke-width', 1]);
36621
+ if (color && !getStyleValue('stroke-width')) styles.push(['stroke-width', 1]);
35417
36622
  applyFrameStyle(styles);
35418
36623
  },
35419
36624
  onOpacity: function(value) {
@@ -35770,13 +36975,13 @@
35770
36975
 
35771
36976
  // Font and face menus, as in the label panel
35772
36977
  row = makeRow(appearance);
35773
- fontSelect = El('select').attr('title', 'Font').addClass('scalebar-font-select')
36978
+ fontSelect = El('select').attr('aria-label', 'Font').addClass('scalebar-font-select')
35774
36979
  .appendTo(makeCell(row, 'Font'))
35775
36980
  .on('change', function() {
35776
36981
  if (fontSelect.node().value) applyFont(fontSelect.node().value);
35777
36982
  });
35778
36983
  renderFontOptions();
35779
- fontStyleSelect = El('select').attr('title', 'Font style')
36984
+ fontStyleSelect = El('select').attr('aria-label', 'Font style')
35780
36985
  .addClass('scalebar-font-style-select')
35781
36986
  .appendTo(makeCell(row, 'Font style'))
35782
36987
  .on('change', function() {
@@ -35790,10 +36995,13 @@
35790
36995
  El('span').appendTo(colorCell).text('Color');
35791
36996
  colorChit = El('div').addClass('label-color-chit').attr('role', 'button')
35792
36997
  .on('click', function() { colorPicker.toggle(); });
35793
- colorInput = El('input').attr('type', 'text').attr('title', 'Scale bar color')
36998
+ colorInput = El('input').attr('type', 'text').attr('aria-label', 'Scale bar color')
35794
36999
  .on('change', function() {
35795
37000
  var color = colorInput.node().value.trim();
35796
- if (!color) {
37001
+ var d = getScalebarRecord();
37002
+ // An emptied field unsets the colour, so the scalebar is drawn in
37003
+ // the default one.
37004
+ if (!color && !(d && d.color)) {
35797
37005
  updateControls();
35798
37006
  return;
35799
37007
  }
@@ -35816,7 +37024,7 @@
35816
37024
  'scalebar-bar-width');
35817
37025
  marginInput = makeNumberInput(makeCell(row, 'Inset'), 'margin',
35818
37026
  'scalebar-margin')
35819
- .attr('title', 'Distance from the edges of the frame, in pixels');
37027
+ .attr('aria-label', 'Distance from the edges of the frame, in pixels');
35820
37028
 
35821
37029
  row = El('div').addClass('label-style-row label-panel-button-row').appendTo(form);
35822
37030
  makePanelActionButton(row, 'Remove scale bar', function() {
@@ -37117,6 +38325,25 @@
37117
38325
  })
37118
38326
  };
37119
38327
  },
38328
+ // The vertices of each part of each feature in a path layer, in pixels
38329
+ // from the top left of the map, for a test comparing a drawn path with the
38330
+ // pointer positions that drew it.
38331
+ getLayerPathPixels: function(name) {
38332
+ var o = gui.model.getLayers().filter(function(o) {
38333
+ return getLayerName(o.layer) === name;
38334
+ })[0];
38335
+ var ext = gui.map.getExtent();
38336
+ var arcs;
38337
+ if (!o) return null;
38338
+ arcs = o.layer.gui ? o.layer.gui.displayArcs : o.dataset.arcs;
38339
+ return (o.layer.shapes || []).map(function(shp) {
38340
+ return shp ? shp.map(function(ids) {
38341
+ var iter = arcs.getShapeIter(ids), pts = [];
38342
+ while (iter.hasNext()) pts.push(ext.translateCoords(iter.x, iter.y));
38343
+ return pts;
38344
+ }) : null;
38345
+ });
38346
+ },
37120
38347
  selectLayer: function(name) {
37121
38348
  var target = gui.model.getLayers().filter(function(o) {
37122
38349
  return getLayerName(o.layer) === name;
@@ -37134,6 +38361,19 @@
37134
38361
  };
37135
38362
  });
37136
38363
  },
38364
+ // What each overlay layer last drew: the ids of its features, and whether
38365
+ // it marked their vertices. The overlay is drawn to canvas, so there is no
38366
+ // DOM state to assert on.
38367
+ getOverlayInfo: function() {
38368
+ var layers = gui.map && gui.map.getOverlayLayers ?
38369
+ gui.map.getOverlayLayers() : [];
38370
+ return layers.map(function(lyr) {
38371
+ return {
38372
+ ids: lyr.gui.style.ids || null,
38373
+ vertices: !!lyr.gui.style.vertices
38374
+ };
38375
+ });
38376
+ },
37137
38377
  // Knot handle positions, in display coordinates.
37138
38378
  getLabelPathKnotCoords: function() {
37139
38379
  var lyr = getLabelPathGuideLayers(gui).filter(function(lyr) {