mapshaper 0.7.69 → 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.
@@ -3053,12 +3053,12 @@
3053
3053
  return decimals >= 0 ? num.toFixed(decimals) : String(num);
3054
3054
  }
3055
3055
 
3056
- function formatNumber$3(val) {
3056
+ function formatNumber$4(val) {
3057
3057
  return val + '';
3058
3058
  }
3059
3059
 
3060
3060
  function formatIntlNumber(val) {
3061
- var str = formatNumber$3(val);
3061
+ var str = formatNumber$4(val);
3062
3062
  return '"' + str.replace('.', ',') + '"'; // need to quote if comma-delimited
3063
3063
  }
3064
3064
 
@@ -3607,7 +3607,7 @@
3607
3607
  endsWith, every, expandoBuffer, extend, extendBuffer,
3608
3608
  find, findMedian, findQuantile, findRankByValue, findStringPrefix,
3609
3609
  findValueByPct, findValueByRank, forEach, forEachProperty, format,
3610
- formatDateISO, formatIntlNumber, formatNumber: formatNumber$3, formatNumberForDisplay,
3610
+ formatDateISO, formatIntlNumber, formatNumber: formatNumber$4, formatNumberForDisplay,
3611
3611
  formatVersionedName, formatter,
3612
3612
  genericSort, getArrayBounds, getGenericComparator, getKeyComparator,
3613
3613
  getSortedIds, getUniqueName, groupBy,
@@ -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)) {
@@ -11518,9 +11494,12 @@
11518
11494
  }
11519
11495
 
11520
11496
  if (e.deleteVertex || e.deletePoint || copyable || e.deleteFeature ||
11521
- e.flipLabel) {
11497
+ e.flipLabel || e.boldText) {
11522
11498
 
11523
11499
  addMenuLabel('actions');
11500
+ if (e.boldText) {
11501
+ addMenuItem(e.boldText.label, e.boldText.run);
11502
+ }
11524
11503
  if (e.deleteVertex) {
11525
11504
  addMenuItem('delete vertex', e.deleteVertex);
11526
11505
  }
@@ -12904,6 +12883,11 @@
12904
12883
  if (cmd.name == 'style' || cmd.name == 'svg-style') {
12905
12884
  return getStyleCullInfo(opts, isStyleProperty);
12906
12885
  }
12886
+ // Adding a label or a labeled copy of a layer is not a style edit, and can
12887
+ // not be superseded by one.
12888
+ if (cmd.name == 'labels' && !('coordinates' in opts) && !opts.no_replace) {
12889
+ return getStyleCullInfo(getLabelStyleOpts(opts), isStyleProperty);
12890
+ }
12907
12891
  if (cmd.name == 'classify') {
12908
12892
  return getClassifyCullInfo(opts);
12909
12893
  }
@@ -12923,6 +12907,16 @@
12923
12907
  };
12924
12908
  }
12925
12909
 
12910
+ // -labels text= writes the label-text property.
12911
+ function getLabelStyleOpts(opts) {
12912
+ var o = Object.assign({}, opts);
12913
+ if ('text' in o) {
12914
+ o['label-text'] = o.text;
12915
+ delete o.text;
12916
+ }
12917
+ return o;
12918
+ }
12919
+
12926
12920
  function getStyleFields$2(opts, isStyleProperty) {
12927
12921
  var fields = [];
12928
12922
  Object.keys(opts).forEach(function(name) {
@@ -13448,10 +13442,10 @@
13448
13442
 
13449
13443
  gui.on('path_extend', function(e) {
13450
13444
  var redo = function() {
13451
- gui.dispatchEvent('redo_path_extend', {p: e.p, shapes: e.shapes2});
13445
+ gui.dispatchEvent('redo_path_extend', {points: e.points, shapes: e.shapes2});
13452
13446
  };
13453
13447
  var undo = function() {
13454
- gui.dispatchEvent('undo_path_extend', {shapes: e.shapes1});
13448
+ gui.dispatchEvent('undo_path_extend', {points: e.points, shapes: e.shapes1});
13455
13449
  };
13456
13450
  addHistoryState(undo, redo);
13457
13451
  });
@@ -13921,6 +13915,11 @@
13921
13915
  var SOFT_BREAK = '<wbr>';
13922
13916
  var SOFT_BREAK_RXP = /<wbr>/gi;
13923
13917
 
13918
+ // A bold stretch, which is the other markup label text carries. See
13919
+ // svg-label-markup.mjs.
13920
+ var BOLD_OPEN = '<b>';
13921
+ var BOLD_CLOSE = '</b>';
13922
+
13924
13923
  // How a label lays out its line breaks, which is not the same for the two kinds.
13925
13924
  // An anchored label stacks its lines with <tspan>. A path label joins them with
13926
13925
  // a space, because a <tspan> inside a <textPath> advances along the curve
@@ -13956,6 +13955,66 @@
13956
13955
  return {text: text, breaks: breaks};
13957
13956
  }
13958
13957
 
13958
+ // Data value -> {text, breaks, bold}: the string the user edits, the offsets
13959
+ // in it where the stored value has a soft break, and its bold stretches as
13960
+ // [start, end) offsets into it (see gui-label-bold.mjs).
13961
+ //
13962
+ // The two kinds of markup are independent -- a bold stretch can cross a soft
13963
+ // break, and a soft break can sit at either end of one -- so the bold is read
13964
+ // first and its offsets are moved as the soft breaks come out.
13965
+ function readLabelValue(val) {
13966
+ var str = val === null || val === undefined ? '' : String(val).replace(ANY_NEWLINE, '\n');
13967
+ var o = internal.svg.parseBoldMarkup(str);
13968
+ var starts = [], m;
13969
+ SOFT_BREAK_RXP.lastIndex = 0;
13970
+ while ((m = SOFT_BREAK_RXP.exec(o.text))) starts.push(m.index);
13971
+ return {
13972
+ text: o.text.replace(SOFT_BREAK_RXP, ''),
13973
+ breaks: starts.map(function(start, i) {
13974
+ return start - i * SOFT_BREAK.length;
13975
+ }),
13976
+ bold: internal.svg.normalizeBoldRanges(o.bold.map(function(r) {
13977
+ return [removeSoftBreakOffset(r[0], starts), removeSoftBreakOffset(r[1], starts)];
13978
+ }))
13979
+ };
13980
+ }
13981
+
13982
+ // An offset into text with soft breaks in it -> the same place once they are
13983
+ // removed. One that falls inside a marker goes to its start.
13984
+ function removeSoftBreakOffset(x, starts) {
13985
+ var shift = 0;
13986
+ for (var i = 0; i < starts.length && starts[i] < x; i++) {
13987
+ if (x < starts[i] + SOFT_BREAK.length) return starts[i] - shift;
13988
+ shift += SOFT_BREAK.length;
13989
+ }
13990
+ return x - shift;
13991
+ }
13992
+
13993
+ // The string the user edits, its soft breaks and its bold ranges -> the value
13994
+ // to store, with both kinds of markup written in. Where they meet, a bold
13995
+ // stretch closes before a soft break and opens after one, so that the break
13996
+ // is never inside a pair of tags it is not part of.
13997
+ function writeLabelValue(text, breaks, bold) {
13998
+ var marks = [];
13999
+ (bold || []).forEach(function(r) {
14000
+ marks.push({pos: r[0], order: 2, str: BOLD_OPEN});
14001
+ marks.push({pos: r[1], order: 0, str: BOLD_CLOSE});
14002
+ });
14003
+ (breaks || []).forEach(function(pos) {
14004
+ marks.push({pos: pos, order: 1, str: SOFT_BREAK});
14005
+ });
14006
+ if (marks.length === 0) return text;
14007
+ marks.sort(function(a, b) {
14008
+ return a.pos - b.pos || a.order - b.order;
14009
+ });
14010
+ var out = '', prev = 0;
14011
+ marks.forEach(function(mark) {
14012
+ out += text.substring(prev, mark.pos) + mark.str;
14013
+ prev = mark.pos;
14014
+ });
14015
+ return out + text.substring(prev);
14016
+ }
14017
+
13959
14018
  // The string the user edits, plus soft breaks at @breaks (ascending offsets
13960
14019
  // into it) -> the same string with the markers written in.
13961
14020
  function insertSoftBreaks(text, breaks) {
@@ -14136,6 +14195,29 @@
14136
14195
  return Math.min(renderedIndex, length);
14137
14196
  }
14138
14197
 
14198
+ // The word a double-click at edited index @i selects, as [start, end), or an
14199
+ // empty range at @i for a click between words. A click on the right half of a
14200
+ // word's last letter lands on the index after it, which is still that word.
14201
+ //
14202
+ // Hyphens and apostrophes count as part of a word, so that "cease-fire" and
14203
+ // "Israel's" are one word each, as they are one name on the map.
14204
+ function getWordRange(text, i) {
14205
+ var at = isWordChar(text.charAt(i)) ? i : isWordChar(text.charAt(i - 1)) ? i - 1 : -1;
14206
+ var a, b;
14207
+ if (at < 0) return [i, i];
14208
+ a = at;
14209
+ b = at + 1;
14210
+ while (a > 0 && isWordChar(text.charAt(a - 1))) a--;
14211
+ while (b < text.length && isWordChar(text.charAt(b))) b++;
14212
+ return [a, b];
14213
+ }
14214
+
14215
+ var WORD_CHAR_RXP = /[\p{L}\p{N}\p{M}'\u2019-]/u;
14216
+
14217
+ function isWordChar(c) {
14218
+ return !!c && WORD_CHAR_RXP.test(c);
14219
+ }
14220
+
14139
14221
  // Strips the placeholder back out, so that a value read off a rendered node is
14140
14222
  // not mistaken for text the user typed.
14141
14223
  function stripPlaceholder(str) {
@@ -14200,7 +14282,7 @@
14200
14282
  function getAddLabelCommand(coords, opts) {
14201
14283
  var o = opts || {};
14202
14284
  var target = o.target || {mode: 'new', newLayerName: DEFAULT_LABEL_LAYER_NAME};
14203
- var parts = ['-add-label', 'coordinates=' + formatCoords(coords)];
14285
+ var parts = ['-labels', 'coordinates=' + formatCoords(coords)];
14204
14286
  if (o.text) {
14205
14287
  // A real newline would break the command parser, so encodeLabelText()
14206
14288
  // writes the two-character escape the label renderer also accepts.
@@ -14267,7 +14349,7 @@
14267
14349
  // id: feature id of the label
14268
14350
  // target: layer name, or null to use the current target
14269
14351
  function getLabelPlacementCommand(opts) {
14270
- var parts = ['-style', 'label-start-offset=' + opts.offset];
14352
+ var parts = ['-labels', 'label-start-offset=' + opts.offset];
14271
14353
  if (opts.anchor) {
14272
14354
  parts.push('text-anchor=' + opts.anchor);
14273
14355
  }
@@ -14287,7 +14369,7 @@
14287
14369
  // with and the position goes -- "stop taking a position, carry these offsets
14288
14370
  // instead", which is one command because it is one edit.
14289
14371
  //
14290
- // label-pos= is always written, even by a label that has none. -style reads an
14372
+ // label-pos= is always written, even by a label that has none. -labels reads an
14291
14373
  // empty value as "remove this", and removing a property no record carries adds
14292
14374
  // nothing to the layer, so there is nothing to be gained by asking first.
14293
14375
  //
@@ -14297,7 +14379,7 @@
14297
14379
  // target: layer name, or null to use the current target
14298
14380
  // via: (optional) the callout-via the text carried with it
14299
14381
  function getLabelOffsetCommand(opts) {
14300
- var parts = ['-style', 'dx=' + opts.dx, 'dy=' + opts.dy,
14382
+ var parts = ['-labels', 'dx=' + opts.dx, 'dy=' + opts.dy,
14301
14383
  'text-anchor=' + opts.anchor, 'label-pos='];
14302
14384
  if (opts.via) parts.push('callout-via=' + quoteCommandValue(opts.via));
14303
14385
  parts.push('ids=' + opts.id);
@@ -14308,16 +14390,16 @@
14308
14390
  // The command a drag on one of a label's handles writes, run once on release.
14309
14391
  //
14310
14392
  // values: {field: value}, where '' removes the field -- which is what
14311
- // -style reads an empty value as, and how a handle goes back to automatic
14393
+ // -labels reads an empty value as, and how a handle goes back to automatic
14312
14394
  // id: feature id of the label
14313
14395
  // opts:
14314
14396
  // target: layer name, or null to use the current target
14315
14397
  // text: (optional) rewrapped label-text, with real newlines and soft
14316
- // breaks. A second -style in the same string, so that a new width and
14398
+ // breaks. A second -labels in the same string, so that a new width and
14317
14399
  // the lines it breaks into are one undo step.
14318
14400
  function getLabelStyleCommand(values, id, opts) {
14319
14401
  var o = opts || {};
14320
- var parts = ['-style'];
14402
+ var parts = ['-labels'];
14321
14403
  Object.keys(values).forEach(function(name) {
14322
14404
  var val = values[name];
14323
14405
  parts.push(name + '=' + (val === '' ? '' : quoteCommandValue(String(val))));
@@ -14339,7 +14421,7 @@
14339
14421
  function getLabelTextCommand(text, id, target) {
14340
14422
  // A real newline would break the command parser, so encodeLabelText() writes
14341
14423
  // the two-character escape that the label renderer also accepts.
14342
- var parts = ['-style', 'label-text=' + quoteCommandValue(encodeLabelText(text))];
14424
+ var parts = ['-labels', 'text=' + quoteCommandValue(encodeLabelText(text))];
14343
14425
  parts.push('ids=' + id);
14344
14426
  if (target) parts.push('target=' + quoteCommandValue(target));
14345
14427
  return parts.join(' ');
@@ -15454,6 +15536,19 @@
15454
15536
  return cachedFontStyles[fontName];
15455
15537
  }
15456
15538
 
15539
+ // Whether words in @fontName can be set in bold: a bold the browser
15540
+ // synthesizes is not one the Node measurer can match, so wrapping and the
15541
+ // path-fit check would disagree with what is drawn. A font that cannot be
15542
+ // identified is given the benefit of the doubt -- the browser finds whatever
15543
+ // bold it has, and there is nothing to check against.
15544
+ function fontHasBoldFace(fontName) {
15545
+ var variants = fontName ? getFontStyleVariants(fontName) : null;
15546
+ if (!variants || variants.length === 0) return true;
15547
+ return variants.some(function(variant) {
15548
+ return Number(variant.weight) >= 600;
15549
+ });
15550
+ }
15551
+
15457
15552
  // The installed font the browser draws unfonted text in, or '' if it cannot
15458
15553
  // be worked out.
15459
15554
  //
@@ -15511,6 +15606,11 @@
15511
15606
  return drawnFont || '';
15512
15607
  }
15513
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
+
15514
15614
  function getInstalledFontNames() {
15515
15615
  var names = [];
15516
15616
  getInstalledFonts().forEach(function(group) {
@@ -15879,7 +15979,7 @@
15879
15979
  var tile = El('div')
15880
15980
  .addClass('label-color-preset')
15881
15981
  .attr('role', 'button')
15882
- .attr('title', color)
15982
+ .attr('aria-label', color)
15883
15983
  .appendTo(rowEl)
15884
15984
  .css('background-color', color);
15885
15985
  if (row.length == 16 && i == 8) tile.addClass('label-color-preset-group-start');
@@ -16356,7 +16456,7 @@
16356
16456
  // in a menu that is only on screen while it is being used, and a
16357
16457
  // destructive action is better for waiting until intent is shown.
16358
16458
  El('div').addClass('label-saved-style-delete').attr('role', 'button')
16359
- .attr('title', 'Delete this ' + opts.styleLabel)
16459
+ .attr('aria-label', 'Delete this ' + opts.styleLabel)
16360
16460
  .appendTo(itemEl).html('&times;')
16361
16461
  .on('click', function() {
16362
16462
  deleteStyle(item);
@@ -16429,7 +16529,7 @@
16429
16529
  // Escape. Whether that means giving up the keyboard is the
16430
16530
  // caller's question: the field cannot know what else wants it.
16431
16531
  // decimals how finely a typed size is kept (default 1)
16432
- // title tooltip for the field
16532
+ // title the field's accessible name (not a tooltip)
16433
16533
  function SizeField(parent, opts) {
16434
16534
  var o = Object.assign({min: 1, max: 999, step: 1, bigStep: 10}, opts || {});
16435
16535
  var box = El('div').addClass('size-field').appendTo(parent);
@@ -16442,7 +16542,7 @@
16442
16542
  var dirty = false; // holds typing that has not been committed
16443
16543
  var disabled = false;
16444
16544
 
16445
- if (o.title) input.attr('title', o.title);
16545
+ if (o.title) input.attr('aria-label', o.title);
16446
16546
 
16447
16547
  // While the caret is in this field the keyboard belongs to it. Without this
16448
16548
  // the GUI's own handlers see the keystrokes: Escape would disarm the tool,
@@ -16616,6 +16716,15 @@
16616
16716
  !/^(button|checkbox|radio|submit)$/.test(node.type);
16617
16717
  }
16618
16718
 
16719
+ // Whether clicking this node puts a native menu on screen, which must then be
16720
+ // left holding the focus: a menu opens on mousedown, so a panel that lets go of
16721
+ // focus on the click that follows closes it again -- it flashes open and shut.
16722
+ // OPTION counts because a browser that reports the chosen option as the click
16723
+ // target is reporting a menu interaction either way.
16724
+ function opensAMenu(node) {
16725
+ return !!node && /^(SELECT|OPTION)$/.test(node.nodeName);
16726
+ }
16727
+
16619
16728
  // While the caret is in one of @panel's fields, the keyboard belongs to that
16620
16729
  // field. Without this the GUI's own handlers see the keystrokes: a Backspace
16621
16730
  // typed into a field takes back the last knot of a curve being drawn, and an
@@ -16723,6 +16832,53 @@
16723
16832
  return section;
16724
16833
  }
16725
16834
 
16835
+ // A switch: a track with a knob that sits left when off and right when on,
16836
+ // which is the direction users expect and the only thing that says which
16837
+ // state is which without a label for each. A third state says that the
16838
+ // features it is asking about disagree -- the knob sits over the join of a
16839
+ // half-and-half track, which is the only control that has to show "some of
16840
+ // them" rather than a value. A section's switch goes in its heading row, and
16841
+ // the section shows only its heading while it is off (the .collapsed class).
16842
+ //
16843
+ // role=checkbox rather than switch, which is what it looks like: 'mixed' is a
16844
+ // legal aria-checked value for a checkbox and not for a switch, and a switch
16845
+ // reporting a mixed selection as unchecked would be telling a screen reader
16846
+ // the one thing the third state exists to avoid saying.
16847
+ //
16848
+ // opts.onChange(on) the switch was clicked
16849
+ // opts.title the switch's accessible name (not a tooltip)
16850
+ // opts.className
16851
+ function makePanelToggle(parent, opts) {
16852
+ var track = El('div').addClass('label-toggle').attr('role', 'checkbox').appendTo(parent);
16853
+ var state = 'off';
16854
+ if (opts.className) track.addClass(opts.className);
16855
+ var disabled = false;
16856
+ El('div').addClass('label-toggle-knob').appendTo(track);
16857
+ if (opts.title) track.attr('aria-label', opts.title);
16858
+ // A click on a mixed switch turns everything on. It is the convention, and
16859
+ // it is the reading that reaches a state the switch can describe: the next
16860
+ // click then turns everything off, so both are one click away.
16861
+ track.on('click', function() {
16862
+ if (disabled) return;
16863
+ opts.onChange(state != 'on');
16864
+ });
16865
+ return {
16866
+ // val: 'on', 'off' or 'mixed'; anything else reads as off
16867
+ setState: function(val) {
16868
+ state = val == 'on' || val == 'mixed' ? val : 'off';
16869
+ track.classed('on', state == 'on')
16870
+ .classed('mixed', state == 'mixed')
16871
+ .attr('aria-checked', state == 'mixed' ? 'mixed' :
16872
+ state == 'on' ? 'true' : 'false');
16873
+ },
16874
+ setDisabled: function(off) {
16875
+ disabled = !!off;
16876
+ track.classed('disabled', disabled)
16877
+ .attr('aria-disabled', disabled ? 'true' : 'false');
16878
+ }
16879
+ };
16880
+ }
16881
+
16726
16882
  // Deliberately not focusable: the GUI is pointer-only, so a tab stop here
16727
16883
  // would lead into a control the keyboard cannot then operate. See the focus
16728
16884
  // note in page.css.
@@ -16783,7 +16939,7 @@
16783
16939
  // opacity qualifies the colour, and one border says so where two fields side
16784
16940
  // by side said they were separate settings. It also leaves the narrow column
16785
16941
  // of the row free for a field that needs it. The opacity has no caption; its
16786
- // percent sign and its tooltip say what it is.
16942
+ // percent sign says what it is.
16787
16943
  //
16788
16944
  // Returns the field's box; the opacity input is made by makeOpacityInput().
16789
16945
  function makeColorOpacityField(parent, chit, input, opacityOpts) {
@@ -16799,6 +16955,8 @@
16799
16955
  // opts.onColor(hex) a colour was typed, picked or previewed to a finish
16800
16956
  // opts.onOpacity(frac) a usable percentage was typed
16801
16957
  // opts.revert() one that was not, so put the row back as it was
16958
+ // opts.noOpacity a colour with no opacity of its own, like a pattern's,
16959
+ // which fill-opacity fades along with its background
16802
16960
  //
16803
16961
  // control.aside is the row's narrow column, empty, for a field that belongs
16804
16962
  // beside the colour -- a stroke's width, say.
@@ -16830,16 +16988,20 @@
16830
16988
  control.picker.toggle();
16831
16989
  });
16832
16990
  control.input = El('input').attr('type', 'text')
16833
- .attr('title', opts.label + ' color')
16991
+ .attr('aria-label', opts.label + ' color')
16834
16992
  .on('change', function() {
16835
16993
  var color = control.input.node().value.trim();
16836
16994
  if (isHexColor(color)) control.picker.setColor(color);
16837
16995
  opts.onColor(color);
16838
16996
  });
16839
- control.opacity = makeColorOpacityField(colorCell, control.chit, control.input, {
16840
- onSet: opts.onOpacity,
16841
- revert: opts.revert
16842
- }).opacity;
16997
+ if (opts.noOpacity) {
16998
+ makeColorField(colorCell, control.chit, control.input);
16999
+ } else {
17000
+ control.opacity = makeColorOpacityField(colorCell, control.chit, control.input, {
17001
+ onSet: opts.onOpacity,
17002
+ revert: opts.revert
17003
+ }).opacity;
17004
+ }
16843
17005
  control.picker = new ColorPicker(colorCell, {
16844
17006
  presetRows: layerColorPresetRows,
16845
17007
  // A drag in the picker shows on the map's own terms -- the swatch and the
@@ -16862,7 +17024,7 @@
16862
17024
  // opts.revert() it was not, so put back what the field was showing
16863
17025
  function makeOpacityInput(parent, opts) {
16864
17026
  var input = El('input').attr('type', 'text').addClass('label-opacity-input')
16865
- .attr('title', 'Opacity, 0-100%')
17027
+ .attr('aria-label', 'Opacity, 0-100%')
16866
17028
  .appendTo(parent)
16867
17029
  .on('change', function() {
16868
17030
  var val = parseOpacityValue(input.node().value);
@@ -16879,13 +17041,13 @@
16879
17041
  //
16880
17042
  // The style panel is worth having open before there is anything to point it at:
16881
17043
  // pick a font, then place labels in it. Values set with nothing selected are
16882
- // held here rather than written to a layer, and -add-label writes them when a
17044
+ // held here rather than written to a layer, and -labels coordinates= writes them when a
16883
17045
  // label finally exists -- so this is a tool default, not data, and it never
16884
17046
  // needs its own undo step.
16885
17047
  //
16886
17048
  // See docs/development/label-tool-design.md.
16887
17049
 
16888
- // The style properties -add-label accepts. A panel control whose property is
17050
+ // The style properties -labels accepts. A panel control whose property is
16889
17051
  // missing from this list would appear to do nothing when set with no selection,
16890
17052
  // so the list is checked rather than assumed.
16891
17053
  var NEW_LABEL_STYLE_FIELDS = [
@@ -16907,7 +17069,7 @@
16907
17069
  //
16908
17070
  // A blank or zero value removes the field instead of setting it: that is how
16909
17071
  // the panel says "no icon" (icon='', icon-size=0) and "no inline css", and
16910
- // carrying those through to -add-label would write properties that mean
17072
+ // carrying those through to -labels would write properties that mean
16911
17073
  // nothing.
16912
17074
  function mergeStyleValues(style, values) {
16913
17075
  var out = Object.assign({}, style);
@@ -16933,7 +17095,7 @@
16933
17095
  // somewhere and typing looks like it should do, and it is the position an icon
16934
17096
  // is drawn to sit behind.
16935
17097
  //
16936
- // Only the tool defaults this. -add-label with no position still creates a
17098
+ // Only the tool defaults this. -labels coordinates= with no position still creates a
16937
17099
  // label without one, so the default is a choice this tool makes and passes on
16938
17100
  // explicitly, not a meaning the command gives to its absence.
16939
17101
  var DEFAULT_NEW_LABEL_STYLE = {'label-pos': 'c'};
@@ -17247,13 +17409,17 @@
17247
17409
 
17248
17410
  // Offsets into @text (real newlines, no soft breaks) where a line starts
17249
17411
  // because it was wrapped, in ascending order.
17250
- function getSoftBreaks(text, rec) {
17412
+ //
17413
+ // @bold: the text's bold stretches, as [start, end) offsets into it. A bold
17414
+ // word is wider, so it moves the breaks.
17415
+ function getSoftBreaks(text, rec, bold) {
17251
17416
  var width = getLabelWrapWidth(rec);
17252
17417
  var key, breaks;
17253
17418
  if (!width || !text) return [];
17254
- key = getWrapKey(text, rec);
17419
+ bold = bold || [];
17420
+ key = getWrapKey(text, rec, bold);
17255
17421
  if (cache$2.has(key)) return cache$2.get(key);
17256
- breaks = measureSoftBreaks(text, rec, width);
17422
+ breaks = measureSoftBreaks(text, rec, width, bold);
17257
17423
  if (cache$2.size >= CACHE_LIMIT) cache$2.clear();
17258
17424
  cache$2.set(key, breaks);
17259
17425
  return breaks;
@@ -17263,29 +17429,30 @@
17263
17429
  // unchanged when neither the old value nor the new one has a soft break, so
17264
17430
  // that a label that does not wrap keeps the line breaks it was written with.
17265
17431
  function rewrapLabelValue(value, rec) {
17266
- var o = readSoftBreaks(value);
17267
- var breaks = getSoftBreaks(o.text, rec);
17432
+ var o = readLabelValue(value);
17433
+ var breaks = getSoftBreaks(o.text, rec, o.bold);
17268
17434
  if (sameSoftBreaks(o.breaks, breaks)) return value;
17269
- return encodeLabelText(insertSoftBreaks(o.text, breaks));
17435
+ return encodeLabelText(writeLabelValue(o.text, breaks, o.bold));
17270
17436
  }
17271
17437
 
17272
17438
  function clearWrapCache() {
17273
17439
  cache$2.clear();
17274
17440
  }
17275
17441
 
17276
- function getWrapKey(text, rec) {
17442
+ function getWrapKey(text, rec, bold) {
17277
17443
  var parts = [text];
17278
17444
  WRAP_FIELDS.forEach(function(name) {
17279
17445
  var val = rec[name];
17280
17446
  parts.push(val === null || val === undefined ? '' : String(val));
17281
17447
  });
17282
17448
  parts.push(rec['class'] || '');
17449
+ parts.push(bold.join(';'));
17283
17450
  return parts.join('\u0000');
17284
17451
  }
17285
17452
 
17286
- function measureSoftBreaks(text, rec, width) {
17453
+ function measureSoftBreaks(text, rec, width, bold) {
17287
17454
  var w = getWrapper();
17288
- var style, fontSize, node, range;
17455
+ var style, fontSize, pieces, range;
17289
17456
  if (!w) return [];
17290
17457
  style = getLabelFontStyle(w, rec);
17291
17458
  if (!style) return [];
@@ -17293,15 +17460,15 @@
17293
17460
  w.div.style[name] = style[name] || '';
17294
17461
  });
17295
17462
  w.div.style.width = width + 'px';
17296
- w.div.textContent = text;
17297
- node = w.div.firstChild;
17463
+ pieces = writeRuns(w.div, text, bold);
17298
17464
  fontSize = parseFloat(style.fontSize) || 12;
17299
17465
  range = document.createRange();
17300
17466
  var breaks = findSoftBreaks(text, function(i) {
17467
+ var piece = findPiece(pieces, i);
17301
17468
  var end = isHighSurrogate(text.charCodeAt(i)) ? i + 2 : i + 1;
17302
17469
  var r;
17303
- range.setStart(node, i);
17304
- range.setEnd(node, Math.min(end, text.length));
17470
+ range.setStart(piece.node, i - piece.start);
17471
+ range.setEnd(piece.node, Math.min(end, piece.end) - piece.start);
17305
17472
  r = range.getBoundingClientRect();
17306
17473
  return r.height > 0 ? r.top : null;
17307
17474
  }, fontSize * LINE_HEIGHT / 2);
@@ -17309,6 +17476,35 @@
17309
17476
  return breaks;
17310
17477
  }
17311
17478
 
17479
+ // Lays @text out in @div a run at a time, the bold ones in bold spans, and
17480
+ // returns each run's text node with the offsets it covers: [{node, start,
17481
+ // end}]. Offsets in the text become offsets in one of these nodes.
17482
+ function writeRuns(div, text, bold) {
17483
+ var pos = 0;
17484
+ div.textContent = '';
17485
+ return internal.svg.getBoldRuns(text, bold, 0, text.length).map(function(run) {
17486
+ var node = document.createTextNode(run.text);
17487
+ var span;
17488
+ if (run.bold) {
17489
+ span = document.createElement('span');
17490
+ span.style.fontWeight = internal.svg.LABEL_BOLD_WEIGHT;
17491
+ span.appendChild(node);
17492
+ div.appendChild(span);
17493
+ } else {
17494
+ div.appendChild(node);
17495
+ }
17496
+ pos += run.text.length;
17497
+ return {node: node, start: pos - run.text.length, end: pos};
17498
+ });
17499
+ }
17500
+
17501
+ function findPiece(pieces, i) {
17502
+ for (var j = 0; j < pieces.length; j++) {
17503
+ if (i < pieces[j].end) return pieces[j];
17504
+ }
17505
+ return pieces[pieces.length - 1];
17506
+ }
17507
+
17312
17508
  function isHighSurrogate(c) {
17313
17509
  return c >= 0xD800 && c <= 0xDBFF;
17314
17510
  }
@@ -17359,6 +17555,80 @@
17359
17555
  return wrapper;
17360
17556
  }
17361
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
+
17362
17632
  var fontField = 'font-family';
17363
17633
  var fontSizeField = 'font-size';
17364
17634
  var fontStyleField = 'font-style';
@@ -17391,7 +17661,6 @@
17391
17661
  var defaultFontWeight = '400';
17392
17662
  var defaultLabelColor = '#000000';
17393
17663
  var defaultIconColor = '#000000';
17394
- var defaultIconSize = 5;
17395
17664
  // The width a halo is switched on at: past the edge of the glyphs, so a
17396
17665
  // stroke of twice this.
17397
17666
  var defaultHaloWidth = 2;
@@ -17464,6 +17733,10 @@
17464
17733
  name: 'star'
17465
17734
  }, {
17466
17735
  name: 'ring'
17736
+ }, {
17737
+ name: 'nyt-star',
17738
+ title: 'NYT star',
17739
+ nytOnly: true
17467
17740
  }];
17468
17741
  var defaultIconShape = 'circle';
17469
17742
  // Alignment writes label-align rather than text-anchor, because text-anchor
@@ -17529,14 +17802,15 @@
17529
17802
  elbow: '<path d="M3.5 12.5L8 4.5H13"></path>',
17530
17803
  curve: '<path d="M3.5 12.5Q4.5 4.5 12.5 4"></path>',
17531
17804
  none: '<path d="M3 8H13"></path>',
17532
- 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>',
17533
17806
  'open-arrow': '<path d="M3.2 8H13"></path><path d="M7.38 5.07L3.2 8L7.38 10.93"></path>'
17534
17807
  };
17535
17808
  var iconButtonSymbols = {
17536
17809
  circle: '<circle cx="8" cy="8" r="4.25"></circle>',
17537
17810
  square: '<rect x="4" y="4" width="8" height="8"></rect>',
17538
17811
  ring: '<circle cx="8" cy="8" r="3.8"></circle>',
17539
- 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>'
17540
17814
  };
17541
17815
 
17542
17816
  function LabelTool(gui) {
@@ -17551,6 +17825,7 @@
17551
17825
  // The shape the toggle turns back on, so that switching a symbol off and on
17552
17826
  // again does not silently change a star into a circle.
17553
17827
  var lastIconShape = defaultIconShape;
17828
+ var shownIconTypes = null;
17554
17829
  // Likewise the halo's width, which is what switching one off removes.
17555
17830
  var lastHaloWidth = defaultHaloWidth;
17556
17831
  // And the callout's shape.
@@ -17684,7 +17959,7 @@
17684
17959
  // number otherwise keep theirs, and so does a colour field, because the
17685
17960
  // field beside it has one and "Color" says the percentage is its opacity.
17686
17961
  var fontRow = El('div').addClass('label-style-row').appendTo(textSection);
17687
- fontSelect = El('select').attr('title', 'Font').appendTo(fontRow).on('change', function() {
17962
+ fontSelect = El('select').attr('aria-label', 'Font').appendTo(fontRow).on('change', function() {
17688
17963
  if (fontSelect.node().value) {
17689
17964
  applyFont(fontSelect.node().value);
17690
17965
  }
@@ -17692,7 +17967,7 @@
17692
17967
 
17693
17968
  var styleSizeRow = El('div').addClass('label-style-row label-split-row').appendTo(textSection);
17694
17969
  var fontStyleRow = El('div').addClass('label-split-cell label-font-style-row').appendTo(styleSizeRow);
17695
- 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() {
17696
17971
  if (fontStyleSelect.node().value) {
17697
17972
  applyFontStyleVariant(fontStyleSelect.node().value);
17698
17973
  }
@@ -17718,7 +17993,7 @@
17718
17993
  var textColorCell = El('div').addClass('label-split-cell label-color-row label-text-color-row').appendTo(colorRow);
17719
17994
  El('span').appendTo(textColorCell).text('Color');
17720
17995
  colorChit = El('div').addClass('label-color-chit').attr('role', 'button');
17721
- colorInput = El('input').attr('type', 'text').attr('title', 'Text color');
17996
+ colorInput = El('input').attr('type', 'text').attr('aria-label', 'Text color');
17722
17997
  var textColorField = addColorOpacityField(textColorCell, colorChit, colorInput, applyLabelOpacity);
17723
17998
  colorFieldBox = textColorField.box;
17724
17999
  opacityInput = textColorField.opacity;
@@ -17751,7 +18026,7 @@
17751
18026
  applyLabelAlign(item.name);
17752
18027
  })
17753
18028
  .attr('data-align', item.name)
17754
- .attr('title', item.title);
18029
+ .attr('aria-label', item.title);
17755
18030
  alignBtns[item.name] = btn;
17756
18031
  appendAlignButtonSymbol(btn, item.name);
17757
18032
  });
@@ -17780,7 +18055,7 @@
17780
18055
  // spacing sits above.
17781
18056
  haloSection = addSection('Halo');
17782
18057
  var haloTitle = haloSection.findChild('.label-style-section-title');
17783
- haloToggle = makeToggle(haloTitle, {
18058
+ haloToggle = makePanelToggle(haloTitle, {
17784
18059
  title: 'Draw a halo around the text',
17785
18060
  className: 'label-halo-toggle',
17786
18061
  onChange: setHaloOn
@@ -17790,7 +18065,7 @@
17790
18065
  var haloColorCell = El('div').addClass('label-split-cell label-color-row label-halo-color-row').appendTo(haloColorRow);
17791
18066
  El('span').appendTo(haloColorCell).text('Color');
17792
18067
  haloColorChit = El('div').addClass('label-color-chit').attr('role', 'button');
17793
- haloColorInput = El('input').attr('type', 'text').attr('title', 'Halo color');
18068
+ haloColorInput = El('input').attr('type', 'text').attr('aria-label', 'Halo color');
17794
18069
  var haloColorField = addColorOpacityField(haloColorCell, haloColorChit, haloColorInput, applyHaloOpacity);
17795
18070
  haloColorFieldBox = haloColorField.box;
17796
18071
  haloOpacityInput = haloColorField.opacity;
@@ -17831,7 +18106,7 @@
17831
18106
  // a label with no icon: nothing to apply it to.
17832
18107
  iconSection = addSection('Icon');
17833
18108
  var iconTitle = iconSection.findChild('.label-style-section-title');
17834
- iconToggle = makeToggle(iconTitle, {
18109
+ iconToggle = makePanelToggle(iconTitle, {
17835
18110
  title: 'Draw a symbol at the label anchor',
17836
18111
  className: 'label-icon-toggle',
17837
18112
  onChange: setIconOn
@@ -17841,12 +18116,12 @@
17841
18116
  var iconRow = El('div').addClass('label-style-row label-icon-shapes-row').appendTo(iconSection);
17842
18117
  var iconGroup = iconGroupEl = El('div').addClass('label-btn-group label-icon-buttons').appendTo(iconRow);
17843
18118
  iconBtns = {};
17844
- iconTypes.forEach(function(icon) {
18119
+ getShownIconTypes().forEach(function(icon) {
17845
18120
  var btn = makePanelButton(iconGroup, '', function() {
17846
18121
  applyIcon(icon.name);
17847
18122
  })
17848
18123
  .attr('data-icon', icon.name)
17849
- .attr('title', icon.name);
18124
+ .attr('aria-label', icon.title || icon.name);
17850
18125
  iconBtns[icon.name] = btn;
17851
18126
  appendIconButtonSymbol(btn, icon.name);
17852
18127
  });
@@ -17855,7 +18130,7 @@
17855
18130
  var iconColorCell = El('div').addClass('label-split-cell label-color-row label-icon-color-row').appendTo(iconColorRow);
17856
18131
  El('span').appendTo(iconColorCell).text('Color');
17857
18132
  iconColorChit = El('div').addClass('label-color-chit').attr('role', 'button');
17858
- iconColorInput = El('input').attr('type', 'text').attr('title', 'Symbol color');
18133
+ iconColorInput = El('input').attr('type', 'text').attr('aria-label', 'Symbol color');
17859
18134
  var iconColorField = addColorOpacityField(iconColorCell, iconColorChit, iconColorInput, applyIconOpacity);
17860
18135
  iconColorFieldBox = iconColorField.box;
17861
18136
  iconOpacityInput = iconColorField.opacity;
@@ -17903,7 +18178,7 @@
17903
18178
  applyLabelPosition(pos);
17904
18179
  })
17905
18180
  .attr('data-position', pos)
17906
- .attr('title', pos);
18181
+ .attr('aria-label', pos);
17907
18182
  });
17908
18183
 
17909
18184
 
@@ -17913,7 +18188,7 @@
17913
18188
  setLabelPositionMode(gui, item.name, getPositionModeKind());
17914
18189
  })
17915
18190
  .attr('data-drag-mode', item.name)
17916
- .attr('title', item.title);
18191
+ .attr('aria-label', item.title);
17917
18192
  });
17918
18193
 
17919
18194
  presetControl = new StylePresetControl(panel, {
@@ -17938,7 +18213,7 @@
17938
18213
  // the map rather than typed here. See docs/development/text-annotation-design.md.
17939
18214
  function initCalloutSection() {
17940
18215
  calloutSection = addSection('Callout');
17941
- calloutToggle = makeToggle(calloutSection.findChild('.label-style-section-title'), {
18216
+ calloutToggle = makePanelToggle(calloutSection.findChild('.label-style-section-title'), {
17942
18217
  title: 'Draw a line from the label anchor to its text',
17943
18218
  className: 'label-callout-toggle',
17944
18219
  onChange: setCalloutOn
@@ -17998,7 +18273,7 @@
17998
18273
  var colorCell = El('div').addClass('label-split-cell label-color-row label-callout-color-row').appendTo(colorRow);
17999
18274
  El('span').appendTo(colorCell).text('Color');
18000
18275
  calloutColorChit = El('div').addClass('label-color-chit').attr('role', 'button');
18001
- calloutColorInput = El('input').attr('type', 'text').attr('title', 'Callout color');
18276
+ calloutColorInput = El('input').attr('type', 'text').attr('aria-label', 'Callout color');
18002
18277
  var calloutColorField = addColorOpacityField(colorCell, calloutColorChit, calloutColorInput, applyCalloutOpacity);
18003
18278
  calloutColorFieldBox = calloutColorField.box;
18004
18279
  calloutOpacityInput = calloutColorField.opacity;
@@ -18022,7 +18297,7 @@
18022
18297
  var gapCell = El('div').addClass('label-split-cell label-spacing-row label-callout-gap-row').appendTo(colorRow);
18023
18298
  El('span').appendTo(gapCell).text('Gap');
18024
18299
  calloutGapInput = El('input').attr('type', 'text').addClass('label-measure-input')
18025
- .attr('title', 'Space between the callout and the anchor, in px')
18300
+ .attr('aria-label', 'Space between the callout and the anchor, in px')
18026
18301
  .attr('placeholder', 'auto')
18027
18302
  .attr('data-placeholder', 'auto')
18028
18303
  .appendTo(gapCell)
@@ -18034,7 +18309,7 @@
18034
18309
  function makeCalloutButton(parent, item, action) {
18035
18310
  var btn = makePanelButton(parent, '', action)
18036
18311
  .attr('data-callout', item.name)
18037
- .attr('title', item.title);
18312
+ .attr('aria-label', item.title);
18038
18313
  El('<svg class="label-callout-symbol" viewBox="0 0 16 16" aria-hidden="true">' +
18039
18314
  calloutButtonSymbols[item.name] + '</svg>').appendTo(btn);
18040
18315
  return btn;
@@ -18059,48 +18334,6 @@
18059
18334
  return makePanelSection(panel, title, opts);
18060
18335
  }
18061
18336
 
18062
- // A switch: a track with a knob that sits left when off and right when on,
18063
- // which is the direction users expect and the only thing that says which
18064
- // state is which without a label for each. A third state says that the
18065
- // labels it is asking about disagree -- the knob sits over the join of a
18066
- // half-and-half track, which is the panel's only control that has to show
18067
- // "some of them" rather than a value.
18068
- //
18069
- // role=checkbox rather than switch, which is what it looks like: 'mixed' is a
18070
- // legal aria-checked value for a checkbox and not for a switch, and a switch
18071
- // reporting a mixed selection as unchecked would be telling a screen reader
18072
- // the one thing the third state exists to avoid saying.
18073
- function makeToggle(parent, opts) {
18074
- var track = El('div').addClass('label-toggle').attr('role', 'checkbox').appendTo(parent);
18075
- var state = 'off';
18076
- if (opts.className) track.addClass(opts.className);
18077
- var disabled = false;
18078
- El('div').addClass('label-toggle-knob').appendTo(track);
18079
- if (opts.title) track.attr('title', opts.title);
18080
- // A click on a mixed switch turns everything on. It is the convention, and
18081
- // it is the reading that reaches a state the switch can describe: the next
18082
- // click then turns everything off, so both are one click away.
18083
- track.on('click', function() {
18084
- if (disabled) return;
18085
- opts.onChange(state != 'on');
18086
- });
18087
- return {
18088
- // val: 'on', 'off' or 'mixed'; anything else reads as off
18089
- setState: function(val) {
18090
- state = val == 'on' || val == 'mixed' ? val : 'off';
18091
- track.classed('on', state == 'on')
18092
- .classed('mixed', state == 'mixed')
18093
- .attr('aria-checked', state == 'mixed' ? 'mixed' :
18094
- state == 'on' ? 'true' : 'false');
18095
- },
18096
- setDisabled: function(off) {
18097
- disabled = !!off;
18098
- track.classed('disabled', disabled)
18099
- .attr('aria-disabled', disabled ? 'true' : 'false');
18100
- }
18101
- };
18102
- }
18103
-
18104
18337
  function addColorOpacityField(parent, chit, input, onOpacity) {
18105
18338
  return makeColorOpacityField(parent, chit, input, {
18106
18339
  onSet: onOpacity,
@@ -18303,8 +18536,10 @@
18303
18536
  // rest of the selection has nothing to compare.
18304
18537
  var iconIds = getIconValueIds();
18305
18538
  var iconVal = getCommonValue(iconIds, iconField);
18306
- var iconSize = getShownValue(iconIds, iconSizeField, {useDefault: true, defaultValue: defaultIconSize});
18307
- 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});
18308
18543
  var iconOpacity = getShownValue(iconIds, iconOpacityField, {useDefault: true, defaultValue: 1});
18309
18544
  var haloIds = getHaloValueIds();
18310
18545
  var haloWidth = getShownValue(haloIds, haloWidthField, {useDefault: true, defaultValue: lastHaloWidth});
@@ -18649,7 +18884,7 @@
18649
18884
  // border the buttons share fades with them -- a live border around dead
18650
18885
  // buttons is the one part of a disabled control that still looks usable.
18651
18886
  iconGroupEl.classed('disabled', off);
18652
- iconTypes.forEach(function(icon) {
18887
+ getShownIconTypes().forEach(function(icon) {
18653
18888
  iconBtns[icon.name].classed('selected', !off && icon.name == iconVal);
18654
18889
  setPanelButtonDisabled(iconBtns[icon.name], off);
18655
18890
  });
@@ -19248,12 +19483,23 @@
19248
19483
 
19249
19484
  function getSelectedIcon() {
19250
19485
  var out = '';
19251
- iconTypes.forEach(function(icon) {
19486
+ getShownIconTypes().forEach(function(icon) {
19252
19487
  if (iconBtns[icon.name].hasClass('selected')) out = icon.name;
19253
19488
  });
19254
19489
  return out;
19255
19490
  }
19256
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
+
19257
19503
  function applyStyleObject(style) {
19258
19504
  var styles = [];
19259
19505
  savedStyleFields.forEach(function(field) {
@@ -19284,8 +19530,9 @@
19284
19530
  // The size the symbols in the selection already share, where they share
19285
19531
  // one: a shape applied to a mixed selection gives the labels that had no
19286
19532
  // symbol the size of the ones that did, rather than resetting them all to
19287
- // the default.
19288
- 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());
19289
19536
  // The symbol's own opacity goes on with it, because the label's opacity
19290
19537
  // is applied to both elements: without this, text set to 50% would give
19291
19538
  // a half-faded symbol while the Icon section showed it at 100%.
@@ -19297,6 +19544,14 @@
19297
19544
  applyStyleValues(styles);
19298
19545
  }
19299
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
+
19300
19555
  // Switching a symbol on moves a label sitting at the centre out from under
19301
19556
  // it, in the same command, so that the pair is one undo step and the text
19302
19557
  // is never briefly drawn over the symbol it just asked for.
@@ -19337,7 +19592,9 @@
19337
19592
  }
19338
19593
 
19339
19594
  function nudgeIconSize(delta) {
19340
- var size = getNumericSize(getIconTargetIds(), iconSizeField, defaultIconSize);
19595
+ var ids = getIconTargetIds();
19596
+ var size = getNumericSize(ids, iconSizeField,
19597
+ getDefaultIconSize(getCommonValue(ids, iconField)));
19341
19598
  if (!controlsEnabled()) return;
19342
19599
  size = Math.max(1, size + delta);
19343
19600
  applyIconSize(size);
@@ -19428,14 +19685,6 @@
19428
19685
  return !!node && /^(INPUT|SELECT|TEXTAREA|BUTTON|OPTION)$/.test(node.nodeName);
19429
19686
  }
19430
19687
 
19431
- // Whether clicking this node puts a native menu on screen, which must then be
19432
- // left holding the focus. OPTION counts because a browser that reports the
19433
- // chosen option as the click target is reporting a menu interaction either
19434
- // way, and a choice restores the caret through its change handler.
19435
- function opensAMenu(node) {
19436
- return !!node && /^(SELECT|OPTION)$/.test(node.nodeName);
19437
- }
19438
-
19439
19688
  // Hands the keyboard back after a control has done its job: to the label
19440
19689
  // being typed into if there is one, and to nothing at all otherwise.
19441
19690
  function releaseFocus() {
@@ -19454,7 +19703,7 @@
19454
19703
  if (styles.length === 0) return;
19455
19704
  releaseFocus();
19456
19705
  // What the panel is set to is always what the next label gets, whether or
19457
- // not these values also went to a label that already exists. -add-label
19706
+ // not these values also went to a label that already exists. -labels
19458
19707
  // writes them when that label is created, which is why this needs no
19459
19708
  // command and no undo step of its own.
19460
19709
  if (labelModeIsOn()) {
@@ -19478,7 +19727,7 @@
19478
19727
 
19479
19728
  function applyStyleCommand(styles, ids) {
19480
19729
  var lyr = getActiveLayer();
19481
- var parts = ['-style'];
19730
+ var parts = ['-labels'];
19482
19731
  if (!gui.console || !lyr) return;
19483
19732
  styles.forEach(function(style) {
19484
19733
  parts.push(style[0] + '=' + quoteCommandValue(style[1]));
@@ -19500,7 +19749,7 @@
19500
19749
  // A text block's breaks are only right for the width and font they were
19501
19750
  // found with, so a change to either rewraps it in the same command: one undo
19502
19751
  // step, and no moment at which the block is drawn with its old lines in its
19503
- // new font. One -style per label, since each has text of its own.
19752
+ // new font. One -labels per label, since each has text of its own.
19504
19753
  function getRewrapCommands(styles, ids, lyr) {
19505
19754
  var table = lyr.data;
19506
19755
  var changes = {};
@@ -19634,14 +19883,513 @@
19634
19883
  return measureSvg;
19635
19884
  }
19636
19885
 
19886
+ // The pattern fill controls of the polygon panel, and the fill-pattern codes
19887
+ // they stand for.
19888
+ //
19889
+ // The panel covers the patterns that are one colour over a background: hatch
19890
+ // lines, dots and squares. The background is the feature's own fill, so a
19891
+ // pattern is described by five settings -- {type, color, size, gap, angle} --
19892
+ // and the fill. A hatch has no background in the code: it is a run of stripes,
19893
+ // and the panel's hatch is two of them, the gap in the fill colour and the line
19894
+ // in the pattern colour. Anything else (dashes, several colours, a background
19895
+ // that is not the fill) is a custom pattern, which the panel shows as its code.
19896
+ //
19897
+ // Written into every code rather than read from the fill when drawn, because
19898
+ // the code is what -style stores and what the SVG exporter turns into a
19899
+ // <pattern>: a pattern with no colour of its own would need both to change.
19900
+
19901
+ var patternTypes = ['hatches', 'dots', 'squares'];
19902
+
19903
+ var patternDefaults = {
19904
+ hatches: {size: 1, gap: 3, angle: 45},
19905
+ dots: {size: 2, gap: 3, angle: 0},
19906
+ squares: {size: 2, gap: 2, angle: 0}
19907
+ };
19908
+
19909
+ var defaultPatternColor = '#000000';
19910
+
19911
+ // The settings a newly chosen pattern type starts from. The colour carries
19912
+ // over from the pattern being replaced; the sizes do not, because a hatch
19913
+ // line's width and a dot's diameter look nothing alike at the same number.
19914
+ function getDefaultPatternControls(type, color) {
19915
+ var d = patternDefaults[type];
19916
+ return {type: type, color: color || defaultPatternColor,
19917
+ size: d.size, gap: d.gap, angle: d.angle};
19918
+ }
19919
+
19920
+ // The background a pattern is given over a feature with @fill. A feature with
19921
+ // no fill gets a clear one, so that the pattern is drawn over whatever is
19922
+ // underneath.
19923
+ function getPatternBackground(fill) {
19924
+ return isBlank$1(fill) ? 'none' : String(fill).trim();
19925
+ }
19926
+
19927
+ // Whether the settings describe a pattern the renderer will draw. A hatch with
19928
+ // no gap would be a solid fill, and the parser refuses a stripe of no width.
19929
+ function isValidPatternControls(o) {
19930
+ if (!o || patternTypes.indexOf(o.type) == -1 || isBlank$1(o.color)) return false;
19931
+ if (!(o.size > 0) || !isFinite(o.angle)) return false;
19932
+ return o.type == 'hatches' ? o.gap > 0 : o.gap >= 0;
19933
+ }
19934
+
19935
+ // The fill-pattern code for the settings @o over @background. The angle is left
19936
+ // out where it is the parser's own default, which keeps the common codes as
19937
+ // short as the ones in the documentation.
19938
+ function formatFillPattern(o, background) {
19939
+ var bg = background || 'none';
19940
+ if (o.type == 'hatches') {
19941
+ return 'hatches ' + formatAngle(o.angle, 45) +
19942
+ formatPx(o.gap) + ' ' + bg + ' ' + formatPx(o.size) + ' ' + o.color;
19943
+ }
19944
+ return o.type + ' ' + formatAngle(o.angle, 0) +
19945
+ formatPx(o.size) + ' ' + o.color + ' ' + formatPx(o.gap) + ' ' + bg;
19946
+ }
19947
+
19948
+ // The settings a parsed fill-pattern code (from parsePattern()) was made from,
19949
+ // given the fill of the feature it is on -- or null if the panel's settings
19950
+ // cannot describe it, which makes it a custom pattern.
19951
+ function getPatternControls(parsed, fill) {
19952
+ var bg = getPatternBackground(fill);
19953
+ var line;
19954
+ if (!parsed) return null;
19955
+ if (parsed.type == 'hatches') {
19956
+ if (parsed.widths.length != 2) return null;
19957
+ line = sameColor(parsed.colors[0], bg) ? 1 : sameColor(parsed.colors[1], bg) ? 0 : -1;
19958
+ if (line == -1) return null;
19959
+ return {type: 'hatches', color: parsed.colors[line], size: parsed.widths[line],
19960
+ gap: parsed.widths[1 - line], angle: parsed.rotation};
19961
+ }
19962
+ if (parsed.type == 'dots' || parsed.type == 'squares') {
19963
+ if (parsed.colors.length != 1 || !sameColor(parsed.background, bg)) return null;
19964
+ return {type: parsed.type, color: parsed.colors[0], size: parsed.size,
19965
+ gap: parsed.spacing, angle: parsed.rotation};
19966
+ }
19967
+ return null;
19968
+ }
19969
+
19970
+ // The same pattern over a different fill, or null for a custom pattern, which
19971
+ // is left as it is.
19972
+ function refillPattern(parsed, oldFill, newFill) {
19973
+ var o = getPatternControls(parsed, oldFill);
19974
+ return o ? formatFillPattern(o, getPatternBackground(newFill)) : null;
19975
+ }
19976
+
19977
+ // A -style expression giving each feature the pattern @o over its own fill.
19978
+ // For a change that sets fills the panel cannot know in advance, like
19979
+ // -classify's random colours.
19980
+ function formatFillPatternExpression(o) {
19981
+ var sentinel = '\u0000';
19982
+ var parts = formatFillPattern(o, sentinel).split(sentinel);
19983
+ return JSON.stringify(parts[0]) + ' + (fill || "none") + ' + JSON.stringify(parts[1]);
19984
+ }
19985
+
19986
+ // Collects per-feature edits into as few -style commands as they allow.
19987
+ // @edits: [{id, styles: [[name, value], ...]}]
19988
+ // Returns [{styles, ids}], in the order each set of styles first appears.
19989
+ function groupStyleEdits(edits) {
19990
+ var groups = [];
19991
+ var index = {};
19992
+ edits.forEach(function(edit) {
19993
+ var key = JSON.stringify(edit.styles);
19994
+ if (!(key in index)) {
19995
+ index[key] = groups.length;
19996
+ groups.push({styles: edit.styles, ids: []});
19997
+ }
19998
+ groups[index[key]].ids.push(edit.id);
19999
+ });
20000
+ return groups;
20001
+ }
20002
+
20003
+ function formatAngle(angle, defaultAngle) {
20004
+ return angle == defaultAngle ? '' : formatNumber$3(angle) + 'deg ';
20005
+ }
20006
+
20007
+ function formatPx(val) {
20008
+ return formatNumber$3(val) + 'px';
20009
+ }
20010
+
20011
+ function formatNumber$3(val) {
20012
+ return String(Math.round(Number(val) * 100) / 100);
20013
+ }
20014
+
20015
+ function sameColor(a, b) {
20016
+ return normalizeColor(a) == normalizeColor(b);
20017
+ }
20018
+
20019
+ function normalizeColor(color) {
20020
+ var str = String(color || '').trim().toLowerCase();
20021
+ return str == 'transparent' ? 'none' : str;
20022
+ }
20023
+
20024
+ function isBlank$1(val) {
20025
+ return val === undefined || val === null || String(val).trim() === '';
20026
+ }
20027
+
20028
+ // The polygon panel's "Pattern" section. See gui-fill-pattern.mjs for how its
20029
+ // settings map to fill-pattern codes.
20030
+ //
20031
+ // Switched like the label panel's Halo and Icon sections: a pattern is a
20032
+ // yes/no that the rest of the section then qualifies, and the section shows
20033
+ // only its heading while it is off.
20034
+ //
20035
+ // opts.getRecords() the target layer's records
20036
+ // opts.getTargetIds() the features being styled
20037
+ // opts.applyEdits(edits, title) run per-feature edits: [{id, styles}]
20038
+ // opts.revert() put the panel back as the data has it
20039
+ // opts.releaseFocus()
20040
+ function PatternFillControl(parent, opts) {
20041
+ var section = makePanelSection(parent, 'Pattern');
20042
+ var shown = {type: 'none'};
20043
+ // What switching the pattern on applies when the features have none: the
20044
+ // last pattern the section showed, so that off and on again is a round trip.
20045
+ var lastPattern = null;
20046
+ // Custom was chosen from the menu, and the code field is waiting for a code.
20047
+ // Until one is applied the data still says what it said before, so the
20048
+ // choice has to be remembered or the next refresh would undo it.
20049
+ var customPending = false;
20050
+ var toggle, typeSelect, mixedOption, colorControl, angleField, sizeRow, sizeCaption,
20051
+ sizeField, gapField, customRow, codeInput;
20052
+
20053
+ initRows();
20054
+
20055
+ this.section = section;
20056
+
20057
+ this.update = update;
20058
+
20059
+ this.reset = function() {
20060
+ customPending = false;
20061
+ };
20062
+
20063
+ this.hidePicker = function() {
20064
+ colorControl.picker.hide();
20065
+ };
20066
+
20067
+ // What a saved style records for the pattern, over the fill it records.
20068
+ this.getCode = function(fill) {
20069
+ if (isSimple(shown)) return formatFillPattern(shown, getPatternBackground(fill));
20070
+ if (shown.type == 'custom') return shown.code || '';
20071
+ return '';
20072
+ };
20073
+
20074
+ // Per-feature edits applying @styles to the targets. A new fill is also
20075
+ // written into the background of each feature's pattern, since that is where
20076
+ // the pattern's background colour is kept; custom patterns are left alone.
20077
+ this.getStyleEdits = function(styles) {
20078
+ var records = opts.getRecords();
20079
+ var fill = findStyle(styles, 'fill');
20080
+ var refill = fill !== undefined && findStyle(styles, 'fill-pattern') === undefined;
20081
+ return opts.getTargetIds().map(function(id) {
20082
+ var rec = records[id];
20083
+ var code = rec && rec['fill-pattern'];
20084
+ var edit = {id: id, styles: styles};
20085
+ var refilled;
20086
+ if (refill && !isBlank(code)) {
20087
+ refilled = refillPattern(internal.parsePattern(code), rec.fill, fill);
20088
+ if (refilled) edit.styles = styles.concat([['fill-pattern', refilled]]);
20089
+ }
20090
+ return edit;
20091
+ });
20092
+ };
20093
+
20094
+ // Edits that keep each feature's pattern over its fill after a command that
20095
+ // sets fills the panel cannot know beforehand. A -style expression reads
20096
+ // the new fill when it runs.
20097
+ this.getRefillExpressionEdits = function(ids) {
20098
+ var records = opts.getRecords();
20099
+ var edits = [];
20100
+ ids.forEach(function(id) {
20101
+ var rec = records[id];
20102
+ var code = rec && rec['fill-pattern'];
20103
+ var o = isBlank(code) ? null : getPatternControls(internal.parsePattern(code), rec.fill);
20104
+ if (o) edits.push({id: id, styles: [['fill-pattern', formatFillPatternExpression(o)]]});
20105
+ });
20106
+ return edits;
20107
+ };
20108
+
20109
+ function initRows() {
20110
+ toggle = makePanelToggle(section.findChild('.label-style-section-title'), {
20111
+ title: 'Fill with a pattern',
20112
+ className: 'layer-pattern-toggle',
20113
+ onChange: setPatternOn
20114
+ });
20115
+ var typeRow = El('div').addClass('label-style-row layer-pattern-type-row').appendTo(section);
20116
+ typeSelect = El('select').attr('aria-label', 'Pattern type').appendTo(typeRow)
20117
+ .on('change', function() {
20118
+ selectType(typeSelect.node().value);
20119
+ });
20120
+ [['hatches', 'Hatches'], ['dots', 'Dots'], ['squares', 'Squares'],
20121
+ ['custom', 'Custom']].forEach(function(o) {
20122
+ El('option').attr('value', o[0]).appendTo(typeSelect).text(o[1]);
20123
+ });
20124
+ // Shown only while the features being styled have different patterns,
20125
+ // which is not a choice the menu offers.
20126
+ mixedOption = El('option').attr('value', 'mixed').appendTo(typeSelect).text('Mixed');
20127
+ mixedOption.node().disabled = true;
20128
+
20129
+ colorControl = makeColorRow(section, {
20130
+ label: 'Color',
20131
+ noOpacity: true,
20132
+ onColor: function(color) {
20133
+ if (color) applyChange({color: color});
20134
+ },
20135
+ revert: opts.revert
20136
+ });
20137
+ colorControl.row.addClass('layer-pattern-color-row');
20138
+ El('span').appendTo(colorControl.aside).text('Angle');
20139
+ angleField = new SizeField(colorControl.aside, {
20140
+ min: -180, max: 180, step: 15, bigStep: 45,
20141
+ title: 'Pattern angle in degrees',
20142
+ onSet: function(value) {
20143
+ applyChange({angle: value});
20144
+ },
20145
+ onStep: function(delta) {
20146
+ applyChange({angle: Math.max(-180, Math.min(180, shown.angle + delta))});
20147
+ },
20148
+ onDone: opts.releaseFocus
20149
+ });
20150
+
20151
+ sizeRow = El('div').addClass('label-style-row layer-pattern-size-row').appendTo(section);
20152
+ var sizeCell = El('div').addClass('label-split-cell').appendTo(sizeRow);
20153
+ var gapCell = El('div').addClass('label-split-cell').appendTo(sizeRow);
20154
+ sizeCaption = El('span').appendTo(sizeCell).text('Size');
20155
+ sizeField = new SizeField(sizeCell, {
20156
+ min: 0.25, step: 0.5, bigStep: 2, decimals: 2,
20157
+ title: 'Size in px',
20158
+ onSet: function(value) {
20159
+ applyChange({size: value});
20160
+ },
20161
+ onStep: function(delta) {
20162
+ applyChange({size: Math.max(0.25, shown.size + delta)});
20163
+ },
20164
+ onDone: opts.releaseFocus
20165
+ });
20166
+ El('span').appendTo(gapCell).text('Gap');
20167
+ gapField = new SizeField(gapCell, {
20168
+ min: 0, step: 0.5, bigStep: 2, decimals: 2,
20169
+ title: 'Gap in px',
20170
+ onSet: function(value) {
20171
+ applyChange({gap: value});
20172
+ },
20173
+ onStep: function(delta) {
20174
+ applyChange({gap: Math.max(0, shown.gap + delta)});
20175
+ },
20176
+ onDone: opts.releaseFocus
20177
+ });
20178
+
20179
+ customRow = El('div').addClass('label-style-row layer-pattern-code-row').appendTo(section);
20180
+ var caption = El('div').addClass('label-style-row-label').appendTo(customRow).text('Code');
20181
+ makeFieldTip(caption,
20182
+ 'The fill-pattern syntax of the -style command.\n' +
20183
+ 'See the command reference for details.');
20184
+ codeInput = El('input').attr('type', 'text').attr('aria-label', 'Pattern code').appendTo(customRow)
20185
+ .on('change', function() {
20186
+ applyCode(codeInput.node().value.trim());
20187
+ });
20188
+ }
20189
+
20190
+ function update() {
20191
+ var ids = opts.getTargetIds();
20192
+ var toggleState = getToggleState(ids);
20193
+ var state = getCommonPatternState(ids);
20194
+ if (customPending && state.type != 'custom') {
20195
+ state = {type: 'custom', code: shown.type == 'custom' ? shown.code : getPendingCode(state)};
20196
+ }
20197
+ shown = state;
20198
+ if (isSimple(state) || state.type == 'custom' && state.code) lastPattern = state;
20199
+ toggle.setState(toggleState);
20200
+ section.classed('collapsed', toggleState == 'off');
20201
+ mixedOption.classed('hidden', state.type != 'mixed');
20202
+ typeSelect.node().value = state.type;
20203
+ colorControl.row.classed('hidden', !isSimple(state));
20204
+ sizeRow.classed('hidden', !isSimple(state));
20205
+ customRow.classed('hidden', state.type != 'custom');
20206
+ if (isSimple(state)) {
20207
+ colorControl.showColor(state.color);
20208
+ angleField.setValue(state.angle);
20209
+ sizeCaption.text(state.type == 'hatches' ? 'Width' : 'Size');
20210
+ sizeField.setValue(state.size);
20211
+ gapField.setValue(state.gap);
20212
+ } else {
20213
+ colorControl.picker.hide();
20214
+ }
20215
+ if (state.type == 'custom' && document.activeElement !== codeInput.node()) {
20216
+ codeInput.node().value = state.code || '';
20217
+ }
20218
+ }
20219
+
20220
+ function selectType(type) {
20221
+ if (type == 'custom') {
20222
+ customPending = true;
20223
+ update();
20224
+ codeInput.node().focus();
20225
+ return;
20226
+ }
20227
+ customPending = false;
20228
+ if (patternTypes.indexOf(type) > -1) {
20229
+ applyControls(getDefaultPatternControls(type, isSimple(shown) ? shown.color : null));
20230
+ }
20231
+ }
20232
+
20233
+ // On gives every feature being styled a pattern: the one the features that
20234
+ // have a pattern agree on, if they do, or else the last one shown, or else a
20235
+ // default hatch. Off takes the pattern off all of them.
20236
+ function setPatternOn(on) {
20237
+ var ids = opts.getTargetIds();
20238
+ var common, pattern;
20239
+ customPending = false;
20240
+ if (!on) {
20241
+ applyToTargets(function() { return ''; }, 'Remove pattern fill');
20242
+ return;
20243
+ }
20244
+ common = getCommonPatternState(ids.filter(hasPattern));
20245
+ pattern = isSimple(common) || common.type == 'custom' ? common :
20246
+ lastPattern || getDefaultPatternControls('hatches');
20247
+ if (pattern.type == 'custom') {
20248
+ applyToTargets(function() { return pattern.code; }, 'Pattern fill');
20249
+ } else {
20250
+ applyControls(pattern);
20251
+ }
20252
+ }
20253
+
20254
+ function getToggleState(ids) {
20255
+ var n = ids.filter(hasPattern).length;
20256
+ return n === 0 ? 'off' : n < ids.length ? 'mixed' : 'on';
20257
+ }
20258
+
20259
+ function hasPattern(id) {
20260
+ var rec = opts.getRecords()[id];
20261
+ return !isBlank(rec && rec['fill-pattern']);
20262
+ }
20263
+
20264
+ function applyChange(change) {
20265
+ if (!isSimple(shown)) return;
20266
+ var o = Object.assign({}, shown, change);
20267
+ if (!isValidPatternControls(o)) {
20268
+ opts.revert();
20269
+ return;
20270
+ }
20271
+ applyControls(o);
20272
+ }
20273
+
20274
+ function applyControls(o) {
20275
+ applyToTargets(function(rec) {
20276
+ return formatFillPattern(o, getPatternBackground(rec && rec.fill));
20277
+ }, 'Pattern fill');
20278
+ }
20279
+
20280
+ function applyCode(code) {
20281
+ if (code && !internal.parsePattern(code)) {
20282
+ codeInput.node().value = shown.code || '';
20283
+ opts.revert();
20284
+ return;
20285
+ }
20286
+ if (!code) customPending = false;
20287
+ applyToTargets(function() { return code; }, code ? 'Pattern fill' : 'Remove pattern fill');
20288
+ }
20289
+
20290
+ function applyToTargets(getCode, title) {
20291
+ var records = opts.getRecords();
20292
+ opts.applyEdits(opts.getTargetIds().map(function(id) {
20293
+ return {id: id, styles: [['fill-pattern', getCode(records[id])]]};
20294
+ }), title);
20295
+ }
20296
+
20297
+ // The pattern the features being styled have in common, as panel settings,
20298
+ // {type: 'custom', code}, {type: 'none'} -- or {type: 'mixed'} if they differ.
20299
+ // Features with the same pattern over different fills agree: each pattern's
20300
+ // background is its own feature's fill.
20301
+ function getCommonPatternState(ids) {
20302
+ var records = opts.getRecords();
20303
+ var cache = {};
20304
+ var firstKey = null, first = null;
20305
+ var rec, cacheKey, state, key;
20306
+ for (var i=0; i<ids.length; i++) {
20307
+ rec = records[ids[i]];
20308
+ cacheKey = (rec && rec['fill-pattern']) + '\t' + (rec && rec.fill);
20309
+ state = cache[cacheKey] || (cache[cacheKey] = readPatternState(rec));
20310
+ key = JSON.stringify(state);
20311
+ if (firstKey === null) {
20312
+ firstKey = key;
20313
+ first = state;
20314
+ } else if (key != firstKey) {
20315
+ return {type: 'mixed'};
20316
+ }
20317
+ }
20318
+ return first || {type: 'none'};
20319
+ }
20320
+
20321
+ // The code the Custom field starts from: the pattern being replaced, so that
20322
+ // choosing Custom is a way to see the code for the settings shown.
20323
+ function getPendingCode(state) {
20324
+ var records = opts.getRecords();
20325
+ var ids = opts.getTargetIds();
20326
+ var rec = ids.length > 0 ? records[ids[0]] : null;
20327
+ return isSimple(state) ? formatFillPattern(state, getPatternBackground(rec && rec.fill)) : '';
20328
+ }
20329
+ }
20330
+
20331
+ function readPatternState(rec) {
20332
+ var code = rec && rec['fill-pattern'];
20333
+ if (isBlank(code)) return {type: 'none'};
20334
+ return getPatternControls(internal.parsePattern(code), rec.fill) ||
20335
+ {type: 'custom', code: String(code)};
20336
+ }
20337
+
20338
+ function isSimple(state) {
20339
+ return patternTypes.indexOf(state.type) > -1;
20340
+ }
20341
+
20342
+ function findStyle(styles, name) {
20343
+ for (var i=0; i<styles.length; i++) {
20344
+ if (styles[i][0] == name) return styles[i][1];
20345
+ }
20346
+ return undefined;
20347
+ }
20348
+
20349
+ function isBlank(val) {
20350
+ return val === undefined || val === null || String(val).trim() === '';
20351
+ }
20352
+
19637
20353
  var savedStylesKey = 'layer_style_presets';
19638
- var styleFields = ['stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'fill', 'fill-opacity'];
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
+ }];
19639
20385
 
19640
20386
  function LayerStyleTool(gui) {
19641
20387
  var parent = gui.container.findChild('.mshp-main-map');
19642
20388
  var panel = El('div').addClass('label-style-panel layer-style-panel rollover').appendTo(parent).hide();
19643
- var title, editingStatus, clearLink, strokeControl, fillControl, strokeWidthField, dashControl, randomFillBtn, presetControl, hit;
20389
+ var title, editingStatus, clearLink, strokeControl, fillControl, strokeWidthField, dashControl, arrowControl, randomFillBtn, presetControl, patternControl, hit;
19644
20390
  var targetLayer = null;
20391
+ // What the arrowhead switch turns on, for lines that have no heads
20392
+ var lastArrow = {shape: 'arrow', position: 'end'};
19645
20393
 
19646
20394
  initPanel();
19647
20395
  hit = gui.map.getHitControl && gui.map.getHitControl();
@@ -19685,6 +20433,7 @@
19685
20433
 
19686
20434
  function turnOn() {
19687
20435
  targetLayer = getActiveLayer();
20436
+ patternControl.reset();
19688
20437
  applyDefaultLineStyle();
19689
20438
  panel.show();
19690
20439
  updateControls();
@@ -19694,6 +20443,8 @@
19694
20443
  panel.hide();
19695
20444
  strokeControl.picker.hide();
19696
20445
  fillControl.picker.hide();
20446
+ patternControl.hidePicker();
20447
+ patternControl.reset();
19697
20448
  targetLayer = null;
19698
20449
  }
19699
20450
 
@@ -19710,8 +20461,8 @@
19710
20461
  // picker's Close button, and the panel's own buttons in the browsers that
19711
20462
  // focus a button on click. Left alone while the user is typing into a
19712
20463
  // field, which a click elsewhere in the panel ends on its own.
19713
- panel.node().addEventListener('click', function() {
19714
- if (isTextInput(document.activeElement)) return;
20464
+ panel.node().addEventListener('click', function(e) {
20465
+ if (isTextInput(document.activeElement) || opensAMenu(e.target)) return;
19715
20466
  releaseFocus();
19716
20467
  });
19717
20468
 
@@ -19727,11 +20478,22 @@
19727
20478
  strokeControl = addColorControl(panel, 'Stroke', 'stroke', '#000000');
19728
20479
  strokeWidthField = addStrokeWidthControl(strokeControl.aside);
19729
20480
  dashControl = addDashArrayControl(panel);
20481
+ arrowControl = addArrowControl(panel);
19730
20482
 
19731
20483
  var buttonRow = El('div').addClass('label-style-row label-panel-button-row').appendTo(panel);
19732
20484
  randomFillBtn = makePanelActionButton(buttonRow, 'Random fills', applyRandomFillColors);
19733
20485
  makePanelActionButton(buttonRow, 'Clear style', clearLayerStyle);
19734
20486
 
20487
+ patternControl = new PatternFillControl(panel, {
20488
+ getRecords: function() {
20489
+ return targetLayer && targetLayer.data ? targetLayer.data.getRecords() : [];
20490
+ },
20491
+ getTargetIds: getTargetIds,
20492
+ applyEdits: runStyleEdits,
20493
+ revert: updateControls,
20494
+ releaseFocus: releaseFocus
20495
+ });
20496
+
19735
20497
  presetControl = new StylePresetControl(panel, {
19736
20498
  storageKey: savedStylesKey,
19737
20499
  type: getStyleType,
@@ -19810,6 +20572,197 @@
19810
20572
  return {row: row, input: input};
19811
20573
  }
19812
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
+
19813
20766
  function releaseFocus() {
19814
20767
  releasePanelFocus(panel.node());
19815
20768
  }
@@ -19824,11 +20777,15 @@
19824
20777
  strokeControl.row.show();
19825
20778
  fillControl.row.classed('hidden', geom != 'polygon');
19826
20779
  dashControl.row.classed('hidden', geom != 'polyline');
20780
+ arrowControl.section.classed('hidden', geom != 'polyline');
19827
20781
  updateColorControl(strokeControl);
19828
20782
  updateColorControl(fillControl);
19829
20783
  updateStrokeWidthControl();
19830
20784
  updateDashArrayControl();
20785
+ if (geom == 'polyline') updateArrowControl();
19831
20786
  randomFillBtn.classed('hidden', geom != 'polygon');
20787
+ patternControl.section.classed('hidden', geom != 'polygon');
20788
+ if (geom == 'polygon') patternControl.update();
19832
20789
  presetControl.render();
19833
20790
  updateSavedStyleControls();
19834
20791
  }
@@ -19892,7 +20849,11 @@
19892
20849
  if (control.field == 'stroke' && strokeWidthIsUnsetForTargets()) {
19893
20850
  styles.push(['stroke-width', 1]);
19894
20851
  }
19895
- runStyleCommand(styles);
20852
+ if (control.field == 'fill') {
20853
+ runStyleEdits(patternControl.getStyleEdits(styles));
20854
+ } else {
20855
+ runStyleCommand(styles);
20856
+ }
19896
20857
  }
19897
20858
 
19898
20859
  function applyStrokeWidthStyle(value) {
@@ -19912,11 +20873,30 @@
19912
20873
  }
19913
20874
 
19914
20875
  function runStyleCommand(styles) {
19915
- var parts = ['-style'];
19916
20876
  releaseFocus();
19917
20877
  syncTargetLayer();
19918
20878
  var ids = getTargetIds();
19919
20879
  if (!gui.console || !targetLayer || ids.length === 0) return;
20880
+ runCommand(formatStyleCommand(styles, ids), 'Style layer');
20881
+ }
20882
+
20883
+ // Per-feature edits ([{id, styles}]), as one -style command for each set of
20884
+ // styles. They are run as one command string, which is one undo step.
20885
+ function runStyleEdits(edits, title) {
20886
+ releaseFocus();
20887
+ syncTargetLayer();
20888
+ if (!gui.console || !targetLayer || edits.length === 0) return;
20889
+ runCommand(formatStyleEditCommands(edits), title || 'Style layer');
20890
+ }
20891
+
20892
+ function formatStyleEditCommands(edits) {
20893
+ return groupStyleEdits(edits).map(function(group) {
20894
+ return formatStyleCommand(group.styles, group.ids);
20895
+ }).join(' ');
20896
+ }
20897
+
20898
+ function formatStyleCommand(styles, ids) {
20899
+ var parts = ['-style'];
19920
20900
  styles.forEach(function(style) {
19921
20901
  parts.push(style[0] + '=' + quoteCommandValue(style[1]));
19922
20902
  });
@@ -19924,7 +20904,7 @@
19924
20904
  if (ids.length < internal.getFeatureCount(targetLayer)) {
19925
20905
  parts.push('ids=' + ids.join(','));
19926
20906
  }
19927
- runCommand(parts.join(' '), 'Style layer');
20907
+ return parts.join(' ');
19928
20908
  }
19929
20909
 
19930
20910
  function applyDefaultLineStyle() {
@@ -19938,13 +20918,21 @@
19938
20918
  }
19939
20919
  }
19940
20920
 
20921
+ // Patterns keep their backgrounds in step with the fills, and the new fills
20922
+ // are not known until -classify has chosen them, so the patterns are
20923
+ // rewritten by an expression that reads each feature's fill when it runs.
19941
20924
  function applyRandomFillColors() {
19942
20925
  var cmd = '-classify colors=random non-adjacent';
20926
+ var patternEdits;
19943
20927
  syncTargetLayer();
19944
20928
  if (!gui.console || !targetLayer || targetLayer.geometry_type != 'polygon') return;
19945
20929
  if (getActiveLayer() != targetLayer) {
19946
20930
  cmd += ' target=' + internal.formatOptionValue(internal.getLayerTargetId(gui.model, targetLayer));
19947
20931
  }
20932
+ patternEdits = patternControl.getRefillExpressionEdits(getAllFeatureIds(targetLayer));
20933
+ if (patternEdits.length > 0) {
20934
+ cmd += ' ' + formatStyleEditCommands(patternEdits);
20935
+ }
19948
20936
  runCommand(cmd, 'Random fill colors');
19949
20937
  }
19950
20938
 
@@ -19963,7 +20951,7 @@
19963
20951
  }
19964
20952
  });
19965
20953
  if (styles.length > 0) {
19966
- runStyleCommand(styles);
20954
+ runStyleEdits(patternControl.getStyleEdits(styles));
19967
20955
  }
19968
20956
  }
19969
20957
 
@@ -19974,14 +20962,24 @@
19974
20962
  addStyleValue(style, 'stroke-opacity', parseOpacityValue(strokeControl.opacity.node().value));
19975
20963
  if (targetLayer && targetLayer.geometry_type == 'polyline') {
19976
20964
  addStyleValue(style, 'stroke-dasharray', getControlValue(dashControl.input));
20965
+ addArrowStyleValues(style);
19977
20966
  }
19978
20967
  if (targetLayer && targetLayer.geometry_type == 'polygon') {
19979
20968
  addStyleValue(style, 'fill', getControlValue(fillControl.input));
19980
20969
  addStyleValue(style, 'fill-opacity', parseOpacityValue(fillControl.opacity.node().value));
20970
+ addStyleValue(style, 'fill-pattern', patternControl.getCode(getControlValue(fillControl.input)));
19981
20971
  }
19982
20972
  return style;
19983
20973
  }
19984
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
+
19985
20983
  function addStyleValue(style, field, value) {
19986
20984
  if (value || value === 0) {
19987
20985
  style[field] = value;
@@ -20192,8 +21190,8 @@
20192
21190
  revert: updateControls,
20193
21191
  release: releaseFocus
20194
21192
  });
20195
- panel.node().addEventListener('click', function() {
20196
- if (isTextInput(document.activeElement)) return;
21193
+ panel.node().addEventListener('click', function(e) {
21194
+ if (isTextInput(document.activeElement) || opensAMenu(e.target)) return;
20197
21195
  releaseFocus();
20198
21196
  });
20199
21197
 
@@ -20447,9 +21445,8 @@
20447
21445
  var expr = createExprInput.node().value.trim();
20448
21446
  var cmd;
20449
21447
  if (!expr || !gui.console) return;
20450
- cmd = createCopyCheckbox.node().checked ?
20451
- '-filter true + name=labels -style label-text=' + quoteCommandValue(expr) :
20452
- '-style label-text=' + quoteCommandValue(expr);
21448
+ cmd = '-labels text=' + quoteCommandValue(expr);
21449
+ if (createCopyCheckbox.node().checked) cmd += ' + name=labels';
20453
21450
  runGuiEditCommand(gui, cmd, {
20454
21451
  title: 'Create labels',
20455
21452
  onSuccess: openLabelStyles
@@ -21434,7 +22431,7 @@
21434
22431
  // hit test, the reposition pass and the selection cue all find labels by those,
21435
22432
  // and a node with no feature behind it must not be found by any of them.
21436
22433
  //
21437
- // @rec: the label's record, as -add-label will write it
22434
+ // @rec: the label's record, as -labels coordinates= will write it
21438
22435
  // @shp: its knots, in the layer's display coordinates
21439
22436
  // Returns a markup string, or '' if there is nothing to draw.
21440
22437
  function renderPendingSymbol(rec, shp, ext, idPrefix) {
@@ -22254,6 +23251,7 @@
22254
23251
  // data.ids = [drawingId];
22255
23252
  // data.id = drawingId;
22256
23253
  data.ids = utils$1.uniq(data.ids.concat([drawingId]));
23254
+ data.drawing_id = drawingId;
22257
23255
  }
22258
23256
  if (pinnedOn) {
22259
23257
  data.pinned = true;
@@ -24181,12 +25179,7 @@
24181
25179
  styleOpts.interactionMode == 'edit_polygons' ||
24182
25180
  styleOpts.interactionMode == 'snip_lines') {
24183
25181
  // special overlay: shape editing mode
24184
- lyr = getOverlayLayer(activeLyr, hitData.ids);
24185
- lyr.gui.style = getLineEditingStyle(hitData);
24186
- if (activeLyr.geometry_type == 'polygon') {
24187
- lyr.gui.style.fillColor = hoverFill;
24188
- }
24189
- return [lyr];
25182
+ return getShapeEditingLayers(activeLyr, hitData);
24190
25183
  }
24191
25184
  layers = [];
24192
25185
  if (styleOpts.interactionMode == 'label') {
@@ -24321,15 +25314,50 @@
24321
25314
  };
24322
25315
  }
24323
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
+
24324
25352
  // style for vertex edit mode
24325
- function getLineEditingStyle(o) {
25353
+ function getLineEditingStyle(o, ids, showVertices) {
24326
25354
  var isVertex = o.hit_type == 'vertex' || o.hit_type == 'disabled';
24327
25355
  return {
24328
- ids: o.ids,
25356
+ ids: ids,
24329
25357
  overlay: true,
24330
25358
  strokeColor: black,
24331
25359
  strokeWidth: 1.2,
24332
- vertices: true,
25360
+ vertices: showVertices,
24333
25361
  vertex_overlay_color: getVertexOverlayColor(o.hit_type),
24334
25362
  vertex_overlay_scale: isVertex ? 2.5 : 2,
24335
25363
  vertex_overlay: o.hit_coordinates || null,
@@ -24530,6 +25558,11 @@
24530
25558
  // pixel distance threshold for hovering near a vertex or segment midpoint
24531
25559
  var HOVER_THRESHOLD$1 = 10;
24532
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
+
24533
25566
  function initLineEditing(gui, ext, hit) {
24534
25567
  var hoverVertexInfo;
24535
25568
  var prevVertexAddedEvent;
@@ -24539,7 +25572,8 @@
24539
25572
  var drawingId = -1; // feature id of path being drawn
24540
25573
  var sessionCount = 0;
24541
25574
  var alert;
24542
- 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
24543
25577
  var _dragging = false;
24544
25578
 
24545
25579
  function active() {
@@ -24559,7 +25593,7 @@
24559
25593
  }
24560
25594
 
24561
25595
  function pencilIsActive() {
24562
- return active() && (cmdKeyDown() || pathDrawing()) && !vertexDragging() && !!pencilPoints;
25596
+ return active() && (cmdKeyDown() || pathDrawing()) && !vertexDragging();
24563
25597
  }
24564
25598
 
24565
25599
  function polygonMode() {
@@ -24596,19 +25630,32 @@
24596
25630
  fullRedraw();
24597
25631
  });
24598
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
+
24599
25641
  gui.on('undo_path_add', function(e) {
24600
25642
  deleteLastPath(hit.getHitTarget());
24601
25643
  clearDrawingInfo();
24602
25644
  });
24603
25645
 
25646
+ // e.points: the vertices that the edit added to the path
24604
25647
  gui.on('redo_path_extend', function(e) {
24605
25648
  var target = hit.getHitTarget();
24606
-
24607
- if (pathDrawing() && prevHoverEvent) {
24608
- updatePathEndpoint(e.p);
24609
- 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();
24610
25657
  } else {
24611
- appendVertex$1(target, e.p);
25658
+ points.forEach(function(p) { appendVertex$1(target, p); });
24612
25659
  }
24613
25660
  if (e.shapes) {
24614
25661
  replaceDrawnShapes(e.shapes);
@@ -24617,11 +25664,13 @@
24617
25664
 
24618
25665
  gui.on('undo_path_extend', function(e) {
24619
25666
  var target = hit.getHitTarget();
24620
- 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++) {
24621
25670
  deleteLastVertex(target);
25671
+ }
25672
+ if (pathDrawing() && prevHoverEvent) {
24622
25673
  updatePathEndpoint(pixToDataCoords(prevHoverEvent.x, prevHoverEvent.y));
24623
- } else {
24624
- deleteLastVertex(target);
24625
25674
  }
24626
25675
  if (e.shapes) {
24627
25676
  replaceDrawnShapes(e.shapes);
@@ -24741,60 +25790,170 @@
24741
25790
  });
24742
25791
 
24743
25792
  gui.map.getMouse().on('dragend', function(e) {
24744
- pencilPoints = []; // re-enable pencil after closing a path
25793
+ finishStroke();
25794
+ blockDrag = false;
24745
25795
  });
24746
25796
 
24747
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
+ }
24748
25802
  if (!pencilIsActive()) {
24749
- if (!pencilPoints) {
24750
- // null points signals that a path was just completed -- block panning
24751
- e.stopPropagation();
24752
- }
24753
25803
  return;
24754
25804
  }
24755
25805
  if (gui.keyboard.spaceIsPressed()) {
24756
25806
  // pan if dragging with spacebar down
24757
- pencilPoints = []; // don't continue previous line after panning
25807
+ finishStroke();
24758
25808
  return;
24759
25809
  }
24760
25810
  e.stopPropagation(); // prevent panning
24761
- hoverVertexInfo = findPathStartInfo(e);
24762
- var xy = [e.x, e.y], xy2;
24763
- var p = pixToDataCoords(e.x, e.y);
24764
- var addedToPath = true;
24765
- if (!pathDrawing()) {
24766
- pencilPoints = [xy];
24767
- startNewPath(p);
24768
- } else if (pencilPoints.length == 0) {
24769
- // start pencil-drawing when a path is started
24770
- pencilPoints = [xy];
24771
- extendCurrentPath(p);
24772
- } else if (polygonMode() && hoverVertexInfo && pencilPoints.length > 2) {
24773
- // close path
24774
- p = hoverVertexInfo.point;
24775
- appendVertex$1(hit.getHitTarget(), p);
24776
- extendCurrentPath(p);
24777
- pencilPoints = null; // stop drawing
24778
- } else if (pencilPoints.length >= 2 && pointExceedsTolerance(xy, pencilPoints, 1.2)) {
24779
- xy2 = pencilPoints.pop();
24780
- p = pixToDataCoords(xy2[0], xy2[1]);
24781
- extendCurrentPath(p);
24782
- // kludgy way to get a smoother line (could be better)
24783
- // not this pencilPoints = [getAvgPoint(pencilPoints), xy2, xy];
24784
- // not this pencilPoints = pencilPoints.slice(-2).concat([xy2, xy]);
24785
- 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;
24786
25821
  } else {
24787
- // skip this point, update the hover line
24788
- pencilPoints.push(xy);
24789
- updatePathEndpoint(p);
24790
- addedToPath = false;
24791
- }
24792
- if (addedToPath) {
24793
- //
24794
- prevVertexAddedEvent = e;
25822
+ addStrokeSample(e);
24795
25823
  }
24796
25824
  }, null, 3); // higher priority than hit control
24797
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
+
24798
25957
  hit.on('drag', function(e) {
24799
25958
  if (!vertexDragging() || pathDrawing()) {
24800
25959
  return;
@@ -24874,7 +26033,8 @@
24874
26033
  if (detectDoubleClick(e)) return; // ignore second click of a dblclick
24875
26034
  var p = pixToDataCoords(e.x, e.y);
24876
26035
  if (pathDrawing()) {
24877
- 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');
24878
26038
  } else if (hoverVertexInfo?.type == 'interpolated') {
24879
26039
  // don't start new path if hovering along a segment -- this is
24880
26040
  // likely to be an attempt to add a new vertex, not start a new path
@@ -24974,6 +26134,11 @@
24974
26134
 
24975
26135
  function finishCurrentPath() {
24976
26136
  if (!pathDrawing()) return;
26137
+ if (stroke) {
26138
+ // finishing mid-drag (e.g. with the Enter key)
26139
+ finishStroke();
26140
+ blockDrag = true;
26141
+ }
24977
26142
  var target = hit.getHitTarget();
24978
26143
  if (getLastArcLength(target) <= 2) { // includes hover point
24979
26144
  // deleteLastPath(target);
@@ -24997,15 +26162,21 @@
24997
26162
  updateCursor();
24998
26163
  }
24999
26164
 
25000
- // p: [x, y] source data coordinates of new point on path
25001
- 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) {
25002
26172
  var target = hit.getHitTarget();
25003
26173
  var shapes1, shapes2;
25004
- // finish the path if a vertex is selected (but not an interpolated point)
25005
- var finish = hoverVertexInfo?.type == 'vertex';
25006
26174
  if (getLastArcLength(target) < 2) {
25007
26175
  stop$1('Defective path');
25008
26176
  }
26177
+ for (var i=(placed || 0) + 1; i<points.length; i++) {
26178
+ appendVertex$1(target, points[i]);
26179
+ }
25009
26180
  if (finish && polygonMode()) {
25010
26181
  shapes1 = target.shapes.slice(initialShapeCount);
25011
26182
  try {
@@ -25017,12 +26188,12 @@
25017
26188
  }
25018
26189
  if (shapes2) {
25019
26190
  replaceDrawnShapes(shapes2);
25020
- gui.dispatchEvent('path_extend', {target, p, shapes1, shapes2});
26191
+ gui.dispatchEvent('path_extend', {target, points, shapes1, shapes2});
25021
26192
  clearDrawingInfo();
25022
26193
  fullRedraw();
25023
26194
  } else {
25024
- appendVertex$1(target, p);
25025
- 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});
25026
26197
  hit.triggerChangeEvent(); // trigger overlay redraw
25027
26198
  }
25028
26199
  }
@@ -25923,6 +27094,112 @@
25923
27094
  return true;
25924
27095
  }
25925
27096
 
27097
+ // The bold stretches of a label being edited, as [start, end) offsets into the
27098
+ // text the user edits, and how they follow the text as it changes.
27099
+ //
27100
+ // The editor keeps these beside the textarea's value rather than as markup in
27101
+ // it, so that the caret and the selection index the text the user sees. The
27102
+ // markup they come from and go back to is read and written by
27103
+ // readLabelValue() and writeLabelValue() in gui-label-text.mjs.
27104
+ //
27105
+ // Every function returns a new, normalized list -- sorted, non-empty, and with
27106
+ // no two ranges touching -- and leaves its argument alone.
27107
+
27108
+ function isBoldAt(ranges, i) {
27109
+ for (var j = 0; j < ranges.length; j++) {
27110
+ if (i >= ranges[j][0] && i < ranges[j][1]) return true;
27111
+ }
27112
+ return false;
27113
+ }
27114
+
27115
+ // Whether every character in [start, end) is bold. False for an empty range.
27116
+ function rangeIsAllBold(ranges, start, end) {
27117
+ var pos = start;
27118
+ if (!(end > start)) return false;
27119
+ for (var j = 0; j < ranges.length && pos < end; j++) {
27120
+ if (ranges[j][1] <= pos) continue;
27121
+ if (ranges[j][0] > pos) return false;
27122
+ pos = ranges[j][1];
27123
+ }
27124
+ return pos >= end;
27125
+ }
27126
+
27127
+ // [start, end) set bold (@on) or not.
27128
+ function setBoldRange(ranges, start, end, on) {
27129
+ var out = [];
27130
+ ranges.forEach(function(r) {
27131
+ if (r[1] <= start || r[0] >= end) {
27132
+ out.push([r[0], r[1]]);
27133
+ return;
27134
+ }
27135
+ if (r[0] < start) out.push([r[0], start]);
27136
+ if (r[1] > end) out.push([end, r[1]]);
27137
+ });
27138
+ if (on && end > start) out.push([start, end]);
27139
+ return normalize(out);
27140
+ }
27141
+
27142
+ // What pressing Bold does to [start, end): unbolds a range that is all bold
27143
+ // and bolds any other, including one that is partly bold -- the convention of
27144
+ // every word processor, and the reading that leaves the range one weight.
27145
+ function toggleBoldRange(ranges, start, end) {
27146
+ return setBoldRange(ranges, start, end, !rangeIsAllBold(ranges, start, end));
27147
+ }
27148
+
27149
+ // The ranges for @newText, given the ranges they were for @oldText.
27150
+ //
27151
+ // The edit is found as the text between the longest common prefix and suffix,
27152
+ // which is exact for everything a textarea does in one step -- typing,
27153
+ // deleting, pasting over a selection, an IME composition -- and still gives a
27154
+ // sensible answer for its own undo, which can replace a larger stretch at once.
27155
+ //
27156
+ // Inserted text takes the weight of the character before it, so typing on at
27157
+ // the end of a bold word stays bold and typing after it does not; at the start
27158
+ // of the text it takes the weight of the first character.
27159
+ function updateBoldForEdit(ranges, oldText, newText) {
27160
+ var edit, inherit, mapped;
27161
+ if (ranges.length === 0 || oldText === newText) return normalize(ranges);
27162
+ edit = findEdit(oldText, newText);
27163
+ inherit = edit.start > 0 ? isBoldAt(ranges, edit.start - 1) :
27164
+ isBoldAt(ranges, edit.start + edit.removed);
27165
+ mapped = ranges.map(function(r) {
27166
+ return [mapOffset(r[0], edit), mapOffset(r[1], edit)];
27167
+ });
27168
+ return setBoldRange(normalize(mapped), edit.start, edit.start + edit.inserted,
27169
+ inherit);
27170
+ }
27171
+
27172
+ function sameBoldRanges(a, b) {
27173
+ if (a.length != b.length) return false;
27174
+ for (var i = 0; i < a.length; i++) {
27175
+ if (a[i][0] !== b[i][0] || a[i][1] !== b[i][1]) return false;
27176
+ }
27177
+ return true;
27178
+ }
27179
+
27180
+ // {start, removed, inserted}: characters [start, start + removed) of the old
27181
+ // text became [start, start + inserted) of the new one.
27182
+ function findEdit(a, b) {
27183
+ var max = Math.min(a.length, b.length);
27184
+ var pre = 0, suf = 0;
27185
+ while (pre < max && a.charCodeAt(pre) === b.charCodeAt(pre)) pre++;
27186
+ while (suf < max - pre &&
27187
+ a.charCodeAt(a.length - 1 - suf) === b.charCodeAt(b.length - 1 - suf)) suf++;
27188
+ return {start: pre, removed: a.length - pre - suf, inserted: b.length - pre - suf};
27189
+ }
27190
+
27191
+ // A boundary inside the removed stretch collapses to its start. One after it
27192
+ // moves by the change in length.
27193
+ function mapOffset(x, edit) {
27194
+ if (x <= edit.start) return x;
27195
+ if (x >= edit.start + edit.removed) return x - edit.removed + edit.inserted;
27196
+ return edit.start;
27197
+ }
27198
+
27199
+ function normalize(ranges) {
27200
+ return internal.svg.normalizeBoldRanges(ranges);
27201
+ }
27202
+
25926
27203
  // Caret and selection geometry, computed from the SVG character position APIs.
25927
27204
  //
25928
27205
  // The APIs behave identically on <text> and on <text><textPath>, so this code
@@ -26242,16 +27519,19 @@
26242
27519
  // so the tool cannot do this at its own call sites.
26243
27520
  self.open = function(target, id, opts) {
26244
27521
  var rec = getRecord(target, id);
27522
+ var value;
26245
27523
  if (!rec) return false;
26246
27524
  self.close();
27525
+ value = readLabelValue(rec['label-text']);
26247
27526
  session = {
26248
27527
  target: target,
26249
27528
  id: id,
26250
27529
  pending: null,
26251
27530
  created: false,
26252
27531
  onClose: opts && opts.onClose || null,
26253
- startText: decodeLabelText(rec['label-text']),
26254
- startBreaks: readSoftBreaks(rec['label-text']).breaks,
27532
+ startText: value.text,
27533
+ startBreaks: value.breaks,
27534
+ startBold: value.bold,
26255
27535
  nodes: null
26256
27536
  };
26257
27537
  startSession();
@@ -26297,6 +27577,7 @@
26297
27577
  onClose: opts.onClose || null,
26298
27578
  startText: '',
26299
27579
  startBreaks: [],
27580
+ startBold: [],
26300
27581
  nodes: null
26301
27582
  };
26302
27583
  startSession();
@@ -26305,6 +27586,7 @@
26305
27586
 
26306
27587
  function startSession() {
26307
27588
  session.text = session.startText;
27589
+ session.bold = session.startBold;
26308
27590
  getTextarea().value = session.text;
26309
27591
  setCaret(session.text.length, session.text.length);
26310
27592
  focusTextarea();
@@ -26320,6 +27602,74 @@
26320
27602
  self.refresh();
26321
27603
  }
26322
27604
 
27605
+ // The text a Bold item in the context menu would act on, as {start, end,
27606
+ // bold}, where @bold says whether all of it is bold already; or null. That is
27607
+ // the selection, or with nothing selected, the word under @p -- read when the
27608
+ // menu opens, because by the time an item is chosen the pointer is on the
27609
+ // menu rather than the label.
27610
+ self.getBoldTargetAtPoint = function(p) {
27611
+ var start = getCaretStart(), end = getCaretEnd();
27612
+ var range;
27613
+ if (!session) return null;
27614
+ if (!(end > start)) {
27615
+ start = getEditIndexAtPoint(p);
27616
+ if (start < 0) return null;
27617
+ range = getWordRange(session.text, start);
27618
+ start = range[0];
27619
+ end = range[1];
27620
+ }
27621
+ if (!(end > start)) return null;
27622
+ return {start: start, end: end, bold: rangeIsAllBold(session.bold, start, end)};
27623
+ };
27624
+
27625
+ // Bolds the selected text, or unbolds it if it is all bold already; or the
27626
+ // text from @start to @end, which is selected first. The text stays
27627
+ // selected, so that a second press takes it back.
27628
+ self.toggleBold = function(start, end) {
27629
+ if (!session) return;
27630
+ if (start >= 0 && end > start) {
27631
+ setCaret(start, end);
27632
+ } else {
27633
+ start = getCaretStart();
27634
+ end = getCaretEnd();
27635
+ }
27636
+ if (!(end > start)) return;
27637
+ session.bold = toggleBoldRange(session.bold, start, end);
27638
+ self.refresh();
27639
+ };
27640
+
27641
+ // Selects the text between two points in the label's own coordinate space,
27642
+ // for a drag across it. The selection runs from @p0 towards @p1, so that
27643
+ // shift-arrow then extends it from the end the pointer let go of.
27644
+ self.selectBetweenPoints = function(p0, p1) {
27645
+ var a = getEditIndexAtPoint(p0);
27646
+ var b = getEditIndexAtPoint(p1);
27647
+ if (a < 0 || b < 0) return false;
27648
+ setCaret(Math.min(a, b), Math.max(a, b), b < a ? 'backward' : 'forward');
27649
+ redrawCaret();
27650
+ return true;
27651
+ };
27652
+
27653
+ // Selects the word under @p, for a double-click inside the text.
27654
+ self.selectWordAtPoint = function(p) {
27655
+ var i = getEditIndexAtPoint(p);
27656
+ var range;
27657
+ if (i < 0) return false;
27658
+ range = getWordRange(session.text, i);
27659
+ setCaret(range[0], range[1]);
27660
+ redrawCaret();
27661
+ return true;
27662
+ };
27663
+
27664
+ function getEditIndexAtPoint(p) {
27665
+ var provider, layout, i;
27666
+ if (!session || !session.nodes) return -1;
27667
+ provider = getProvider(session.nodes);
27668
+ layout = getLayout(session);
27669
+ i = getCaretIndexAtPoint(provider, p, getRenderedLength(session.text, layout));
27670
+ return i < 0 ? -1 : getEditIndex(session.text, i, layout);
27671
+ }
27672
+
26323
27673
  self.isOpen = function() {
26324
27674
  return !!session;
26325
27675
  };
@@ -26350,6 +27700,7 @@
26350
27700
  // arriving just after a redraw is holding one of those.
26351
27701
  if (session.pending && node.closest &&
26352
27702
  node.closest('.label-edit-pending')) return true;
27703
+ if (session.nodes && session.nodes.symbol.contains(node)) return true;
26353
27704
  return !!groups && (groups.hit.contains(node) || groups.back.contains(node) ||
26354
27705
  groups.front.contains(node));
26355
27706
  };
@@ -26408,7 +27759,7 @@
26408
27759
  if (i < 0) return false;
26409
27760
  i = getEditIndex(session.text, i, layout);
26410
27761
  setCaret(i, i);
26411
- drawOverlay(session);
27762
+ redrawCaret();
26412
27763
  return true;
26413
27764
  };
26414
27765
 
@@ -26442,7 +27793,7 @@
26442
27793
  var blank = textHasNoGlyphs(o.text);
26443
27794
  var breaks = getBreaks(o);
26444
27795
  if (o.pending) {
26445
- if (!blank) o.pending.create(insertSoftBreaks(o.text, breaks));
27796
+ if (!blank) o.pending.create(writeLabelValue(o.text, breaks, o.bold));
26446
27797
  return;
26447
27798
  }
26448
27799
  if (blank) {
@@ -26450,8 +27801,9 @@
26450
27801
  removeLabel(o);
26451
27802
  return;
26452
27803
  }
26453
- if (o.text === o.startText && sameSoftBreaks(breaks, o.startBreaks)) return;
26454
- runGuiEditCommand(gui, getLabelTextCommand(insertSoftBreaks(o.text, breaks),
27804
+ if (o.text === o.startText && sameSoftBreaks(breaks, o.startBreaks) &&
27805
+ sameBoldRanges(o.bold, o.startBold)) return;
27806
+ runGuiEditCommand(gui, getLabelTextCommand(writeLabelValue(o.text, breaks, o.bold),
26455
27807
  o.id, o.target.name), {
26456
27808
  title: 'Label text'
26457
27809
  });
@@ -26464,7 +27816,7 @@
26464
27816
  var rec;
26465
27817
  if (!shp || shp.length > 1) return [];
26466
27818
  rec = o.pending ? o.pending.getStyle() : getRecord(o.target, o.id);
26467
- return getSoftBreaks(o.text, rec);
27819
+ return getSoftBreaks(o.text, rec, o.bold);
26468
27820
  }
26469
27821
 
26470
27822
  // Deletes the label's feature, geometry and all.
@@ -26551,7 +27903,7 @@
26551
27903
  var rec = Object.assign({}, o.pending.getStyle());
26552
27904
  // With its soft breaks, so that what is aligned and what a callout meets
26553
27905
  // is the block as wrapped
26554
- rec['label-text'] = encodeLabelText(insertSoftBreaks(o.text, getBreaks(o)));
27906
+ rec['label-text'] = encodeLabelText(writeLabelValue(o.text, getBreaks(o), o.bold));
26555
27907
  // No need to expand label-pos here, or to measure its text: the renderer
26556
27908
  // resolves the position and asks for the width it needs, so a pending
26557
27909
  // label is laid out by exactly the same code as a committed one.
@@ -26599,11 +27951,15 @@
26599
27951
  // - Line breaks. An empty <tspan> lays out nothing, so a line just opened
26600
27952
  // with Enter had no position for the caret to move to. The placeholder in
26601
27953
  // getRenderedLines() gives it one.
27954
+ //
27955
+ // Bold stretches are <tspan>s inside the line they fall on. The character
27956
+ // position APIs count the characters of descendant elements in document
27957
+ // order, so the rendered indexes stay the edited ones whatever the runs are.
26602
27958
  function writeText(o) {
26603
27959
  var content = o.nodes.content;
26604
27960
  var breaks = getBreaks(o);
26605
- var written = o.text + '\u0000' + breaks.join(',');
26606
- var lines, i, tspan;
27961
+ var written = o.text + '\u0000' + breaks.join(',') + '\u0000' + o.bold.join(';');
27962
+ var lines, full, pos, i, tspan;
26607
27963
  // refresh() runs on every map render, which during a pan is every frame;
26608
27964
  // rebuilding text nodes that already say the right thing is pure waste
26609
27965
  if (o.writtenTo === content && o.writtenText === written) return;
@@ -26617,23 +27973,53 @@
26617
27973
  // or from a label that was multi-line before it was given a path -- and
26618
27974
  // turning them into spaces is what export does too.
26619
27975
  if (getLayout(o) === LINES_JOINED) {
26620
- content.appendChild(document.createTextNode(getRenderedContent(o.text)));
27976
+ full = getRenderedContent(o.text);
27977
+ appendRuns(content, full, o.bold, 0, full.length);
26621
27978
  return;
26622
27979
  }
26623
27980
  // A soft break keeps the space it broke at at the end of its line, where
26624
27981
  // the rendered label drops it: here every typed character has to be one
26625
27982
  // the caret can sit beside.
27983
+ //
27984
+ // The lines laid end to end are the edited text character for character --
27985
+ // each hard break is the placeholder that opens the line after it -- so a
27986
+ // line's offset in the one is its offset in the other.
26626
27987
  lines = getRenderedLines(o.text, breaks);
26627
- content.appendChild(document.createTextNode(lines[0]));
27988
+ full = lines.join('');
27989
+ pos = lines[0].length;
27990
+ appendRuns(content, full, o.bold, 0, pos);
26628
27991
  for (i = 1; i < lines.length; i++) {
26629
27992
  tspan = document.createElementNS(SVG_NS$3, 'tspan');
26630
27993
  tspan.setAttribute('x', o.nodes.text.getAttribute('x') || 0);
26631
27994
  tspan.setAttribute('dy', getLineHeight(o));
26632
- tspan.appendChild(document.createTextNode(lines[i]));
27995
+ appendRuns(tspan, full, o.bold, pos, pos + lines[i].length);
27996
+ pos += lines[i].length;
26633
27997
  content.appendChild(tspan);
26634
27998
  }
26635
27999
  }
26636
28000
 
28001
+ // Characters [start, end) of @str into @parent, the bold ones in bold
28002
+ // <tspan>s and the rest as text. An empty stretch still gets a text node, as
28003
+ // an empty line always did.
28004
+ function appendRuns(parent, str, bold, start, end) {
28005
+ var runs = internal.svg.getBoldRuns(str, bold, start, end);
28006
+ var tspan;
28007
+ if (runs.length === 0) {
28008
+ parent.appendChild(document.createTextNode(''));
28009
+ return;
28010
+ }
28011
+ runs.forEach(function(run) {
28012
+ if (!run.bold) {
28013
+ parent.appendChild(document.createTextNode(run.text));
28014
+ return;
28015
+ }
28016
+ tspan = document.createElementNS(SVG_NS$3, 'tspan');
28017
+ tspan.setAttribute('font-weight', internal.svg.LABEL_BOLD_WEIGHT);
28018
+ tspan.appendChild(document.createTextNode(run.text));
28019
+ parent.appendChild(tspan);
28020
+ });
28021
+ }
28022
+
26637
28023
  // Cached per session: asking costs a record lookup and a geometry test, and
26638
28024
  // the answer cannot change while a session is open.
26639
28025
  function getLayout(o) {
@@ -26759,18 +28145,33 @@
26759
28145
  return el;
26760
28146
  }
26761
28147
 
28148
+ // Updated in place rather than rebuilt. Every caret move redraws the
28149
+ // overlay, and a click is one: a region replaced by the first click of a
28150
+ // double-click takes the node the button went down on out of the document,
28151
+ // and the browser then fires neither the click nor the dblclick.
26762
28152
  function drawHitRegion(o, g, box, caret, pathId) {
26763
- var el;
26764
- clear(g);
26765
- if (box) g.appendChild(rect(box, 'label-edit-hit-area'));
26766
- if (!pathId) return;
28153
+ var area = g.querySelector('.label-edit-hit-area');
28154
+ var line = g.querySelector('.label-edit-hit-baseline');
28155
+ if (box && area) {
28156
+ setRect(area, box);
28157
+ } else if (box) {
28158
+ g.insertBefore(rect(box, 'label-edit-hit-area'), g.firstChild);
28159
+ } else if (area) {
28160
+ g.removeChild(area);
28161
+ }
28162
+ if (!pathId) {
28163
+ if (line) g.removeChild(line);
28164
+ return;
28165
+ }
26767
28166
  // A thickened, transparent copy of the baseline: about one em wide, so that
26768
28167
  // the region follows the curve rather than boxing it.
26769
- el = document.createElementNS(SVG_NS$3, 'use');
26770
- el.setAttribute('href', '#' + pathId);
26771
- el.setAttribute('class', 'label-edit-hit-baseline');
26772
- el.setAttribute('stroke-width', caret ? caret.ascent + caret.descent : 12);
26773
- g.appendChild(el);
28168
+ if (!line) {
28169
+ line = document.createElementNS(SVG_NS$3, 'use');
28170
+ line.setAttribute('class', 'label-edit-hit-baseline');
28171
+ g.appendChild(line);
28172
+ }
28173
+ line.setAttribute('href', '#' + pathId);
28174
+ line.setAttribute('stroke-width', caret ? caret.ascent + caret.descent : 12);
26774
28175
  }
26775
28176
 
26776
28177
  function getLabelPathId(nodes) {
@@ -26863,12 +28264,16 @@
26863
28264
 
26864
28265
  function rect(box, className) {
26865
28266
  var el = document.createElementNS(SVG_NS$3, 'rect');
28267
+ setRect(el, box);
28268
+ el.setAttribute('class', className);
28269
+ return el;
28270
+ }
28271
+
28272
+ function setRect(el, box) {
26866
28273
  el.setAttribute('x', box.x);
26867
28274
  el.setAttribute('y', box.y);
26868
28275
  el.setAttribute('width', box.width);
26869
28276
  el.setAttribute('height', box.height);
26870
- el.setAttribute('class', className);
26871
- return el;
26872
28277
  }
26873
28278
 
26874
28279
  function caretLine(caret) {
@@ -26898,6 +28303,7 @@
26898
28303
 
26899
28304
  textarea.addEventListener('input', function() {
26900
28305
  if (!session) return;
28306
+ session.bold = updateBoldForEdit(session.bold, session.text, textarea.value);
26901
28307
  session.text = textarea.value;
26902
28308
  self.refresh();
26903
28309
  });
@@ -26922,6 +28328,13 @@
26922
28328
  return !e.isComposing && e.keyCode != 229;
26923
28329
  }
26924
28330
 
28331
+ // Cmd-B on a Mac, Ctrl-B elsewhere, as in every text editor. A textarea
28332
+ // has no default for it to replace.
28333
+ function isBoldKey(e) {
28334
+ if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey) return false;
28335
+ return (e.key || '').toLowerCase() == 'b';
28336
+ }
28337
+
26925
28338
  textarea.addEventListener('keydown', function(e) {
26926
28339
  if (!session) return;
26927
28340
  // The GUI's global key handlers are told to keep out of the way by
@@ -26948,6 +28361,10 @@
26948
28361
  e.preventDefault();
26949
28362
  e.stopPropagation();
26950
28363
  self.close();
28364
+ } else if (isBoldKey(e)) {
28365
+ e.preventDefault();
28366
+ e.stopPropagation();
28367
+ self.toggleBold();
26951
28368
  } else {
26952
28369
  e.stopPropagation();
26953
28370
  }
@@ -27001,10 +28418,11 @@
27001
28418
  return textarea ? textarea.selectionEnd : 0;
27002
28419
  }
27003
28420
 
27004
- function setCaret(start, end) {
28421
+ // @direction: 'forward' (the default) or 'backward', which end of the
28422
+ // selection shift-arrow moves
28423
+ function setCaret(start, end, direction) {
27005
28424
  if (!textarea) return;
27006
- textarea.selectionStart = start;
27007
- textarea.selectionEnd = end;
28425
+ textarea.setSelectionRange(start, end, direction || 'forward');
27008
28426
  }
27009
28427
  }
27010
28428
 
@@ -27697,7 +29115,7 @@
27697
29115
  }
27698
29116
 
27699
29117
  // The label tool: arming a label type, placing an anchor or a curve, turning
27700
- // the result into an -add-label command, and handing the new label to the
29118
+ // the result into a -labels coordinates= command, and handing the new label to the
27701
29119
  // in-place text editor.
27702
29120
  //
27703
29121
  // Creating a label and editing one are one gesture, not two: a click leaves a
@@ -27761,6 +29179,14 @@
27761
29179
  // The mouseup that ended the last block drag, or null. A short one is
27762
29180
  // followed by a click from the same mouseup, which it has already handled.
27763
29181
  var blockRelease = null;
29182
+ // A drag across the text of the label being typed into, which selects the
29183
+ // characters it passes over: {start}, where start is the pointer's position
29184
+ // at the press, filled in by the first drag event.
29185
+ var selectDrag = null;
29186
+ // The mouseup that ended the last select drag, or null, for the same reason
29187
+ // as blockRelease: the click that follows it would put the caret down and
29188
+ // lose the selection just made.
29189
+ var selectRelease = null;
27764
29190
  var editor = new LabelEditor(gui, ext);
27765
29191
  var selection = new LabelSelection(gui, ext, hit, function() {
27766
29192
  return editor.isOpen() ? editor.getFeatureId() : -1;
@@ -27790,7 +29216,7 @@
27790
29216
  // tools do on entry as well (`addEmptyLayer()` in `gui-edit-points.mjs` and
27791
29217
  // `gui-draw-lines2.mjs`). It is named rather than left unnamed, unlike
27792
29218
  // theirs: it is the layer `getLabelTarget()` would have created for a label
27793
- // anyway, and naming it is what lets the `-add-label` commands name their
29219
+ // anyway, and naming it is what lets the `-labels` commands name their
27794
29220
  // target and so replay from the session history.
27795
29221
  //
27796
29222
  // Called before entering the tool's own mode, because getInitialTool() reads
@@ -27840,6 +29266,8 @@
27840
29266
  handleDrag = null;
27841
29267
  releasedDrag = null;
27842
29268
  blockRelease = null;
29269
+ selectDrag = null;
29270
+ selectRelease = null;
27843
29271
  shownHandles = null;
27844
29272
  clearBlockDrag();
27845
29273
  hoverHandle = null;
@@ -27966,6 +29394,10 @@
27966
29394
  blockRelease = null;
27967
29395
  return;
27968
29396
  }
29397
+ if (selectRelease && getDomEvent(e) == selectRelease) {
29398
+ selectRelease = null;
29399
+ return;
29400
+ }
27969
29401
  if (armed == 'path' && drawingCurve()) {
27970
29402
  extendCurve(pixToMapCoords(e.x, e.y));
27971
29403
  return;
@@ -28038,9 +29470,34 @@
28038
29470
  e.flipLabel = getFlipAction(target, id);
28039
29471
  }
28040
29472
  }
29473
+ if (target && editor.isOpen() && clickIsOnEditedLabel(e)) {
29474
+ addBoldAction(e, target);
29475
+ }
28041
29476
  gui.contextMenu.open(e, target);
28042
29477
  });
28043
29478
 
29479
+ // Bold for the words being typed: the selection, or with nothing selected,
29480
+ // the word that was right-clicked. The item says what it will do, since a
29481
+ // menu has no pressed state to show that the words are bold already. There
29482
+ // is none in a font with no bold face (see fontHasBoldFace()).
29483
+ function addBoldAction(e, target) {
29484
+ var range = editor.getBoldTargetAtPoint(getLabelSpacePoint(editor.getAnchorCoords(), e));
29485
+ if (!range || !fontHasBoldFace(getEditedFontName(target))) return;
29486
+ e.boldText = {
29487
+ label: range.bold ? 'remove bold' : 'bold text',
29488
+ run: function() { editor.toggleBold(range.start, range.end); }
29489
+ };
29490
+ }
29491
+
29492
+ // The font the label being typed into is drawn in: its own, or for a label
29493
+ // not created yet, the one the panel will give it.
29494
+ function getEditedFontName(target) {
29495
+ var id = editor.getFeatureId();
29496
+ var rec = id > -1 ? getRecord(target, id) : getNewLabelStyle(gui);
29497
+ var font = rec && rec['font-family'];
29498
+ return font || (id > -1 ? getDefaultFontName() : getNewLabelFontName());
29499
+ }
29500
+
28044
29501
  // The label a right-click was aimed at, or -1.
28045
29502
  //
28046
29503
  // A label being typed into is not a hit target -- the editor's own overlay
@@ -28296,7 +29753,16 @@
28296
29753
  // takes a drag -- everything else over a label is left alone so that the map
28297
29754
  // still pans, which is why these handlers stop the event themselves.
28298
29755
  hit.on('dragstart', function(e) {
28299
- if (!active() || drawingCurve() || editor.isOpen()) return;
29756
+ if (!active() || drawingCurve()) return;
29757
+ // With a label open for typing, a drag across its text selects some of it,
29758
+ // as it would in any text field. Anywhere else the map still pans.
29759
+ if (editor.isOpen()) {
29760
+ if (clickIsOnEditedLabel(e)) {
29761
+ selectDrag = {start: null};
29762
+ consumeDrag(e);
29763
+ }
29764
+ return;
29765
+ }
28300
29766
  // The handle comes from the last hover, not from testing the pointer here.
28301
29767
  // dragstart arrives with the pointer already moved off the handle -- by the
28302
29768
  // first mousemove that made it a drag, which on a quick gesture is further
@@ -28321,6 +29787,22 @@
28321
29787
  consumeDrag(e);
28322
29788
  });
28323
29789
 
29790
+ // The selection runs from where the button went down, which dragstart does
29791
+ // not report -- it arrives after the first move -- but every drag event
29792
+ // carries its distance from the press.
29793
+ function updateSelectDrag(e) {
29794
+ var anchor = editor.getAnchorCoords();
29795
+ if (!editor.isOpen() || !anchor) {
29796
+ selectDrag = null;
29797
+ return;
29798
+ }
29799
+ if (!selectDrag.start) {
29800
+ selectDrag.start = {x: e.x - (e.dragX || 0), y: e.y - (e.dragY || 0)};
29801
+ }
29802
+ editor.selectBetweenPoints(getLabelSpacePoint(anchor, selectDrag.start),
29803
+ getLabelSpacePoint(anchor, e));
29804
+ }
29805
+
28324
29806
  // handle: {id, index, point, pointer} from findHandle(), or the same shape
28325
29807
  // made up for a drag that grabbed a label somewhere other than its knot
28326
29808
  function beginKnotDrag(handle) {
@@ -28346,6 +29828,11 @@
28346
29828
 
28347
29829
  hit.on('drag', function(e) {
28348
29830
  var shp, p;
29831
+ if (selectDrag) {
29832
+ consumeDrag(e);
29833
+ updateSelectDrag(e);
29834
+ return;
29835
+ }
28349
29836
  if (textDrag) {
28350
29837
  consumeDrag(e);
28351
29838
  updateTextDrag(e);
@@ -28380,6 +29867,12 @@
28380
29867
 
28381
29868
  hit.on('dragend', function(e) {
28382
29869
  var o = drag;
29870
+ if (selectDrag) {
29871
+ selectDrag = null;
29872
+ selectRelease = getDomEvent(e);
29873
+ consumeDrag(e);
29874
+ return;
29875
+ }
28383
29876
  if (textDrag) {
28384
29877
  o = textDrag;
28385
29878
  textDrag = null;
@@ -29020,9 +30513,9 @@
29020
30513
  selection.refresh(true);
29021
30514
  }
29022
30515
 
29023
- // Turns the previewed offset into one -style.
30516
+ // Turns the previewed offset into one -labels.
29024
30517
  //
29025
- // Only the label under the pointer moves, even with several selected: -style
30518
+ // Only the label under the pointer moves, even with several selected: -labels
29026
30519
  // writes one value to every id it is given, so a group drag would set them
29027
30520
  // all to the same absolute offset rather than nudging each by its own delta.
29028
30521
  function commitOffsetDrag(o) {
@@ -29166,7 +30659,7 @@
29166
30659
  }
29167
30660
 
29168
30661
  function dragging() {
29169
- return !!(drag || textDrag || handleDrag || blockDrag);
30662
+ return !!(drag || textDrag || handleDrag || blockDrag || selectDrag);
29170
30663
  }
29171
30664
 
29172
30665
  // The exception is about the pointer being on the glyphs, not about the mode:
@@ -29284,6 +30777,12 @@
29284
30777
  resetHandle(hoverHandle);
29285
30778
  return;
29286
30779
  }
30780
+ // Inside the text being typed, a double-click selects a word -- which is
30781
+ // the quickest way to pick the words to set in bold.
30782
+ if (editor.isOpen() && clickIsOnEditedLabel(e)) {
30783
+ editor.selectWordAtPoint(getLabelSpacePoint(editor.getAnchorCoords(), e));
30784
+ return;
30785
+ }
29287
30786
  // Double-clicking a label reaches straight into its text, which is the
29288
30787
  // gesture most editors use and saves selecting it first. The single-click
29289
30788
  // route got there already if the label was the selection; this covers the
@@ -29576,10 +31075,20 @@
29576
31075
  // array of field names of relevant svg display properties
29577
31076
  fields = getStyleFields(lyr).filter(function(f) {return f in styleIndex;}),
29578
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');
29579
31083
 
29580
31084
  var styler = function(style, i) {
29581
31085
  var rec = records[i];
29582
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
+ }
29583
31092
  for (var j=0; j<fields.length; j++) {
29584
31093
  fname = fields[j];
29585
31094
  val = rec && rec[fname];
@@ -29601,6 +31110,12 @@
29601
31110
  }
29602
31111
  };
29603
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
+ }
29604
31119
  // use squares if radius is missing... (TODO: check behavior with labels, etc)
29605
31120
  if (lyr.geometry_type == 'point' && fields.includes('r') === false) {
29606
31121
  style.dotSize = 1;
@@ -29615,7 +31130,18 @@
29615
31130
  // return fields.indexOf('r') > -1; // require 'r' field for point symbols
29616
31131
  return fields.includes('fill') || fields.includes('r'); // support colored squares
29617
31132
  }
29618
- 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;
29619
31145
  }
29620
31146
 
29621
31147
  function getStyleFields(lyr) {
@@ -30411,7 +31937,7 @@
30411
31937
  canv.setAttribute('height', h);
30412
31938
  ctx.scale(w / tw, h / th);
30413
31939
  if (o.background) {
30414
- ctx.fillStyle = o.background;
31940
+ ctx.fillStyle = getCanvasColor(o.background);
30415
31941
  ctx.fillRect(0, 0, tw, th);
30416
31942
  }
30417
31943
  if (o.type == 'dots' || o.type == 'squares') makeDotFill(o, ctx, 1);
@@ -30475,10 +32001,16 @@
30475
32001
  };
30476
32002
  }
30477
32003
 
32004
+ // SVG's "none" is not a canvas colour, and a canvas ignores a fillStyle it
32005
+ // cannot parse -- keeping the last one set, which on a fresh tile is black.
32006
+ function getCanvasColor(color) {
32007
+ return String(color).trim().toLowerCase() == 'none' ? 'transparent' : color;
32008
+ }
32009
+
30478
32010
  function makeDashFill(o, ctx, res) {
30479
32011
  var x = 0;
30480
32012
  for (var i=0; i<o.colors.length; i++) {
30481
- ctx.fillStyle = o.colors[i];
32013
+ ctx.fillStyle = getCanvasColor(o.colors[i]);
30482
32014
  ctx.fillRect(x, 0, o.width * res, o.dashes[0] * res);
30483
32015
  x += res * (o.spacing + o.width);
30484
32016
  }
@@ -30493,7 +32025,7 @@
30493
32025
  var x = 0, y = 0;
30494
32026
  for (var i=0; i<dots; i++) {
30495
32027
  if (o.type == 'dots') ctx.beginPath();
30496
- ctx.fillStyle = o.colors[(i + Math.floor(i / n)) % n];
32028
+ ctx.fillStyle = getCanvasColor(o.colors[(i + Math.floor(i / n)) % n]);
30497
32029
  if (o.type == 'dots') {
30498
32030
  ctx.arc(x + r, y + r, r, 0, Math.PI * 2);
30499
32031
  } else {
@@ -30510,8 +32042,8 @@
30510
32042
  var w;
30511
32043
  for (var i=0, x=0; i<o.widths.length; i++) {
30512
32044
  w = o.widths[i] * res;
30513
- ctx.fillStyle = o.colors[i];
30514
- ctx.fillRect(x, 0, x + w, h);
32045
+ ctx.fillStyle = getCanvasColor(o.colors[i]);
32046
+ ctx.fillRect(x, 0, w, h);
30515
32047
  x += w;
30516
32048
  }
30517
32049
  }
@@ -30568,7 +32100,7 @@
30568
32100
  arcs = getArcsForRendering(lyr, ext);
30569
32101
  filter = getShapeFilter(arcs, layer.shapes, ext);
30570
32102
  canv.drawStyledPaths(layer.shapes, arcs, style, filter);
30571
- if (style.vertices) {
32103
+ if (style.vertices || style.vertex_overlay || style.pending_snip) {
30572
32104
  canv.drawVertices(layer.shapes, arcs, style, filter);
30573
32105
  }
30574
32106
  }
@@ -30715,6 +32247,8 @@
30715
32247
  };
30716
32248
  */
30717
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.
30718
32252
  _self.drawVertices = function(shapes, arcs, style, filter) {
30719
32253
  var iter = new internal.ShapeIter(arcs);
30720
32254
  var t = getScaledTransform(_ext);
@@ -30724,7 +32258,7 @@
30724
32258
  var i, j, p;
30725
32259
  _ctx.beginPath();
30726
32260
  _ctx.fillStyle = color;
30727
- for (i=0; i<shapes.length; i++) {
32261
+ for (i=0; style.vertices && i<shapes.length; i++) {
30728
32262
  var shp = shapes[i];
30729
32263
  if (!shp || filter && !filter(i)) continue;
30730
32264
  for (j=0; j<shp.length; j++) {
@@ -30762,8 +32296,10 @@
30762
32296
  _self.drawStyledPaths = function(shapes, arcs, style, filter) {
30763
32297
  var styleIndex = {};
30764
32298
  var batchSize = 100;
30765
- var startPath = getPathStart(_ext, getScaledLineScale(_ext, style));
32299
+ var lineScale = getScaledLineScale(_ext, style);
32300
+ var startPath = getPathStart(_ext, lineScale);
30766
32301
  var draw = getShapePencil(arcs, _ext);
32302
+ var arrowPencil = null;
30767
32303
  var key, item, shp;
30768
32304
  var styler = style.styler || null;
30769
32305
  var drawStyle = styler ? utils$1.defaults({}, style) : style;
@@ -30773,6 +32309,13 @@
30773
32309
  if (styler) {
30774
32310
  styler(drawStyle, i);
30775
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
+ }
30776
32319
  if (!drawStyle.batchOverlay && (drawStyle.overlay ||
30777
32320
  drawStyle.opacity < 1 || drawStyle.fillOpacity < 1 ||
30778
32321
  drawStyle.strokeOpacity < 1 || drawStyle.fillEffect)) {
@@ -31127,6 +32670,10 @@
31127
32670
  return x + 0.5 | 0;
31128
32671
  }
31129
32672
 
32673
+ function noRound(x) {
32674
+ return x;
32675
+ }
32676
+
31130
32677
  function roundToHalfPix(x) {
31131
32678
  return (x * 2 | 0) / 2;
31132
32679
  }
@@ -31138,15 +32685,113 @@
31138
32685
  if (style.fillEffect) {
31139
32686
  ctx.fillStyle = getCanvasFillEffect(ctx, shp, arcs, ext, style);
31140
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;
31141
32692
  for (var i=0, n=shp ? shp.length : 0; i<n; i++) {
31142
32693
  iter.init(shp[i]);
31143
32694
  // 0.2 trades visible seams for performance
31144
32695
  // drawPath(protectIterForDrawing(iter, ext), t, ctx, 0.2);
31145
- drawPath2(protectIterForDrawing(iter, ext), t, ctx, roundToPix);
32696
+ drawPath2(protectIterForDrawing(iter, ext), t, ctx, round);
31146
32697
  }
31147
32698
  };
31148
32699
  }
31149
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;
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;
32734
+ };
32735
+ }
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
+
31150
32795
  function protectIterForDrawing(iter, ext) {
31151
32796
  var bounds, k;
31152
32797
  if (ext.scale() > 100) {
@@ -34759,13 +36404,13 @@
34759
36404
 
34760
36405
  // Font and face menus, as in the label panel
34761
36406
  row = makeRow(appearance);
34762
- fontSelect = El('select').attr('title', 'Font').addClass('scalebar-font-select')
36407
+ fontSelect = El('select').attr('aria-label', 'Font').addClass('scalebar-font-select')
34763
36408
  .appendTo(makeCell(row, 'Font'))
34764
36409
  .on('change', function() {
34765
36410
  if (fontSelect.node().value) applyFont(fontSelect.node().value);
34766
36411
  });
34767
36412
  renderFontOptions();
34768
- fontStyleSelect = El('select').attr('title', 'Font style')
36413
+ fontStyleSelect = El('select').attr('aria-label', 'Font style')
34769
36414
  .addClass('scalebar-font-style-select')
34770
36415
  .appendTo(makeCell(row, 'Font style'))
34771
36416
  .on('change', function() {
@@ -34779,7 +36424,7 @@
34779
36424
  El('span').appendTo(colorCell).text('Color');
34780
36425
  colorChit = El('div').addClass('label-color-chit').attr('role', 'button')
34781
36426
  .on('click', function() { colorPicker.toggle(); });
34782
- colorInput = El('input').attr('type', 'text').attr('title', 'Scale bar color')
36427
+ colorInput = El('input').attr('type', 'text').attr('aria-label', 'Scale bar color')
34783
36428
  .on('change', function() {
34784
36429
  var color = colorInput.node().value.trim();
34785
36430
  if (!color) {
@@ -34805,7 +36450,7 @@
34805
36450
  'scalebar-bar-width');
34806
36451
  marginInput = makeNumberInput(makeCell(row, 'Inset'), 'margin',
34807
36452
  'scalebar-margin')
34808
- .attr('title', 'Distance from the edges of the frame, in pixels');
36453
+ .attr('aria-label', 'Distance from the edges of the frame, in pixels');
34809
36454
 
34810
36455
  row = El('div').addClass('label-style-row label-panel-button-row').appendTo(form);
34811
36456
  makePanelActionButton(row, 'Remove scale bar', function() {
@@ -36106,6 +37751,25 @@
36106
37751
  })
36107
37752
  };
36108
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
+ },
36109
37773
  selectLayer: function(name) {
36110
37774
  var target = gui.model.getLayers().filter(function(o) {
36111
37775
  return getLayerName(o.layer) === name;
@@ -36123,6 +37787,19 @@
36123
37787
  };
36124
37788
  });
36125
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
+ },
36126
37803
  // Knot handle positions, in display coordinates.
36127
37804
  getLabelPathKnotCoords: function() {
36128
37805
  var lyr = getLabelPathGuideLayers(gui).filter(function(lyr) {