mapshaper 0.7.69 → 0.7.70

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,
@@ -11518,9 +11518,12 @@
11518
11518
  }
11519
11519
 
11520
11520
  if (e.deleteVertex || e.deletePoint || copyable || e.deleteFeature ||
11521
- e.flipLabel) {
11521
+ e.flipLabel || e.boldText) {
11522
11522
 
11523
11523
  addMenuLabel('actions');
11524
+ if (e.boldText) {
11525
+ addMenuItem(e.boldText.label, e.boldText.run);
11526
+ }
11524
11527
  if (e.deleteVertex) {
11525
11528
  addMenuItem('delete vertex', e.deleteVertex);
11526
11529
  }
@@ -12904,6 +12907,11 @@
12904
12907
  if (cmd.name == 'style' || cmd.name == 'svg-style') {
12905
12908
  return getStyleCullInfo(opts, isStyleProperty);
12906
12909
  }
12910
+ // Adding a label or a labeled copy of a layer is not a style edit, and can
12911
+ // not be superseded by one.
12912
+ if (cmd.name == 'labels' && !('coordinates' in opts) && !opts.no_replace) {
12913
+ return getStyleCullInfo(getLabelStyleOpts(opts), isStyleProperty);
12914
+ }
12907
12915
  if (cmd.name == 'classify') {
12908
12916
  return getClassifyCullInfo(opts);
12909
12917
  }
@@ -12923,6 +12931,16 @@
12923
12931
  };
12924
12932
  }
12925
12933
 
12934
+ // -labels text= writes the label-text property.
12935
+ function getLabelStyleOpts(opts) {
12936
+ var o = Object.assign({}, opts);
12937
+ if ('text' in o) {
12938
+ o['label-text'] = o.text;
12939
+ delete o.text;
12940
+ }
12941
+ return o;
12942
+ }
12943
+
12926
12944
  function getStyleFields$2(opts, isStyleProperty) {
12927
12945
  var fields = [];
12928
12946
  Object.keys(opts).forEach(function(name) {
@@ -13921,6 +13939,11 @@
13921
13939
  var SOFT_BREAK = '<wbr>';
13922
13940
  var SOFT_BREAK_RXP = /<wbr>/gi;
13923
13941
 
13942
+ // A bold stretch, which is the other markup label text carries. See
13943
+ // svg-label-markup.mjs.
13944
+ var BOLD_OPEN = '<b>';
13945
+ var BOLD_CLOSE = '</b>';
13946
+
13924
13947
  // How a label lays out its line breaks, which is not the same for the two kinds.
13925
13948
  // An anchored label stacks its lines with <tspan>. A path label joins them with
13926
13949
  // a space, because a <tspan> inside a <textPath> advances along the curve
@@ -13956,6 +13979,66 @@
13956
13979
  return {text: text, breaks: breaks};
13957
13980
  }
13958
13981
 
13982
+ // Data value -> {text, breaks, bold}: the string the user edits, the offsets
13983
+ // in it where the stored value has a soft break, and its bold stretches as
13984
+ // [start, end) offsets into it (see gui-label-bold.mjs).
13985
+ //
13986
+ // The two kinds of markup are independent -- a bold stretch can cross a soft
13987
+ // break, and a soft break can sit at either end of one -- so the bold is read
13988
+ // first and its offsets are moved as the soft breaks come out.
13989
+ function readLabelValue(val) {
13990
+ var str = val === null || val === undefined ? '' : String(val).replace(ANY_NEWLINE, '\n');
13991
+ var o = internal.svg.parseBoldMarkup(str);
13992
+ var starts = [], m;
13993
+ SOFT_BREAK_RXP.lastIndex = 0;
13994
+ while ((m = SOFT_BREAK_RXP.exec(o.text))) starts.push(m.index);
13995
+ return {
13996
+ text: o.text.replace(SOFT_BREAK_RXP, ''),
13997
+ breaks: starts.map(function(start, i) {
13998
+ return start - i * SOFT_BREAK.length;
13999
+ }),
14000
+ bold: internal.svg.normalizeBoldRanges(o.bold.map(function(r) {
14001
+ return [removeSoftBreakOffset(r[0], starts), removeSoftBreakOffset(r[1], starts)];
14002
+ }))
14003
+ };
14004
+ }
14005
+
14006
+ // An offset into text with soft breaks in it -> the same place once they are
14007
+ // removed. One that falls inside a marker goes to its start.
14008
+ function removeSoftBreakOffset(x, starts) {
14009
+ var shift = 0;
14010
+ for (var i = 0; i < starts.length && starts[i] < x; i++) {
14011
+ if (x < starts[i] + SOFT_BREAK.length) return starts[i] - shift;
14012
+ shift += SOFT_BREAK.length;
14013
+ }
14014
+ return x - shift;
14015
+ }
14016
+
14017
+ // The string the user edits, its soft breaks and its bold ranges -> the value
14018
+ // to store, with both kinds of markup written in. Where they meet, a bold
14019
+ // stretch closes before a soft break and opens after one, so that the break
14020
+ // is never inside a pair of tags it is not part of.
14021
+ function writeLabelValue(text, breaks, bold) {
14022
+ var marks = [];
14023
+ (bold || []).forEach(function(r) {
14024
+ marks.push({pos: r[0], order: 2, str: BOLD_OPEN});
14025
+ marks.push({pos: r[1], order: 0, str: BOLD_CLOSE});
14026
+ });
14027
+ (breaks || []).forEach(function(pos) {
14028
+ marks.push({pos: pos, order: 1, str: SOFT_BREAK});
14029
+ });
14030
+ if (marks.length === 0) return text;
14031
+ marks.sort(function(a, b) {
14032
+ return a.pos - b.pos || a.order - b.order;
14033
+ });
14034
+ var out = '', prev = 0;
14035
+ marks.forEach(function(mark) {
14036
+ out += text.substring(prev, mark.pos) + mark.str;
14037
+ prev = mark.pos;
14038
+ });
14039
+ return out + text.substring(prev);
14040
+ }
14041
+
13959
14042
  // The string the user edits, plus soft breaks at @breaks (ascending offsets
13960
14043
  // into it) -> the same string with the markers written in.
13961
14044
  function insertSoftBreaks(text, breaks) {
@@ -14136,6 +14219,29 @@
14136
14219
  return Math.min(renderedIndex, length);
14137
14220
  }
14138
14221
 
14222
+ // The word a double-click at edited index @i selects, as [start, end), or an
14223
+ // empty range at @i for a click between words. A click on the right half of a
14224
+ // word's last letter lands on the index after it, which is still that word.
14225
+ //
14226
+ // Hyphens and apostrophes count as part of a word, so that "cease-fire" and
14227
+ // "Israel's" are one word each, as they are one name on the map.
14228
+ function getWordRange(text, i) {
14229
+ var at = isWordChar(text.charAt(i)) ? i : isWordChar(text.charAt(i - 1)) ? i - 1 : -1;
14230
+ var a, b;
14231
+ if (at < 0) return [i, i];
14232
+ a = at;
14233
+ b = at + 1;
14234
+ while (a > 0 && isWordChar(text.charAt(a - 1))) a--;
14235
+ while (b < text.length && isWordChar(text.charAt(b))) b++;
14236
+ return [a, b];
14237
+ }
14238
+
14239
+ var WORD_CHAR_RXP = /[\p{L}\p{N}\p{M}'\u2019-]/u;
14240
+
14241
+ function isWordChar(c) {
14242
+ return !!c && WORD_CHAR_RXP.test(c);
14243
+ }
14244
+
14139
14245
  // Strips the placeholder back out, so that a value read off a rendered node is
14140
14246
  // not mistaken for text the user typed.
14141
14247
  function stripPlaceholder(str) {
@@ -14200,7 +14306,7 @@
14200
14306
  function getAddLabelCommand(coords, opts) {
14201
14307
  var o = opts || {};
14202
14308
  var target = o.target || {mode: 'new', newLayerName: DEFAULT_LABEL_LAYER_NAME};
14203
- var parts = ['-add-label', 'coordinates=' + formatCoords(coords)];
14309
+ var parts = ['-labels', 'coordinates=' + formatCoords(coords)];
14204
14310
  if (o.text) {
14205
14311
  // A real newline would break the command parser, so encodeLabelText()
14206
14312
  // writes the two-character escape the label renderer also accepts.
@@ -14267,7 +14373,7 @@
14267
14373
  // id: feature id of the label
14268
14374
  // target: layer name, or null to use the current target
14269
14375
  function getLabelPlacementCommand(opts) {
14270
- var parts = ['-style', 'label-start-offset=' + opts.offset];
14376
+ var parts = ['-labels', 'label-start-offset=' + opts.offset];
14271
14377
  if (opts.anchor) {
14272
14378
  parts.push('text-anchor=' + opts.anchor);
14273
14379
  }
@@ -14287,7 +14393,7 @@
14287
14393
  // with and the position goes -- "stop taking a position, carry these offsets
14288
14394
  // instead", which is one command because it is one edit.
14289
14395
  //
14290
- // label-pos= is always written, even by a label that has none. -style reads an
14396
+ // label-pos= is always written, even by a label that has none. -labels reads an
14291
14397
  // empty value as "remove this", and removing a property no record carries adds
14292
14398
  // nothing to the layer, so there is nothing to be gained by asking first.
14293
14399
  //
@@ -14297,7 +14403,7 @@
14297
14403
  // target: layer name, or null to use the current target
14298
14404
  // via: (optional) the callout-via the text carried with it
14299
14405
  function getLabelOffsetCommand(opts) {
14300
- var parts = ['-style', 'dx=' + opts.dx, 'dy=' + opts.dy,
14406
+ var parts = ['-labels', 'dx=' + opts.dx, 'dy=' + opts.dy,
14301
14407
  'text-anchor=' + opts.anchor, 'label-pos='];
14302
14408
  if (opts.via) parts.push('callout-via=' + quoteCommandValue(opts.via));
14303
14409
  parts.push('ids=' + opts.id);
@@ -14308,16 +14414,16 @@
14308
14414
  // The command a drag on one of a label's handles writes, run once on release.
14309
14415
  //
14310
14416
  // 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
14417
+ // -labels reads an empty value as, and how a handle goes back to automatic
14312
14418
  // id: feature id of the label
14313
14419
  // opts:
14314
14420
  // target: layer name, or null to use the current target
14315
14421
  // 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
14422
+ // breaks. A second -labels in the same string, so that a new width and
14317
14423
  // the lines it breaks into are one undo step.
14318
14424
  function getLabelStyleCommand(values, id, opts) {
14319
14425
  var o = opts || {};
14320
- var parts = ['-style'];
14426
+ var parts = ['-labels'];
14321
14427
  Object.keys(values).forEach(function(name) {
14322
14428
  var val = values[name];
14323
14429
  parts.push(name + '=' + (val === '' ? '' : quoteCommandValue(String(val))));
@@ -14339,7 +14445,7 @@
14339
14445
  function getLabelTextCommand(text, id, target) {
14340
14446
  // A real newline would break the command parser, so encodeLabelText() writes
14341
14447
  // the two-character escape that the label renderer also accepts.
14342
- var parts = ['-style', 'label-text=' + quoteCommandValue(encodeLabelText(text))];
14448
+ var parts = ['-labels', 'text=' + quoteCommandValue(encodeLabelText(text))];
14343
14449
  parts.push('ids=' + id);
14344
14450
  if (target) parts.push('target=' + quoteCommandValue(target));
14345
14451
  return parts.join(' ');
@@ -15454,6 +15560,19 @@
15454
15560
  return cachedFontStyles[fontName];
15455
15561
  }
15456
15562
 
15563
+ // Whether words in @fontName can be set in bold: a bold the browser
15564
+ // synthesizes is not one the Node measurer can match, so wrapping and the
15565
+ // path-fit check would disagree with what is drawn. A font that cannot be
15566
+ // identified is given the benefit of the doubt -- the browser finds whatever
15567
+ // bold it has, and there is nothing to check against.
15568
+ function fontHasBoldFace(fontName) {
15569
+ var variants = fontName ? getFontStyleVariants(fontName) : null;
15570
+ if (!variants || variants.length === 0) return true;
15571
+ return variants.some(function(variant) {
15572
+ return Number(variant.weight) >= 600;
15573
+ });
15574
+ }
15575
+
15457
15576
  // The installed font the browser draws unfonted text in, or '' if it cannot
15458
15577
  // be worked out.
15459
15578
  //
@@ -16616,6 +16735,15 @@
16616
16735
  !/^(button|checkbox|radio|submit)$/.test(node.type);
16617
16736
  }
16618
16737
 
16738
+ // Whether clicking this node puts a native menu on screen, which must then be
16739
+ // left holding the focus: a menu opens on mousedown, so a panel that lets go of
16740
+ // focus on the click that follows closes it again -- it flashes open and shut.
16741
+ // OPTION counts because a browser that reports the chosen option as the click
16742
+ // target is reporting a menu interaction either way.
16743
+ function opensAMenu(node) {
16744
+ return !!node && /^(SELECT|OPTION)$/.test(node.nodeName);
16745
+ }
16746
+
16619
16747
  // While the caret is in one of @panel's fields, the keyboard belongs to that
16620
16748
  // field. Without this the GUI's own handlers see the keystrokes: a Backspace
16621
16749
  // typed into a field takes back the last knot of a curve being drawn, and an
@@ -16723,6 +16851,52 @@
16723
16851
  return section;
16724
16852
  }
16725
16853
 
16854
+ // A switch: a track with a knob that sits left when off and right when on,
16855
+ // which is the direction users expect and the only thing that says which
16856
+ // state is which without a label for each. A third state says that the
16857
+ // features it is asking about disagree -- the knob sits over the join of a
16858
+ // half-and-half track, which is the only control that has to show "some of
16859
+ // them" rather than a value. A section's switch goes in its heading row, and
16860
+ // the section shows only its heading while it is off (the .collapsed class).
16861
+ //
16862
+ // role=checkbox rather than switch, which is what it looks like: 'mixed' is a
16863
+ // legal aria-checked value for a checkbox and not for a switch, and a switch
16864
+ // reporting a mixed selection as unchecked would be telling a screen reader
16865
+ // the one thing the third state exists to avoid saying.
16866
+ //
16867
+ // opts.onChange(on) the switch was clicked
16868
+ // opts.title, opts.className
16869
+ function makePanelToggle(parent, opts) {
16870
+ var track = El('div').addClass('label-toggle').attr('role', 'checkbox').appendTo(parent);
16871
+ var state = 'off';
16872
+ if (opts.className) track.addClass(opts.className);
16873
+ var disabled = false;
16874
+ El('div').addClass('label-toggle-knob').appendTo(track);
16875
+ if (opts.title) track.attr('title', opts.title);
16876
+ // A click on a mixed switch turns everything on. It is the convention, and
16877
+ // it is the reading that reaches a state the switch can describe: the next
16878
+ // click then turns everything off, so both are one click away.
16879
+ track.on('click', function() {
16880
+ if (disabled) return;
16881
+ opts.onChange(state != 'on');
16882
+ });
16883
+ return {
16884
+ // val: 'on', 'off' or 'mixed'; anything else reads as off
16885
+ setState: function(val) {
16886
+ state = val == 'on' || val == 'mixed' ? val : 'off';
16887
+ track.classed('on', state == 'on')
16888
+ .classed('mixed', state == 'mixed')
16889
+ .attr('aria-checked', state == 'mixed' ? 'mixed' :
16890
+ state == 'on' ? 'true' : 'false');
16891
+ },
16892
+ setDisabled: function(off) {
16893
+ disabled = !!off;
16894
+ track.classed('disabled', disabled)
16895
+ .attr('aria-disabled', disabled ? 'true' : 'false');
16896
+ }
16897
+ };
16898
+ }
16899
+
16726
16900
  // Deliberately not focusable: the GUI is pointer-only, so a tab stop here
16727
16901
  // would lead into a control the keyboard cannot then operate. See the focus
16728
16902
  // note in page.css.
@@ -16799,6 +16973,8 @@
16799
16973
  // opts.onColor(hex) a colour was typed, picked or previewed to a finish
16800
16974
  // opts.onOpacity(frac) a usable percentage was typed
16801
16975
  // opts.revert() one that was not, so put the row back as it was
16976
+ // opts.noOpacity a colour with no opacity of its own, like a pattern's,
16977
+ // which fill-opacity fades along with its background
16802
16978
  //
16803
16979
  // control.aside is the row's narrow column, empty, for a field that belongs
16804
16980
  // beside the colour -- a stroke's width, say.
@@ -16836,10 +17012,14 @@
16836
17012
  if (isHexColor(color)) control.picker.setColor(color);
16837
17013
  opts.onColor(color);
16838
17014
  });
16839
- control.opacity = makeColorOpacityField(colorCell, control.chit, control.input, {
16840
- onSet: opts.onOpacity,
16841
- revert: opts.revert
16842
- }).opacity;
17015
+ if (opts.noOpacity) {
17016
+ makeColorField(colorCell, control.chit, control.input);
17017
+ } else {
17018
+ control.opacity = makeColorOpacityField(colorCell, control.chit, control.input, {
17019
+ onSet: opts.onOpacity,
17020
+ revert: opts.revert
17021
+ }).opacity;
17022
+ }
16843
17023
  control.picker = new ColorPicker(colorCell, {
16844
17024
  presetRows: layerColorPresetRows,
16845
17025
  // A drag in the picker shows on the map's own terms -- the swatch and the
@@ -16879,13 +17059,13 @@
16879
17059
  //
16880
17060
  // The style panel is worth having open before there is anything to point it at:
16881
17061
  // 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
17062
+ // held here rather than written to a layer, and -labels coordinates= writes them when a
16883
17063
  // label finally exists -- so this is a tool default, not data, and it never
16884
17064
  // needs its own undo step.
16885
17065
  //
16886
17066
  // See docs/development/label-tool-design.md.
16887
17067
 
16888
- // The style properties -add-label accepts. A panel control whose property is
17068
+ // The style properties -labels accepts. A panel control whose property is
16889
17069
  // missing from this list would appear to do nothing when set with no selection,
16890
17070
  // so the list is checked rather than assumed.
16891
17071
  var NEW_LABEL_STYLE_FIELDS = [
@@ -16907,7 +17087,7 @@
16907
17087
  //
16908
17088
  // A blank or zero value removes the field instead of setting it: that is how
16909
17089
  // 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
17090
+ // carrying those through to -labels would write properties that mean
16911
17091
  // nothing.
16912
17092
  function mergeStyleValues(style, values) {
16913
17093
  var out = Object.assign({}, style);
@@ -16933,7 +17113,7 @@
16933
17113
  // somewhere and typing looks like it should do, and it is the position an icon
16934
17114
  // is drawn to sit behind.
16935
17115
  //
16936
- // Only the tool defaults this. -add-label with no position still creates a
17116
+ // Only the tool defaults this. -labels coordinates= with no position still creates a
16937
17117
  // label without one, so the default is a choice this tool makes and passes on
16938
17118
  // explicitly, not a meaning the command gives to its absence.
16939
17119
  var DEFAULT_NEW_LABEL_STYLE = {'label-pos': 'c'};
@@ -17247,13 +17427,17 @@
17247
17427
 
17248
17428
  // Offsets into @text (real newlines, no soft breaks) where a line starts
17249
17429
  // because it was wrapped, in ascending order.
17250
- function getSoftBreaks(text, rec) {
17430
+ //
17431
+ // @bold: the text's bold stretches, as [start, end) offsets into it. A bold
17432
+ // word is wider, so it moves the breaks.
17433
+ function getSoftBreaks(text, rec, bold) {
17251
17434
  var width = getLabelWrapWidth(rec);
17252
17435
  var key, breaks;
17253
17436
  if (!width || !text) return [];
17254
- key = getWrapKey(text, rec);
17437
+ bold = bold || [];
17438
+ key = getWrapKey(text, rec, bold);
17255
17439
  if (cache$2.has(key)) return cache$2.get(key);
17256
- breaks = measureSoftBreaks(text, rec, width);
17440
+ breaks = measureSoftBreaks(text, rec, width, bold);
17257
17441
  if (cache$2.size >= CACHE_LIMIT) cache$2.clear();
17258
17442
  cache$2.set(key, breaks);
17259
17443
  return breaks;
@@ -17263,29 +17447,30 @@
17263
17447
  // unchanged when neither the old value nor the new one has a soft break, so
17264
17448
  // that a label that does not wrap keeps the line breaks it was written with.
17265
17449
  function rewrapLabelValue(value, rec) {
17266
- var o = readSoftBreaks(value);
17267
- var breaks = getSoftBreaks(o.text, rec);
17450
+ var o = readLabelValue(value);
17451
+ var breaks = getSoftBreaks(o.text, rec, o.bold);
17268
17452
  if (sameSoftBreaks(o.breaks, breaks)) return value;
17269
- return encodeLabelText(insertSoftBreaks(o.text, breaks));
17453
+ return encodeLabelText(writeLabelValue(o.text, breaks, o.bold));
17270
17454
  }
17271
17455
 
17272
17456
  function clearWrapCache() {
17273
17457
  cache$2.clear();
17274
17458
  }
17275
17459
 
17276
- function getWrapKey(text, rec) {
17460
+ function getWrapKey(text, rec, bold) {
17277
17461
  var parts = [text];
17278
17462
  WRAP_FIELDS.forEach(function(name) {
17279
17463
  var val = rec[name];
17280
17464
  parts.push(val === null || val === undefined ? '' : String(val));
17281
17465
  });
17282
17466
  parts.push(rec['class'] || '');
17467
+ parts.push(bold.join(';'));
17283
17468
  return parts.join('\u0000');
17284
17469
  }
17285
17470
 
17286
- function measureSoftBreaks(text, rec, width) {
17471
+ function measureSoftBreaks(text, rec, width, bold) {
17287
17472
  var w = getWrapper();
17288
- var style, fontSize, node, range;
17473
+ var style, fontSize, pieces, range;
17289
17474
  if (!w) return [];
17290
17475
  style = getLabelFontStyle(w, rec);
17291
17476
  if (!style) return [];
@@ -17293,15 +17478,15 @@
17293
17478
  w.div.style[name] = style[name] || '';
17294
17479
  });
17295
17480
  w.div.style.width = width + 'px';
17296
- w.div.textContent = text;
17297
- node = w.div.firstChild;
17481
+ pieces = writeRuns(w.div, text, bold);
17298
17482
  fontSize = parseFloat(style.fontSize) || 12;
17299
17483
  range = document.createRange();
17300
17484
  var breaks = findSoftBreaks(text, function(i) {
17485
+ var piece = findPiece(pieces, i);
17301
17486
  var end = isHighSurrogate(text.charCodeAt(i)) ? i + 2 : i + 1;
17302
17487
  var r;
17303
- range.setStart(node, i);
17304
- range.setEnd(node, Math.min(end, text.length));
17488
+ range.setStart(piece.node, i - piece.start);
17489
+ range.setEnd(piece.node, Math.min(end, piece.end) - piece.start);
17305
17490
  r = range.getBoundingClientRect();
17306
17491
  return r.height > 0 ? r.top : null;
17307
17492
  }, fontSize * LINE_HEIGHT / 2);
@@ -17309,6 +17494,35 @@
17309
17494
  return breaks;
17310
17495
  }
17311
17496
 
17497
+ // Lays @text out in @div a run at a time, the bold ones in bold spans, and
17498
+ // returns each run's text node with the offsets it covers: [{node, start,
17499
+ // end}]. Offsets in the text become offsets in one of these nodes.
17500
+ function writeRuns(div, text, bold) {
17501
+ var pos = 0;
17502
+ div.textContent = '';
17503
+ return internal.svg.getBoldRuns(text, bold, 0, text.length).map(function(run) {
17504
+ var node = document.createTextNode(run.text);
17505
+ var span;
17506
+ if (run.bold) {
17507
+ span = document.createElement('span');
17508
+ span.style.fontWeight = internal.svg.LABEL_BOLD_WEIGHT;
17509
+ span.appendChild(node);
17510
+ div.appendChild(span);
17511
+ } else {
17512
+ div.appendChild(node);
17513
+ }
17514
+ pos += run.text.length;
17515
+ return {node: node, start: pos - run.text.length, end: pos};
17516
+ });
17517
+ }
17518
+
17519
+ function findPiece(pieces, i) {
17520
+ for (var j = 0; j < pieces.length; j++) {
17521
+ if (i < pieces[j].end) return pieces[j];
17522
+ }
17523
+ return pieces[pieces.length - 1];
17524
+ }
17525
+
17312
17526
  function isHighSurrogate(c) {
17313
17527
  return c >= 0xD800 && c <= 0xDBFF;
17314
17528
  }
@@ -17780,7 +17994,7 @@
17780
17994
  // spacing sits above.
17781
17995
  haloSection = addSection('Halo');
17782
17996
  var haloTitle = haloSection.findChild('.label-style-section-title');
17783
- haloToggle = makeToggle(haloTitle, {
17997
+ haloToggle = makePanelToggle(haloTitle, {
17784
17998
  title: 'Draw a halo around the text',
17785
17999
  className: 'label-halo-toggle',
17786
18000
  onChange: setHaloOn
@@ -17831,7 +18045,7 @@
17831
18045
  // a label with no icon: nothing to apply it to.
17832
18046
  iconSection = addSection('Icon');
17833
18047
  var iconTitle = iconSection.findChild('.label-style-section-title');
17834
- iconToggle = makeToggle(iconTitle, {
18048
+ iconToggle = makePanelToggle(iconTitle, {
17835
18049
  title: 'Draw a symbol at the label anchor',
17836
18050
  className: 'label-icon-toggle',
17837
18051
  onChange: setIconOn
@@ -17938,7 +18152,7 @@
17938
18152
  // the map rather than typed here. See docs/development/text-annotation-design.md.
17939
18153
  function initCalloutSection() {
17940
18154
  calloutSection = addSection('Callout');
17941
- calloutToggle = makeToggle(calloutSection.findChild('.label-style-section-title'), {
18155
+ calloutToggle = makePanelToggle(calloutSection.findChild('.label-style-section-title'), {
17942
18156
  title: 'Draw a line from the label anchor to its text',
17943
18157
  className: 'label-callout-toggle',
17944
18158
  onChange: setCalloutOn
@@ -18059,48 +18273,6 @@
18059
18273
  return makePanelSection(panel, title, opts);
18060
18274
  }
18061
18275
 
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
18276
  function addColorOpacityField(parent, chit, input, onOpacity) {
18105
18277
  return makeColorOpacityField(parent, chit, input, {
18106
18278
  onSet: onOpacity,
@@ -19428,14 +19600,6 @@
19428
19600
  return !!node && /^(INPUT|SELECT|TEXTAREA|BUTTON|OPTION)$/.test(node.nodeName);
19429
19601
  }
19430
19602
 
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
19603
  // Hands the keyboard back after a control has done its job: to the label
19440
19604
  // being typed into if there is one, and to nothing at all otherwise.
19441
19605
  function releaseFocus() {
@@ -19454,7 +19618,7 @@
19454
19618
  if (styles.length === 0) return;
19455
19619
  releaseFocus();
19456
19620
  // 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
19621
+ // not these values also went to a label that already exists. -labels
19458
19622
  // writes them when that label is created, which is why this needs no
19459
19623
  // command and no undo step of its own.
19460
19624
  if (labelModeIsOn()) {
@@ -19478,7 +19642,7 @@
19478
19642
 
19479
19643
  function applyStyleCommand(styles, ids) {
19480
19644
  var lyr = getActiveLayer();
19481
- var parts = ['-style'];
19645
+ var parts = ['-labels'];
19482
19646
  if (!gui.console || !lyr) return;
19483
19647
  styles.forEach(function(style) {
19484
19648
  parts.push(style[0] + '=' + quoteCommandValue(style[1]));
@@ -19500,7 +19664,7 @@
19500
19664
  // A text block's breaks are only right for the width and font they were
19501
19665
  // found with, so a change to either rewraps it in the same command: one undo
19502
19666
  // 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.
19667
+ // new font. One -labels per label, since each has text of its own.
19504
19668
  function getRewrapCommands(styles, ids, lyr) {
19505
19669
  var table = lyr.data;
19506
19670
  var changes = {};
@@ -19634,13 +19798,480 @@
19634
19798
  return measureSvg;
19635
19799
  }
19636
19800
 
19801
+ // The pattern fill controls of the polygon panel, and the fill-pattern codes
19802
+ // they stand for.
19803
+ //
19804
+ // The panel covers the patterns that are one colour over a background: hatch
19805
+ // lines, dots and squares. The background is the feature's own fill, so a
19806
+ // pattern is described by five settings -- {type, color, size, gap, angle} --
19807
+ // and the fill. A hatch has no background in the code: it is a run of stripes,
19808
+ // and the panel's hatch is two of them, the gap in the fill colour and the line
19809
+ // in the pattern colour. Anything else (dashes, several colours, a background
19810
+ // that is not the fill) is a custom pattern, which the panel shows as its code.
19811
+ //
19812
+ // Written into every code rather than read from the fill when drawn, because
19813
+ // the code is what -style stores and what the SVG exporter turns into a
19814
+ // <pattern>: a pattern with no colour of its own would need both to change.
19815
+
19816
+ var patternTypes = ['hatches', 'dots', 'squares'];
19817
+
19818
+ var patternDefaults = {
19819
+ hatches: {size: 1, gap: 3, angle: 45},
19820
+ dots: {size: 2, gap: 3, angle: 0},
19821
+ squares: {size: 2, gap: 2, angle: 0}
19822
+ };
19823
+
19824
+ var defaultPatternColor = '#000000';
19825
+
19826
+ // The settings a newly chosen pattern type starts from. The colour carries
19827
+ // over from the pattern being replaced; the sizes do not, because a hatch
19828
+ // line's width and a dot's diameter look nothing alike at the same number.
19829
+ function getDefaultPatternControls(type, color) {
19830
+ var d = patternDefaults[type];
19831
+ return {type: type, color: color || defaultPatternColor,
19832
+ size: d.size, gap: d.gap, angle: d.angle};
19833
+ }
19834
+
19835
+ // The background a pattern is given over a feature with @fill. A feature with
19836
+ // no fill gets a clear one, so that the pattern is drawn over whatever is
19837
+ // underneath.
19838
+ function getPatternBackground(fill) {
19839
+ return isBlank$1(fill) ? 'none' : String(fill).trim();
19840
+ }
19841
+
19842
+ // Whether the settings describe a pattern the renderer will draw. A hatch with
19843
+ // no gap would be a solid fill, and the parser refuses a stripe of no width.
19844
+ function isValidPatternControls(o) {
19845
+ if (!o || patternTypes.indexOf(o.type) == -1 || isBlank$1(o.color)) return false;
19846
+ if (!(o.size > 0) || !isFinite(o.angle)) return false;
19847
+ return o.type == 'hatches' ? o.gap > 0 : o.gap >= 0;
19848
+ }
19849
+
19850
+ // The fill-pattern code for the settings @o over @background. The angle is left
19851
+ // out where it is the parser's own default, which keeps the common codes as
19852
+ // short as the ones in the documentation.
19853
+ function formatFillPattern(o, background) {
19854
+ var bg = background || 'none';
19855
+ if (o.type == 'hatches') {
19856
+ return 'hatches ' + formatAngle(o.angle, 45) +
19857
+ formatPx(o.gap) + ' ' + bg + ' ' + formatPx(o.size) + ' ' + o.color;
19858
+ }
19859
+ return o.type + ' ' + formatAngle(o.angle, 0) +
19860
+ formatPx(o.size) + ' ' + o.color + ' ' + formatPx(o.gap) + ' ' + bg;
19861
+ }
19862
+
19863
+ // The settings a parsed fill-pattern code (from parsePattern()) was made from,
19864
+ // given the fill of the feature it is on -- or null if the panel's settings
19865
+ // cannot describe it, which makes it a custom pattern.
19866
+ function getPatternControls(parsed, fill) {
19867
+ var bg = getPatternBackground(fill);
19868
+ var line;
19869
+ if (!parsed) return null;
19870
+ if (parsed.type == 'hatches') {
19871
+ if (parsed.widths.length != 2) return null;
19872
+ line = sameColor(parsed.colors[0], bg) ? 1 : sameColor(parsed.colors[1], bg) ? 0 : -1;
19873
+ if (line == -1) return null;
19874
+ return {type: 'hatches', color: parsed.colors[line], size: parsed.widths[line],
19875
+ gap: parsed.widths[1 - line], angle: parsed.rotation};
19876
+ }
19877
+ if (parsed.type == 'dots' || parsed.type == 'squares') {
19878
+ if (parsed.colors.length != 1 || !sameColor(parsed.background, bg)) return null;
19879
+ return {type: parsed.type, color: parsed.colors[0], size: parsed.size,
19880
+ gap: parsed.spacing, angle: parsed.rotation};
19881
+ }
19882
+ return null;
19883
+ }
19884
+
19885
+ // The same pattern over a different fill, or null for a custom pattern, which
19886
+ // is left as it is.
19887
+ function refillPattern(parsed, oldFill, newFill) {
19888
+ var o = getPatternControls(parsed, oldFill);
19889
+ return o ? formatFillPattern(o, getPatternBackground(newFill)) : null;
19890
+ }
19891
+
19892
+ // A -style expression giving each feature the pattern @o over its own fill.
19893
+ // For a change that sets fills the panel cannot know in advance, like
19894
+ // -classify's random colours.
19895
+ function formatFillPatternExpression(o) {
19896
+ var sentinel = '\u0000';
19897
+ var parts = formatFillPattern(o, sentinel).split(sentinel);
19898
+ return JSON.stringify(parts[0]) + ' + (fill || "none") + ' + JSON.stringify(parts[1]);
19899
+ }
19900
+
19901
+ // Collects per-feature edits into as few -style commands as they allow.
19902
+ // @edits: [{id, styles: [[name, value], ...]}]
19903
+ // Returns [{styles, ids}], in the order each set of styles first appears.
19904
+ function groupStyleEdits(edits) {
19905
+ var groups = [];
19906
+ var index = {};
19907
+ edits.forEach(function(edit) {
19908
+ var key = JSON.stringify(edit.styles);
19909
+ if (!(key in index)) {
19910
+ index[key] = groups.length;
19911
+ groups.push({styles: edit.styles, ids: []});
19912
+ }
19913
+ groups[index[key]].ids.push(edit.id);
19914
+ });
19915
+ return groups;
19916
+ }
19917
+
19918
+ function formatAngle(angle, defaultAngle) {
19919
+ return angle == defaultAngle ? '' : formatNumber$3(angle) + 'deg ';
19920
+ }
19921
+
19922
+ function formatPx(val) {
19923
+ return formatNumber$3(val) + 'px';
19924
+ }
19925
+
19926
+ function formatNumber$3(val) {
19927
+ return String(Math.round(Number(val) * 100) / 100);
19928
+ }
19929
+
19930
+ function sameColor(a, b) {
19931
+ return normalizeColor(a) == normalizeColor(b);
19932
+ }
19933
+
19934
+ function normalizeColor(color) {
19935
+ var str = String(color || '').trim().toLowerCase();
19936
+ return str == 'transparent' ? 'none' : str;
19937
+ }
19938
+
19939
+ function isBlank$1(val) {
19940
+ return val === undefined || val === null || String(val).trim() === '';
19941
+ }
19942
+
19943
+ // The polygon panel's "Pattern" section. See gui-fill-pattern.mjs for how its
19944
+ // settings map to fill-pattern codes.
19945
+ //
19946
+ // Switched like the label panel's Halo and Icon sections: a pattern is a
19947
+ // yes/no that the rest of the section then qualifies, and the section shows
19948
+ // only its heading while it is off.
19949
+ //
19950
+ // opts.getRecords() the target layer's records
19951
+ // opts.getTargetIds() the features being styled
19952
+ // opts.applyEdits(edits, title) run per-feature edits: [{id, styles}]
19953
+ // opts.revert() put the panel back as the data has it
19954
+ // opts.releaseFocus()
19955
+ function PatternFillControl(parent, opts) {
19956
+ var section = makePanelSection(parent, 'Pattern');
19957
+ var shown = {type: 'none'};
19958
+ // What switching the pattern on applies when the features have none: the
19959
+ // last pattern the section showed, so that off and on again is a round trip.
19960
+ var lastPattern = null;
19961
+ // Custom was chosen from the menu, and the code field is waiting for a code.
19962
+ // Until one is applied the data still says what it said before, so the
19963
+ // choice has to be remembered or the next refresh would undo it.
19964
+ var customPending = false;
19965
+ var toggle, typeSelect, mixedOption, colorControl, angleField, sizeRow, sizeCaption,
19966
+ sizeField, gapField, customRow, codeInput;
19967
+
19968
+ initRows();
19969
+
19970
+ this.section = section;
19971
+
19972
+ this.update = update;
19973
+
19974
+ this.reset = function() {
19975
+ customPending = false;
19976
+ };
19977
+
19978
+ this.hidePicker = function() {
19979
+ colorControl.picker.hide();
19980
+ };
19981
+
19982
+ // What a saved style records for the pattern, over the fill it records.
19983
+ this.getCode = function(fill) {
19984
+ if (isSimple(shown)) return formatFillPattern(shown, getPatternBackground(fill));
19985
+ if (shown.type == 'custom') return shown.code || '';
19986
+ return '';
19987
+ };
19988
+
19989
+ // Per-feature edits applying @styles to the targets. A new fill is also
19990
+ // written into the background of each feature's pattern, since that is where
19991
+ // the pattern's background colour is kept; custom patterns are left alone.
19992
+ this.getStyleEdits = function(styles) {
19993
+ var records = opts.getRecords();
19994
+ var fill = findStyle(styles, 'fill');
19995
+ var refill = fill !== undefined && findStyle(styles, 'fill-pattern') === undefined;
19996
+ return opts.getTargetIds().map(function(id) {
19997
+ var rec = records[id];
19998
+ var code = rec && rec['fill-pattern'];
19999
+ var edit = {id: id, styles: styles};
20000
+ var refilled;
20001
+ if (refill && !isBlank(code)) {
20002
+ refilled = refillPattern(internal.parsePattern(code), rec.fill, fill);
20003
+ if (refilled) edit.styles = styles.concat([['fill-pattern', refilled]]);
20004
+ }
20005
+ return edit;
20006
+ });
20007
+ };
20008
+
20009
+ // Edits that keep each feature's pattern over its fill after a command that
20010
+ // sets fills the panel cannot know beforehand. A -style expression reads
20011
+ // the new fill when it runs.
20012
+ this.getRefillExpressionEdits = function(ids) {
20013
+ var records = opts.getRecords();
20014
+ var edits = [];
20015
+ ids.forEach(function(id) {
20016
+ var rec = records[id];
20017
+ var code = rec && rec['fill-pattern'];
20018
+ var o = isBlank(code) ? null : getPatternControls(internal.parsePattern(code), rec.fill);
20019
+ if (o) edits.push({id: id, styles: [['fill-pattern', formatFillPatternExpression(o)]]});
20020
+ });
20021
+ return edits;
20022
+ };
20023
+
20024
+ function initRows() {
20025
+ toggle = makePanelToggle(section.findChild('.label-style-section-title'), {
20026
+ title: 'Fill with a pattern',
20027
+ className: 'layer-pattern-toggle',
20028
+ onChange: setPatternOn
20029
+ });
20030
+ var typeRow = El('div').addClass('label-style-row layer-pattern-type-row').appendTo(section);
20031
+ typeSelect = El('select').attr('title', 'Pattern type').appendTo(typeRow)
20032
+ .on('change', function() {
20033
+ selectType(typeSelect.node().value);
20034
+ });
20035
+ [['hatches', 'Hatches'], ['dots', 'Dots'], ['squares', 'Squares'],
20036
+ ['custom', 'Custom']].forEach(function(o) {
20037
+ El('option').attr('value', o[0]).appendTo(typeSelect).text(o[1]);
20038
+ });
20039
+ // Shown only while the features being styled have different patterns,
20040
+ // which is not a choice the menu offers.
20041
+ mixedOption = El('option').attr('value', 'mixed').appendTo(typeSelect).text('Mixed');
20042
+ mixedOption.node().disabled = true;
20043
+
20044
+ colorControl = makeColorRow(section, {
20045
+ label: 'Color',
20046
+ noOpacity: true,
20047
+ onColor: function(color) {
20048
+ if (color) applyChange({color: color});
20049
+ },
20050
+ revert: opts.revert
20051
+ });
20052
+ colorControl.row.addClass('layer-pattern-color-row');
20053
+ El('span').appendTo(colorControl.aside).text('Angle');
20054
+ angleField = new SizeField(colorControl.aside, {
20055
+ min: -180, max: 180, step: 15, bigStep: 45,
20056
+ title: 'Pattern angle in degrees',
20057
+ onSet: function(value) {
20058
+ applyChange({angle: value});
20059
+ },
20060
+ onStep: function(delta) {
20061
+ applyChange({angle: Math.max(-180, Math.min(180, shown.angle + delta))});
20062
+ },
20063
+ onDone: opts.releaseFocus
20064
+ });
20065
+
20066
+ sizeRow = El('div').addClass('label-style-row layer-pattern-size-row').appendTo(section);
20067
+ var sizeCell = El('div').addClass('label-split-cell').appendTo(sizeRow);
20068
+ var gapCell = El('div').addClass('label-split-cell').appendTo(sizeRow);
20069
+ sizeCaption = El('span').appendTo(sizeCell).text('Size');
20070
+ sizeField = new SizeField(sizeCell, {
20071
+ min: 0.25, step: 0.5, bigStep: 2, decimals: 2,
20072
+ title: 'Size in px',
20073
+ onSet: function(value) {
20074
+ applyChange({size: value});
20075
+ },
20076
+ onStep: function(delta) {
20077
+ applyChange({size: Math.max(0.25, shown.size + delta)});
20078
+ },
20079
+ onDone: opts.releaseFocus
20080
+ });
20081
+ El('span').appendTo(gapCell).text('Gap');
20082
+ gapField = new SizeField(gapCell, {
20083
+ min: 0, step: 0.5, bigStep: 2, decimals: 2,
20084
+ title: 'Gap in px',
20085
+ onSet: function(value) {
20086
+ applyChange({gap: value});
20087
+ },
20088
+ onStep: function(delta) {
20089
+ applyChange({gap: Math.max(0, shown.gap + delta)});
20090
+ },
20091
+ onDone: opts.releaseFocus
20092
+ });
20093
+
20094
+ customRow = El('div').addClass('label-style-row layer-pattern-code-row').appendTo(section);
20095
+ var caption = El('div').addClass('label-style-row-label').appendTo(customRow).text('Code');
20096
+ makeFieldTip(caption,
20097
+ 'The fill-pattern syntax of the -style command.\n' +
20098
+ 'See the command reference for details.');
20099
+ codeInput = El('input').attr('type', 'text').attr('title', 'Pattern code').appendTo(customRow)
20100
+ .on('change', function() {
20101
+ applyCode(codeInput.node().value.trim());
20102
+ });
20103
+ }
20104
+
20105
+ function update() {
20106
+ var ids = opts.getTargetIds();
20107
+ var toggleState = getToggleState(ids);
20108
+ var state = getCommonPatternState(ids);
20109
+ if (customPending && state.type != 'custom') {
20110
+ state = {type: 'custom', code: shown.type == 'custom' ? shown.code : getPendingCode(state)};
20111
+ }
20112
+ shown = state;
20113
+ if (isSimple(state) || state.type == 'custom' && state.code) lastPattern = state;
20114
+ toggle.setState(toggleState);
20115
+ section.classed('collapsed', toggleState == 'off');
20116
+ mixedOption.classed('hidden', state.type != 'mixed');
20117
+ typeSelect.node().value = state.type;
20118
+ colorControl.row.classed('hidden', !isSimple(state));
20119
+ sizeRow.classed('hidden', !isSimple(state));
20120
+ customRow.classed('hidden', state.type != 'custom');
20121
+ if (isSimple(state)) {
20122
+ colorControl.showColor(state.color);
20123
+ angleField.setValue(state.angle);
20124
+ sizeCaption.text(state.type == 'hatches' ? 'Width' : 'Size');
20125
+ sizeField.setValue(state.size);
20126
+ gapField.setValue(state.gap);
20127
+ } else {
20128
+ colorControl.picker.hide();
20129
+ }
20130
+ if (state.type == 'custom' && document.activeElement !== codeInput.node()) {
20131
+ codeInput.node().value = state.code || '';
20132
+ }
20133
+ }
20134
+
20135
+ function selectType(type) {
20136
+ if (type == 'custom') {
20137
+ customPending = true;
20138
+ update();
20139
+ codeInput.node().focus();
20140
+ return;
20141
+ }
20142
+ customPending = false;
20143
+ if (patternTypes.indexOf(type) > -1) {
20144
+ applyControls(getDefaultPatternControls(type, isSimple(shown) ? shown.color : null));
20145
+ }
20146
+ }
20147
+
20148
+ // On gives every feature being styled a pattern: the one the features that
20149
+ // have a pattern agree on, if they do, or else the last one shown, or else a
20150
+ // default hatch. Off takes the pattern off all of them.
20151
+ function setPatternOn(on) {
20152
+ var ids = opts.getTargetIds();
20153
+ var common, pattern;
20154
+ customPending = false;
20155
+ if (!on) {
20156
+ applyToTargets(function() { return ''; }, 'Remove pattern fill');
20157
+ return;
20158
+ }
20159
+ common = getCommonPatternState(ids.filter(hasPattern));
20160
+ pattern = isSimple(common) || common.type == 'custom' ? common :
20161
+ lastPattern || getDefaultPatternControls('hatches');
20162
+ if (pattern.type == 'custom') {
20163
+ applyToTargets(function() { return pattern.code; }, 'Pattern fill');
20164
+ } else {
20165
+ applyControls(pattern);
20166
+ }
20167
+ }
20168
+
20169
+ function getToggleState(ids) {
20170
+ var n = ids.filter(hasPattern).length;
20171
+ return n === 0 ? 'off' : n < ids.length ? 'mixed' : 'on';
20172
+ }
20173
+
20174
+ function hasPattern(id) {
20175
+ var rec = opts.getRecords()[id];
20176
+ return !isBlank(rec && rec['fill-pattern']);
20177
+ }
20178
+
20179
+ function applyChange(change) {
20180
+ if (!isSimple(shown)) return;
20181
+ var o = Object.assign({}, shown, change);
20182
+ if (!isValidPatternControls(o)) {
20183
+ opts.revert();
20184
+ return;
20185
+ }
20186
+ applyControls(o);
20187
+ }
20188
+
20189
+ function applyControls(o) {
20190
+ applyToTargets(function(rec) {
20191
+ return formatFillPattern(o, getPatternBackground(rec && rec.fill));
20192
+ }, 'Pattern fill');
20193
+ }
20194
+
20195
+ function applyCode(code) {
20196
+ if (code && !internal.parsePattern(code)) {
20197
+ codeInput.node().value = shown.code || '';
20198
+ opts.revert();
20199
+ return;
20200
+ }
20201
+ if (!code) customPending = false;
20202
+ applyToTargets(function() { return code; }, code ? 'Pattern fill' : 'Remove pattern fill');
20203
+ }
20204
+
20205
+ function applyToTargets(getCode, title) {
20206
+ var records = opts.getRecords();
20207
+ opts.applyEdits(opts.getTargetIds().map(function(id) {
20208
+ return {id: id, styles: [['fill-pattern', getCode(records[id])]]};
20209
+ }), title);
20210
+ }
20211
+
20212
+ // The pattern the features being styled have in common, as panel settings,
20213
+ // {type: 'custom', code}, {type: 'none'} -- or {type: 'mixed'} if they differ.
20214
+ // Features with the same pattern over different fills agree: each pattern's
20215
+ // background is its own feature's fill.
20216
+ function getCommonPatternState(ids) {
20217
+ var records = opts.getRecords();
20218
+ var cache = {};
20219
+ var firstKey = null, first = null;
20220
+ var rec, cacheKey, state, key;
20221
+ for (var i=0; i<ids.length; i++) {
20222
+ rec = records[ids[i]];
20223
+ cacheKey = (rec && rec['fill-pattern']) + '\t' + (rec && rec.fill);
20224
+ state = cache[cacheKey] || (cache[cacheKey] = readPatternState(rec));
20225
+ key = JSON.stringify(state);
20226
+ if (firstKey === null) {
20227
+ firstKey = key;
20228
+ first = state;
20229
+ } else if (key != firstKey) {
20230
+ return {type: 'mixed'};
20231
+ }
20232
+ }
20233
+ return first || {type: 'none'};
20234
+ }
20235
+
20236
+ // The code the Custom field starts from: the pattern being replaced, so that
20237
+ // choosing Custom is a way to see the code for the settings shown.
20238
+ function getPendingCode(state) {
20239
+ var records = opts.getRecords();
20240
+ var ids = opts.getTargetIds();
20241
+ var rec = ids.length > 0 ? records[ids[0]] : null;
20242
+ return isSimple(state) ? formatFillPattern(state, getPatternBackground(rec && rec.fill)) : '';
20243
+ }
20244
+ }
20245
+
20246
+ function readPatternState(rec) {
20247
+ var code = rec && rec['fill-pattern'];
20248
+ if (isBlank(code)) return {type: 'none'};
20249
+ return getPatternControls(internal.parsePattern(code), rec.fill) ||
20250
+ {type: 'custom', code: String(code)};
20251
+ }
20252
+
20253
+ function isSimple(state) {
20254
+ return patternTypes.indexOf(state.type) > -1;
20255
+ }
20256
+
20257
+ function findStyle(styles, name) {
20258
+ for (var i=0; i<styles.length; i++) {
20259
+ if (styles[i][0] == name) return styles[i][1];
20260
+ }
20261
+ return undefined;
20262
+ }
20263
+
20264
+ function isBlank(val) {
20265
+ return val === undefined || val === null || String(val).trim() === '';
20266
+ }
20267
+
19637
20268
  var savedStylesKey = 'layer_style_presets';
19638
- var styleFields = ['stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'fill', 'fill-opacity'];
20269
+ var styleFields = ['stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'fill', 'fill-opacity', 'fill-pattern'];
19639
20270
 
19640
20271
  function LayerStyleTool(gui) {
19641
20272
  var parent = gui.container.findChild('.mshp-main-map');
19642
20273
  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;
20274
+ var title, editingStatus, clearLink, strokeControl, fillControl, strokeWidthField, dashControl, randomFillBtn, presetControl, patternControl, hit;
19644
20275
  var targetLayer = null;
19645
20276
 
19646
20277
  initPanel();
@@ -19685,6 +20316,7 @@
19685
20316
 
19686
20317
  function turnOn() {
19687
20318
  targetLayer = getActiveLayer();
20319
+ patternControl.reset();
19688
20320
  applyDefaultLineStyle();
19689
20321
  panel.show();
19690
20322
  updateControls();
@@ -19694,6 +20326,8 @@
19694
20326
  panel.hide();
19695
20327
  strokeControl.picker.hide();
19696
20328
  fillControl.picker.hide();
20329
+ patternControl.hidePicker();
20330
+ patternControl.reset();
19697
20331
  targetLayer = null;
19698
20332
  }
19699
20333
 
@@ -19710,8 +20344,8 @@
19710
20344
  // picker's Close button, and the panel's own buttons in the browsers that
19711
20345
  // focus a button on click. Left alone while the user is typing into a
19712
20346
  // field, which a click elsewhere in the panel ends on its own.
19713
- panel.node().addEventListener('click', function() {
19714
- if (isTextInput(document.activeElement)) return;
20347
+ panel.node().addEventListener('click', function(e) {
20348
+ if (isTextInput(document.activeElement) || opensAMenu(e.target)) return;
19715
20349
  releaseFocus();
19716
20350
  });
19717
20351
 
@@ -19732,6 +20366,16 @@
19732
20366
  randomFillBtn = makePanelActionButton(buttonRow, 'Random fills', applyRandomFillColors);
19733
20367
  makePanelActionButton(buttonRow, 'Clear style', clearLayerStyle);
19734
20368
 
20369
+ patternControl = new PatternFillControl(panel, {
20370
+ getRecords: function() {
20371
+ return targetLayer && targetLayer.data ? targetLayer.data.getRecords() : [];
20372
+ },
20373
+ getTargetIds: getTargetIds,
20374
+ applyEdits: runStyleEdits,
20375
+ revert: updateControls,
20376
+ releaseFocus: releaseFocus
20377
+ });
20378
+
19735
20379
  presetControl = new StylePresetControl(panel, {
19736
20380
  storageKey: savedStylesKey,
19737
20381
  type: getStyleType,
@@ -19829,6 +20473,8 @@
19829
20473
  updateStrokeWidthControl();
19830
20474
  updateDashArrayControl();
19831
20475
  randomFillBtn.classed('hidden', geom != 'polygon');
20476
+ patternControl.section.classed('hidden', geom != 'polygon');
20477
+ if (geom == 'polygon') patternControl.update();
19832
20478
  presetControl.render();
19833
20479
  updateSavedStyleControls();
19834
20480
  }
@@ -19892,7 +20538,11 @@
19892
20538
  if (control.field == 'stroke' && strokeWidthIsUnsetForTargets()) {
19893
20539
  styles.push(['stroke-width', 1]);
19894
20540
  }
19895
- runStyleCommand(styles);
20541
+ if (control.field == 'fill') {
20542
+ runStyleEdits(patternControl.getStyleEdits(styles));
20543
+ } else {
20544
+ runStyleCommand(styles);
20545
+ }
19896
20546
  }
19897
20547
 
19898
20548
  function applyStrokeWidthStyle(value) {
@@ -19912,11 +20562,30 @@
19912
20562
  }
19913
20563
 
19914
20564
  function runStyleCommand(styles) {
19915
- var parts = ['-style'];
19916
20565
  releaseFocus();
19917
20566
  syncTargetLayer();
19918
20567
  var ids = getTargetIds();
19919
20568
  if (!gui.console || !targetLayer || ids.length === 0) return;
20569
+ runCommand(formatStyleCommand(styles, ids), 'Style layer');
20570
+ }
20571
+
20572
+ // Per-feature edits ([{id, styles}]), as one -style command for each set of
20573
+ // styles. They are run as one command string, which is one undo step.
20574
+ function runStyleEdits(edits, title) {
20575
+ releaseFocus();
20576
+ syncTargetLayer();
20577
+ if (!gui.console || !targetLayer || edits.length === 0) return;
20578
+ runCommand(formatStyleEditCommands(edits), title || 'Style layer');
20579
+ }
20580
+
20581
+ function formatStyleEditCommands(edits) {
20582
+ return groupStyleEdits(edits).map(function(group) {
20583
+ return formatStyleCommand(group.styles, group.ids);
20584
+ }).join(' ');
20585
+ }
20586
+
20587
+ function formatStyleCommand(styles, ids) {
20588
+ var parts = ['-style'];
19920
20589
  styles.forEach(function(style) {
19921
20590
  parts.push(style[0] + '=' + quoteCommandValue(style[1]));
19922
20591
  });
@@ -19924,7 +20593,7 @@
19924
20593
  if (ids.length < internal.getFeatureCount(targetLayer)) {
19925
20594
  parts.push('ids=' + ids.join(','));
19926
20595
  }
19927
- runCommand(parts.join(' '), 'Style layer');
20596
+ return parts.join(' ');
19928
20597
  }
19929
20598
 
19930
20599
  function applyDefaultLineStyle() {
@@ -19938,13 +20607,21 @@
19938
20607
  }
19939
20608
  }
19940
20609
 
20610
+ // Patterns keep their backgrounds in step with the fills, and the new fills
20611
+ // are not known until -classify has chosen them, so the patterns are
20612
+ // rewritten by an expression that reads each feature's fill when it runs.
19941
20613
  function applyRandomFillColors() {
19942
20614
  var cmd = '-classify colors=random non-adjacent';
20615
+ var patternEdits;
19943
20616
  syncTargetLayer();
19944
20617
  if (!gui.console || !targetLayer || targetLayer.geometry_type != 'polygon') return;
19945
20618
  if (getActiveLayer() != targetLayer) {
19946
20619
  cmd += ' target=' + internal.formatOptionValue(internal.getLayerTargetId(gui.model, targetLayer));
19947
20620
  }
20621
+ patternEdits = patternControl.getRefillExpressionEdits(getAllFeatureIds(targetLayer));
20622
+ if (patternEdits.length > 0) {
20623
+ cmd += ' ' + formatStyleEditCommands(patternEdits);
20624
+ }
19948
20625
  runCommand(cmd, 'Random fill colors');
19949
20626
  }
19950
20627
 
@@ -19963,7 +20640,7 @@
19963
20640
  }
19964
20641
  });
19965
20642
  if (styles.length > 0) {
19966
- runStyleCommand(styles);
20643
+ runStyleEdits(patternControl.getStyleEdits(styles));
19967
20644
  }
19968
20645
  }
19969
20646
 
@@ -19978,6 +20655,7 @@
19978
20655
  if (targetLayer && targetLayer.geometry_type == 'polygon') {
19979
20656
  addStyleValue(style, 'fill', getControlValue(fillControl.input));
19980
20657
  addStyleValue(style, 'fill-opacity', parseOpacityValue(fillControl.opacity.node().value));
20658
+ addStyleValue(style, 'fill-pattern', patternControl.getCode(getControlValue(fillControl.input)));
19981
20659
  }
19982
20660
  return style;
19983
20661
  }
@@ -20192,8 +20870,8 @@
20192
20870
  revert: updateControls,
20193
20871
  release: releaseFocus
20194
20872
  });
20195
- panel.node().addEventListener('click', function() {
20196
- if (isTextInput(document.activeElement)) return;
20873
+ panel.node().addEventListener('click', function(e) {
20874
+ if (isTextInput(document.activeElement) || opensAMenu(e.target)) return;
20197
20875
  releaseFocus();
20198
20876
  });
20199
20877
 
@@ -20447,9 +21125,8 @@
20447
21125
  var expr = createExprInput.node().value.trim();
20448
21126
  var cmd;
20449
21127
  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);
21128
+ cmd = '-labels text=' + quoteCommandValue(expr);
21129
+ if (createCopyCheckbox.node().checked) cmd += ' + name=labels';
20453
21130
  runGuiEditCommand(gui, cmd, {
20454
21131
  title: 'Create labels',
20455
21132
  onSuccess: openLabelStyles
@@ -21434,7 +22111,7 @@
21434
22111
  // hit test, the reposition pass and the selection cue all find labels by those,
21435
22112
  // and a node with no feature behind it must not be found by any of them.
21436
22113
  //
21437
- // @rec: the label's record, as -add-label will write it
22114
+ // @rec: the label's record, as -labels coordinates= will write it
21438
22115
  // @shp: its knots, in the layer's display coordinates
21439
22116
  // Returns a markup string, or '' if there is nothing to draw.
21440
22117
  function renderPendingSymbol(rec, shp, ext, idPrefix) {
@@ -25923,6 +26600,112 @@
25923
26600
  return true;
25924
26601
  }
25925
26602
 
26603
+ // The bold stretches of a label being edited, as [start, end) offsets into the
26604
+ // text the user edits, and how they follow the text as it changes.
26605
+ //
26606
+ // The editor keeps these beside the textarea's value rather than as markup in
26607
+ // it, so that the caret and the selection index the text the user sees. The
26608
+ // markup they come from and go back to is read and written by
26609
+ // readLabelValue() and writeLabelValue() in gui-label-text.mjs.
26610
+ //
26611
+ // Every function returns a new, normalized list -- sorted, non-empty, and with
26612
+ // no two ranges touching -- and leaves its argument alone.
26613
+
26614
+ function isBoldAt(ranges, i) {
26615
+ for (var j = 0; j < ranges.length; j++) {
26616
+ if (i >= ranges[j][0] && i < ranges[j][1]) return true;
26617
+ }
26618
+ return false;
26619
+ }
26620
+
26621
+ // Whether every character in [start, end) is bold. False for an empty range.
26622
+ function rangeIsAllBold(ranges, start, end) {
26623
+ var pos = start;
26624
+ if (!(end > start)) return false;
26625
+ for (var j = 0; j < ranges.length && pos < end; j++) {
26626
+ if (ranges[j][1] <= pos) continue;
26627
+ if (ranges[j][0] > pos) return false;
26628
+ pos = ranges[j][1];
26629
+ }
26630
+ return pos >= end;
26631
+ }
26632
+
26633
+ // [start, end) set bold (@on) or not.
26634
+ function setBoldRange(ranges, start, end, on) {
26635
+ var out = [];
26636
+ ranges.forEach(function(r) {
26637
+ if (r[1] <= start || r[0] >= end) {
26638
+ out.push([r[0], r[1]]);
26639
+ return;
26640
+ }
26641
+ if (r[0] < start) out.push([r[0], start]);
26642
+ if (r[1] > end) out.push([end, r[1]]);
26643
+ });
26644
+ if (on && end > start) out.push([start, end]);
26645
+ return normalize(out);
26646
+ }
26647
+
26648
+ // What pressing Bold does to [start, end): unbolds a range that is all bold
26649
+ // and bolds any other, including one that is partly bold -- the convention of
26650
+ // every word processor, and the reading that leaves the range one weight.
26651
+ function toggleBoldRange(ranges, start, end) {
26652
+ return setBoldRange(ranges, start, end, !rangeIsAllBold(ranges, start, end));
26653
+ }
26654
+
26655
+ // The ranges for @newText, given the ranges they were for @oldText.
26656
+ //
26657
+ // The edit is found as the text between the longest common prefix and suffix,
26658
+ // which is exact for everything a textarea does in one step -- typing,
26659
+ // deleting, pasting over a selection, an IME composition -- and still gives a
26660
+ // sensible answer for its own undo, which can replace a larger stretch at once.
26661
+ //
26662
+ // Inserted text takes the weight of the character before it, so typing on at
26663
+ // the end of a bold word stays bold and typing after it does not; at the start
26664
+ // of the text it takes the weight of the first character.
26665
+ function updateBoldForEdit(ranges, oldText, newText) {
26666
+ var edit, inherit, mapped;
26667
+ if (ranges.length === 0 || oldText === newText) return normalize(ranges);
26668
+ edit = findEdit(oldText, newText);
26669
+ inherit = edit.start > 0 ? isBoldAt(ranges, edit.start - 1) :
26670
+ isBoldAt(ranges, edit.start + edit.removed);
26671
+ mapped = ranges.map(function(r) {
26672
+ return [mapOffset(r[0], edit), mapOffset(r[1], edit)];
26673
+ });
26674
+ return setBoldRange(normalize(mapped), edit.start, edit.start + edit.inserted,
26675
+ inherit);
26676
+ }
26677
+
26678
+ function sameBoldRanges(a, b) {
26679
+ if (a.length != b.length) return false;
26680
+ for (var i = 0; i < a.length; i++) {
26681
+ if (a[i][0] !== b[i][0] || a[i][1] !== b[i][1]) return false;
26682
+ }
26683
+ return true;
26684
+ }
26685
+
26686
+ // {start, removed, inserted}: characters [start, start + removed) of the old
26687
+ // text became [start, start + inserted) of the new one.
26688
+ function findEdit(a, b) {
26689
+ var max = Math.min(a.length, b.length);
26690
+ var pre = 0, suf = 0;
26691
+ while (pre < max && a.charCodeAt(pre) === b.charCodeAt(pre)) pre++;
26692
+ while (suf < max - pre &&
26693
+ a.charCodeAt(a.length - 1 - suf) === b.charCodeAt(b.length - 1 - suf)) suf++;
26694
+ return {start: pre, removed: a.length - pre - suf, inserted: b.length - pre - suf};
26695
+ }
26696
+
26697
+ // A boundary inside the removed stretch collapses to its start. One after it
26698
+ // moves by the change in length.
26699
+ function mapOffset(x, edit) {
26700
+ if (x <= edit.start) return x;
26701
+ if (x >= edit.start + edit.removed) return x - edit.removed + edit.inserted;
26702
+ return edit.start;
26703
+ }
26704
+
26705
+ function normalize(ranges) {
26706
+ return internal.svg.normalizeBoldRanges(ranges);
26707
+ }
26708
+
25926
26709
  // Caret and selection geometry, computed from the SVG character position APIs.
25927
26710
  //
25928
26711
  // The APIs behave identically on <text> and on <text><textPath>, so this code
@@ -26242,16 +27025,19 @@
26242
27025
  // so the tool cannot do this at its own call sites.
26243
27026
  self.open = function(target, id, opts) {
26244
27027
  var rec = getRecord(target, id);
27028
+ var value;
26245
27029
  if (!rec) return false;
26246
27030
  self.close();
27031
+ value = readLabelValue(rec['label-text']);
26247
27032
  session = {
26248
27033
  target: target,
26249
27034
  id: id,
26250
27035
  pending: null,
26251
27036
  created: false,
26252
27037
  onClose: opts && opts.onClose || null,
26253
- startText: decodeLabelText(rec['label-text']),
26254
- startBreaks: readSoftBreaks(rec['label-text']).breaks,
27038
+ startText: value.text,
27039
+ startBreaks: value.breaks,
27040
+ startBold: value.bold,
26255
27041
  nodes: null
26256
27042
  };
26257
27043
  startSession();
@@ -26297,6 +27083,7 @@
26297
27083
  onClose: opts.onClose || null,
26298
27084
  startText: '',
26299
27085
  startBreaks: [],
27086
+ startBold: [],
26300
27087
  nodes: null
26301
27088
  };
26302
27089
  startSession();
@@ -26305,6 +27092,7 @@
26305
27092
 
26306
27093
  function startSession() {
26307
27094
  session.text = session.startText;
27095
+ session.bold = session.startBold;
26308
27096
  getTextarea().value = session.text;
26309
27097
  setCaret(session.text.length, session.text.length);
26310
27098
  focusTextarea();
@@ -26320,6 +27108,74 @@
26320
27108
  self.refresh();
26321
27109
  }
26322
27110
 
27111
+ // The text a Bold item in the context menu would act on, as {start, end,
27112
+ // bold}, where @bold says whether all of it is bold already; or null. That is
27113
+ // the selection, or with nothing selected, the word under @p -- read when the
27114
+ // menu opens, because by the time an item is chosen the pointer is on the
27115
+ // menu rather than the label.
27116
+ self.getBoldTargetAtPoint = function(p) {
27117
+ var start = getCaretStart(), end = getCaretEnd();
27118
+ var range;
27119
+ if (!session) return null;
27120
+ if (!(end > start)) {
27121
+ start = getEditIndexAtPoint(p);
27122
+ if (start < 0) return null;
27123
+ range = getWordRange(session.text, start);
27124
+ start = range[0];
27125
+ end = range[1];
27126
+ }
27127
+ if (!(end > start)) return null;
27128
+ return {start: start, end: end, bold: rangeIsAllBold(session.bold, start, end)};
27129
+ };
27130
+
27131
+ // Bolds the selected text, or unbolds it if it is all bold already; or the
27132
+ // text from @start to @end, which is selected first. The text stays
27133
+ // selected, so that a second press takes it back.
27134
+ self.toggleBold = function(start, end) {
27135
+ if (!session) return;
27136
+ if (start >= 0 && end > start) {
27137
+ setCaret(start, end);
27138
+ } else {
27139
+ start = getCaretStart();
27140
+ end = getCaretEnd();
27141
+ }
27142
+ if (!(end > start)) return;
27143
+ session.bold = toggleBoldRange(session.bold, start, end);
27144
+ self.refresh();
27145
+ };
27146
+
27147
+ // Selects the text between two points in the label's own coordinate space,
27148
+ // for a drag across it. The selection runs from @p0 towards @p1, so that
27149
+ // shift-arrow then extends it from the end the pointer let go of.
27150
+ self.selectBetweenPoints = function(p0, p1) {
27151
+ var a = getEditIndexAtPoint(p0);
27152
+ var b = getEditIndexAtPoint(p1);
27153
+ if (a < 0 || b < 0) return false;
27154
+ setCaret(Math.min(a, b), Math.max(a, b), b < a ? 'backward' : 'forward');
27155
+ redrawCaret();
27156
+ return true;
27157
+ };
27158
+
27159
+ // Selects the word under @p, for a double-click inside the text.
27160
+ self.selectWordAtPoint = function(p) {
27161
+ var i = getEditIndexAtPoint(p);
27162
+ var range;
27163
+ if (i < 0) return false;
27164
+ range = getWordRange(session.text, i);
27165
+ setCaret(range[0], range[1]);
27166
+ redrawCaret();
27167
+ return true;
27168
+ };
27169
+
27170
+ function getEditIndexAtPoint(p) {
27171
+ var provider, layout, i;
27172
+ if (!session || !session.nodes) return -1;
27173
+ provider = getProvider(session.nodes);
27174
+ layout = getLayout(session);
27175
+ i = getCaretIndexAtPoint(provider, p, getRenderedLength(session.text, layout));
27176
+ return i < 0 ? -1 : getEditIndex(session.text, i, layout);
27177
+ }
27178
+
26323
27179
  self.isOpen = function() {
26324
27180
  return !!session;
26325
27181
  };
@@ -26350,6 +27206,7 @@
26350
27206
  // arriving just after a redraw is holding one of those.
26351
27207
  if (session.pending && node.closest &&
26352
27208
  node.closest('.label-edit-pending')) return true;
27209
+ if (session.nodes && session.nodes.symbol.contains(node)) return true;
26353
27210
  return !!groups && (groups.hit.contains(node) || groups.back.contains(node) ||
26354
27211
  groups.front.contains(node));
26355
27212
  };
@@ -26408,7 +27265,7 @@
26408
27265
  if (i < 0) return false;
26409
27266
  i = getEditIndex(session.text, i, layout);
26410
27267
  setCaret(i, i);
26411
- drawOverlay(session);
27268
+ redrawCaret();
26412
27269
  return true;
26413
27270
  };
26414
27271
 
@@ -26442,7 +27299,7 @@
26442
27299
  var blank = textHasNoGlyphs(o.text);
26443
27300
  var breaks = getBreaks(o);
26444
27301
  if (o.pending) {
26445
- if (!blank) o.pending.create(insertSoftBreaks(o.text, breaks));
27302
+ if (!blank) o.pending.create(writeLabelValue(o.text, breaks, o.bold));
26446
27303
  return;
26447
27304
  }
26448
27305
  if (blank) {
@@ -26450,8 +27307,9 @@
26450
27307
  removeLabel(o);
26451
27308
  return;
26452
27309
  }
26453
- if (o.text === o.startText && sameSoftBreaks(breaks, o.startBreaks)) return;
26454
- runGuiEditCommand(gui, getLabelTextCommand(insertSoftBreaks(o.text, breaks),
27310
+ if (o.text === o.startText && sameSoftBreaks(breaks, o.startBreaks) &&
27311
+ sameBoldRanges(o.bold, o.startBold)) return;
27312
+ runGuiEditCommand(gui, getLabelTextCommand(writeLabelValue(o.text, breaks, o.bold),
26455
27313
  o.id, o.target.name), {
26456
27314
  title: 'Label text'
26457
27315
  });
@@ -26464,7 +27322,7 @@
26464
27322
  var rec;
26465
27323
  if (!shp || shp.length > 1) return [];
26466
27324
  rec = o.pending ? o.pending.getStyle() : getRecord(o.target, o.id);
26467
- return getSoftBreaks(o.text, rec);
27325
+ return getSoftBreaks(o.text, rec, o.bold);
26468
27326
  }
26469
27327
 
26470
27328
  // Deletes the label's feature, geometry and all.
@@ -26551,7 +27409,7 @@
26551
27409
  var rec = Object.assign({}, o.pending.getStyle());
26552
27410
  // With its soft breaks, so that what is aligned and what a callout meets
26553
27411
  // is the block as wrapped
26554
- rec['label-text'] = encodeLabelText(insertSoftBreaks(o.text, getBreaks(o)));
27412
+ rec['label-text'] = encodeLabelText(writeLabelValue(o.text, getBreaks(o), o.bold));
26555
27413
  // No need to expand label-pos here, or to measure its text: the renderer
26556
27414
  // resolves the position and asks for the width it needs, so a pending
26557
27415
  // label is laid out by exactly the same code as a committed one.
@@ -26599,11 +27457,15 @@
26599
27457
  // - Line breaks. An empty <tspan> lays out nothing, so a line just opened
26600
27458
  // with Enter had no position for the caret to move to. The placeholder in
26601
27459
  // getRenderedLines() gives it one.
27460
+ //
27461
+ // Bold stretches are <tspan>s inside the line they fall on. The character
27462
+ // position APIs count the characters of descendant elements in document
27463
+ // order, so the rendered indexes stay the edited ones whatever the runs are.
26602
27464
  function writeText(o) {
26603
27465
  var content = o.nodes.content;
26604
27466
  var breaks = getBreaks(o);
26605
- var written = o.text + '\u0000' + breaks.join(',');
26606
- var lines, i, tspan;
27467
+ var written = o.text + '\u0000' + breaks.join(',') + '\u0000' + o.bold.join(';');
27468
+ var lines, full, pos, i, tspan;
26607
27469
  // refresh() runs on every map render, which during a pan is every frame;
26608
27470
  // rebuilding text nodes that already say the right thing is pure waste
26609
27471
  if (o.writtenTo === content && o.writtenText === written) return;
@@ -26617,23 +27479,53 @@
26617
27479
  // or from a label that was multi-line before it was given a path -- and
26618
27480
  // turning them into spaces is what export does too.
26619
27481
  if (getLayout(o) === LINES_JOINED) {
26620
- content.appendChild(document.createTextNode(getRenderedContent(o.text)));
27482
+ full = getRenderedContent(o.text);
27483
+ appendRuns(content, full, o.bold, 0, full.length);
26621
27484
  return;
26622
27485
  }
26623
27486
  // A soft break keeps the space it broke at at the end of its line, where
26624
27487
  // the rendered label drops it: here every typed character has to be one
26625
27488
  // the caret can sit beside.
27489
+ //
27490
+ // The lines laid end to end are the edited text character for character --
27491
+ // each hard break is the placeholder that opens the line after it -- so a
27492
+ // line's offset in the one is its offset in the other.
26626
27493
  lines = getRenderedLines(o.text, breaks);
26627
- content.appendChild(document.createTextNode(lines[0]));
27494
+ full = lines.join('');
27495
+ pos = lines[0].length;
27496
+ appendRuns(content, full, o.bold, 0, pos);
26628
27497
  for (i = 1; i < lines.length; i++) {
26629
27498
  tspan = document.createElementNS(SVG_NS$3, 'tspan');
26630
27499
  tspan.setAttribute('x', o.nodes.text.getAttribute('x') || 0);
26631
27500
  tspan.setAttribute('dy', getLineHeight(o));
26632
- tspan.appendChild(document.createTextNode(lines[i]));
27501
+ appendRuns(tspan, full, o.bold, pos, pos + lines[i].length);
27502
+ pos += lines[i].length;
26633
27503
  content.appendChild(tspan);
26634
27504
  }
26635
27505
  }
26636
27506
 
27507
+ // Characters [start, end) of @str into @parent, the bold ones in bold
27508
+ // <tspan>s and the rest as text. An empty stretch still gets a text node, as
27509
+ // an empty line always did.
27510
+ function appendRuns(parent, str, bold, start, end) {
27511
+ var runs = internal.svg.getBoldRuns(str, bold, start, end);
27512
+ var tspan;
27513
+ if (runs.length === 0) {
27514
+ parent.appendChild(document.createTextNode(''));
27515
+ return;
27516
+ }
27517
+ runs.forEach(function(run) {
27518
+ if (!run.bold) {
27519
+ parent.appendChild(document.createTextNode(run.text));
27520
+ return;
27521
+ }
27522
+ tspan = document.createElementNS(SVG_NS$3, 'tspan');
27523
+ tspan.setAttribute('font-weight', internal.svg.LABEL_BOLD_WEIGHT);
27524
+ tspan.appendChild(document.createTextNode(run.text));
27525
+ parent.appendChild(tspan);
27526
+ });
27527
+ }
27528
+
26637
27529
  // Cached per session: asking costs a record lookup and a geometry test, and
26638
27530
  // the answer cannot change while a session is open.
26639
27531
  function getLayout(o) {
@@ -26759,18 +27651,33 @@
26759
27651
  return el;
26760
27652
  }
26761
27653
 
27654
+ // Updated in place rather than rebuilt. Every caret move redraws the
27655
+ // overlay, and a click is one: a region replaced by the first click of a
27656
+ // double-click takes the node the button went down on out of the document,
27657
+ // and the browser then fires neither the click nor the dblclick.
26762
27658
  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;
27659
+ var area = g.querySelector('.label-edit-hit-area');
27660
+ var line = g.querySelector('.label-edit-hit-baseline');
27661
+ if (box && area) {
27662
+ setRect(area, box);
27663
+ } else if (box) {
27664
+ g.insertBefore(rect(box, 'label-edit-hit-area'), g.firstChild);
27665
+ } else if (area) {
27666
+ g.removeChild(area);
27667
+ }
27668
+ if (!pathId) {
27669
+ if (line) g.removeChild(line);
27670
+ return;
27671
+ }
26767
27672
  // A thickened, transparent copy of the baseline: about one em wide, so that
26768
27673
  // 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);
27674
+ if (!line) {
27675
+ line = document.createElementNS(SVG_NS$3, 'use');
27676
+ line.setAttribute('class', 'label-edit-hit-baseline');
27677
+ g.appendChild(line);
27678
+ }
27679
+ line.setAttribute('href', '#' + pathId);
27680
+ line.setAttribute('stroke-width', caret ? caret.ascent + caret.descent : 12);
26774
27681
  }
26775
27682
 
26776
27683
  function getLabelPathId(nodes) {
@@ -26863,12 +27770,16 @@
26863
27770
 
26864
27771
  function rect(box, className) {
26865
27772
  var el = document.createElementNS(SVG_NS$3, 'rect');
27773
+ setRect(el, box);
27774
+ el.setAttribute('class', className);
27775
+ return el;
27776
+ }
27777
+
27778
+ function setRect(el, box) {
26866
27779
  el.setAttribute('x', box.x);
26867
27780
  el.setAttribute('y', box.y);
26868
27781
  el.setAttribute('width', box.width);
26869
27782
  el.setAttribute('height', box.height);
26870
- el.setAttribute('class', className);
26871
- return el;
26872
27783
  }
26873
27784
 
26874
27785
  function caretLine(caret) {
@@ -26898,6 +27809,7 @@
26898
27809
 
26899
27810
  textarea.addEventListener('input', function() {
26900
27811
  if (!session) return;
27812
+ session.bold = updateBoldForEdit(session.bold, session.text, textarea.value);
26901
27813
  session.text = textarea.value;
26902
27814
  self.refresh();
26903
27815
  });
@@ -26922,6 +27834,13 @@
26922
27834
  return !e.isComposing && e.keyCode != 229;
26923
27835
  }
26924
27836
 
27837
+ // Cmd-B on a Mac, Ctrl-B elsewhere, as in every text editor. A textarea
27838
+ // has no default for it to replace.
27839
+ function isBoldKey(e) {
27840
+ if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey) return false;
27841
+ return (e.key || '').toLowerCase() == 'b';
27842
+ }
27843
+
26925
27844
  textarea.addEventListener('keydown', function(e) {
26926
27845
  if (!session) return;
26927
27846
  // The GUI's global key handlers are told to keep out of the way by
@@ -26948,6 +27867,10 @@
26948
27867
  e.preventDefault();
26949
27868
  e.stopPropagation();
26950
27869
  self.close();
27870
+ } else if (isBoldKey(e)) {
27871
+ e.preventDefault();
27872
+ e.stopPropagation();
27873
+ self.toggleBold();
26951
27874
  } else {
26952
27875
  e.stopPropagation();
26953
27876
  }
@@ -27001,10 +27924,11 @@
27001
27924
  return textarea ? textarea.selectionEnd : 0;
27002
27925
  }
27003
27926
 
27004
- function setCaret(start, end) {
27927
+ // @direction: 'forward' (the default) or 'backward', which end of the
27928
+ // selection shift-arrow moves
27929
+ function setCaret(start, end, direction) {
27005
27930
  if (!textarea) return;
27006
- textarea.selectionStart = start;
27007
- textarea.selectionEnd = end;
27931
+ textarea.setSelectionRange(start, end, direction || 'forward');
27008
27932
  }
27009
27933
  }
27010
27934
 
@@ -27697,7 +28621,7 @@
27697
28621
  }
27698
28622
 
27699
28623
  // 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
28624
+ // the result into a -labels coordinates= command, and handing the new label to the
27701
28625
  // in-place text editor.
27702
28626
  //
27703
28627
  // Creating a label and editing one are one gesture, not two: a click leaves a
@@ -27761,6 +28685,14 @@
27761
28685
  // The mouseup that ended the last block drag, or null. A short one is
27762
28686
  // followed by a click from the same mouseup, which it has already handled.
27763
28687
  var blockRelease = null;
28688
+ // A drag across the text of the label being typed into, which selects the
28689
+ // characters it passes over: {start}, where start is the pointer's position
28690
+ // at the press, filled in by the first drag event.
28691
+ var selectDrag = null;
28692
+ // The mouseup that ended the last select drag, or null, for the same reason
28693
+ // as blockRelease: the click that follows it would put the caret down and
28694
+ // lose the selection just made.
28695
+ var selectRelease = null;
27764
28696
  var editor = new LabelEditor(gui, ext);
27765
28697
  var selection = new LabelSelection(gui, ext, hit, function() {
27766
28698
  return editor.isOpen() ? editor.getFeatureId() : -1;
@@ -27790,7 +28722,7 @@
27790
28722
  // tools do on entry as well (`addEmptyLayer()` in `gui-edit-points.mjs` and
27791
28723
  // `gui-draw-lines2.mjs`). It is named rather than left unnamed, unlike
27792
28724
  // 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
28725
+ // anyway, and naming it is what lets the `-labels` commands name their
27794
28726
  // target and so replay from the session history.
27795
28727
  //
27796
28728
  // Called before entering the tool's own mode, because getInitialTool() reads
@@ -27840,6 +28772,8 @@
27840
28772
  handleDrag = null;
27841
28773
  releasedDrag = null;
27842
28774
  blockRelease = null;
28775
+ selectDrag = null;
28776
+ selectRelease = null;
27843
28777
  shownHandles = null;
27844
28778
  clearBlockDrag();
27845
28779
  hoverHandle = null;
@@ -27966,6 +28900,10 @@
27966
28900
  blockRelease = null;
27967
28901
  return;
27968
28902
  }
28903
+ if (selectRelease && getDomEvent(e) == selectRelease) {
28904
+ selectRelease = null;
28905
+ return;
28906
+ }
27969
28907
  if (armed == 'path' && drawingCurve()) {
27970
28908
  extendCurve(pixToMapCoords(e.x, e.y));
27971
28909
  return;
@@ -28038,9 +28976,34 @@
28038
28976
  e.flipLabel = getFlipAction(target, id);
28039
28977
  }
28040
28978
  }
28979
+ if (target && editor.isOpen() && clickIsOnEditedLabel(e)) {
28980
+ addBoldAction(e, target);
28981
+ }
28041
28982
  gui.contextMenu.open(e, target);
28042
28983
  });
28043
28984
 
28985
+ // Bold for the words being typed: the selection, or with nothing selected,
28986
+ // the word that was right-clicked. The item says what it will do, since a
28987
+ // menu has no pressed state to show that the words are bold already. There
28988
+ // is none in a font with no bold face (see fontHasBoldFace()).
28989
+ function addBoldAction(e, target) {
28990
+ var range = editor.getBoldTargetAtPoint(getLabelSpacePoint(editor.getAnchorCoords(), e));
28991
+ if (!range || !fontHasBoldFace(getEditedFontName(target))) return;
28992
+ e.boldText = {
28993
+ label: range.bold ? 'remove bold' : 'bold text',
28994
+ run: function() { editor.toggleBold(range.start, range.end); }
28995
+ };
28996
+ }
28997
+
28998
+ // The font the label being typed into is drawn in: its own, or for a label
28999
+ // not created yet, the one the panel will give it.
29000
+ function getEditedFontName(target) {
29001
+ var id = editor.getFeatureId();
29002
+ var rec = id > -1 ? getRecord(target, id) : getNewLabelStyle(gui);
29003
+ var font = rec && rec['font-family'];
29004
+ return font || (id > -1 ? getDefaultFontName() : getNewLabelFontName());
29005
+ }
29006
+
28044
29007
  // The label a right-click was aimed at, or -1.
28045
29008
  //
28046
29009
  // A label being typed into is not a hit target -- the editor's own overlay
@@ -28296,7 +29259,16 @@
28296
29259
  // takes a drag -- everything else over a label is left alone so that the map
28297
29260
  // still pans, which is why these handlers stop the event themselves.
28298
29261
  hit.on('dragstart', function(e) {
28299
- if (!active() || drawingCurve() || editor.isOpen()) return;
29262
+ if (!active() || drawingCurve()) return;
29263
+ // With a label open for typing, a drag across its text selects some of it,
29264
+ // as it would in any text field. Anywhere else the map still pans.
29265
+ if (editor.isOpen()) {
29266
+ if (clickIsOnEditedLabel(e)) {
29267
+ selectDrag = {start: null};
29268
+ consumeDrag(e);
29269
+ }
29270
+ return;
29271
+ }
28300
29272
  // The handle comes from the last hover, not from testing the pointer here.
28301
29273
  // dragstart arrives with the pointer already moved off the handle -- by the
28302
29274
  // first mousemove that made it a drag, which on a quick gesture is further
@@ -28321,6 +29293,22 @@
28321
29293
  consumeDrag(e);
28322
29294
  });
28323
29295
 
29296
+ // The selection runs from where the button went down, which dragstart does
29297
+ // not report -- it arrives after the first move -- but every drag event
29298
+ // carries its distance from the press.
29299
+ function updateSelectDrag(e) {
29300
+ var anchor = editor.getAnchorCoords();
29301
+ if (!editor.isOpen() || !anchor) {
29302
+ selectDrag = null;
29303
+ return;
29304
+ }
29305
+ if (!selectDrag.start) {
29306
+ selectDrag.start = {x: e.x - (e.dragX || 0), y: e.y - (e.dragY || 0)};
29307
+ }
29308
+ editor.selectBetweenPoints(getLabelSpacePoint(anchor, selectDrag.start),
29309
+ getLabelSpacePoint(anchor, e));
29310
+ }
29311
+
28324
29312
  // handle: {id, index, point, pointer} from findHandle(), or the same shape
28325
29313
  // made up for a drag that grabbed a label somewhere other than its knot
28326
29314
  function beginKnotDrag(handle) {
@@ -28346,6 +29334,11 @@
28346
29334
 
28347
29335
  hit.on('drag', function(e) {
28348
29336
  var shp, p;
29337
+ if (selectDrag) {
29338
+ consumeDrag(e);
29339
+ updateSelectDrag(e);
29340
+ return;
29341
+ }
28349
29342
  if (textDrag) {
28350
29343
  consumeDrag(e);
28351
29344
  updateTextDrag(e);
@@ -28380,6 +29373,12 @@
28380
29373
 
28381
29374
  hit.on('dragend', function(e) {
28382
29375
  var o = drag;
29376
+ if (selectDrag) {
29377
+ selectDrag = null;
29378
+ selectRelease = getDomEvent(e);
29379
+ consumeDrag(e);
29380
+ return;
29381
+ }
28383
29382
  if (textDrag) {
28384
29383
  o = textDrag;
28385
29384
  textDrag = null;
@@ -29020,9 +30019,9 @@
29020
30019
  selection.refresh(true);
29021
30020
  }
29022
30021
 
29023
- // Turns the previewed offset into one -style.
30022
+ // Turns the previewed offset into one -labels.
29024
30023
  //
29025
- // Only the label under the pointer moves, even with several selected: -style
30024
+ // Only the label under the pointer moves, even with several selected: -labels
29026
30025
  // writes one value to every id it is given, so a group drag would set them
29027
30026
  // all to the same absolute offset rather than nudging each by its own delta.
29028
30027
  function commitOffsetDrag(o) {
@@ -29166,7 +30165,7 @@
29166
30165
  }
29167
30166
 
29168
30167
  function dragging() {
29169
- return !!(drag || textDrag || handleDrag || blockDrag);
30168
+ return !!(drag || textDrag || handleDrag || blockDrag || selectDrag);
29170
30169
  }
29171
30170
 
29172
30171
  // The exception is about the pointer being on the glyphs, not about the mode:
@@ -29284,6 +30283,12 @@
29284
30283
  resetHandle(hoverHandle);
29285
30284
  return;
29286
30285
  }
30286
+ // Inside the text being typed, a double-click selects a word -- which is
30287
+ // the quickest way to pick the words to set in bold.
30288
+ if (editor.isOpen() && clickIsOnEditedLabel(e)) {
30289
+ editor.selectWordAtPoint(getLabelSpacePoint(editor.getAnchorCoords(), e));
30290
+ return;
30291
+ }
29287
30292
  // Double-clicking a label reaches straight into its text, which is the
29288
30293
  // gesture most editors use and saves selecting it first. The single-click
29289
30294
  // route got there already if the label was the selection; this covers the
@@ -30411,7 +31416,7 @@
30411
31416
  canv.setAttribute('height', h);
30412
31417
  ctx.scale(w / tw, h / th);
30413
31418
  if (o.background) {
30414
- ctx.fillStyle = o.background;
31419
+ ctx.fillStyle = getCanvasColor(o.background);
30415
31420
  ctx.fillRect(0, 0, tw, th);
30416
31421
  }
30417
31422
  if (o.type == 'dots' || o.type == 'squares') makeDotFill(o, ctx, 1);
@@ -30475,10 +31480,16 @@
30475
31480
  };
30476
31481
  }
30477
31482
 
31483
+ // SVG's "none" is not a canvas colour, and a canvas ignores a fillStyle it
31484
+ // cannot parse -- keeping the last one set, which on a fresh tile is black.
31485
+ function getCanvasColor(color) {
31486
+ return String(color).trim().toLowerCase() == 'none' ? 'transparent' : color;
31487
+ }
31488
+
30478
31489
  function makeDashFill(o, ctx, res) {
30479
31490
  var x = 0;
30480
31491
  for (var i=0; i<o.colors.length; i++) {
30481
- ctx.fillStyle = o.colors[i];
31492
+ ctx.fillStyle = getCanvasColor(o.colors[i]);
30482
31493
  ctx.fillRect(x, 0, o.width * res, o.dashes[0] * res);
30483
31494
  x += res * (o.spacing + o.width);
30484
31495
  }
@@ -30493,7 +31504,7 @@
30493
31504
  var x = 0, y = 0;
30494
31505
  for (var i=0; i<dots; i++) {
30495
31506
  if (o.type == 'dots') ctx.beginPath();
30496
- ctx.fillStyle = o.colors[(i + Math.floor(i / n)) % n];
31507
+ ctx.fillStyle = getCanvasColor(o.colors[(i + Math.floor(i / n)) % n]);
30497
31508
  if (o.type == 'dots') {
30498
31509
  ctx.arc(x + r, y + r, r, 0, Math.PI * 2);
30499
31510
  } else {
@@ -30510,8 +31521,8 @@
30510
31521
  var w;
30511
31522
  for (var i=0, x=0; i<o.widths.length; i++) {
30512
31523
  w = o.widths[i] * res;
30513
- ctx.fillStyle = o.colors[i];
30514
- ctx.fillRect(x, 0, x + w, h);
31524
+ ctx.fillStyle = getCanvasColor(o.colors[i]);
31525
+ ctx.fillRect(x, 0, w, h);
30515
31526
  x += w;
30516
31527
  }
30517
31528
  }