mapshaper 0.7.70 → 0.7.71

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.
@@ -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) {
@@ -15998,7 +15979,7 @@
15998
15979
  var tile = El('div')
15999
15980
  .addClass('label-color-preset')
16000
15981
  .attr('role', 'button')
16001
- .attr('title', color)
15982
+ .attr('aria-label', color)
16002
15983
  .appendTo(rowEl)
16003
15984
  .css('background-color', color);
16004
15985
  if (row.length == 16 && i == 8) tile.addClass('label-color-preset-group-start');
@@ -16475,7 +16456,7 @@
16475
16456
  // in a menu that is only on screen while it is being used, and a
16476
16457
  // destructive action is better for waiting until intent is shown.
16477
16458
  El('div').addClass('label-saved-style-delete').attr('role', 'button')
16478
- .attr('title', 'Delete this ' + opts.styleLabel)
16459
+ .attr('aria-label', 'Delete this ' + opts.styleLabel)
16479
16460
  .appendTo(itemEl).html('&times;')
16480
16461
  .on('click', function() {
16481
16462
  deleteStyle(item);
@@ -16548,7 +16529,7 @@
16548
16529
  // Escape. Whether that means giving up the keyboard is the
16549
16530
  // caller's question: the field cannot know what else wants it.
16550
16531
  // decimals how finely a typed size is kept (default 1)
16551
- // title tooltip for the field
16532
+ // title the field's accessible name (not a tooltip)
16552
16533
  function SizeField(parent, opts) {
16553
16534
  var o = Object.assign({min: 1, max: 999, step: 1, bigStep: 10}, opts || {});
16554
16535
  var box = El('div').addClass('size-field').appendTo(parent);
@@ -16561,7 +16542,7 @@
16561
16542
  var dirty = false; // holds typing that has not been committed
16562
16543
  var disabled = false;
16563
16544
 
16564
- if (o.title) input.attr('title', o.title);
16545
+ if (o.title) input.attr('aria-label', o.title);
16565
16546
 
16566
16547
  // While the caret is in this field the keyboard belongs to it. Without this
16567
16548
  // the GUI's own handlers see the keystrokes: Escape would disarm the tool,
@@ -16865,14 +16846,15 @@
16865
16846
  // the one thing the third state exists to avoid saying.
16866
16847
  //
16867
16848
  // opts.onChange(on) the switch was clicked
16868
- // opts.title, opts.className
16849
+ // opts.title the switch's accessible name (not a tooltip)
16850
+ // opts.className
16869
16851
  function makePanelToggle(parent, opts) {
16870
16852
  var track = El('div').addClass('label-toggle').attr('role', 'checkbox').appendTo(parent);
16871
16853
  var state = 'off';
16872
16854
  if (opts.className) track.addClass(opts.className);
16873
16855
  var disabled = false;
16874
16856
  El('div').addClass('label-toggle-knob').appendTo(track);
16875
- if (opts.title) track.attr('title', opts.title);
16857
+ if (opts.title) track.attr('aria-label', opts.title);
16876
16858
  // A click on a mixed switch turns everything on. It is the convention, and
16877
16859
  // it is the reading that reaches a state the switch can describe: the next
16878
16860
  // click then turns everything off, so both are one click away.
@@ -16957,7 +16939,7 @@
16957
16939
  // opacity qualifies the colour, and one border says so where two fields side
16958
16940
  // by side said they were separate settings. It also leaves the narrow column
16959
16941
  // 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.
16942
+ // percent sign says what it is.
16961
16943
  //
16962
16944
  // Returns the field's box; the opacity input is made by makeOpacityInput().
16963
16945
  function makeColorOpacityField(parent, chit, input, opacityOpts) {
@@ -17006,7 +16988,7 @@
17006
16988
  control.picker.toggle();
17007
16989
  });
17008
16990
  control.input = El('input').attr('type', 'text')
17009
- .attr('title', opts.label + ' color')
16991
+ .attr('aria-label', opts.label + ' color')
17010
16992
  .on('change', function() {
17011
16993
  var color = control.input.node().value.trim();
17012
16994
  if (isHexColor(color)) control.picker.setColor(color);
@@ -17042,7 +17024,7 @@
17042
17024
  // opts.revert() it was not, so put back what the field was showing
17043
17025
  function makeOpacityInput(parent, opts) {
17044
17026
  var input = El('input').attr('type', 'text').addClass('label-opacity-input')
17045
- .attr('title', 'Opacity, 0-100%')
17027
+ .attr('aria-label', 'Opacity, 0-100%')
17046
17028
  .appendTo(parent)
17047
17029
  .on('change', function() {
17048
17030
  var val = parseOpacityValue(input.node().value);
@@ -17573,6 +17555,80 @@
17573
17555
  return wrapper;
17574
17556
  }
17575
17557
 
17558
+ // Default sizes and colours of the label tool's anchor symbols, which differ by
17559
+ // shape: a star or a ring at the size of a circle reads as smaller, and the NYT
17560
+ // star is a logo, drawn in its own red.
17561
+ var iconDefaults = {
17562
+ circle: {size: 5},
17563
+ square: {size: 5},
17564
+ star: {size: 8},
17565
+ ring: {size: 8},
17566
+ 'nyt-star': {size: 14, color: '#cc0000'}
17567
+ };
17568
+ var fallbackSize = 5;
17569
+
17570
+ function getDefaultIconSize(shape) {
17571
+ var o = iconDefaults[shape];
17572
+ return o ? o.size : fallbackSize;
17573
+ }
17574
+
17575
+ // '' for a shape drawn in whatever colour the label gives it.
17576
+ function getDefaultIconColor(shape) {
17577
+ var o = iconDefaults[shape];
17578
+ return o && o.color || '';
17579
+ }
17580
+
17581
+ // The icon-size and icon-color to write when labels whose symbol is @prevShape
17582
+ // ('' for none, or for a selection of mixed shapes) are given @shape, from the
17583
+ // @size and @color they share. A missing size is '' or 0, and a colour of ''
17584
+ // is none; null is a colour the labels do not agree on, which is left alone.
17585
+ //
17586
+ // A value that is the old shape's default follows the shape to its own
17587
+ // default, and a value the user chose is kept -- so switching shapes resizes a
17588
+ // symbol nobody sized, and never undoes a size somebody set. The colour is
17589
+ // returned only if it changes, as '' to remove it.
17590
+ function getIconShapeChange(shape, prevShape, size, color) {
17591
+ var out = {};
17592
+ var sizeVal = Number(size);
17593
+ var prevColor = getDefaultIconColor(prevShape);
17594
+ var nextColor = getDefaultIconColor(shape);
17595
+ if (!(sizeVal > 0) || prevShape && sizeVal == getDefaultIconSize(prevShape)) {
17596
+ out.size = getDefaultIconSize(shape);
17597
+ } else {
17598
+ out.size = sizeVal;
17599
+ }
17600
+ if (color !== null && sameColor$1(color, prevColor) && !sameColor$1(color, nextColor)) {
17601
+ out.color = nextColor;
17602
+ }
17603
+ return out;
17604
+ }
17605
+
17606
+ function sameColor$1(a, b) {
17607
+ return String(a || '').toLowerCase() == String(b || '').toLowerCase();
17608
+ }
17609
+
17610
+ // Features offered only to people at The New York Times, who are the ones with
17611
+ // its NYTFranklin typeface installed. This is a convenience, not access
17612
+ // control: anything gated here must still render for everyone, because a map
17613
+ // made with it can be opened anywhere.
17614
+ //
17615
+ // ?nyt=on or ?nyt=off in the page URL overrides the check, so that the gated
17616
+ // features can be tested (and hidden) on any machine.
17617
+ var NYT_FONT = 'NYTFranklin';
17618
+ var cached;
17619
+
17620
+ function isNytUser() {
17621
+ if (cached === undefined) cached = detectNytUser();
17622
+ return cached;
17623
+ }
17624
+
17625
+ function detectNytUser() {
17626
+ var flag = GUI.getUrlVars().nyt;
17627
+ if (flag == 'on' || flag === true) return true;
17628
+ if (flag == 'off' || flag === false) return false;
17629
+ return isInstalledFont(NYT_FONT);
17630
+ }
17631
+
17576
17632
  var fontField = 'font-family';
17577
17633
  var fontSizeField = 'font-size';
17578
17634
  var fontStyleField = 'font-style';
@@ -17605,7 +17661,6 @@
17605
17661
  var defaultFontWeight = '400';
17606
17662
  var defaultLabelColor = '#000000';
17607
17663
  var defaultIconColor = '#000000';
17608
- var defaultIconSize = 5;
17609
17664
  // The width a halo is switched on at: past the edge of the glyphs, so a
17610
17665
  // stroke of twice this.
17611
17666
  var defaultHaloWidth = 2;
@@ -17678,6 +17733,10 @@
17678
17733
  name: 'star'
17679
17734
  }, {
17680
17735
  name: 'ring'
17736
+ }, {
17737
+ name: 'nyt-star',
17738
+ title: 'NYT star',
17739
+ nytOnly: true
17681
17740
  }];
17682
17741
  var defaultIconShape = 'circle';
17683
17742
  // Alignment writes label-align rather than text-anchor, because text-anchor
@@ -17743,14 +17802,15 @@
17743
17802
  elbow: '<path d="M3.5 12.5L8 4.5H13"></path>',
17744
17803
  curve: '<path d="M3.5 12.5Q4.5 4.5 12.5 4"></path>',
17745
17804
  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>',
17805
+ arrow: '<path d="M6.84 8H13"></path><path class="fill" d="M2.5 8L8.7 6.05V9.95Z"></path>',
17747
17806
  'open-arrow': '<path d="M3.2 8H13"></path><path d="M7.38 5.07L3.2 8L7.38 10.93"></path>'
17748
17807
  };
17749
17808
  var iconButtonSymbols = {
17750
17809
  circle: '<circle cx="8" cy="8" r="4.25"></circle>',
17751
17810
  square: '<rect x="4" y="4" width="8" height="8"></rect>',
17752
17811
  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>'
17812
+ 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>',
17813
+ '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
17814
  };
17755
17815
 
17756
17816
  function LabelTool(gui) {
@@ -17765,6 +17825,7 @@
17765
17825
  // The shape the toggle turns back on, so that switching a symbol off and on
17766
17826
  // again does not silently change a star into a circle.
17767
17827
  var lastIconShape = defaultIconShape;
17828
+ var shownIconTypes = null;
17768
17829
  // Likewise the halo's width, which is what switching one off removes.
17769
17830
  var lastHaloWidth = defaultHaloWidth;
17770
17831
  // And the callout's shape.
@@ -17898,7 +17959,7 @@
17898
17959
  // number otherwise keep theirs, and so does a colour field, because the
17899
17960
  // field beside it has one and "Color" says the percentage is its opacity.
17900
17961
  var fontRow = El('div').addClass('label-style-row').appendTo(textSection);
17901
- fontSelect = El('select').attr('title', 'Font').appendTo(fontRow).on('change', function() {
17962
+ fontSelect = El('select').attr('aria-label', 'Font').appendTo(fontRow).on('change', function() {
17902
17963
  if (fontSelect.node().value) {
17903
17964
  applyFont(fontSelect.node().value);
17904
17965
  }
@@ -17906,7 +17967,7 @@
17906
17967
 
17907
17968
  var styleSizeRow = El('div').addClass('label-style-row label-split-row').appendTo(textSection);
17908
17969
  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() {
17970
+ fontStyleSelect = El('select').attr('aria-label', 'Font style').appendTo(fontStyleRow).on('change', function() {
17910
17971
  if (fontStyleSelect.node().value) {
17911
17972
  applyFontStyleVariant(fontStyleSelect.node().value);
17912
17973
  }
@@ -17932,7 +17993,7 @@
17932
17993
  var textColorCell = El('div').addClass('label-split-cell label-color-row label-text-color-row').appendTo(colorRow);
17933
17994
  El('span').appendTo(textColorCell).text('Color');
17934
17995
  colorChit = El('div').addClass('label-color-chit').attr('role', 'button');
17935
- colorInput = El('input').attr('type', 'text').attr('title', 'Text color');
17996
+ colorInput = El('input').attr('type', 'text').attr('aria-label', 'Text color');
17936
17997
  var textColorField = addColorOpacityField(textColorCell, colorChit, colorInput, applyLabelOpacity);
17937
17998
  colorFieldBox = textColorField.box;
17938
17999
  opacityInput = textColorField.opacity;
@@ -17965,7 +18026,7 @@
17965
18026
  applyLabelAlign(item.name);
17966
18027
  })
17967
18028
  .attr('data-align', item.name)
17968
- .attr('title', item.title);
18029
+ .attr('aria-label', item.title);
17969
18030
  alignBtns[item.name] = btn;
17970
18031
  appendAlignButtonSymbol(btn, item.name);
17971
18032
  });
@@ -18004,7 +18065,7 @@
18004
18065
  var haloColorCell = El('div').addClass('label-split-cell label-color-row label-halo-color-row').appendTo(haloColorRow);
18005
18066
  El('span').appendTo(haloColorCell).text('Color');
18006
18067
  haloColorChit = El('div').addClass('label-color-chit').attr('role', 'button');
18007
- haloColorInput = El('input').attr('type', 'text').attr('title', 'Halo color');
18068
+ haloColorInput = El('input').attr('type', 'text').attr('aria-label', 'Halo color');
18008
18069
  var haloColorField = addColorOpacityField(haloColorCell, haloColorChit, haloColorInput, applyHaloOpacity);
18009
18070
  haloColorFieldBox = haloColorField.box;
18010
18071
  haloOpacityInput = haloColorField.opacity;
@@ -18055,12 +18116,12 @@
18055
18116
  var iconRow = El('div').addClass('label-style-row label-icon-shapes-row').appendTo(iconSection);
18056
18117
  var iconGroup = iconGroupEl = El('div').addClass('label-btn-group label-icon-buttons').appendTo(iconRow);
18057
18118
  iconBtns = {};
18058
- iconTypes.forEach(function(icon) {
18119
+ getShownIconTypes().forEach(function(icon) {
18059
18120
  var btn = makePanelButton(iconGroup, '', function() {
18060
18121
  applyIcon(icon.name);
18061
18122
  })
18062
18123
  .attr('data-icon', icon.name)
18063
- .attr('title', icon.name);
18124
+ .attr('aria-label', icon.title || icon.name);
18064
18125
  iconBtns[icon.name] = btn;
18065
18126
  appendIconButtonSymbol(btn, icon.name);
18066
18127
  });
@@ -18069,7 +18130,7 @@
18069
18130
  var iconColorCell = El('div').addClass('label-split-cell label-color-row label-icon-color-row').appendTo(iconColorRow);
18070
18131
  El('span').appendTo(iconColorCell).text('Color');
18071
18132
  iconColorChit = El('div').addClass('label-color-chit').attr('role', 'button');
18072
- iconColorInput = El('input').attr('type', 'text').attr('title', 'Symbol color');
18133
+ iconColorInput = El('input').attr('type', 'text').attr('aria-label', 'Symbol color');
18073
18134
  var iconColorField = addColorOpacityField(iconColorCell, iconColorChit, iconColorInput, applyIconOpacity);
18074
18135
  iconColorFieldBox = iconColorField.box;
18075
18136
  iconOpacityInput = iconColorField.opacity;
@@ -18117,7 +18178,7 @@
18117
18178
  applyLabelPosition(pos);
18118
18179
  })
18119
18180
  .attr('data-position', pos)
18120
- .attr('title', pos);
18181
+ .attr('aria-label', pos);
18121
18182
  });
18122
18183
 
18123
18184
 
@@ -18127,7 +18188,7 @@
18127
18188
  setLabelPositionMode(gui, item.name, getPositionModeKind());
18128
18189
  })
18129
18190
  .attr('data-drag-mode', item.name)
18130
- .attr('title', item.title);
18191
+ .attr('aria-label', item.title);
18131
18192
  });
18132
18193
 
18133
18194
  presetControl = new StylePresetControl(panel, {
@@ -18212,7 +18273,7 @@
18212
18273
  var colorCell = El('div').addClass('label-split-cell label-color-row label-callout-color-row').appendTo(colorRow);
18213
18274
  El('span').appendTo(colorCell).text('Color');
18214
18275
  calloutColorChit = El('div').addClass('label-color-chit').attr('role', 'button');
18215
- calloutColorInput = El('input').attr('type', 'text').attr('title', 'Callout color');
18276
+ calloutColorInput = El('input').attr('type', 'text').attr('aria-label', 'Callout color');
18216
18277
  var calloutColorField = addColorOpacityField(colorCell, calloutColorChit, calloutColorInput, applyCalloutOpacity);
18217
18278
  calloutColorFieldBox = calloutColorField.box;
18218
18279
  calloutOpacityInput = calloutColorField.opacity;
@@ -18236,7 +18297,7 @@
18236
18297
  var gapCell = El('div').addClass('label-split-cell label-spacing-row label-callout-gap-row').appendTo(colorRow);
18237
18298
  El('span').appendTo(gapCell).text('Gap');
18238
18299
  calloutGapInput = El('input').attr('type', 'text').addClass('label-measure-input')
18239
- .attr('title', 'Space between the callout and the anchor, in px')
18300
+ .attr('aria-label', 'Space between the callout and the anchor, in px')
18240
18301
  .attr('placeholder', 'auto')
18241
18302
  .attr('data-placeholder', 'auto')
18242
18303
  .appendTo(gapCell)
@@ -18248,7 +18309,7 @@
18248
18309
  function makeCalloutButton(parent, item, action) {
18249
18310
  var btn = makePanelButton(parent, '', action)
18250
18311
  .attr('data-callout', item.name)
18251
- .attr('title', item.title);
18312
+ .attr('aria-label', item.title);
18252
18313
  El('<svg class="label-callout-symbol" viewBox="0 0 16 16" aria-hidden="true">' +
18253
18314
  calloutButtonSymbols[item.name] + '</svg>').appendTo(btn);
18254
18315
  return btn;
@@ -18475,8 +18536,10 @@
18475
18536
  // rest of the selection has nothing to compare.
18476
18537
  var iconIds = getIconValueIds();
18477
18538
  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});
18539
+ var iconShape = iconVal || lastIconShape;
18540
+ var iconSize = getShownValue(iconIds, iconSizeField, {useDefault: true, defaultValue: getDefaultIconSize(iconShape)});
18541
+ var iconColor = getShownValue(iconIds, iconColorField, {useDefault: true,
18542
+ defaultValue: getDefaultIconColor(iconShape) || defaultIconColor});
18480
18543
  var iconOpacity = getShownValue(iconIds, iconOpacityField, {useDefault: true, defaultValue: 1});
18481
18544
  var haloIds = getHaloValueIds();
18482
18545
  var haloWidth = getShownValue(haloIds, haloWidthField, {useDefault: true, defaultValue: lastHaloWidth});
@@ -18821,7 +18884,7 @@
18821
18884
  // border the buttons share fades with them -- a live border around dead
18822
18885
  // buttons is the one part of a disabled control that still looks usable.
18823
18886
  iconGroupEl.classed('disabled', off);
18824
- iconTypes.forEach(function(icon) {
18887
+ getShownIconTypes().forEach(function(icon) {
18825
18888
  iconBtns[icon.name].classed('selected', !off && icon.name == iconVal);
18826
18889
  setPanelButtonDisabled(iconBtns[icon.name], off);
18827
18890
  });
@@ -19420,12 +19483,23 @@
19420
19483
 
19421
19484
  function getSelectedIcon() {
19422
19485
  var out = '';
19423
- iconTypes.forEach(function(icon) {
19486
+ getShownIconTypes().forEach(function(icon) {
19424
19487
  if (iconBtns[icon.name].hasClass('selected')) out = icon.name;
19425
19488
  });
19426
19489
  return out;
19427
19490
  }
19428
19491
 
19492
+ // A label that already has an NYT star keeps it without the button: it is
19493
+ // drawn for everyone, and only choosing one is gated.
19494
+ function getShownIconTypes() {
19495
+ if (!shownIconTypes) {
19496
+ shownIconTypes = iconTypes.filter(function(icon) {
19497
+ return !icon.nytOnly || isNytUser();
19498
+ });
19499
+ }
19500
+ return shownIconTypes;
19501
+ }
19502
+
19429
19503
  function applyStyleObject(style) {
19430
19504
  var styles = [];
19431
19505
  savedStyleFields.forEach(function(field) {
@@ -19456,8 +19530,9 @@
19456
19530
  // The size the symbols in the selection already share, where they share
19457
19531
  // one: a shape applied to a mixed selection gives the labels that had no
19458
19532
  // 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)]);
19533
+ // the default. A size or colour that was the old shape's default becomes
19534
+ // the new one's (see getIconShapeChange()).
19535
+ addIconShapeDefaults(styles, iconName, getIconValueIds());
19461
19536
  // The symbol's own opacity goes on with it, because the label's opacity
19462
19537
  // is applied to both elements: without this, text set to 50% would give
19463
19538
  // a half-faded symbol while the Icon section showed it at 100%.
@@ -19469,6 +19544,14 @@
19469
19544
  applyStyleValues(styles);
19470
19545
  }
19471
19546
 
19547
+ function addIconShapeDefaults(styles, iconName, ids) {
19548
+ var color = getCommonValueInfo(ids, iconColorField);
19549
+ var change = getIconShapeChange(iconName, getCommonValue(ids, iconField) || '',
19550
+ getCommonValue(ids, iconSizeField), color.mixed ? null : color.value || '');
19551
+ styles.push([iconSizeField, change.size]);
19552
+ if ('color' in change) styles.push([iconColorField, change.color]);
19553
+ }
19554
+
19472
19555
  // Switching a symbol on moves a label sitting at the centre out from under
19473
19556
  // it, in the same command, so that the pair is one undo step and the text
19474
19557
  // is never briefly drawn over the symbol it just asked for.
@@ -19509,7 +19592,9 @@
19509
19592
  }
19510
19593
 
19511
19594
  function nudgeIconSize(delta) {
19512
- var size = getNumericSize(getIconTargetIds(), iconSizeField, defaultIconSize);
19595
+ var ids = getIconTargetIds();
19596
+ var size = getNumericSize(ids, iconSizeField,
19597
+ getDefaultIconSize(getCommonValue(ids, iconField)));
19513
19598
  if (!controlsEnabled()) return;
19514
19599
  size = Math.max(1, size + delta);
19515
19600
  applyIconSize(size);
@@ -20028,7 +20113,7 @@
20028
20113
  onChange: setPatternOn
20029
20114
  });
20030
20115
  var typeRow = El('div').addClass('label-style-row layer-pattern-type-row').appendTo(section);
20031
- typeSelect = El('select').attr('title', 'Pattern type').appendTo(typeRow)
20116
+ typeSelect = El('select').attr('aria-label', 'Pattern type').appendTo(typeRow)
20032
20117
  .on('change', function() {
20033
20118
  selectType(typeSelect.node().value);
20034
20119
  });
@@ -20096,7 +20181,7 @@
20096
20181
  makeFieldTip(caption,
20097
20182
  'The fill-pattern syntax of the -style command.\n' +
20098
20183
  'See the command reference for details.');
20099
- codeInput = El('input').attr('type', 'text').attr('title', 'Pattern code').appendTo(customRow)
20184
+ codeInput = El('input').attr('type', 'text').attr('aria-label', 'Pattern code').appendTo(customRow)
20100
20185
  .on('change', function() {
20101
20186
  applyCode(codeInput.node().value.trim());
20102
20187
  });
@@ -20266,13 +20351,45 @@
20266
20351
  }
20267
20352
 
20268
20353
  var savedStylesKey = 'layer_style_presets';
20269
- var styleFields = ['stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'fill', 'fill-opacity', 'fill-pattern'];
20354
+ var styleFields = ['stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'line-start', 'line-end', 'line-end-size', 'fill', 'fill-opacity', 'fill-pattern'];
20355
+ var arrowShapes = [{
20356
+ name: 'arrow',
20357
+ title: 'solid arrowheads'
20358
+ }, {
20359
+ name: 'open-arrow',
20360
+ title: 'open arrowheads'
20361
+ }, {
20362
+ name: 'dot',
20363
+ title: 'dots'
20364
+ }];
20365
+ // The arrows are the label callout's, which point left, the way a line's
20366
+ // start head does; the dot's left edge is where their tips are.
20367
+ var arrowButtonSymbols = {
20368
+ arrow: calloutButtonSymbols.arrow,
20369
+ 'open-arrow': calloutButtonSymbols['open-arrow'],
20370
+ dot: '<path d="M5 8H13"></path><circle class="fill" cx="5" cy="8" r="2.6"></circle>'
20371
+ };
20372
+ var arrowPositions = [{
20373
+ name: 'start',
20374
+ label: 'Start',
20375
+ title: 'arrowhead at the start of the line'
20376
+ }, {
20377
+ name: 'end',
20378
+ label: 'End',
20379
+ title: 'arrowhead at the end of the line'
20380
+ }, {
20381
+ name: 'both',
20382
+ label: 'Both',
20383
+ title: 'arrowheads at both ends of the line'
20384
+ }];
20270
20385
 
20271
20386
  function LayerStyleTool(gui) {
20272
20387
  var parent = gui.container.findChild('.mshp-main-map');
20273
20388
  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;
20389
+ var title, editingStatus, clearLink, strokeControl, fillControl, strokeWidthField, dashControl, arrowControl, randomFillBtn, presetControl, patternControl, hit;
20275
20390
  var targetLayer = null;
20391
+ // What the arrowhead switch turns on, for lines that have no heads
20392
+ var lastArrow = {shape: 'arrow', position: 'end'};
20276
20393
 
20277
20394
  initPanel();
20278
20395
  hit = gui.map.getHitControl && gui.map.getHitControl();
@@ -20361,6 +20478,7 @@
20361
20478
  strokeControl = addColorControl(panel, 'Stroke', 'stroke', '#000000');
20362
20479
  strokeWidthField = addStrokeWidthControl(strokeControl.aside);
20363
20480
  dashControl = addDashArrayControl(panel);
20481
+ arrowControl = addArrowControl(panel);
20364
20482
 
20365
20483
  var buttonRow = El('div').addClass('label-style-row label-panel-button-row').appendTo(panel);
20366
20484
  randomFillBtn = makePanelActionButton(buttonRow, 'Random fills', applyRandomFillColors);
@@ -20454,6 +20572,197 @@
20454
20572
  return {row: row, input: input};
20455
20573
  }
20456
20574
 
20575
+ // A switch in the heading says whether the lines have arrowheads; the rows
20576
+ // under it are the head's shape with its size beside it, then which ends
20577
+ // get it. A line has one shape for both ends, which is all the panel sets,
20578
+ // though -style can give the two ends different ones.
20579
+ function addArrowControl(parent) {
20580
+ var section = makePanelSection(parent, 'Arrowheads');
20581
+ var toggle = makePanelToggle(section.findChild('.label-style-section-title'), {
20582
+ title: 'Add arrowheads',
20583
+ className: 'layer-arrow-toggle',
20584
+ onChange: setArrowsOn
20585
+ });
20586
+ var shapeRow = El('div').addClass('label-style-row label-split-row').appendTo(section);
20587
+ var shapeCell = El('div').addClass('label-split-cell label-align-row').appendTo(shapeRow);
20588
+ var sizeCell = El('div').addClass('label-split-cell label-spacing-row layer-arrow-size-row').appendTo(shapeRow);
20589
+ var posRow = El('div').addClass('label-style-row label-split-row').appendTo(section);
20590
+ var posCell = El('div').addClass('label-split-cell label-align-row').appendTo(posRow);
20591
+ var control = {section: section, toggle: toggle, shapeBtns: {}, posBtns: {}};
20592
+ El('div').addClass('label-split-cell').appendTo(posRow);
20593
+ El('span').appendTo(shapeCell).text('Shape');
20594
+ var shapeGroup = El('div').addClass('label-btn-group label-callout-buttons layer-arrow-shape-buttons').appendTo(shapeCell);
20595
+ arrowShapes.forEach(function(item) {
20596
+ var btn = makePanelButton(shapeGroup, '', function() {
20597
+ applyArrowShape(item.name);
20598
+ }).attr('data-arrow-shape', item.name).attr('aria-label', item.title);
20599
+ El('<svg class="label-callout-symbol" viewBox="0 0 16 16" aria-hidden="true">' +
20600
+ arrowButtonSymbols[item.name] + '</svg>').appendTo(btn);
20601
+ control.shapeBtns[item.name] = btn;
20602
+ });
20603
+ El('span').appendTo(posCell).text('Ends');
20604
+ var posGroup = El('div').addClass('label-btn-group layer-arrow-position-buttons').appendTo(posCell);
20605
+ arrowPositions.forEach(function(item) {
20606
+ control.posBtns[item.name] = makePanelButton(posGroup, item.label, function() {
20607
+ applyArrowPosition(item.name);
20608
+ }).attr('data-arrow-position', item.name).attr('aria-label', item.title);
20609
+ });
20610
+ El('span').appendTo(sizeCell).text('Size');
20611
+ control.sizeField = new SizeField(sizeCell, {
20612
+ title: 'Arrowhead size in px, the length of its sides or the diameter of a dot',
20613
+ min: 1,
20614
+ max: 60,
20615
+ step: 1,
20616
+ bigStep: 5,
20617
+ onSet: applyArrowSize,
20618
+ onStep: nudgeArrowSize,
20619
+ onDone: releaseFocus
20620
+ });
20621
+ return control;
20622
+ }
20623
+
20624
+ // {shape, position} for one record, 'none' and '' when it has no heads. A
20625
+ // line whose two ends have different shapes reports the start's.
20626
+ function getArrowInfo(rec) {
20627
+ var start = internal.svg.getLineEndType(rec || {}, 'line-start');
20628
+ var end = internal.svg.getLineEndType(rec || {}, 'line-end');
20629
+ var shape = start != 'none' ? start : end;
20630
+ var position = start != 'none' && end != 'none' ? 'both' :
20631
+ start != 'none' ? 'start' : end != 'none' ? 'end' : '';
20632
+ return {shape: shape, position: position};
20633
+ }
20634
+
20635
+ // Switching on gives the lines that have no heads the last shape and ends
20636
+ // the section showed, so that off and on again is a round trip; the lines
20637
+ // that have heads keep theirs. Switching off leaves line-end-size alone,
20638
+ // for the same reason.
20639
+ function setArrowsOn(on) {
20640
+ applyArrowEdits(function(info) {
20641
+ return on ? fillArrowInfo(info) : {shape: 'none', position: ''};
20642
+ });
20643
+ }
20644
+
20645
+ // Each target keeps whichever of shape and position is not being set, so
20646
+ // that changing the shape of a selection whose lines point different ways
20647
+ // leaves them pointing those ways.
20648
+ function applyArrowShape(shape) {
20649
+ applyArrowEdits(function(info) {
20650
+ return {shape: shape, position: fillArrowInfo(info).position};
20651
+ });
20652
+ }
20653
+
20654
+ function applyArrowPosition(position) {
20655
+ applyArrowEdits(function(info) {
20656
+ return {shape: fillArrowInfo(info).shape, position: position};
20657
+ });
20658
+ }
20659
+
20660
+ function fillArrowInfo(info) {
20661
+ return info.shape == 'none' ? {shape: lastArrow.shape, position: lastArrow.position} : info;
20662
+ }
20663
+
20664
+ function applyArrowEdits(getNext) {
20665
+ var records = getTargetRecords();
20666
+ var addStroke = styleFieldIsUnsetForTargets('stroke');
20667
+ var edits = [];
20668
+ getTargetIds().forEach(function(id) {
20669
+ var rec = records[id] || {};
20670
+ var next = getNext(getArrowInfo(rec));
20671
+ var on = next.shape != 'none';
20672
+ var styles = [
20673
+ ['line-start', on && next.position != 'end' ? next.shape : ''],
20674
+ ['line-end', on && next.position != 'start' ? next.shape : '']
20675
+ ].filter(function(style) {
20676
+ // no need to remove what is not there
20677
+ return style[1] || rec[style[0]];
20678
+ });
20679
+ if (on && addStroke) styles.push(['stroke', strokeControl.defaultColor]);
20680
+ if (styles.length > 0) edits.push({id: id, styles: styles});
20681
+ });
20682
+ runStyleEdits(edits);
20683
+ }
20684
+
20685
+ function applyArrowSize(value) {
20686
+ var ids = getArrowTargetIds();
20687
+ if (ids.length === 0) return;
20688
+ runStyleEdits(ids.map(function(id) {
20689
+ return {id: id, styles: [['line-end-size', value]]};
20690
+ }));
20691
+ }
20692
+
20693
+ function nudgeArrowSize(delta) {
20694
+ var shown = getArrowSizeShown(getArrowTargetIds());
20695
+ var size = Number(shown.value);
20696
+ if (!isFinite(size) || shown.value === '') return;
20697
+ applyArrowSize(Math.max(1, Math.round(size + delta)));
20698
+ }
20699
+
20700
+ function getArrowTargetIds() {
20701
+ var records = getTargetRecords();
20702
+ return getTargetIds().filter(function(id) {
20703
+ return getArrowInfo(records[id]).shape != 'none';
20704
+ });
20705
+ }
20706
+
20707
+ // The size a head is drawn at, which is the one its line width gives it
20708
+ // when it has none of its own.
20709
+ function getArrowSizeShown(ids) {
20710
+ var records = getTargetRecords();
20711
+ var value = '', size;
20712
+ for (var i=0; i<ids.length; i++) {
20713
+ size = getArrowSize(records[ids[i]]);
20714
+ if (i > 0 && size !== value) return {value: '', mixed: true};
20715
+ value = size;
20716
+ }
20717
+ return {value: value, mixed: false};
20718
+ }
20719
+
20720
+ function getArrowSize(rec) {
20721
+ var opts = internal.svg.makeLineArrowOpts('arrow', '', rec && rec['line-end-size'],
20722
+ rec && rec['stroke-width'], 1);
20723
+ var size = getArrowInfo(rec).shape == 'dot' ? opts.dotSize : opts.size;
20724
+ return formatNumberValue(Math.round(size * 100) / 100);
20725
+ }
20726
+
20727
+ // The shape and ends shown are those of the lines with heads; lines
20728
+ // without are what the switch's mixed state is for.
20729
+ function updateArrowControl() {
20730
+ var records = getTargetRecords();
20731
+ var ids = getTargetIds();
20732
+ var arrowIds = getArrowTargetIds();
20733
+ var shape, position, info;
20734
+ for (var i=0; i<arrowIds.length; i++) {
20735
+ info = getArrowInfo(records[arrowIds[i]]);
20736
+ if (i === 0) {
20737
+ shape = info.shape;
20738
+ position = info.position;
20739
+ } else {
20740
+ if (info.shape != shape) shape = '';
20741
+ if (info.position != position) position = '';
20742
+ }
20743
+ }
20744
+ if (shape) lastArrow.shape = shape;
20745
+ if (position) lastArrow.position = position;
20746
+ var state = arrowIds.length === 0 ? 'off' :
20747
+ arrowIds.length < ids.length ? 'mixed' : 'on';
20748
+ arrowControl.toggle.setState(state);
20749
+ arrowControl.section.classed('collapsed', state == 'off');
20750
+ arrowShapes.forEach(function(item) {
20751
+ arrowControl.shapeBtns[item.name].classed('selected', item.name == shape);
20752
+ });
20753
+ arrowPositions.forEach(function(item) {
20754
+ arrowControl.posBtns[item.name].classed('selected', item.name == position);
20755
+ });
20756
+ var shown = arrowIds.length > 0 ? getArrowSizeShown(arrowIds) : {value: '', mixed: false};
20757
+ arrowControl.sizeField.setValue(shown.value);
20758
+ arrowControl.sizeField.setPlaceholder(shown.mixed ? 'mixed' : '');
20759
+ arrowControl.sizeField.setDisabled(arrowIds.length === 0);
20760
+ }
20761
+
20762
+ function getTargetRecords() {
20763
+ return targetLayer && targetLayer.data ? targetLayer.data.getRecords() : [];
20764
+ }
20765
+
20457
20766
  function releaseFocus() {
20458
20767
  releasePanelFocus(panel.node());
20459
20768
  }
@@ -20468,10 +20777,12 @@
20468
20777
  strokeControl.row.show();
20469
20778
  fillControl.row.classed('hidden', geom != 'polygon');
20470
20779
  dashControl.row.classed('hidden', geom != 'polyline');
20780
+ arrowControl.section.classed('hidden', geom != 'polyline');
20471
20781
  updateColorControl(strokeControl);
20472
20782
  updateColorControl(fillControl);
20473
20783
  updateStrokeWidthControl();
20474
20784
  updateDashArrayControl();
20785
+ if (geom == 'polyline') updateArrowControl();
20475
20786
  randomFillBtn.classed('hidden', geom != 'polygon');
20476
20787
  patternControl.section.classed('hidden', geom != 'polygon');
20477
20788
  if (geom == 'polygon') patternControl.update();
@@ -20651,6 +20962,7 @@
20651
20962
  addStyleValue(style, 'stroke-opacity', parseOpacityValue(strokeControl.opacity.node().value));
20652
20963
  if (targetLayer && targetLayer.geometry_type == 'polyline') {
20653
20964
  addStyleValue(style, 'stroke-dasharray', getControlValue(dashControl.input));
20965
+ addArrowStyleValues(style);
20654
20966
  }
20655
20967
  if (targetLayer && targetLayer.geometry_type == 'polygon') {
20656
20968
  addStyleValue(style, 'fill', getControlValue(fillControl.input));
@@ -20660,6 +20972,14 @@
20660
20972
  return style;
20661
20973
  }
20662
20974
 
20975
+ // Only when every target agrees, as with the other controls; a size is
20976
+ // saved only if the lines have their own.
20977
+ function addArrowStyleValues(style) {
20978
+ ['line-start', 'line-end', 'line-end-size'].forEach(function(field) {
20979
+ addStyleValue(style, field, getCommonStyleValue(field));
20980
+ });
20981
+ }
20982
+
20663
20983
  function addStyleValue(style, field, value) {
20664
20984
  if (value || value === 0) {
20665
20985
  style[field] = value;
@@ -22931,6 +23251,7 @@
22931
23251
  // data.ids = [drawingId];
22932
23252
  // data.id = drawingId;
22933
23253
  data.ids = utils$1.uniq(data.ids.concat([drawingId]));
23254
+ data.drawing_id = drawingId;
22934
23255
  }
22935
23256
  if (pinnedOn) {
22936
23257
  data.pinned = true;
@@ -24858,12 +25179,7 @@
24858
25179
  styleOpts.interactionMode == 'edit_polygons' ||
24859
25180
  styleOpts.interactionMode == 'snip_lines') {
24860
25181
  // 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];
25182
+ return getShapeEditingLayers(activeLyr, hitData);
24867
25183
  }
24868
25184
  layers = [];
24869
25185
  if (styleOpts.interactionMode == 'label') {
@@ -24998,15 +25314,50 @@
24998
25314
  };
24999
25315
  }
25000
25316
 
25317
+ // The path being drawn is shown without its vertices, which are only of use
25318
+ // for reshaping a completed path. The hover markers go on the last layer, so
25319
+ // they are drawn on top.
25320
+ function getShapeEditingLayers(activeLyr, hitData) {
25321
+ var drawingIds = hitData.drawing_id >= 0 ? [hitData.drawing_id] : [];
25322
+ var otherIds = utils$1.difference(hitData.ids || [], drawingIds);
25323
+ var layers = [], lyr;
25324
+ if (drawingIds.length > 0) {
25325
+ lyr = getShapeEditingLayer(activeLyr, hitData, drawingIds, false);
25326
+ // The end of a path being drawn has closely spaced vertices, and pixel
25327
+ // rounding would show as a staircase.
25328
+ lyr.gui.style.unroundedCoords = true;
25329
+ layers.push(lyr);
25330
+ }
25331
+ if (otherIds.length > 0 || layers.length === 0) {
25332
+ layers.push(getShapeEditingLayer(activeLyr, hitData, otherIds, true));
25333
+ }
25334
+ layers.forEach(function(lyr, i) {
25335
+ if (i < layers.length - 1) {
25336
+ lyr.gui.style.vertex_overlay = null;
25337
+ lyr.gui.style.pending_snip = null;
25338
+ }
25339
+ });
25340
+ return layers;
25341
+ }
25342
+
25343
+ function getShapeEditingLayer(activeLyr, hitData, ids, showVertices) {
25344
+ var lyr = getOverlayLayer(activeLyr, ids);
25345
+ lyr.gui.style = getLineEditingStyle(hitData, ids, showVertices);
25346
+ if (activeLyr.geometry_type == 'polygon') {
25347
+ lyr.gui.style.fillColor = hoverFill;
25348
+ }
25349
+ return lyr;
25350
+ }
25351
+
25001
25352
  // style for vertex edit mode
25002
- function getLineEditingStyle(o) {
25353
+ function getLineEditingStyle(o, ids, showVertices) {
25003
25354
  var isVertex = o.hit_type == 'vertex' || o.hit_type == 'disabled';
25004
25355
  return {
25005
- ids: o.ids,
25356
+ ids: ids,
25006
25357
  overlay: true,
25007
25358
  strokeColor: black,
25008
25359
  strokeWidth: 1.2,
25009
- vertices: true,
25360
+ vertices: showVertices,
25010
25361
  vertex_overlay_color: getVertexOverlayColor(o.hit_type),
25011
25362
  vertex_overlay_scale: isVertex ? 2.5 : 2,
25012
25363
  vertex_overlay: o.hit_coordinates || null,
@@ -25207,6 +25558,11 @@
25207
25558
  // pixel distance threshold for hovering near a vertex or segment midpoint
25208
25559
  var HOVER_THRESHOLD$1 = 10;
25209
25560
 
25561
+ // How far the pointer has to move from where the mouse was pressed before a
25562
+ // drag draws a stroke. This matches the distance under which gui-mouse treats
25563
+ // a press as a click, so a press that stays this close adds only a click vertex.
25564
+ var STROKE_START_DIST = 6;
25565
+
25210
25566
  function initLineEditing(gui, ext, hit) {
25211
25567
  var hoverVertexInfo;
25212
25568
  var prevVertexAddedEvent;
@@ -25216,7 +25572,8 @@
25216
25572
  var drawingId = -1; // feature id of path being drawn
25217
25573
  var sessionCount = 0;
25218
25574
  var alert;
25219
- var pencilPoints = [];
25575
+ var stroke = null; // freehand stroke in progress (see startStroke())
25576
+ var blockDrag = false; // a drag finished a path; ignore it until the mouse is released
25220
25577
  var _dragging = false;
25221
25578
 
25222
25579
  function active() {
@@ -25236,7 +25593,7 @@
25236
25593
  }
25237
25594
 
25238
25595
  function pencilIsActive() {
25239
- return active() && (cmdKeyDown() || pathDrawing()) && !vertexDragging() && !!pencilPoints;
25596
+ return active() && (cmdKeyDown() || pathDrawing()) && !vertexDragging();
25240
25597
  }
25241
25598
 
25242
25599
  function polygonMode() {
@@ -25273,19 +25630,32 @@
25273
25630
  fullRedraw();
25274
25631
  });
25275
25632
 
25633
+ // an undo during a stroke takes back the whole stroke
25634
+ gui.on('undo_redo_pre', function() {
25635
+ if (stroke) {
25636
+ finishStroke();
25637
+ blockDrag = true;
25638
+ }
25639
+ });
25640
+
25276
25641
  gui.on('undo_path_add', function(e) {
25277
25642
  deleteLastPath(hit.getHitTarget());
25278
25643
  clearDrawingInfo();
25279
25644
  });
25280
25645
 
25646
+ // e.points: the vertices that the edit added to the path
25281
25647
  gui.on('redo_path_extend', function(e) {
25282
25648
  var target = hit.getHitTarget();
25283
-
25284
- if (pathDrawing() && prevHoverEvent) {
25285
- updatePathEndpoint(e.p);
25286
- appendVertex$1(target, pixToDataCoords(prevHoverEvent.x, prevHoverEvent.y));
25649
+ var points = e.points;
25650
+ var last = points[points.length - 1];
25651
+ if (pathDrawing()) {
25652
+ // the vertex following the pointer becomes the first added vertex
25653
+ setVertexCoords(target, [target.gui.displayArcs.getPointCount() - 1], points[0]);
25654
+ points.slice(1).forEach(function(p) { appendVertex$1(target, p); });
25655
+ appendVertex$1(target, prevHoverEvent ? pixToDataCoords(prevHoverEvent.x, prevHoverEvent.y) : last);
25656
+ hit.triggerChangeEvent();
25287
25657
  } else {
25288
- appendVertex$1(target, e.p);
25658
+ points.forEach(function(p) { appendVertex$1(target, p); });
25289
25659
  }
25290
25660
  if (e.shapes) {
25291
25661
  replaceDrawnShapes(e.shapes);
@@ -25294,11 +25664,13 @@
25294
25664
 
25295
25665
  gui.on('undo_path_extend', function(e) {
25296
25666
  var target = hit.getHitTarget();
25297
- if (pathDrawing() && prevHoverEvent) {
25667
+ // while drawing, this also removes the vertex that follows the pointer,
25668
+ // and the path's new last vertex takes its place
25669
+ for (var i=0; i<e.points.length; i++) {
25298
25670
  deleteLastVertex(target);
25671
+ }
25672
+ if (pathDrawing() && prevHoverEvent) {
25299
25673
  updatePathEndpoint(pixToDataCoords(prevHoverEvent.x, prevHoverEvent.y));
25300
- } else {
25301
- deleteLastVertex(target);
25302
25674
  }
25303
25675
  if (e.shapes) {
25304
25676
  replaceDrawnShapes(e.shapes);
@@ -25418,60 +25790,170 @@
25418
25790
  });
25419
25791
 
25420
25792
  gui.map.getMouse().on('dragend', function(e) {
25421
- pencilPoints = []; // re-enable pencil after closing a path
25793
+ finishStroke();
25794
+ blockDrag = false;
25422
25795
  });
25423
25796
 
25424
25797
  gui.map.getMouse().on('drag', function(e) {
25798
+ if (blockDrag) {
25799
+ e.stopPropagation(); // don't pan until the mouse is released
25800
+ return;
25801
+ }
25425
25802
  if (!pencilIsActive()) {
25426
- if (!pencilPoints) {
25427
- // null points signals that a path was just completed -- block panning
25428
- e.stopPropagation();
25429
- }
25430
25803
  return;
25431
25804
  }
25432
25805
  if (gui.keyboard.spaceIsPressed()) {
25433
25806
  // pan if dragging with spacebar down
25434
- pencilPoints = []; // don't continue previous line after panning
25807
+ finishStroke();
25435
25808
  return;
25436
25809
  }
25437
25810
  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];
25811
+ if (!stroke) {
25812
+ if (geom.distance2D(0, 0, e.dragX, e.dragY) < STROKE_START_DIST) return;
25813
+ startStroke(e);
25814
+ if (!stroke) return;
25815
+ }
25816
+ hoverVertexInfo = polygonMode() ? findPathStartInfo(e) : null;
25817
+ if (hoverVertexInfo && stroke.sampleCount > 1) {
25818
+ // the stroke has returned to the start of the path -- close it
25819
+ finishStroke(hoverVertexInfo);
25820
+ blockDrag = true;
25463
25821
  } 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;
25822
+ addStrokeSample(e);
25472
25823
  }
25473
25824
  }, null, 3); // higher priority than hit control
25474
25825
 
25826
+ // A stroke is smoothed as it is drawn. Vertices are placed some way behind
25827
+ // the pointer, where later samples no longer change the curve, and they stay
25828
+ // where they are. Between the last of them and the pointer, the path shows
25829
+ // the pointer's own trace (see updateStrokeTail()).
25830
+ //
25831
+ // The first vertex of the stroke (the anchor) is either the start of a new
25832
+ // path or the vertex that was following the pointer when the mouse was
25833
+ // pressed. Vertices are added to the path as they are placed, but the edit
25834
+ // that records them is made when the stroke ends, so that an undo takes back
25835
+ // the whole stroke.
25836
+ function startStroke(e) {
25837
+ var target = hit.getHitTarget();
25838
+ var anchorCommitted = !pathDrawing();
25839
+ var n, anchor;
25840
+ if (anchorCommitted) {
25841
+ // the path_add edit records the anchor
25842
+ hoverVertexInfo = null;
25843
+ startNewPath(pixToDataCoords(e.x - e.dragX, e.y - e.dragY));
25844
+ } else if (polygonMode() && hoverVertexInfo?.type == 'vertex') {
25845
+ // pressing on a vertex closes the polygon, as a click does
25846
+ extendCurrentPath([getLastVertexCoords(target)], true);
25847
+ blockDrag = true;
25848
+ return;
25849
+ } else if (pointerIsOnLastVertex(target)) {
25850
+ // continue from the last vertex rather than doubling it
25851
+ anchorCommitted = true;
25852
+ } else {
25853
+ // leave the vertex under the pointer behind as the anchor, and add a new
25854
+ // one to follow the pointer
25855
+ appendVertex$1(target, getLastVertexCoords(target));
25856
+ }
25857
+ n = target.gui.displayArcs.getPointCount();
25858
+ anchor = target.gui.displayArcs.getVertex2(n - 2);
25859
+ stroke = {
25860
+ anchorCommitted: anchorCommitted,
25861
+ // data coords of the vertices before the pointer that no edit records yet
25862
+ points: anchorCommitted ? [] : [getVertexCoords(target, n - 2)],
25863
+ // fitted in display coords, which survive a zoom mid-stroke
25864
+ fitter: new internal.GaussianStrokeFitter(anchor, {pixelSize: ext.getPixelSize()}),
25865
+ previewCount: 0, // vertices between the placed vertices and the pointer
25866
+ sampleCount: 0,
25867
+ lastEvent: e
25868
+ };
25869
+ }
25870
+
25871
+ // Test if the vertex following the pointer is on the path's last vertex
25872
+ function pointerIsOnLastVertex(target) {
25873
+ var arcs = target.gui.displayArcs;
25874
+ var n = arcs.getPointCount();
25875
+ var a = arcs.getVertex2(n - 1), b = arcs.getVertex2(n - 2);
25876
+ return geom.distance2D(a[0], a[1], b[0], b[1]) / ext.getPixelSize() < 3;
25877
+ }
25878
+
25879
+ function addStrokeSample(e) {
25880
+ var s = stroke;
25881
+ var placed = s.fitter.addSample(ext.pixCoordsToMapCoords(e.x, e.y));
25882
+ s.sampleCount++;
25883
+ s.lastEvent = e;
25884
+ updateStrokeTail(s, placed, s.fitter.getPreview(), pixToDataCoords(e.x, e.y));
25885
+ }
25886
+
25887
+ // Rewrites the end of the path after the stroke's placed vertices: newly
25888
+ // placed vertices, then the preview of the part of the stroke that has not
25889
+ // been placed yet, then the vertex following the pointer. Existing vertices
25890
+ // are overwritten rather than removed and added again, because adding or
25891
+ // removing a vertex copies the coordinates of the whole layer.
25892
+ // placed, preview: display coords
25893
+ // hover: data coords
25894
+ function updateStrokeTail(s, placed, preview, hover) {
25895
+ var target = hit.getHitTarget();
25896
+ var fixed = toDataCoords(target, placed);
25897
+ var tail = fixed.concat(toDataCoords(target, preview), [hover]);
25898
+ var count = s.previewCount + 1; // vertices after the placed vertices
25899
+ var start = target.gui.displayArcs.getPointCount() - count;
25900
+ var i;
25901
+ for (; count > tail.length; count--) {
25902
+ deleteLastVertex(target);
25903
+ }
25904
+ for (i = 0; i < tail.length; i++) {
25905
+ if (i < count) {
25906
+ setVertexCoords(target, [start + i], tail[i]);
25907
+ } else {
25908
+ appendVertex$1(target, tail[i]);
25909
+ }
25910
+ }
25911
+ s.points = s.points.concat(fixed);
25912
+ s.previewCount = tail.length - fixed.length - 1;
25913
+ hit.triggerChangeEvent();
25914
+ }
25915
+
25916
+ function toDataCoords(target, points) {
25917
+ return points.map(function(p) {
25918
+ return translateDisplayPoint(target, p);
25919
+ });
25920
+ }
25921
+
25922
+ // Completes the stroke in progress up to the pointer, and records the
25923
+ // stroke's vertices as a single edit.
25924
+ // end: (optional) hover info of the vertex that the stroke has to end at
25925
+ function finishStroke(end) {
25926
+ var s = stroke;
25927
+ var target, rest, points, count;
25928
+ if (!s) return;
25929
+ stroke = null;
25930
+ target = hit.getHitTarget();
25931
+ rest = end ? s.fitter.addSample(end.displayPoint) : [];
25932
+ rest = toDataCoords(target, rest.concat(s.fitter.finish()));
25933
+ if (end && rest.length > 0) {
25934
+ rest[rest.length - 1] = end.point;
25935
+ }
25936
+ // remove the preview
25937
+ count = s.previewCount + 1;
25938
+ for (; count > 1; count--) {
25939
+ deleteLastVertex(target);
25940
+ }
25941
+ if (rest.length === 0 && s.points.length > 0) {
25942
+ // the last vertex in the path takes the place of the vertex following the pointer
25943
+ deleteLastVertex(target);
25944
+ rest = [s.points.pop()];
25945
+ } else if (rest.length > 0) {
25946
+ setVertexCoords(target, [target.gui.displayArcs.getPointCount() - 1], rest[0]);
25947
+ }
25948
+ points = s.points.concat(rest);
25949
+ if (points.length === 0 || !s.anchorCommitted && points.length == 1) {
25950
+ hit.triggerChangeEvent(); // the stroke never left its anchor
25951
+ return;
25952
+ }
25953
+ prevVertexAddedEvent = s.lastEvent;
25954
+ extendCurrentPath(points, !!end, s.points.length);
25955
+ }
25956
+
25475
25957
  hit.on('drag', function(e) {
25476
25958
  if (!vertexDragging() || pathDrawing()) {
25477
25959
  return;
@@ -25551,7 +26033,8 @@
25551
26033
  if (detectDoubleClick(e)) return; // ignore second click of a dblclick
25552
26034
  var p = pixToDataCoords(e.x, e.y);
25553
26035
  if (pathDrawing()) {
25554
- extendCurrentPath(hoverVertexInfo?.point || p);
26036
+ // finish the path if a vertex is selected (but not an interpolated point)
26037
+ extendCurrentPath([hoverVertexInfo?.point || p], hoverVertexInfo?.type == 'vertex');
25555
26038
  } else if (hoverVertexInfo?.type == 'interpolated') {
25556
26039
  // don't start new path if hovering along a segment -- this is
25557
26040
  // likely to be an attempt to add a new vertex, not start a new path
@@ -25651,6 +26134,11 @@
25651
26134
 
25652
26135
  function finishCurrentPath() {
25653
26136
  if (!pathDrawing()) return;
26137
+ if (stroke) {
26138
+ // finishing mid-drag (e.g. with the Enter key)
26139
+ finishStroke();
26140
+ blockDrag = true;
26141
+ }
25654
26142
  var target = hit.getHitTarget();
25655
26143
  if (getLastArcLength(target) <= 2) { // includes hover point
25656
26144
  // deleteLastPath(target);
@@ -25674,15 +26162,21 @@
25674
26162
  updateCursor();
25675
26163
  }
25676
26164
 
25677
- // p: [x, y] source data coordinates of new point on path
25678
- function extendCurrentPath(p) {
26165
+ // points: [x, y] source data coordinates of vertices to add to the path. The
26166
+ // vertex that follows the pointer becomes points[placed], so it must
26167
+ // already be there; the rest are appended after it.
26168
+ // finish: true if the path ends at an existing vertex (which closes a polygon)
26169
+ // placed: (optional) number of points already in the path, before the vertex
26170
+ // that follows the pointer
26171
+ function extendCurrentPath(points, finish, placed) {
25679
26172
  var target = hit.getHitTarget();
25680
26173
  var shapes1, shapes2;
25681
- // finish the path if a vertex is selected (but not an interpolated point)
25682
- var finish = hoverVertexInfo?.type == 'vertex';
25683
26174
  if (getLastArcLength(target) < 2) {
25684
26175
  stop$1('Defective path');
25685
26176
  }
26177
+ for (var i=(placed || 0) + 1; i<points.length; i++) {
26178
+ appendVertex$1(target, points[i]);
26179
+ }
25686
26180
  if (finish && polygonMode()) {
25687
26181
  shapes1 = target.shapes.slice(initialShapeCount);
25688
26182
  try {
@@ -25694,12 +26188,12 @@
25694
26188
  }
25695
26189
  if (shapes2) {
25696
26190
  replaceDrawnShapes(shapes2);
25697
- gui.dispatchEvent('path_extend', {target, p, shapes1, shapes2});
26191
+ gui.dispatchEvent('path_extend', {target, points, shapes1, shapes2});
25698
26192
  clearDrawingInfo();
25699
26193
  fullRedraw();
25700
26194
  } else {
25701
- appendVertex$1(target, p);
25702
- gui.dispatchEvent('path_extend', {target, p});
26195
+ appendVertex$1(target, points[points.length - 1]); // the new vertex following the pointer
26196
+ gui.dispatchEvent('path_extend', {target, points});
25703
26197
  hit.triggerChangeEvent(); // trigger overlay redraw
25704
26198
  }
25705
26199
  }
@@ -30581,10 +31075,20 @@
30581
31075
  // array of field names of relevant svg display properties
30582
31076
  fields = getStyleFields(lyr).filter(function(f) {return f in styleIndex;}),
30583
31077
  records = lyr.data.getRecords();
31078
+ // Arrowheads are drawn by the canvas as shapes, not set as attributes, so
31079
+ // they are not among the attribute fields. Assigned on every call, like the
31080
+ // rest, because the style object is reused from one feature to the next.
31081
+ var arrowFields = getLineArrowFields(lyr);
31082
+ var hasStrokeFields = fields.includes('stroke') || fields.includes('stroke-width');
30584
31083
 
30585
31084
  var styler = function(style, i) {
30586
31085
  var rec = records[i];
30587
31086
  var fname, val;
31087
+ if (arrowFields) {
31088
+ style.lineStart = rec && rec['line-start'];
31089
+ style.lineEnd = rec && rec['line-end'];
31090
+ style.lineEndSize = rec && rec['line-end-size'];
31091
+ }
30588
31092
  for (var j=0; j<fields.length; j++) {
30589
31093
  fname = fields[j];
30590
31094
  val = rec && rec[fname];
@@ -30606,6 +31110,12 @@
30606
31110
  }
30607
31111
  };
30608
31112
  var style = {styler: styler, type: 'styled'};
31113
+ // A line layer styled with nothing but arrowheads is drawn the way SVG
31114
+ // export draws it, with the black 1px line the layer's group gives it.
31115
+ if (arrowFields && !hasStrokeFields) {
31116
+ style.strokeColor = 'black';
31117
+ style.strokeWidth = 1;
31118
+ }
30609
31119
  // use squares if radius is missing... (TODO: check behavior with labels, etc)
30610
31120
  if (lyr.geometry_type == 'point' && fields.includes('r') === false) {
30611
31121
  style.dotSize = 1;
@@ -30620,7 +31130,18 @@
30620
31130
  // return fields.indexOf('r') > -1; // require 'r' field for point symbols
30621
31131
  return fields.includes('fill') || fields.includes('r'); // support colored squares
30622
31132
  }
30623
- return utils$1.difference(fields, ['opacity', 'class']).length > 0;
31133
+ return utils$1.difference(fields, ['opacity', 'class']).length > 0 ||
31134
+ !!getLineArrowFields(lyr);
31135
+ }
31136
+
31137
+ // The arrowhead fields a line layer has, or null
31138
+ function getLineArrowFields(lyr) {
31139
+ var fields;
31140
+ if (lyr.geometry_type != 'polyline' || !lyr.data) return null;
31141
+ fields = lyr.data.getFields().filter(function(f) {
31142
+ return internal.svg.lineArrowFields.includes(f);
31143
+ });
31144
+ return fields.length > 0 ? fields : null;
30624
31145
  }
30625
31146
 
30626
31147
  function getStyleFields(lyr) {
@@ -31579,7 +32100,7 @@
31579
32100
  arcs = getArcsForRendering(lyr, ext);
31580
32101
  filter = getShapeFilter(arcs, layer.shapes, ext);
31581
32102
  canv.drawStyledPaths(layer.shapes, arcs, style, filter);
31582
- if (style.vertices) {
32103
+ if (style.vertices || style.vertex_overlay || style.pending_snip) {
31583
32104
  canv.drawVertices(layer.shapes, arcs, style, filter);
31584
32105
  }
31585
32106
  }
@@ -31726,6 +32247,8 @@
31726
32247
  };
31727
32248
  */
31728
32249
 
32250
+ // Draws a dot at each vertex (if style.vertices is set), and the markers of
32251
+ // style.vertex_overlay and style.pending_snip.
31729
32252
  _self.drawVertices = function(shapes, arcs, style, filter) {
31730
32253
  var iter = new internal.ShapeIter(arcs);
31731
32254
  var t = getScaledTransform(_ext);
@@ -31735,7 +32258,7 @@
31735
32258
  var i, j, p;
31736
32259
  _ctx.beginPath();
31737
32260
  _ctx.fillStyle = color;
31738
- for (i=0; i<shapes.length; i++) {
32261
+ for (i=0; style.vertices && i<shapes.length; i++) {
31739
32262
  var shp = shapes[i];
31740
32263
  if (!shp || filter && !filter(i)) continue;
31741
32264
  for (j=0; j<shp.length; j++) {
@@ -31773,8 +32296,10 @@
31773
32296
  _self.drawStyledPaths = function(shapes, arcs, style, filter) {
31774
32297
  var styleIndex = {};
31775
32298
  var batchSize = 100;
31776
- var startPath = getPathStart(_ext, getScaledLineScale(_ext, style));
32299
+ var lineScale = getScaledLineScale(_ext, style);
32300
+ var startPath = getPathStart(_ext, lineScale);
31777
32301
  var draw = getShapePencil(arcs, _ext);
32302
+ var arrowPencil = null;
31778
32303
  var key, item, shp;
31779
32304
  var styler = style.styler || null;
31780
32305
  var drawStyle = styler ? utils$1.defaults({}, style) : style;
@@ -31784,6 +32309,13 @@
31784
32309
  if (styler) {
31785
32310
  styler(drawStyle, i);
31786
32311
  }
32312
+ // A line with arrowheads is drawn on its own, trimmed to meet its heads;
32313
+ // see svg-line-arrows.mjs. Only lines styled with line-start or line-end
32314
+ // get here, so the batch below is untouched for everything else.
32315
+ if (drawStyle.lineStart || drawStyle.lineEnd) {
32316
+ if (!arrowPencil) arrowPencil = getArrowLinePencil(arcs, _ext, lineScale);
32317
+ if (arrowPencil(shp, _ctx, drawStyle, startPath, draw)) continue;
32318
+ }
31787
32319
  if (!drawStyle.batchOverlay && (drawStyle.overlay ||
31788
32320
  drawStyle.opacity < 1 || drawStyle.fillOpacity < 1 ||
31789
32321
  drawStyle.strokeOpacity < 1 || drawStyle.fillEffect)) {
@@ -32138,6 +32670,10 @@
32138
32670
  return x + 0.5 | 0;
32139
32671
  }
32140
32672
 
32673
+ function noRound(x) {
32674
+ return x;
32675
+ }
32676
+
32141
32677
  function roundToHalfPix(x) {
32142
32678
  return (x * 2 | 0) / 2;
32143
32679
  }
@@ -32149,15 +32685,113 @@
32149
32685
  if (style.fillEffect) {
32150
32686
  ctx.fillStyle = getCanvasFillEffect(ctx, shp, arcs, ext, style);
32151
32687
  }
32688
+ // Rounding also drops vertices that land on the same pixel, which keeps
32689
+ // detailed shapes fast to draw, so only a style for a few short paths
32690
+ // should turn it off (see gui-overlay-styler.mjs).
32691
+ var round = style.unroundedCoords ? noRound : roundToPix;
32152
32692
  for (var i=0, n=shp ? shp.length : 0; i<n; i++) {
32153
32693
  iter.init(shp[i]);
32154
32694
  // 0.2 trades visible seams for performance
32155
32695
  // drawPath(protectIterForDrawing(iter, ext), t, ctx, 0.2);
32156
- drawPath2(protectIterForDrawing(iter, ext), t, ctx, roundToPix);
32696
+ drawPath2(protectIterForDrawing(iter, ext), t, ctx, round);
32697
+ }
32698
+ };
32699
+ }
32700
+
32701
+ // Returns a function that draws one line feature with its arrowheads, and
32702
+ // returns false if the feature has none to draw (a style of 'none', or no
32703
+ // stroke), for the caller to draw it the usual way.
32704
+ //
32705
+ // Heads are sized by the same factor as the line width, so that a preview
32706
+ // matches the exported SVG. Far enough in that paths are clipped to the view
32707
+ // (see protectIterForDrawing()), the line is drawn clipped and untrimmed and
32708
+ // only the heads are added, since a clipped path's ends are not its ends.
32709
+ function getArrowLinePencil(arcs, ext, lineScale) {
32710
+ var t = getScaledTransform(ext);
32711
+ var iter = new internal.ShapeIter(arcs);
32712
+ var strokeScale = getCanvasStrokeScale(GUI.getPixelRatio(), lineScale);
32713
+ var clipped = ext.scale() > 100;
32714
+ return function(shp, ctx, style, startPath, draw) {
32715
+ var opts = internal.svg.makeLineArrowOpts(style.lineStart, style.lineEnd,
32716
+ style.lineEndSize, style.strokeWidth, strokeScale);
32717
+ var heads = [];
32718
+ var i, coords, shape;
32719
+ if (opts.start == 'none' && opts.end == 'none' || !(style.strokeWidth > 0)) {
32720
+ return false;
32157
32721
  }
32722
+ startPath(ctx, style);
32723
+ for (i=0; i<shp.length; i++) {
32724
+ coords = getPixelCoords(iter, shp[i], t);
32725
+ if (coords.length < 2) continue;
32726
+ shape = internal.svg.getLineArrowShape(coords, opts);
32727
+ heads = heads.concat(shape.heads);
32728
+ if (!clipped) traceCoords(shape.coords, ctx);
32729
+ }
32730
+ if (clipped) draw(shp, ctx, style);
32731
+ endPath(ctx, style);
32732
+ drawArrowHeads(heads, ctx, style, opts.width);
32733
+ return true;
32158
32734
  };
32159
32735
  }
32160
32736
 
32737
+ function getPixelCoords(iter, path, t) {
32738
+ var coords = [];
32739
+ var x, y, prev;
32740
+ iter.init(path);
32741
+ while (iter.hasNext()) {
32742
+ x = iter.x * t.mx + t.bx;
32743
+ y = iter.y * t.my + t.by;
32744
+ if (prev && prev[0] == x && prev[1] == y) continue;
32745
+ prev = [x, y];
32746
+ coords.push(prev);
32747
+ }
32748
+ return coords;
32749
+ }
32750
+
32751
+ function traceCoords(coords, ctx) {
32752
+ ctx.moveTo(coords[0][0], coords[0][1]);
32753
+ for (var i=1; i<coords.length; i++) {
32754
+ ctx.lineTo(coords[i][0], coords[i][1]);
32755
+ }
32756
+ }
32757
+
32758
+ // Solid heads and dots are filled with the line's colour; open ones are stroked with
32759
+ // its width, always whole and round-cornered, whatever the line's dashes and
32760
+ // caps.
32761
+ function drawArrowHeads(heads, ctx, style, width) {
32762
+ var alpha = (style.opacity >= 0 ? style.opacity : 1) *
32763
+ (style.strokeOpacity >= 0 ? style.strokeOpacity : 1);
32764
+ var head, p;
32765
+ if (heads.length === 0) return;
32766
+ ctx.globalAlpha = alpha;
32767
+ ctx.fillStyle = style.strokeColor;
32768
+ ctx.strokeStyle = style.strokeColor;
32769
+ ctx.lineWidth = width;
32770
+ ctx.lineCap = 'round';
32771
+ ctx.lineJoin = 'round';
32772
+ ctx.setLineDash([]);
32773
+ for (var i=0; i<heads.length; i++) {
32774
+ head = heads[i];
32775
+ ctx.beginPath();
32776
+ if (head.type == 'dot') {
32777
+ ctx.arc(head.center[0], head.center[1], head.radius, 0, Math.PI * 2);
32778
+ ctx.fill();
32779
+ continue;
32780
+ }
32781
+ p = head.points;
32782
+ ctx.moveTo(p[0][0], p[0][1]);
32783
+ ctx.lineTo(p[1][0], p[1][1]);
32784
+ ctx.lineTo(p[2][0], p[2][1]);
32785
+ if (head.type == 'arrow') {
32786
+ ctx.closePath();
32787
+ ctx.fill();
32788
+ } else {
32789
+ ctx.stroke();
32790
+ }
32791
+ }
32792
+ ctx.globalAlpha = 1;
32793
+ }
32794
+
32161
32795
  function protectIterForDrawing(iter, ext) {
32162
32796
  var bounds, k;
32163
32797
  if (ext.scale() > 100) {
@@ -35770,13 +36404,13 @@
35770
36404
 
35771
36405
  // Font and face menus, as in the label panel
35772
36406
  row = makeRow(appearance);
35773
- fontSelect = El('select').attr('title', 'Font').addClass('scalebar-font-select')
36407
+ fontSelect = El('select').attr('aria-label', 'Font').addClass('scalebar-font-select')
35774
36408
  .appendTo(makeCell(row, 'Font'))
35775
36409
  .on('change', function() {
35776
36410
  if (fontSelect.node().value) applyFont(fontSelect.node().value);
35777
36411
  });
35778
36412
  renderFontOptions();
35779
- fontStyleSelect = El('select').attr('title', 'Font style')
36413
+ fontStyleSelect = El('select').attr('aria-label', 'Font style')
35780
36414
  .addClass('scalebar-font-style-select')
35781
36415
  .appendTo(makeCell(row, 'Font style'))
35782
36416
  .on('change', function() {
@@ -35790,7 +36424,7 @@
35790
36424
  El('span').appendTo(colorCell).text('Color');
35791
36425
  colorChit = El('div').addClass('label-color-chit').attr('role', 'button')
35792
36426
  .on('click', function() { colorPicker.toggle(); });
35793
- colorInput = El('input').attr('type', 'text').attr('title', 'Scale bar color')
36427
+ colorInput = El('input').attr('type', 'text').attr('aria-label', 'Scale bar color')
35794
36428
  .on('change', function() {
35795
36429
  var color = colorInput.node().value.trim();
35796
36430
  if (!color) {
@@ -35816,7 +36450,7 @@
35816
36450
  'scalebar-bar-width');
35817
36451
  marginInput = makeNumberInput(makeCell(row, 'Inset'), 'margin',
35818
36452
  'scalebar-margin')
35819
- .attr('title', 'Distance from the edges of the frame, in pixels');
36453
+ .attr('aria-label', 'Distance from the edges of the frame, in pixels');
35820
36454
 
35821
36455
  row = El('div').addClass('label-style-row label-panel-button-row').appendTo(form);
35822
36456
  makePanelActionButton(row, 'Remove scale bar', function() {
@@ -37117,6 +37751,25 @@
37117
37751
  })
37118
37752
  };
37119
37753
  },
37754
+ // The vertices of each part of each feature in a path layer, in pixels
37755
+ // from the top left of the map, for a test comparing a drawn path with the
37756
+ // pointer positions that drew it.
37757
+ getLayerPathPixels: function(name) {
37758
+ var o = gui.model.getLayers().filter(function(o) {
37759
+ return getLayerName(o.layer) === name;
37760
+ })[0];
37761
+ var ext = gui.map.getExtent();
37762
+ var arcs;
37763
+ if (!o) return null;
37764
+ arcs = o.layer.gui ? o.layer.gui.displayArcs : o.dataset.arcs;
37765
+ return (o.layer.shapes || []).map(function(shp) {
37766
+ return shp ? shp.map(function(ids) {
37767
+ var iter = arcs.getShapeIter(ids), pts = [];
37768
+ while (iter.hasNext()) pts.push(ext.translateCoords(iter.x, iter.y));
37769
+ return pts;
37770
+ }) : null;
37771
+ });
37772
+ },
37120
37773
  selectLayer: function(name) {
37121
37774
  var target = gui.model.getLayers().filter(function(o) {
37122
37775
  return getLayerName(o.layer) === name;
@@ -37134,6 +37787,19 @@
37134
37787
  };
37135
37788
  });
37136
37789
  },
37790
+ // What each overlay layer last drew: the ids of its features, and whether
37791
+ // it marked their vertices. The overlay is drawn to canvas, so there is no
37792
+ // DOM state to assert on.
37793
+ getOverlayInfo: function() {
37794
+ var layers = gui.map && gui.map.getOverlayLayers ?
37795
+ gui.map.getOverlayLayers() : [];
37796
+ return layers.map(function(lyr) {
37797
+ return {
37798
+ ids: lyr.gui.style.ids || null,
37799
+ vertices: !!lyr.gui.style.vertices
37800
+ };
37801
+ });
37802
+ },
37137
37803
  // Knot handle positions, in display coordinates.
37138
37804
  getLabelPathKnotCoords: function() {
37139
37805
  var lyr = getLabelPathGuideLayers(gui).filter(function(lyr) {