mapshaper 0.7.68 → 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
 
@@ -3324,7 +3324,7 @@
3324
3324
  return str;
3325
3325
  }
3326
3326
 
3327
- function formatValue(val, matches) {
3327
+ function formatValue$2(val, matches) {
3328
3328
  var flags = matches[1];
3329
3329
  var padding = matches[2];
3330
3330
  var decimals = matches[3] ? parseInt(matches[3].substr(1)) : void 0;
@@ -3420,7 +3420,7 @@
3420
3420
  error$1("[format()] Data does not match format string; format:", fmt, "data:", arguments);
3421
3421
  }
3422
3422
  for (var i=0; i<n; i++) {
3423
- str += formatValue(arguments[i], formatCodes[i]) + literals[i+1];
3423
+ str += formatValue$2(arguments[i], formatCodes[i]) + literals[i+1];
3424
3424
  }
3425
3425
  return str;
3426
3426
  };
@@ -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,
@@ -8623,10 +8623,10 @@
8623
8623
  }
8624
8624
 
8625
8625
  // Whether a layer can be shown on the map alongside the active layer, and so
8626
- // has an eye icon in the layers panel. A frame is drawn by preview mode, and a
8627
- // data-only layer has nothing to draw.
8626
+ // has an eye icon in the layers panel. A frame and its furniture are drawn by
8627
+ // preview mode, and a data-only layer has nothing to draw.
8628
8628
  function layerIsPinnable(lyr, dataset) {
8629
- if (dataset && internal.isFrameLayer(lyr, dataset.arcs)) return false;
8629
+ if (internal.isFrameComponentLayer(lyr, dataset)) return false;
8630
8630
  return internal.layerIsGeometric(lyr) || internal.layerHasRaster(lyr) ||
8631
8631
  internal.layerHasFurniture(lyr);
8632
8632
  }
@@ -10888,6 +10888,9 @@
10888
10888
  if (opts.format == 'svg' || opts.format == 'topojson') {
10889
10889
  opts.gui_frame = getGuiFrameContext();
10890
10890
  }
10891
+ if (opts.format == 'svg') {
10892
+ targets = addFrameFurnitureTargets(targets, opts.gui_frame);
10893
+ }
10891
10894
  try {
10892
10895
  var files = await internal.exportTargetLayers(model, targets, opts);
10893
10896
  } catch(e) {
@@ -10953,7 +10956,7 @@
10953
10956
  function initLayerMenu() {
10954
10957
  var list = menu.findChild('.export-layer-list').empty();
10955
10958
  var layers = model.getLayers().filter(function(o) {
10956
- return !internal.isFrameLayer(o.layer, o.dataset.arcs);
10959
+ return !internal.isFrameComponentLayer(o.layer, o.dataset);
10957
10960
  });
10958
10961
  sortLayersForMenuDisplay(layers);
10959
10962
 
@@ -11147,21 +11150,42 @@
11147
11150
  };
11148
11151
  }
11149
11152
 
11153
+ // Snapshots keep the frame and its furniture
11150
11154
  function addFrameTarget(targets) {
11151
11155
  var frame = internal.getActiveFrame(model);
11152
- var group;
11153
11156
  if (!frame) return targets;
11157
+ return addDatasetTargets(targets, frame.dataset,
11158
+ [frame.layer].concat(internal.getFrameFurnitureLayers(frame.dataset)));
11159
+ }
11160
+
11161
+ // The frame's furniture is drawn in SVG output, the way it is in preview.
11162
+ // An unprojected frame can't show a scalebar, so it doesn't block the export.
11163
+ function addFrameFurnitureTargets(targets, frameContext) {
11164
+ var frame = internal.getActiveFrame(model);
11165
+ var furniture = frame ? internal.getFrameFurnitureLayers(frame.dataset) : [];
11166
+ if (furniture.length === 0) return targets;
11167
+ if (internal.getFurnitureFrameProblem(frameContext && frameContext.data)) {
11168
+ console.warn('Map furniture was not exported: the map frame is unprojected');
11169
+ return targets;
11170
+ }
11171
+ return addDatasetTargets(targets, frame.dataset, furniture);
11172
+ }
11173
+
11174
+ function addDatasetTargets(targets, dataset, layers) {
11175
+ var group;
11154
11176
  targets = targets.map(function(target) {
11155
11177
  return Object.assign({}, target, {layers: target.layers.slice()});
11156
11178
  });
11157
11179
  group = targets.find(function(target) {
11158
- return target.dataset == frame.dataset;
11180
+ return target.dataset == dataset;
11159
11181
  });
11160
- if (group) {
11161
- if (!group.layers.includes(frame.layer)) group.layers.push(frame.layer);
11162
- } else {
11163
- targets.push({dataset: frame.dataset, layers: [frame.layer]});
11182
+ if (!group) {
11183
+ group = {dataset: dataset, layers: []};
11184
+ targets.push(group);
11164
11185
  }
11186
+ layers.forEach(function(lyr) {
11187
+ if (!group.layers.includes(lyr)) group.layers.push(lyr);
11188
+ });
11165
11189
  return targets;
11166
11190
  }
11167
11191
 
@@ -11456,6 +11480,9 @@
11456
11480
  if (e.frameProperties) {
11457
11481
  addMenuItem('frame properties', e.frameProperties, '');
11458
11482
  }
11483
+ if (e.scalebarProperties) {
11484
+ addMenuItem(e.scalebarLabel || 'scale bar', e.scalebarProperties, '');
11485
+ }
11459
11486
  if (e.resizeFrame) {
11460
11487
  addMenuItem('resize frame', e.resizeFrame, '');
11461
11488
  }
@@ -11491,9 +11518,12 @@
11491
11518
  }
11492
11519
 
11493
11520
  if (e.deleteVertex || e.deletePoint || copyable || e.deleteFeature ||
11494
- e.flipLabel) {
11521
+ e.flipLabel || e.boldText) {
11495
11522
 
11496
11523
  addMenuLabel('actions');
11524
+ if (e.boldText) {
11525
+ addMenuItem(e.boldText.label, e.boldText.run);
11526
+ }
11497
11527
  if (e.deleteVertex) {
11498
11528
  addMenuItem('delete vertex', e.deleteVertex);
11499
11529
  }
@@ -11883,6 +11913,8 @@
11883
11913
  sortLayersForMenuDisplay(model.getLayers()).forEach(function(o) {
11884
11914
  var lyr = o.layer;
11885
11915
  var isFrame = internal.isFrameLayer(lyr, o.dataset.arcs);
11916
+ // furniture is shown as part of its frame's entry
11917
+ if (!isFrame && internal.isFrameComponentLayer(lyr, o.dataset)) return;
11886
11918
  var opts = {
11887
11919
  show_source: layerCount < 5,
11888
11920
  pinnable: pinnableCount > 0 && isPinnable(lyr, o.dataset)
@@ -11982,25 +12014,39 @@
11982
12014
  function deleteLayer() {
11983
12015
  var target = findLayerById(id);
11984
12016
  var undoTransaction;
12017
+ var furniture;
11985
12018
  if (!target) return;
12019
+ // a frame's furniture goes with it, rather than becoming a stray layer
12020
+ furniture = internal.isFrameLayer(target.layer, target.dataset.arcs) ?
12021
+ internal.getFrameFurnitureLayers(target.dataset) : [];
11986
12022
  undoTransaction = createUndoTransaction(gui, 'delete layer');
11987
12023
  if (map.isVisibleLayer(target.layer)) {
11988
12024
  // TODO: check for double map refresh after model.deleteLayer() below
11989
12025
  setLayerPinning(target.layer, false);
11990
12026
  }
11991
12027
  if (undoTransaction) {
11992
- undoTransaction.run(function() {
11993
- model.deleteLayer(target.layer, target.dataset);
11994
- });
12028
+ undoTransaction.run(deleteLayers);
11995
12029
  addUndoTransactionToHistory(gui, undoTransaction, {
11996
12030
  flags: {select: true, arc_count: true},
11997
12031
  entryPrefix: 'delete-layer'
11998
12032
  });
11999
12033
  } else {
12034
+ deleteLayers();
12035
+ }
12036
+
12037
+ function deleteLayers() {
12038
+ furniture.forEach(function(lyr) {
12039
+ model.deleteLayer(lyr, target.dataset);
12040
+ });
12000
12041
  model.deleteLayer(target.layer, target.dataset);
12001
12042
  }
12002
12043
  }
12003
12044
 
12045
+ function openScalebarProperties() {
12046
+ var target = findLayerById(id);
12047
+ if (target && gui.scalebarProperties) gui.scalebarProperties.open(target);
12048
+ }
12049
+
12004
12050
  // Duplicate a layer by running an equivalent command, so the copy is
12005
12051
  // recorded in session history and can be undone. Uses '-filter true +'
12006
12052
  // to append a copy without replacing the source, keeps the same name
@@ -12088,6 +12134,10 @@
12088
12134
  internal.isFrameLayer(target.layer, target.dataset.arcs);
12089
12135
  if (isFrame) {
12090
12136
  menuEvent.frameProperties = openFrameProperties;
12137
+ menuEvent.scalebarProperties = openScalebarProperties;
12138
+ menuEvent.scalebarLabel =
12139
+ internal.findFrameFurnitureLayer(target.dataset, 'scalebar') ?
12140
+ 'scale bar' : 'add scale bar';
12091
12141
  menuEvent.resizeFrame = resizeFrame;
12092
12142
  menuEvent.deleteFrame = deleteLayer;
12093
12143
  } else {
@@ -12857,6 +12907,11 @@
12857
12907
  if (cmd.name == 'style' || cmd.name == 'svg-style') {
12858
12908
  return getStyleCullInfo(opts, isStyleProperty);
12859
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
+ }
12860
12915
  if (cmd.name == 'classify') {
12861
12916
  return getClassifyCullInfo(opts);
12862
12917
  }
@@ -12876,6 +12931,16 @@
12876
12931
  };
12877
12932
  }
12878
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
+
12879
12944
  function getStyleFields$2(opts, isStyleProperty) {
12880
12945
  var fields = [];
12881
12946
  Object.keys(opts).forEach(function(name) {
@@ -13874,6 +13939,11 @@
13874
13939
  var SOFT_BREAK = '<wbr>';
13875
13940
  var SOFT_BREAK_RXP = /<wbr>/gi;
13876
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
+
13877
13947
  // How a label lays out its line breaks, which is not the same for the two kinds.
13878
13948
  // An anchored label stacks its lines with <tspan>. A path label joins them with
13879
13949
  // a space, because a <tspan> inside a <textPath> advances along the curve
@@ -13909,6 +13979,66 @@
13909
13979
  return {text: text, breaks: breaks};
13910
13980
  }
13911
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
+
13912
14042
  // The string the user edits, plus soft breaks at @breaks (ascending offsets
13913
14043
  // into it) -> the same string with the markers written in.
13914
14044
  function insertSoftBreaks(text, breaks) {
@@ -14089,6 +14219,29 @@
14089
14219
  return Math.min(renderedIndex, length);
14090
14220
  }
14091
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
+
14092
14245
  // Strips the placeholder back out, so that a value read off a rendered node is
14093
14246
  // not mistaken for text the user typed.
14094
14247
  function stripPlaceholder(str) {
@@ -14153,7 +14306,7 @@
14153
14306
  function getAddLabelCommand(coords, opts) {
14154
14307
  var o = opts || {};
14155
14308
  var target = o.target || {mode: 'new', newLayerName: DEFAULT_LABEL_LAYER_NAME};
14156
- var parts = ['-add-label', 'coordinates=' + formatCoords(coords)];
14309
+ var parts = ['-labels', 'coordinates=' + formatCoords(coords)];
14157
14310
  if (o.text) {
14158
14311
  // A real newline would break the command parser, so encodeLabelText()
14159
14312
  // writes the two-character escape the label renderer also accepts.
@@ -14220,7 +14373,7 @@
14220
14373
  // id: feature id of the label
14221
14374
  // target: layer name, or null to use the current target
14222
14375
  function getLabelPlacementCommand(opts) {
14223
- var parts = ['-style', 'label-start-offset=' + opts.offset];
14376
+ var parts = ['-labels', 'label-start-offset=' + opts.offset];
14224
14377
  if (opts.anchor) {
14225
14378
  parts.push('text-anchor=' + opts.anchor);
14226
14379
  }
@@ -14240,7 +14393,7 @@
14240
14393
  // with and the position goes -- "stop taking a position, carry these offsets
14241
14394
  // instead", which is one command because it is one edit.
14242
14395
  //
14243
- // 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
14244
14397
  // empty value as "remove this", and removing a property no record carries adds
14245
14398
  // nothing to the layer, so there is nothing to be gained by asking first.
14246
14399
  //
@@ -14250,7 +14403,7 @@
14250
14403
  // target: layer name, or null to use the current target
14251
14404
  // via: (optional) the callout-via the text carried with it
14252
14405
  function getLabelOffsetCommand(opts) {
14253
- var parts = ['-style', 'dx=' + opts.dx, 'dy=' + opts.dy,
14406
+ var parts = ['-labels', 'dx=' + opts.dx, 'dy=' + opts.dy,
14254
14407
  'text-anchor=' + opts.anchor, 'label-pos='];
14255
14408
  if (opts.via) parts.push('callout-via=' + quoteCommandValue(opts.via));
14256
14409
  parts.push('ids=' + opts.id);
@@ -14261,16 +14414,16 @@
14261
14414
  // The command a drag on one of a label's handles writes, run once on release.
14262
14415
  //
14263
14416
  // values: {field: value}, where '' removes the field -- which is what
14264
- // -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
14265
14418
  // id: feature id of the label
14266
14419
  // opts:
14267
14420
  // target: layer name, or null to use the current target
14268
14421
  // text: (optional) rewrapped label-text, with real newlines and soft
14269
- // 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
14270
14423
  // the lines it breaks into are one undo step.
14271
14424
  function getLabelStyleCommand(values, id, opts) {
14272
14425
  var o = opts || {};
14273
- var parts = ['-style'];
14426
+ var parts = ['-labels'];
14274
14427
  Object.keys(values).forEach(function(name) {
14275
14428
  var val = values[name];
14276
14429
  parts.push(name + '=' + (val === '' ? '' : quoteCommandValue(String(val))));
@@ -14292,7 +14445,7 @@
14292
14445
  function getLabelTextCommand(text, id, target) {
14293
14446
  // A real newline would break the command parser, so encodeLabelText() writes
14294
14447
  // the two-character escape that the label renderer also accepts.
14295
- var parts = ['-style', 'label-text=' + quoteCommandValue(encodeLabelText(text))];
14448
+ var parts = ['-labels', 'text=' + quoteCommandValue(encodeLabelText(text))];
14296
14449
  parts.push('ids=' + id);
14297
14450
  if (target) parts.push('target=' + quoteCommandValue(target));
14298
14451
  return parts.join(' ');
@@ -15407,6 +15560,19 @@
15407
15560
  return cachedFontStyles[fontName];
15408
15561
  }
15409
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
+
15410
15576
  // The installed font the browser draws unfonted text in, or '' if it cannot
15411
15577
  // be worked out.
15412
15578
  //
@@ -16569,6 +16735,15 @@
16569
16735
  !/^(button|checkbox|radio|submit)$/.test(node.type);
16570
16736
  }
16571
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
+
16572
16747
  // While the caret is in one of @panel's fields, the keyboard belongs to that
16573
16748
  // field. Without this the GUI's own handlers see the keystrokes: a Backspace
16574
16749
  // typed into a field takes back the last knot of a curve being drawn, and an
@@ -16612,20 +16787,43 @@
16612
16787
 
16613
16788
  // The fraction an opacity control's contents mean ("50%", " 50 " -> 0.5), or
16614
16789
  // null if it is not holding a number. Out-of-range values are clamped rather
16615
- // than refused: a pasted 150% is an intent, not a mistake.
16790
+ // than refused: a pasted 150% is an intent, not a mistake. A blank field is
16791
+ // null, not zero: it is what a colour with no value shows beside it.
16616
16792
  function parseOpacityValue(str) {
16617
- var pct = Number(String(str).replace('%', '').trim());
16618
- if (!isFinite(pct)) return null;
16793
+ var txt = String(str).replace('%', '').trim();
16794
+ var pct = Number(txt);
16795
+ if (txt === '' || !isFinite(pct)) return null;
16619
16796
  return Math.max(0, Math.min(100, pct)) / 100;
16620
16797
  }
16621
16798
 
16799
+ // A typed dash pattern in the form -style stroke-dasharray= takes: lengths
16800
+ // separated by single spaces. Commas and runs of whitespace are accepted
16801
+ // because SVG accepts them, and a pasted "4, 2" means the same as "4 2".
16802
+ // Returns '' for a blank field. Does not check that the lengths are numbers.
16803
+ function normalizeDashArrayInput(str) {
16804
+ return String(str).trim().split(/[\s,]+/).filter(Boolean).join(' ');
16805
+ }
16806
+
16622
16807
  // A stored fraction as the percentage a control shows, or '' for no value --
16623
16808
  // which is how a control over a selection that does not agree shows.
16624
16809
  function formatOpacityPct(val) {
16810
+ if (isUnsetValue(val)) return '';
16625
16811
  val = Number(val);
16626
16812
  return isFinite(val) ? Math.round(Math.max(0, Math.min(1, val)) * 100) + '%' : '';
16627
16813
  }
16628
16814
 
16815
+ // The opacity shown beside a colour. A colour with no opacity of its own is
16816
+ // drawn opaque, and says so; with no colour either, there is nothing for an
16817
+ // opacity to apply to, and the field is blank like the colour.
16818
+ function formatColorOpacityPct(opacity, hasColor) {
16819
+ if (isUnsetValue(opacity)) return hasColor ? '100%' : '';
16820
+ return formatOpacityPct(opacity);
16821
+ }
16822
+
16823
+ function isUnsetValue(val) {
16824
+ return val === undefined || val === null || val === '';
16825
+ }
16826
+
16629
16827
  // The parts the style panels are built from. They were the label panel's, and
16630
16828
  // three panels drawing the same control three ways is how they came to look
16631
16829
  // like three programs: a colour was a swatch inside a field in one and a
@@ -16653,6 +16851,52 @@
16653
16851
  return section;
16654
16852
  }
16655
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
+
16656
16900
  // Deliberately not focusable: the GUI is pointer-only, so a tab stop here
16657
16901
  // would lead into a control the keyboard cannot then operate. See the focus
16658
16902
  // note in page.css.
@@ -16685,6 +16929,16 @@
16685
16929
  return btn;
16686
16930
  }
16687
16931
 
16932
+ // The "?" the rest of the app uses for field help (see .tip-button in
16933
+ // elements.css and the static ones in index.html). The bubble is white-space:
16934
+ // pre, so the line breaks in the text are the ones it gets.
16935
+ function makeFieldTip(parent, text) {
16936
+ var btn = El('div').addClass('tip-button').appendTo(parent).text('?');
16937
+ var anchor = El('div').addClass('tip-anchor').appendTo(btn);
16938
+ El('div').addClass('tip').appendTo(anchor).text(text);
16939
+ return btn;
16940
+ }
16941
+
16688
16942
  function setPanelButtonDisabled(el, disabled) {
16689
16943
  el.classed('disabled', !!disabled)
16690
16944
  .attr('aria-disabled', disabled ? 'true' : 'false');
@@ -16719,6 +16973,8 @@
16719
16973
  // opts.onColor(hex) a colour was typed, picked or previewed to a finish
16720
16974
  // opts.onOpacity(frac) a usable percentage was typed
16721
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
16722
16978
  //
16723
16979
  // control.aside is the row's narrow column, empty, for a field that belongs
16724
16980
  // beside the colour -- a stroke's width, say.
@@ -16756,10 +17012,14 @@
16756
17012
  if (isHexColor(color)) control.picker.setColor(color);
16757
17013
  opts.onColor(color);
16758
17014
  });
16759
- control.opacity = makeColorOpacityField(colorCell, control.chit, control.input, {
16760
- onSet: opts.onOpacity,
16761
- revert: opts.revert
16762
- }).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
+ }
16763
17023
  control.picker = new ColorPicker(colorCell, {
16764
17024
  presetRows: layerColorPresetRows,
16765
17025
  // A drag in the picker shows on the map's own terms -- the swatch and the
@@ -16799,13 +17059,13 @@
16799
17059
  //
16800
17060
  // The style panel is worth having open before there is anything to point it at:
16801
17061
  // pick a font, then place labels in it. Values set with nothing selected are
16802
- // 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
16803
17063
  // label finally exists -- so this is a tool default, not data, and it never
16804
17064
  // needs its own undo step.
16805
17065
  //
16806
17066
  // See docs/development/label-tool-design.md.
16807
17067
 
16808
- // The style properties -add-label accepts. A panel control whose property is
17068
+ // The style properties -labels accepts. A panel control whose property is
16809
17069
  // missing from this list would appear to do nothing when set with no selection,
16810
17070
  // so the list is checked rather than assumed.
16811
17071
  var NEW_LABEL_STYLE_FIELDS = [
@@ -16827,7 +17087,7 @@
16827
17087
  //
16828
17088
  // A blank or zero value removes the field instead of setting it: that is how
16829
17089
  // the panel says "no icon" (icon='', icon-size=0) and "no inline css", and
16830
- // carrying those through to -add-label would write properties that mean
17090
+ // carrying those through to -labels would write properties that mean
16831
17091
  // nothing.
16832
17092
  function mergeStyleValues(style, values) {
16833
17093
  var out = Object.assign({}, style);
@@ -16853,7 +17113,7 @@
16853
17113
  // somewhere and typing looks like it should do, and it is the position an icon
16854
17114
  // is drawn to sit behind.
16855
17115
  //
16856
- // 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
16857
17117
  // label without one, so the default is a choice this tool makes and passes on
16858
17118
  // explicitly, not a meaning the command gives to its absence.
16859
17119
  var DEFAULT_NEW_LABEL_STYLE = {'label-pos': 'c'};
@@ -17167,13 +17427,17 @@
17167
17427
 
17168
17428
  // Offsets into @text (real newlines, no soft breaks) where a line starts
17169
17429
  // because it was wrapped, in ascending order.
17170
- 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) {
17171
17434
  var width = getLabelWrapWidth(rec);
17172
17435
  var key, breaks;
17173
17436
  if (!width || !text) return [];
17174
- key = getWrapKey(text, rec);
17437
+ bold = bold || [];
17438
+ key = getWrapKey(text, rec, bold);
17175
17439
  if (cache$2.has(key)) return cache$2.get(key);
17176
- breaks = measureSoftBreaks(text, rec, width);
17440
+ breaks = measureSoftBreaks(text, rec, width, bold);
17177
17441
  if (cache$2.size >= CACHE_LIMIT) cache$2.clear();
17178
17442
  cache$2.set(key, breaks);
17179
17443
  return breaks;
@@ -17183,29 +17447,30 @@
17183
17447
  // unchanged when neither the old value nor the new one has a soft break, so
17184
17448
  // that a label that does not wrap keeps the line breaks it was written with.
17185
17449
  function rewrapLabelValue(value, rec) {
17186
- var o = readSoftBreaks(value);
17187
- var breaks = getSoftBreaks(o.text, rec);
17450
+ var o = readLabelValue(value);
17451
+ var breaks = getSoftBreaks(o.text, rec, o.bold);
17188
17452
  if (sameSoftBreaks(o.breaks, breaks)) return value;
17189
- return encodeLabelText(insertSoftBreaks(o.text, breaks));
17453
+ return encodeLabelText(writeLabelValue(o.text, breaks, o.bold));
17190
17454
  }
17191
17455
 
17192
17456
  function clearWrapCache() {
17193
17457
  cache$2.clear();
17194
17458
  }
17195
17459
 
17196
- function getWrapKey(text, rec) {
17460
+ function getWrapKey(text, rec, bold) {
17197
17461
  var parts = [text];
17198
17462
  WRAP_FIELDS.forEach(function(name) {
17199
17463
  var val = rec[name];
17200
17464
  parts.push(val === null || val === undefined ? '' : String(val));
17201
17465
  });
17202
17466
  parts.push(rec['class'] || '');
17467
+ parts.push(bold.join(';'));
17203
17468
  return parts.join('\u0000');
17204
17469
  }
17205
17470
 
17206
- function measureSoftBreaks(text, rec, width) {
17471
+ function measureSoftBreaks(text, rec, width, bold) {
17207
17472
  var w = getWrapper();
17208
- var style, fontSize, node, range;
17473
+ var style, fontSize, pieces, range;
17209
17474
  if (!w) return [];
17210
17475
  style = getLabelFontStyle(w, rec);
17211
17476
  if (!style) return [];
@@ -17213,15 +17478,15 @@
17213
17478
  w.div.style[name] = style[name] || '';
17214
17479
  });
17215
17480
  w.div.style.width = width + 'px';
17216
- w.div.textContent = text;
17217
- node = w.div.firstChild;
17481
+ pieces = writeRuns(w.div, text, bold);
17218
17482
  fontSize = parseFloat(style.fontSize) || 12;
17219
17483
  range = document.createRange();
17220
17484
  var breaks = findSoftBreaks(text, function(i) {
17485
+ var piece = findPiece(pieces, i);
17221
17486
  var end = isHighSurrogate(text.charCodeAt(i)) ? i + 2 : i + 1;
17222
17487
  var r;
17223
- range.setStart(node, i);
17224
- 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);
17225
17490
  r = range.getBoundingClientRect();
17226
17491
  return r.height > 0 ? r.top : null;
17227
17492
  }, fontSize * LINE_HEIGHT / 2);
@@ -17229,6 +17494,35 @@
17229
17494
  return breaks;
17230
17495
  }
17231
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
+
17232
17526
  function isHighSurrogate(c) {
17233
17527
  return c >= 0xD800 && c <= 0xDBFF;
17234
17528
  }
@@ -17700,7 +17994,7 @@
17700
17994
  // spacing sits above.
17701
17995
  haloSection = addSection('Halo');
17702
17996
  var haloTitle = haloSection.findChild('.label-style-section-title');
17703
- haloToggle = makeToggle(haloTitle, {
17997
+ haloToggle = makePanelToggle(haloTitle, {
17704
17998
  title: 'Draw a halo around the text',
17705
17999
  className: 'label-halo-toggle',
17706
18000
  onChange: setHaloOn
@@ -17751,7 +18045,7 @@
17751
18045
  // a label with no icon: nothing to apply it to.
17752
18046
  iconSection = addSection('Icon');
17753
18047
  var iconTitle = iconSection.findChild('.label-style-section-title');
17754
- iconToggle = makeToggle(iconTitle, {
18048
+ iconToggle = makePanelToggle(iconTitle, {
17755
18049
  title: 'Draw a symbol at the label anchor',
17756
18050
  className: 'label-icon-toggle',
17757
18051
  onChange: setIconOn
@@ -17858,7 +18152,7 @@
17858
18152
  // the map rather than typed here. See docs/development/text-annotation-design.md.
17859
18153
  function initCalloutSection() {
17860
18154
  calloutSection = addSection('Callout');
17861
- calloutToggle = makeToggle(calloutSection.findChild('.label-style-section-title'), {
18155
+ calloutToggle = makePanelToggle(calloutSection.findChild('.label-style-section-title'), {
17862
18156
  title: 'Draw a line from the label anchor to its text',
17863
18157
  className: 'label-callout-toggle',
17864
18158
  onChange: setCalloutOn
@@ -17979,48 +18273,6 @@
17979
18273
  return makePanelSection(panel, title, opts);
17980
18274
  }
17981
18275
 
17982
- // A switch: a track with a knob that sits left when off and right when on,
17983
- // which is the direction users expect and the only thing that says which
17984
- // state is which without a label for each. A third state says that the
17985
- // labels it is asking about disagree -- the knob sits over the join of a
17986
- // half-and-half track, which is the panel's only control that has to show
17987
- // "some of them" rather than a value.
17988
- //
17989
- // role=checkbox rather than switch, which is what it looks like: 'mixed' is a
17990
- // legal aria-checked value for a checkbox and not for a switch, and a switch
17991
- // reporting a mixed selection as unchecked would be telling a screen reader
17992
- // the one thing the third state exists to avoid saying.
17993
- function makeToggle(parent, opts) {
17994
- var track = El('div').addClass('label-toggle').attr('role', 'checkbox').appendTo(parent);
17995
- var state = 'off';
17996
- if (opts.className) track.addClass(opts.className);
17997
- var disabled = false;
17998
- El('div').addClass('label-toggle-knob').appendTo(track);
17999
- if (opts.title) track.attr('title', opts.title);
18000
- // A click on a mixed switch turns everything on. It is the convention, and
18001
- // it is the reading that reaches a state the switch can describe: the next
18002
- // click then turns everything off, so both are one click away.
18003
- track.on('click', function() {
18004
- if (disabled) return;
18005
- opts.onChange(state != 'on');
18006
- });
18007
- return {
18008
- // val: 'on', 'off' or 'mixed'; anything else reads as off
18009
- setState: function(val) {
18010
- state = val == 'on' || val == 'mixed' ? val : 'off';
18011
- track.classed('on', state == 'on')
18012
- .classed('mixed', state == 'mixed')
18013
- .attr('aria-checked', state == 'mixed' ? 'mixed' :
18014
- state == 'on' ? 'true' : 'false');
18015
- },
18016
- setDisabled: function(off) {
18017
- disabled = !!off;
18018
- track.classed('disabled', disabled)
18019
- .attr('aria-disabled', disabled ? 'true' : 'false');
18020
- }
18021
- };
18022
- }
18023
-
18024
18276
  function addColorOpacityField(parent, chit, input, onOpacity) {
18025
18277
  return makeColorOpacityField(parent, chit, input, {
18026
18278
  onSet: onOpacity,
@@ -19348,14 +19600,6 @@
19348
19600
  return !!node && /^(INPUT|SELECT|TEXTAREA|BUTTON|OPTION)$/.test(node.nodeName);
19349
19601
  }
19350
19602
 
19351
- // Whether clicking this node puts a native menu on screen, which must then be
19352
- // left holding the focus. OPTION counts because a browser that reports the
19353
- // chosen option as the click target is reporting a menu interaction either
19354
- // way, and a choice restores the caret through its change handler.
19355
- function opensAMenu(node) {
19356
- return !!node && /^(SELECT|OPTION)$/.test(node.nodeName);
19357
- }
19358
-
19359
19603
  // Hands the keyboard back after a control has done its job: to the label
19360
19604
  // being typed into if there is one, and to nothing at all otherwise.
19361
19605
  function releaseFocus() {
@@ -19374,7 +19618,7 @@
19374
19618
  if (styles.length === 0) return;
19375
19619
  releaseFocus();
19376
19620
  // What the panel is set to is always what the next label gets, whether or
19377
- // 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
19378
19622
  // writes them when that label is created, which is why this needs no
19379
19623
  // command and no undo step of its own.
19380
19624
  if (labelModeIsOn()) {
@@ -19398,7 +19642,7 @@
19398
19642
 
19399
19643
  function applyStyleCommand(styles, ids) {
19400
19644
  var lyr = getActiveLayer();
19401
- var parts = ['-style'];
19645
+ var parts = ['-labels'];
19402
19646
  if (!gui.console || !lyr) return;
19403
19647
  styles.forEach(function(style) {
19404
19648
  parts.push(style[0] + '=' + quoteCommandValue(style[1]));
@@ -19420,7 +19664,7 @@
19420
19664
  // A text block's breaks are only right for the width and font they were
19421
19665
  // found with, so a change to either rewraps it in the same command: one undo
19422
19666
  // step, and no moment at which the block is drawn with its old lines in its
19423
- // 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.
19424
19668
  function getRewrapCommands(styles, ids, lyr) {
19425
19669
  var table = lyr.data;
19426
19670
  var changes = {};
@@ -19554,13 +19798,480 @@
19554
19798
  return measureSvg;
19555
19799
  }
19556
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
+
19557
20268
  var savedStylesKey = 'layer_style_presets';
19558
- var styleFields = ['stroke', 'stroke-width', 'stroke-opacity', 'fill', 'fill-opacity'];
20269
+ var styleFields = ['stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'fill', 'fill-opacity', 'fill-pattern'];
19559
20270
 
19560
20271
  function LayerStyleTool(gui) {
19561
20272
  var parent = gui.container.findChild('.mshp-main-map');
19562
20273
  var panel = El('div').addClass('label-style-panel layer-style-panel rollover').appendTo(parent).hide();
19563
- var title, editingStatus, clearLink, strokeControl, fillControl, strokeWidthField, randomFillBtn, presetControl, hit;
20274
+ var title, editingStatus, clearLink, strokeControl, fillControl, strokeWidthField, dashControl, randomFillBtn, presetControl, patternControl, hit;
19564
20275
  var targetLayer = null;
19565
20276
 
19566
20277
  initPanel();
@@ -19605,6 +20316,7 @@
19605
20316
 
19606
20317
  function turnOn() {
19607
20318
  targetLayer = getActiveLayer();
20319
+ patternControl.reset();
19608
20320
  applyDefaultLineStyle();
19609
20321
  panel.show();
19610
20322
  updateControls();
@@ -19614,6 +20326,8 @@
19614
20326
  panel.hide();
19615
20327
  strokeControl.picker.hide();
19616
20328
  fillControl.picker.hide();
20329
+ patternControl.hidePicker();
20330
+ patternControl.reset();
19617
20331
  targetLayer = null;
19618
20332
  }
19619
20333
 
@@ -19630,8 +20344,8 @@
19630
20344
  // picker's Close button, and the panel's own buttons in the browsers that
19631
20345
  // focus a button on click. Left alone while the user is typing into a
19632
20346
  // field, which a click elsewhere in the panel ends on its own.
19633
- panel.node().addEventListener('click', function() {
19634
- if (isTextInput(document.activeElement)) return;
20347
+ panel.node().addEventListener('click', function(e) {
20348
+ if (isTextInput(document.activeElement) || opensAMenu(e.target)) return;
19635
20349
  releaseFocus();
19636
20350
  });
19637
20351
 
@@ -19646,11 +20360,22 @@
19646
20360
  fillControl = addColorControl(panel, 'Fill', 'fill', '');
19647
20361
  strokeControl = addColorControl(panel, 'Stroke', 'stroke', '#000000');
19648
20362
  strokeWidthField = addStrokeWidthControl(strokeControl.aside);
20363
+ dashControl = addDashArrayControl(panel);
19649
20364
 
19650
20365
  var buttonRow = El('div').addClass('label-style-row label-panel-button-row').appendTo(panel);
19651
20366
  randomFillBtn = makePanelActionButton(buttonRow, 'Random fills', applyRandomFillColors);
19652
20367
  makePanelActionButton(buttonRow, 'Clear style', clearLayerStyle);
19653
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
+
19654
20379
  presetControl = new StylePresetControl(panel, {
19655
20380
  storageKey: savedStylesKey,
19656
20381
  type: getStyleType,
@@ -19706,6 +20431,29 @@
19706
20431
  });
19707
20432
  }
19708
20433
 
20434
+ // A literal stroke-dasharray, in the wide column so that it lines up with
20435
+ // the stroke's colour above it. A blank field makes the line solid again.
20436
+ function addDashArrayControl(parent) {
20437
+ var row = El('div').addClass('label-style-row label-split-row layer-dash-row').appendTo(parent);
20438
+ var cell = El('div').addClass('label-split-cell').appendTo(row);
20439
+ El('div').addClass('label-split-cell').appendTo(row);
20440
+ var caption = El('div').addClass('label-style-row-label').appendTo(cell).text('Dashes');
20441
+ makeFieldTip(caption,
20442
+ 'Dash and gap lengths in pixels, separated\n' +
20443
+ 'by spaces. "4" gives 4px dashes and 4px gaps.\n' +
20444
+ '"6 3" gives 6px dashes and 3px gaps.');
20445
+ var input = El('input').attr('type', 'text').appendTo(cell)
20446
+ .on('change', function() {
20447
+ var value = normalizeDashArrayInput(input.node().value);
20448
+ if (value && internal.parseStyleLiteral('stroke-dasharray', value) === undefined) {
20449
+ updateControls();
20450
+ return;
20451
+ }
20452
+ applyDashArrayStyle(value);
20453
+ });
20454
+ return {row: row, input: input};
20455
+ }
20456
+
19709
20457
  function releaseFocus() {
19710
20458
  releasePanelFocus(panel.node());
19711
20459
  }
@@ -19719,10 +20467,14 @@
19719
20467
  updateEditingStatus(manualIds.length);
19720
20468
  strokeControl.row.show();
19721
20469
  fillControl.row.classed('hidden', geom != 'polygon');
20470
+ dashControl.row.classed('hidden', geom != 'polyline');
19722
20471
  updateColorControl(strokeControl);
19723
20472
  updateColorControl(fillControl);
19724
20473
  updateStrokeWidthControl();
20474
+ updateDashArrayControl();
19725
20475
  randomFillBtn.classed('hidden', geom != 'polygon');
20476
+ patternControl.section.classed('hidden', geom != 'polygon');
20477
+ if (geom == 'polygon') patternControl.update();
19726
20478
  presetControl.render();
19727
20479
  updateSavedStyleControls();
19728
20480
  }
@@ -19735,10 +20487,13 @@
19735
20487
  if (!isHexColor(value)) control.picker.hide();
19736
20488
  }
19737
20489
 
20490
+ // A selection whose colours disagree still has colours, so an opacity unset
20491
+ // on all of it shows as full rather than blank.
19738
20492
  function updateOpacityControl(control) {
19739
- var value = getCommonStyleValue(control.field + '-opacity');
19740
- control.opacity.node().value =
19741
- formatOpacityPct(value === '' || value === undefined || value === null ? 1 : value);
20493
+ var field = control.field + '-opacity';
20494
+ control.opacity.node().value = styleFieldIsUnsetForTargets(field) ?
20495
+ formatColorOpacityPct(null, !styleFieldIsUnsetForTargets(control.field)) :
20496
+ formatOpacityPct(getCommonStyleValue(field));
19742
20497
  }
19743
20498
 
19744
20499
  function updateStrokeWidthControl() {
@@ -19748,6 +20503,11 @@
19748
20503
  getDefaultStrokeWidth() : value));
19749
20504
  }
19750
20505
 
20506
+ function updateDashArrayControl() {
20507
+ var value = getCommonStyleValue('stroke-dasharray');
20508
+ dashControl.input.node().value = value === undefined || value === null ? '' : String(value);
20509
+ }
20510
+
19751
20511
  function nudgeStrokeWidth(direction) {
19752
20512
  var value = Number(getCommonStyleValue('stroke-width'));
19753
20513
  if (!isFinite(value)) value = getDefaultStrokeWidth();
@@ -19778,8 +20538,12 @@
19778
20538
  if (control.field == 'stroke' && strokeWidthIsUnsetForTargets()) {
19779
20539
  styles.push(['stroke-width', 1]);
19780
20540
  }
19781
- runStyleCommand(styles);
19782
- }
20541
+ if (control.field == 'fill') {
20542
+ runStyleEdits(patternControl.getStyleEdits(styles));
20543
+ } else {
20544
+ runStyleCommand(styles);
20545
+ }
20546
+ }
19783
20547
 
19784
20548
  function applyStrokeWidthStyle(value) {
19785
20549
  var styles = [['stroke-width', value]];
@@ -19789,12 +20553,39 @@
19789
20553
  runStyleCommand(styles);
19790
20554
  }
19791
20555
 
20556
+ function applyDashArrayStyle(value) {
20557
+ var styles = [['stroke-dasharray', value]];
20558
+ if (value && styleFieldIsUnsetForTargets('stroke')) {
20559
+ styles.push(['stroke', strokeControl.defaultColor]);
20560
+ }
20561
+ runStyleCommand(styles);
20562
+ }
20563
+
19792
20564
  function runStyleCommand(styles) {
19793
- var parts = ['-style'];
19794
20565
  releaseFocus();
19795
20566
  syncTargetLayer();
19796
20567
  var ids = getTargetIds();
19797
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'];
19798
20589
  styles.forEach(function(style) {
19799
20590
  parts.push(style[0] + '=' + quoteCommandValue(style[1]));
19800
20591
  });
@@ -19802,7 +20593,7 @@
19802
20593
  if (ids.length < internal.getFeatureCount(targetLayer)) {
19803
20594
  parts.push('ids=' + ids.join(','));
19804
20595
  }
19805
- runCommand(parts.join(' '), 'Style layer');
20596
+ return parts.join(' ');
19806
20597
  }
19807
20598
 
19808
20599
  function applyDefaultLineStyle() {
@@ -19816,13 +20607,21 @@
19816
20607
  }
19817
20608
  }
19818
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.
19819
20613
  function applyRandomFillColors() {
19820
20614
  var cmd = '-classify colors=random non-adjacent';
20615
+ var patternEdits;
19821
20616
  syncTargetLayer();
19822
20617
  if (!gui.console || !targetLayer || targetLayer.geometry_type != 'polygon') return;
19823
20618
  if (getActiveLayer() != targetLayer) {
19824
20619
  cmd += ' target=' + internal.formatOptionValue(internal.getLayerTargetId(gui.model, targetLayer));
19825
20620
  }
20621
+ patternEdits = patternControl.getRefillExpressionEdits(getAllFeatureIds(targetLayer));
20622
+ if (patternEdits.length > 0) {
20623
+ cmd += ' ' + formatStyleEditCommands(patternEdits);
20624
+ }
19826
20625
  runCommand(cmd, 'Random fill colors');
19827
20626
  }
19828
20627
 
@@ -19841,7 +20640,7 @@
19841
20640
  }
19842
20641
  });
19843
20642
  if (styles.length > 0) {
19844
- runStyleCommand(styles);
20643
+ runStyleEdits(patternControl.getStyleEdits(styles));
19845
20644
  }
19846
20645
  }
19847
20646
 
@@ -19850,9 +20649,13 @@
19850
20649
  addStyleValue(style, 'stroke', getControlValue(strokeControl.input));
19851
20650
  addStyleValue(style, 'stroke-width', strokeWidthField.getValue());
19852
20651
  addStyleValue(style, 'stroke-opacity', parseOpacityValue(strokeControl.opacity.node().value));
20652
+ if (targetLayer && targetLayer.geometry_type == 'polyline') {
20653
+ addStyleValue(style, 'stroke-dasharray', getControlValue(dashControl.input));
20654
+ }
19853
20655
  if (targetLayer && targetLayer.geometry_type == 'polygon') {
19854
20656
  addStyleValue(style, 'fill', getControlValue(fillControl.input));
19855
20657
  addStyleValue(style, 'fill-opacity', parseOpacityValue(fillControl.opacity.node().value));
20658
+ addStyleValue(style, 'fill-pattern', patternControl.getCode(getControlValue(fillControl.input)));
19856
20659
  }
19857
20660
  return style;
19858
20661
  }
@@ -20067,8 +20870,8 @@
20067
20870
  revert: updateControls,
20068
20871
  release: releaseFocus
20069
20872
  });
20070
- panel.node().addEventListener('click', function() {
20071
- if (isTextInput(document.activeElement)) return;
20873
+ panel.node().addEventListener('click', function(e) {
20874
+ if (isTextInput(document.activeElement) || opensAMenu(e.target)) return;
20072
20875
  releaseFocus();
20073
20876
  });
20074
20877
 
@@ -20260,27 +21063,42 @@
20260
21063
  var radius = getCommonValue('r');
20261
21064
  var fill = getCommonValue('fill');
20262
21065
  var stroke = getCommonValue('stroke');
20263
- var fillOpacity = getCommonValue('fill-opacity');
20264
- var strokeOpacity = getCommonValue('stroke-opacity');
20265
21066
  var strokeWidth = getCommonValue('stroke-width');
20266
21067
  setCircleRadius(radius);
20267
21068
  setCircleColor(circleFillControl, fill);
20268
21069
  setCircleColor(circleStrokeControl, stroke);
20269
- setCircleOpacity(circleFillControl, fillOpacity);
20270
- setCircleOpacity(circleStrokeControl, strokeOpacity);
21070
+ updateCircleOpacity(circleFillControl);
21071
+ updateCircleOpacity(circleStrokeControl);
20271
21072
  setCircleStrokeWidth(strokeWidth === '' ? 0 : strokeWidth);
20272
21073
  if (representation != 'circle' && representation != 'unstyled') {
20273
21074
  setCircleRadius(defaultCircleRadius);
20274
21075
  setCircleColor(circleFillControl, '');
20275
21076
  setCircleColor(circleStrokeControl, '');
20276
- setCircleOpacity(circleFillControl, '');
20277
- setCircleOpacity(circleStrokeControl, '');
21077
+ circleFillControl.opacity.node().value = '';
21078
+ circleStrokeControl.opacity.node().value = '';
20278
21079
  setCircleStrokeWidth(0);
20279
21080
  }
20280
21081
  }
20281
21082
 
20282
- function setCircleOpacity(control, value) {
20283
- control.opacity.node().value = formatOpacityPct(value === '' ? 1 : value);
21083
+ // The opacity to put in the style command: undefined to leave the property
21084
+ // as it is, null for a field holding something that is not an opacity.
21085
+ // A blank field has nothing to say, and 100% over an unset opacity is the
21086
+ // default being shown rather than a value to store.
21087
+ function getCircleOpacityToWrite(control) {
21088
+ var str = control.opacity.node().value.trim();
21089
+ var val = parseOpacityValue(str);
21090
+ if (str === '') return undefined;
21091
+ if (val === 1 && styleFieldIsUnset(control.field + '-opacity')) return undefined;
21092
+ return val;
21093
+ }
21094
+
21095
+ // A selection whose colours disagree still has colours, so an opacity unset
21096
+ // on all of it shows as full rather than blank.
21097
+ function updateCircleOpacity(control) {
21098
+ var field = control.field + '-opacity';
21099
+ control.opacity.node().value = styleFieldIsUnset(field) ?
21100
+ formatColorOpacityPct(null, !styleFieldIsUnset(control.field)) :
21101
+ formatOpacityPct(getCommonValue(field));
20284
21102
  }
20285
21103
 
20286
21104
  function renderCreateFields() {
@@ -20307,9 +21125,8 @@
20307
21125
  var expr = createExprInput.node().value.trim();
20308
21126
  var cmd;
20309
21127
  if (!expr || !gui.console) return;
20310
- cmd = createCopyCheckbox.node().checked ?
20311
- '-filter true + name=labels -style label-text=' + quoteCommandValue(expr) :
20312
- '-style label-text=' + quoteCommandValue(expr);
21128
+ cmd = '-labels text=' + quoteCommandValue(expr);
21129
+ if (createCopyCheckbox.node().checked) cmd += ' + name=labels';
20313
21130
  runGuiEditCommand(gui, cmd, {
20314
21131
  title: 'Create labels',
20315
21132
  onSuccess: openLabelStyles
@@ -20331,16 +21148,16 @@
20331
21148
  function createSimpleCircles() {
20332
21149
  runCommand('-style r=' + defaultCreatedCircleRadius +
20333
21150
  ' fill=' + quoteCommandValue(defaultCircleFill) +
20334
- ' fill-opacity=1 stroke-opacity=1 stroke-width=0', 'Create circles');
21151
+ ' stroke-width=0', 'Create circles');
20335
21152
  }
20336
21153
 
20337
21154
  function applyCircleStyles() {
20338
21155
  var representation = getPointRepresentation();
20339
21156
  var radius = getCircleRadius();
20340
21157
  var fill = circleFillControl.input.node().value.trim();
20341
- var fillOpacity = parseOpacityValue(circleFillControl.opacity.node().value);
21158
+ var fillOpacity = getCircleOpacityToWrite(circleFillControl);
20342
21159
  var stroke = circleStrokeControl.input.node().value.trim();
20343
- var strokeOpacity = parseOpacityValue(circleStrokeControl.opacity.node().value);
21160
+ var strokeOpacity = getCircleOpacityToWrite(circleStrokeControl);
20344
21161
  var strokeWidth = getCircleStrokeWidth();
20345
21162
  var args;
20346
21163
  if (!gui.console || !(representation == 'unstyled' || representation == 'circle') ||
@@ -20349,11 +21166,9 @@
20349
21166
  fill = defaultCircleFill;
20350
21167
  setCircleColor(circleFillControl, fill);
20351
21168
  }
20352
- args = [
20353
- 'fill-opacity=' + fillOpacity,
20354
- 'stroke-opacity=' + strokeOpacity,
20355
- 'stroke-width=' + strokeWidth
20356
- ];
21169
+ args = ['stroke-width=' + strokeWidth];
21170
+ if (fillOpacity !== undefined) args.push('fill-opacity=' + fillOpacity);
21171
+ if (strokeOpacity !== undefined) args.push('stroke-opacity=' + strokeOpacity);
20357
21172
  if (radius !== null) args.unshift('r=' + radius);
20358
21173
  if (fill) args.push('fill=' + quoteCommandValue(fill));
20359
21174
  if (stroke) args.push('stroke=' + quoteCommandValue(stroke));
@@ -20580,7 +21395,7 @@
20580
21395
  var setDefaultTargets = self.setDefaultTargets;
20581
21396
  utils$1.extend(self, EventDispatcher.prototype);
20582
21397
 
20583
- // A map frame is composition state, not editable content. Commands may
21398
+ // A map frame (and its furniture) is composition state, not editable content. Commands may
20584
21399
  // explicitly target it, but it must not replace the GUI's active content
20585
21400
  // layer or become the implicit target of the next console command.
20586
21401
  self.setDefaultTargets = function(targets, opts) {
@@ -20647,15 +21462,25 @@
20647
21462
  };
20648
21463
 
20649
21464
  self.selectNextLayer = function() {
20650
- var next = self.findNextLayer(self.getActiveLayer().layer);
21465
+ var next = findContentLayer(self.findNextLayer);
20651
21466
  if (next) self.selectLayer(next.layer, next.dataset);
20652
21467
  };
20653
21468
 
20654
21469
  self.selectPrevLayer = function() {
20655
- var prev = self.findPrevLayer(self.getActiveLayer().layer);
21470
+ var prev = findContentLayer(self.findPrevLayer);
20656
21471
  if (prev) self.selectLayer(prev.layer, prev.dataset);
20657
21472
  };
20658
21473
 
21474
+ // step past the frame and its furniture, which can't be selected
21475
+ function findContentLayer(step) {
21476
+ var start = self.getActiveLayer().layer;
21477
+ var o = step.call(self, start);
21478
+ while (o && o.layer != start && internal.isFrameComponentLayer(o.layer, o.dataset)) {
21479
+ o = step.call(self, o.layer);
21480
+ }
21481
+ return o;
21482
+ }
21483
+
20659
21484
  return self;
20660
21485
 
20661
21486
  function getContentTargets(targets) {
@@ -20663,7 +21488,7 @@
20663
21488
  return {
20664
21489
  dataset: target.dataset,
20665
21490
  layers: target.layers.filter(function(lyr) {
20666
- return !internal.isFrameLayer(lyr, target.dataset.arcs);
21491
+ return !internal.isFrameComponentLayer(lyr, target.dataset);
20667
21492
  })
20668
21493
  };
20669
21494
  }).filter(function(target) {
@@ -20673,7 +21498,7 @@
20673
21498
 
20674
21499
  function getFirstContentTarget() {
20675
21500
  var target = self.getLayers().find(function(o) {
20676
- return !internal.isFrameLayer(o.layer, o.dataset.arcs);
21501
+ return !internal.isFrameComponentLayer(o.layer, o.dataset);
20677
21502
  });
20678
21503
  return target ? [{dataset: target.dataset, layers: [target.layer]}] : [];
20679
21504
  }
@@ -21286,7 +22111,7 @@
21286
22111
  // hit test, the reposition pass and the selection cue all find labels by those,
21287
22112
  // and a node with no feature behind it must not be found by any of them.
21288
22113
  //
21289
- // @rec: the label's record, as -add-label will write it
22114
+ // @rec: the label's record, as -labels coordinates= will write it
21290
22115
  // @shp: its knots, in the layer's display coordinates
21291
22116
  // Returns a markup string, or '' if there is nothing to draw.
21292
22117
  function renderPendingSymbol(rec, shp, ext, idPrefix) {
@@ -23056,15 +23881,22 @@
23056
23881
  this.zoomToBbox = zoomToBbox;
23057
23882
 
23058
23883
  if (gui.options.homeControl) {
23059
- gui.buttons.addButton("#home-icon").on('click', function() {
23060
- if (disabled()) return;
23061
- gui.dispatchEvent('map_reset');
23062
- });
23884
+ gui.buttons.addButton("#home-icon")
23885
+ .attr('data-tooltip', 'Zoom to fit')
23886
+ .attr('aria-label', 'Zoom to fit')
23887
+ .on('click', function() {
23888
+ if (disabled()) return;
23889
+ gui.dispatchEvent('map_reset');
23890
+ });
23063
23891
  }
23064
23892
 
23065
23893
  if (gui.options.zoomControl) {
23066
- inBtn = gui.buttons.addButton("#zoom-in-icon");
23067
- outBtn = gui.buttons.addButton("#zoom-out-icon");
23894
+ inBtn = gui.buttons.addButton("#zoom-in-icon")
23895
+ .attr('data-tooltip', 'Zoom in')
23896
+ .attr('aria-label', 'Zoom in');
23897
+ outBtn = gui.buttons.addButton("#zoom-out-icon")
23898
+ .attr('data-tooltip', 'Zoom out')
23899
+ .attr('aria-label', 'Zoom out');
23068
23900
  initVariableClick(inBtn.node(), zoomIn);
23069
23901
  initVariableClick(outBtn.node(), zoomOut);
23070
23902
  ext.on('change', function() {
@@ -25768,6 +26600,112 @@
25768
26600
  return true;
25769
26601
  }
25770
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
+
25771
26709
  // Caret and selection geometry, computed from the SVG character position APIs.
25772
26710
  //
25773
26711
  // The APIs behave identically on <text> and on <text><textPath>, so this code
@@ -26087,16 +27025,19 @@
26087
27025
  // so the tool cannot do this at its own call sites.
26088
27026
  self.open = function(target, id, opts) {
26089
27027
  var rec = getRecord(target, id);
27028
+ var value;
26090
27029
  if (!rec) return false;
26091
27030
  self.close();
27031
+ value = readLabelValue(rec['label-text']);
26092
27032
  session = {
26093
27033
  target: target,
26094
27034
  id: id,
26095
27035
  pending: null,
26096
27036
  created: false,
26097
27037
  onClose: opts && opts.onClose || null,
26098
- startText: decodeLabelText(rec['label-text']),
26099
- startBreaks: readSoftBreaks(rec['label-text']).breaks,
27038
+ startText: value.text,
27039
+ startBreaks: value.breaks,
27040
+ startBold: value.bold,
26100
27041
  nodes: null
26101
27042
  };
26102
27043
  startSession();
@@ -26142,6 +27083,7 @@
26142
27083
  onClose: opts.onClose || null,
26143
27084
  startText: '',
26144
27085
  startBreaks: [],
27086
+ startBold: [],
26145
27087
  nodes: null
26146
27088
  };
26147
27089
  startSession();
@@ -26150,6 +27092,7 @@
26150
27092
 
26151
27093
  function startSession() {
26152
27094
  session.text = session.startText;
27095
+ session.bold = session.startBold;
26153
27096
  getTextarea().value = session.text;
26154
27097
  setCaret(session.text.length, session.text.length);
26155
27098
  focusTextarea();
@@ -26165,6 +27108,74 @@
26165
27108
  self.refresh();
26166
27109
  }
26167
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
+
26168
27179
  self.isOpen = function() {
26169
27180
  return !!session;
26170
27181
  };
@@ -26195,6 +27206,7 @@
26195
27206
  // arriving just after a redraw is holding one of those.
26196
27207
  if (session.pending && node.closest &&
26197
27208
  node.closest('.label-edit-pending')) return true;
27209
+ if (session.nodes && session.nodes.symbol.contains(node)) return true;
26198
27210
  return !!groups && (groups.hit.contains(node) || groups.back.contains(node) ||
26199
27211
  groups.front.contains(node));
26200
27212
  };
@@ -26253,7 +27265,7 @@
26253
27265
  if (i < 0) return false;
26254
27266
  i = getEditIndex(session.text, i, layout);
26255
27267
  setCaret(i, i);
26256
- drawOverlay(session);
27268
+ redrawCaret();
26257
27269
  return true;
26258
27270
  };
26259
27271
 
@@ -26287,7 +27299,7 @@
26287
27299
  var blank = textHasNoGlyphs(o.text);
26288
27300
  var breaks = getBreaks(o);
26289
27301
  if (o.pending) {
26290
- if (!blank) o.pending.create(insertSoftBreaks(o.text, breaks));
27302
+ if (!blank) o.pending.create(writeLabelValue(o.text, breaks, o.bold));
26291
27303
  return;
26292
27304
  }
26293
27305
  if (blank) {
@@ -26295,8 +27307,9 @@
26295
27307
  removeLabel(o);
26296
27308
  return;
26297
27309
  }
26298
- if (o.text === o.startText && sameSoftBreaks(breaks, o.startBreaks)) return;
26299
- 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),
26300
27313
  o.id, o.target.name), {
26301
27314
  title: 'Label text'
26302
27315
  });
@@ -26309,7 +27322,7 @@
26309
27322
  var rec;
26310
27323
  if (!shp || shp.length > 1) return [];
26311
27324
  rec = o.pending ? o.pending.getStyle() : getRecord(o.target, o.id);
26312
- return getSoftBreaks(o.text, rec);
27325
+ return getSoftBreaks(o.text, rec, o.bold);
26313
27326
  }
26314
27327
 
26315
27328
  // Deletes the label's feature, geometry and all.
@@ -26396,7 +27409,7 @@
26396
27409
  var rec = Object.assign({}, o.pending.getStyle());
26397
27410
  // With its soft breaks, so that what is aligned and what a callout meets
26398
27411
  // is the block as wrapped
26399
- rec['label-text'] = encodeLabelText(insertSoftBreaks(o.text, getBreaks(o)));
27412
+ rec['label-text'] = encodeLabelText(writeLabelValue(o.text, getBreaks(o), o.bold));
26400
27413
  // No need to expand label-pos here, or to measure its text: the renderer
26401
27414
  // resolves the position and asks for the width it needs, so a pending
26402
27415
  // label is laid out by exactly the same code as a committed one.
@@ -26444,11 +27457,15 @@
26444
27457
  // - Line breaks. An empty <tspan> lays out nothing, so a line just opened
26445
27458
  // with Enter had no position for the caret to move to. The placeholder in
26446
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.
26447
27464
  function writeText(o) {
26448
27465
  var content = o.nodes.content;
26449
27466
  var breaks = getBreaks(o);
26450
- var written = o.text + '\u0000' + breaks.join(',');
26451
- var lines, i, tspan;
27467
+ var written = o.text + '\u0000' + breaks.join(',') + '\u0000' + o.bold.join(';');
27468
+ var lines, full, pos, i, tspan;
26452
27469
  // refresh() runs on every map render, which during a pan is every frame;
26453
27470
  // rebuilding text nodes that already say the right thing is pure waste
26454
27471
  if (o.writtenTo === content && o.writtenText === written) return;
@@ -26462,23 +27479,53 @@
26462
27479
  // or from a label that was multi-line before it was given a path -- and
26463
27480
  // turning them into spaces is what export does too.
26464
27481
  if (getLayout(o) === LINES_JOINED) {
26465
- content.appendChild(document.createTextNode(getRenderedContent(o.text)));
27482
+ full = getRenderedContent(o.text);
27483
+ appendRuns(content, full, o.bold, 0, full.length);
26466
27484
  return;
26467
27485
  }
26468
27486
  // A soft break keeps the space it broke at at the end of its line, where
26469
27487
  // the rendered label drops it: here every typed character has to be one
26470
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.
26471
27493
  lines = getRenderedLines(o.text, breaks);
26472
- content.appendChild(document.createTextNode(lines[0]));
27494
+ full = lines.join('');
27495
+ pos = lines[0].length;
27496
+ appendRuns(content, full, o.bold, 0, pos);
26473
27497
  for (i = 1; i < lines.length; i++) {
26474
27498
  tspan = document.createElementNS(SVG_NS$3, 'tspan');
26475
27499
  tspan.setAttribute('x', o.nodes.text.getAttribute('x') || 0);
26476
27500
  tspan.setAttribute('dy', getLineHeight(o));
26477
- tspan.appendChild(document.createTextNode(lines[i]));
27501
+ appendRuns(tspan, full, o.bold, pos, pos + lines[i].length);
27502
+ pos += lines[i].length;
26478
27503
  content.appendChild(tspan);
26479
27504
  }
26480
27505
  }
26481
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
+
26482
27529
  // Cached per session: asking costs a record lookup and a geometry test, and
26483
27530
  // the answer cannot change while a session is open.
26484
27531
  function getLayout(o) {
@@ -26604,18 +27651,33 @@
26604
27651
  return el;
26605
27652
  }
26606
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.
26607
27658
  function drawHitRegion(o, g, box, caret, pathId) {
26608
- var el;
26609
- clear(g);
26610
- if (box) g.appendChild(rect(box, 'label-edit-hit-area'));
26611
- 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
+ }
26612
27672
  // A thickened, transparent copy of the baseline: about one em wide, so that
26613
27673
  // the region follows the curve rather than boxing it.
26614
- el = document.createElementNS(SVG_NS$3, 'use');
26615
- el.setAttribute('href', '#' + pathId);
26616
- el.setAttribute('class', 'label-edit-hit-baseline');
26617
- el.setAttribute('stroke-width', caret ? caret.ascent + caret.descent : 12);
26618
- 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);
26619
27681
  }
26620
27682
 
26621
27683
  function getLabelPathId(nodes) {
@@ -26708,12 +27770,16 @@
26708
27770
 
26709
27771
  function rect(box, className) {
26710
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) {
26711
27779
  el.setAttribute('x', box.x);
26712
27780
  el.setAttribute('y', box.y);
26713
27781
  el.setAttribute('width', box.width);
26714
27782
  el.setAttribute('height', box.height);
26715
- el.setAttribute('class', className);
26716
- return el;
26717
27783
  }
26718
27784
 
26719
27785
  function caretLine(caret) {
@@ -26743,6 +27809,7 @@
26743
27809
 
26744
27810
  textarea.addEventListener('input', function() {
26745
27811
  if (!session) return;
27812
+ session.bold = updateBoldForEdit(session.bold, session.text, textarea.value);
26746
27813
  session.text = textarea.value;
26747
27814
  self.refresh();
26748
27815
  });
@@ -26767,6 +27834,13 @@
26767
27834
  return !e.isComposing && e.keyCode != 229;
26768
27835
  }
26769
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
+
26770
27844
  textarea.addEventListener('keydown', function(e) {
26771
27845
  if (!session) return;
26772
27846
  // The GUI's global key handlers are told to keep out of the way by
@@ -26793,6 +27867,10 @@
26793
27867
  e.preventDefault();
26794
27868
  e.stopPropagation();
26795
27869
  self.close();
27870
+ } else if (isBoldKey(e)) {
27871
+ e.preventDefault();
27872
+ e.stopPropagation();
27873
+ self.toggleBold();
26796
27874
  } else {
26797
27875
  e.stopPropagation();
26798
27876
  }
@@ -26846,10 +27924,11 @@
26846
27924
  return textarea ? textarea.selectionEnd : 0;
26847
27925
  }
26848
27926
 
26849
- 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) {
26850
27930
  if (!textarea) return;
26851
- textarea.selectionStart = start;
26852
- textarea.selectionEnd = end;
27931
+ textarea.setSelectionRange(start, end, direction || 'forward');
26853
27932
  }
26854
27933
  }
26855
27934
 
@@ -27542,7 +28621,7 @@
27542
28621
  }
27543
28622
 
27544
28623
  // The label tool: arming a label type, placing an anchor or a curve, turning
27545
- // 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
27546
28625
  // in-place text editor.
27547
28626
  //
27548
28627
  // Creating a label and editing one are one gesture, not two: a click leaves a
@@ -27606,6 +28685,14 @@
27606
28685
  // The mouseup that ended the last block drag, or null. A short one is
27607
28686
  // followed by a click from the same mouseup, which it has already handled.
27608
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;
27609
28696
  var editor = new LabelEditor(gui, ext);
27610
28697
  var selection = new LabelSelection(gui, ext, hit, function() {
27611
28698
  return editor.isOpen() ? editor.getFeatureId() : -1;
@@ -27635,7 +28722,7 @@
27635
28722
  // tools do on entry as well (`addEmptyLayer()` in `gui-edit-points.mjs` and
27636
28723
  // `gui-draw-lines2.mjs`). It is named rather than left unnamed, unlike
27637
28724
  // theirs: it is the layer `getLabelTarget()` would have created for a label
27638
- // 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
27639
28726
  // target and so replay from the session history.
27640
28727
  //
27641
28728
  // Called before entering the tool's own mode, because getInitialTool() reads
@@ -27685,6 +28772,8 @@
27685
28772
  handleDrag = null;
27686
28773
  releasedDrag = null;
27687
28774
  blockRelease = null;
28775
+ selectDrag = null;
28776
+ selectRelease = null;
27688
28777
  shownHandles = null;
27689
28778
  clearBlockDrag();
27690
28779
  hoverHandle = null;
@@ -27811,6 +28900,10 @@
27811
28900
  blockRelease = null;
27812
28901
  return;
27813
28902
  }
28903
+ if (selectRelease && getDomEvent(e) == selectRelease) {
28904
+ selectRelease = null;
28905
+ return;
28906
+ }
27814
28907
  if (armed == 'path' && drawingCurve()) {
27815
28908
  extendCurve(pixToMapCoords(e.x, e.y));
27816
28909
  return;
@@ -27883,9 +28976,34 @@
27883
28976
  e.flipLabel = getFlipAction(target, id);
27884
28977
  }
27885
28978
  }
28979
+ if (target && editor.isOpen() && clickIsOnEditedLabel(e)) {
28980
+ addBoldAction(e, target);
28981
+ }
27886
28982
  gui.contextMenu.open(e, target);
27887
28983
  });
27888
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
+
27889
29007
  // The label a right-click was aimed at, or -1.
27890
29008
  //
27891
29009
  // A label being typed into is not a hit target -- the editor's own overlay
@@ -28141,7 +29259,16 @@
28141
29259
  // takes a drag -- everything else over a label is left alone so that the map
28142
29260
  // still pans, which is why these handlers stop the event themselves.
28143
29261
  hit.on('dragstart', function(e) {
28144
- 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
+ }
28145
29272
  // The handle comes from the last hover, not from testing the pointer here.
28146
29273
  // dragstart arrives with the pointer already moved off the handle -- by the
28147
29274
  // first mousemove that made it a drag, which on a quick gesture is further
@@ -28166,6 +29293,22 @@
28166
29293
  consumeDrag(e);
28167
29294
  });
28168
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
+
28169
29312
  // handle: {id, index, point, pointer} from findHandle(), or the same shape
28170
29313
  // made up for a drag that grabbed a label somewhere other than its knot
28171
29314
  function beginKnotDrag(handle) {
@@ -28191,6 +29334,11 @@
28191
29334
 
28192
29335
  hit.on('drag', function(e) {
28193
29336
  var shp, p;
29337
+ if (selectDrag) {
29338
+ consumeDrag(e);
29339
+ updateSelectDrag(e);
29340
+ return;
29341
+ }
28194
29342
  if (textDrag) {
28195
29343
  consumeDrag(e);
28196
29344
  updateTextDrag(e);
@@ -28225,6 +29373,12 @@
28225
29373
 
28226
29374
  hit.on('dragend', function(e) {
28227
29375
  var o = drag;
29376
+ if (selectDrag) {
29377
+ selectDrag = null;
29378
+ selectRelease = getDomEvent(e);
29379
+ consumeDrag(e);
29380
+ return;
29381
+ }
28228
29382
  if (textDrag) {
28229
29383
  o = textDrag;
28230
29384
  textDrag = null;
@@ -28865,9 +30019,9 @@
28865
30019
  selection.refresh(true);
28866
30020
  }
28867
30021
 
28868
- // Turns the previewed offset into one -style.
30022
+ // Turns the previewed offset into one -labels.
28869
30023
  //
28870
- // 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
28871
30025
  // writes one value to every id it is given, so a group drag would set them
28872
30026
  // all to the same absolute offset rather than nudging each by its own delta.
28873
30027
  function commitOffsetDrag(o) {
@@ -29011,7 +30165,7 @@
29011
30165
  }
29012
30166
 
29013
30167
  function dragging() {
29014
- return !!(drag || textDrag || handleDrag || blockDrag);
30168
+ return !!(drag || textDrag || handleDrag || blockDrag || selectDrag);
29015
30169
  }
29016
30170
 
29017
30171
  // The exception is about the pointer being on the glyphs, not about the mode:
@@ -29129,6 +30283,12 @@
29129
30283
  resetHandle(hoverHandle);
29130
30284
  return;
29131
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
+ }
29132
30292
  // Double-clicking a label reaches straight into its text, which is the
29133
30293
  // gesture most editors use and saves selecting it first. The single-click
29134
30294
  // route got there already if the label was the selection; this covers the
@@ -29341,6 +30501,7 @@
29341
30501
  referenceStyle = { // outline style for reference layers
29342
30502
  type: 'outline',
29343
30503
  strokeColors: [null, '#87b73b'], // was 78c110
30504
+ // strokeColors: [null, 'rgba(79,140,0,0.67)'],
29344
30505
  strokeWidth: 0.85,
29345
30506
  dotColor: "#73ba20",
29346
30507
  dotSize: 1
@@ -30220,43 +31381,115 @@
30220
31381
  }
30221
31382
 
30222
31383
 
30223
- function getCanvasFillPattern(style) {
30224
- var fill = hatches[style.fillPattern];
30225
- if (fill === undefined) {
30226
- fill = makePatternFill(style);
30227
- hatches[style.fillPattern] = fill;
31384
+ // symbolScale: the preview symbol scale (1 outside preview). Pattern sizes are
31385
+ // in output pixels, like stroke widths, so the tile grows with the page.
31386
+ // anchor: canvas-pixel position the pattern origin is pinned to, so the tile
31387
+ // travels with the map instead of sticking to the viewport. Panning only
31388
+ // updates this matrix. The tile image is rebuilt when the scale changes,
31389
+ // which is at most once per pattern per render.
31390
+ function getCanvasFillPattern(style, symbolScale, anchor) {
31391
+ var scale = symbolScale > 0 ? symbolScale : 1;
31392
+ var entry = hatches[style.fillPattern];
31393
+ if (!entry || entry.scale != scale) {
31394
+ entry = makePatternEntry(style, scale);
31395
+ hatches[style.fillPattern] = entry;
30228
31396
  }
30229
- return fill || style.fill || '#000'; // use fill if hatches are invalid
31397
+ if (!entry || !entry.pattern) return style.fill || '#000';
31398
+ if (anchor) applyPatternAnchor(entry, anchor);
31399
+ return entry.pattern;
30230
31400
  }
30231
31401
 
30232
- function makePatternFill(style) {
31402
+ function makePatternEntry(style, scale) {
30233
31403
  var o = internal.parsePattern(style.fillPattern);
30234
31404
  if (!o) return null;
30235
31405
  var canv = document.createElement('canvas');
30236
31406
  var ctx = canv.getContext('2d');
30237
- var res = GUI.getPixelRatio();
30238
- var w = o.tileSize[0] * res;
30239
- var h = o.tileSize[1] * res;
31407
+ var k = GUI.getPixelRatio() * scale;
31408
+ var tw = o.tileSize[0], th = o.tileSize[1];
31409
+ // A canvas has whole-pixel dimensions, but the scaled tile generally does
31410
+ // not. The tile is drawn into the nearest whole-pixel canvas, and the
31411
+ // pattern transform stretches it back to its exact size so that the
31412
+ // repeat period doesn't drift across a large polygon.
31413
+ var w = Math.max(1, Math.round(tw * k));
31414
+ var h = Math.max(1, Math.round(th * k));
30240
31415
  canv.setAttribute('width', w);
30241
31416
  canv.setAttribute('height', h);
31417
+ ctx.scale(w / tw, h / th);
30242
31418
  if (o.background) {
30243
- ctx.fillStyle = o.background;
30244
- ctx.fillRect(0, 0, w, h);
30245
- }
30246
- if (o.type == 'dots' || o.type == 'squares') makeDotFill(o, ctx, res);
30247
- if (o.type == 'dashes') makeDashFill(o, ctx, res);
30248
- if (o.type == 'hatches') makeHatchFill(o, ctx, res);
30249
- var pattern = ctx.createPattern(canv, 'repeat');
30250
- if (o.rotation) {
30251
- pattern.setTransform(new DOMMatrix('rotate(' + o.rotation + 'deg)'));
31419
+ ctx.fillStyle = getCanvasColor(o.background);
31420
+ ctx.fillRect(0, 0, tw, th);
30252
31421
  }
30253
- return pattern;
31422
+ if (o.type == 'dots' || o.type == 'squares') makeDotFill(o, ctx, 1);
31423
+ if (o.type == 'dashes') makeDashFill(o, ctx, 1);
31424
+ if (o.type == 'hatches') makeHatchFill(o, ctx, 1);
31425
+ return {
31426
+ scale: scale,
31427
+ pattern: ctx.createPattern(canv, 'repeat'),
31428
+ rotation: o.rotation || 0,
31429
+ sx: tw * k / w,
31430
+ sy: th * k / h,
31431
+ w: w,
31432
+ h: h
31433
+ };
31434
+ }
31435
+
31436
+ // Pins the cached pattern to @anchor. Skipped when the view has not moved,
31437
+ // so a hover redraw does not touch the pattern.
31438
+ function applyPatternAnchor(entry, anchor) {
31439
+ if (entry.ax == anchor.x && entry.ay == anchor.y) return;
31440
+ entry.ax = anchor.x;
31441
+ entry.ay = anchor.y;
31442
+ var t = getPatternTransform(entry, anchor);
31443
+ entry.pattern.setTransform(new DOMMatrix([t.a, t.b, t.c, t.d, t.e, t.f]));
31444
+ }
31445
+
31446
+ // Matrix taking pattern-image pixels to canvas pixels: translate to the
31447
+ // anchor, then rotate, then scale. The anchor is folded into one tile so a
31448
+ // map origin far outside the viewport does not blow the matrix precision.
31449
+ function getPatternTransform(entry, anchor) {
31450
+ var p = wrapPatternAnchor(anchor.x, anchor.y, entry);
31451
+ var rad = (entry.rotation || 0) * Math.PI / 180;
31452
+ var c = Math.cos(rad);
31453
+ var s = Math.sin(rad);
31454
+ return {
31455
+ a: c * entry.sx,
31456
+ b: s * entry.sx,
31457
+ c: -s * entry.sy,
31458
+ d: c * entry.sy,
31459
+ e: p.x,
31460
+ f: p.y
31461
+ };
31462
+ }
31463
+
31464
+ // @entry.sx/sy scale image pixels to canvas pixels; the image repeats every
31465
+ // @entry.w by @entry.h pixels. Returns a point in the same place on the
31466
+ // pattern, within one tile of the origin.
31467
+ function wrapPatternAnchor(x, y, entry) {
31468
+ var rad = (entry.rotation || 0) * Math.PI / 180;
31469
+ var c = Math.cos(rad);
31470
+ var s = Math.sin(rad);
31471
+ var rx = c * x + s * y;
31472
+ var ry = -s * x + c * y;
31473
+ var qx = rx / entry.sx - Math.floor(rx / entry.sx / entry.w) * entry.w;
31474
+ var qy = ry / entry.sy - Math.floor(ry / entry.sy / entry.h) * entry.h;
31475
+ var lx = qx * entry.sx;
31476
+ var ly = qy * entry.sy;
31477
+ return {
31478
+ x: c * lx - s * ly,
31479
+ y: s * lx + c * ly
31480
+ };
31481
+ }
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;
30254
31487
  }
30255
31488
 
30256
31489
  function makeDashFill(o, ctx, res) {
30257
31490
  var x = 0;
30258
31491
  for (var i=0; i<o.colors.length; i++) {
30259
- ctx.fillStyle = o.colors[i];
31492
+ ctx.fillStyle = getCanvasColor(o.colors[i]);
30260
31493
  ctx.fillRect(x, 0, o.width * res, o.dashes[0] * res);
30261
31494
  x += res * (o.spacing + o.width);
30262
31495
  }
@@ -30271,7 +31504,7 @@
30271
31504
  var x = 0, y = 0;
30272
31505
  for (var i=0; i<dots; i++) {
30273
31506
  if (o.type == 'dots') ctx.beginPath();
30274
- ctx.fillStyle = o.colors[(i + Math.floor(i / n)) % n];
31507
+ ctx.fillStyle = getCanvasColor(o.colors[(i + Math.floor(i / n)) % n]);
30275
31508
  if (o.type == 'dots') {
30276
31509
  ctx.arc(x + r, y + r, r, 0, Math.PI * 2);
30277
31510
  } else {
@@ -30288,8 +31521,8 @@
30288
31521
  var w;
30289
31522
  for (var i=0, x=0; i<o.widths.length; i++) {
30290
31523
  w = o.widths[i] * res;
30291
- ctx.fillStyle = o.colors[i];
30292
- ctx.fillRect(x, 0, x + w, h);
31524
+ ctx.fillStyle = getCanvasColor(o.colors[i]);
31525
+ ctx.fillRect(x, 0, w, h);
30293
31526
  x += w;
30294
31527
  }
30295
31528
  }
@@ -30941,27 +32174,66 @@
30941
32174
  return iter;
30942
32175
  }
30943
32176
 
32177
+ // Same multiplier as ctx.lineWidth. Retina canvases are pixRatio times the
32178
+ // CSS pixel; preview mode multiplies styled strokes by the symbol scale.
32179
+ function getCanvasStrokeScale(pixRatio, lineScale) {
32180
+ return (pixRatio > 1 ? pixRatio : 1) * (lineScale || 1);
32181
+ }
32182
+
32183
+ // Scale a stroke-dasharray into canvas pixels. Scale 1 returns the authored
32184
+ // tokens, so a 100% preview and a non-preview view keep the pattern as stored.
32185
+ function scaleCanvasLineDash(lineDash, scale) {
32186
+ var parts = String(lineDash).split(' ');
32187
+ var out, i;
32188
+ if (!(scale > 0) || scale === 1) return parts;
32189
+ out = new Array(parts.length);
32190
+ for (i = 0; i < parts.length; i++) {
32191
+ out[i] = +parts[i] * scale;
32192
+ }
32193
+ return out;
32194
+ }
32195
+
32196
+ // Where a fill pattern's origin sits, in canvas pixels. In preview that is
32197
+ // the top-left of the page; otherwise the geographic origin, so a pan (which
32198
+ // only changes the translation) carries the pattern with the shapes.
32199
+ function getPatternAnchor(ext) {
32200
+ var t = ext.getTransform(GUI.getPixelRatio());
32201
+ var frame = ext.getFrameData();
32202
+ if (frame && frame.bbox) {
32203
+ return {
32204
+ x: frame.bbox[0] * t.mx + t.bx,
32205
+ y: frame.bbox[3] * t.my + t.by
32206
+ };
32207
+ }
32208
+ return {x: t.bx, y: t.by};
32209
+ }
32210
+
30944
32211
  function getPathStart(ext, lineScale) {
30945
32212
  var pixRatio = GUI.getPixelRatio();
30946
32213
  if (!lineScale) lineScale = 1;
32214
+ // Same factor as lineWidth, so dash length and gap stay in proportion to
32215
+ // the stroke. In preview that factor includes the symbol scale; without it
32216
+ // the pattern stays a fixed screen size while the line grows.
32217
+ var strokeScale = getCanvasStrokeScale(pixRatio, lineScale);
32218
+ // The symbol scale itself, not lineScale: lineScale adds a bump to widen
32219
+ // hover outlines, which a fill has no reason to follow.
32220
+ var patternScale = ext.getSymbolScale() || 1;
32221
+ // Page corner in preview, so the pattern is fixed to the page the way the
32222
+ // exported SVG is. With no frame, the geographic origin, which is enough
32223
+ // for panning: the pattern is not scaled by the geographic zoom then.
32224
+ var patternAnchor = getPatternAnchor(ext);
30947
32225
  return function(ctx, style) {
30948
32226
  var strokeWidth;
30949
32227
  ctx.beginPath();
30950
32228
  if (style.strokeWidth > 0) {
30951
32229
  strokeWidth = style.strokeWidth;
30952
- if (pixRatio > 1) {
30953
- // bump up thin lines on retina, but not to more than 1px
30954
- // (tests on Chrome showed much faster rendering of 1px lines)
30955
- // strokeWidth = strokeWidth < 1 ? 1 : strokeWidth * pixRatio;
30956
- strokeWidth = strokeWidth * pixRatio;
30957
- }
30958
32230
  ctx.lineCap = style.lineCap || 'round';
30959
32231
  ctx.lineJoin = style.lineJoin || 'round';
30960
- ctx.lineWidth = strokeWidth * lineScale;
32232
+ ctx.lineWidth = strokeWidth * strokeScale;
30961
32233
  ctx.strokeStyle = style.strokeColor;
30962
32234
  if (style.lineDash){
30963
32235
  ctx.lineCap = 'butt';
30964
- ctx.setLineDash(style.lineDash.split(' '));
32236
+ ctx.setLineDash(scaleCanvasLineDash(style.lineDash, strokeScale));
30965
32237
  }
30966
32238
  if (style.miterLimit) {
30967
32239
  ctx.miterLimit = style.miterLimit;
@@ -30969,7 +32241,7 @@
30969
32241
  }
30970
32242
 
30971
32243
  if (style.fillPattern) {
30972
- ctx.fillStyle = getCanvasFillPattern(style);
32244
+ ctx.fillStyle = getCanvasFillPattern(style, patternScale, patternAnchor);
30973
32245
  } else if (style.fillColor) {
30974
32246
  ctx.fillStyle = style.fillColor;
30975
32247
  }
@@ -31008,20 +32280,28 @@
31008
32280
  return internal.svg.getTransform(p, scale);
31009
32281
  }
31010
32282
 
31011
- function repositionFurniture(container, layer, ext) {
31012
- var g = El.findAll('.mapshaper-svg-furniture', container)[0];
31013
- g.setAttribute('transform', getSvgFurnitureTransform(ext));
32283
+ function repositionFurniture(container, ext) {
32284
+ if (!ext.getFrameData()) return;
32285
+ El.findAll('.mapshaper-svg-furniture', container).forEach(function(g) {
32286
+ g.setAttribute('transform', getSvgFurnitureTransform(ext));
32287
+ });
31014
32288
  }
31015
32289
 
32290
+ // Returns an SVG string, or '' if the furniture can't be drawn in the current
32291
+ // frame (e.g. the map is unprojected). The scalebar panel explains why.
31016
32292
  function renderFurniture(lyr, ext) {
31017
- var frame = ext.getFrameData(); // frame should be set if we're rendering a furniture layer
31018
- var obj = internal.getEmptyLayerForSVG(lyr, {});
31019
- if (!frame) {
31020
- stop$1('Missing map frame data');
31021
- }
32293
+ var frame = ext.getFrameData();
32294
+ var obj;
32295
+ if (internal.getFurnitureFrameProblem(frame)) return '';
32296
+ obj = internal.getEmptyLayerForSVG(lyr, {});
31022
32297
  obj.properties.transform = getSvgFurnitureTransform(ext);
31023
32298
  obj.properties.class = 'mapshaper-svg-furniture';
31024
- obj.children = internal.renderFurnitureLayer(lyr, frame);
32299
+ try {
32300
+ obj.children = internal.renderFurnitureLayer(lyr, frame);
32301
+ } catch(e) {
32302
+ console.error(e);
32303
+ return '';
32304
+ }
31025
32305
  return internal.svg.stringify(obj);
31026
32306
  }
31027
32307
 
@@ -31063,8 +32343,6 @@
31063
32343
  resize(ext);
31064
32344
  if (type == 'label' || type == 'symbol') {
31065
32345
  html = renderSymbols(lyr.gui.displayLayer, ext, id);
31066
- } else if (type == 'furniture') {
31067
- html = renderFurniture(lyr.gui.displayLayer, ext);
31068
32346
  }
31069
32347
  g.innerHTML = html;
31070
32348
  if (type == 'label' || type == 'symbol') {
@@ -31078,6 +32356,26 @@
31078
32356
  }
31079
32357
  };
31080
32358
 
32359
+ // Furniture layers are dataset layers (they have no display layer)
32360
+ el.drawFurniture = function(layers) {
32361
+ el.clear();
32362
+ resize(ext);
32363
+ layers.forEach(function(lyr) {
32364
+ var html = renderFurniture(lyr, ext);
32365
+ var g;
32366
+ if (!html) return;
32367
+ g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
32368
+ g.setAttribute('class', 'mapshaper-svg-layer mapshaper-furniture-layer');
32369
+ g.innerHTML = html;
32370
+ svg.append(g);
32371
+ });
32372
+ };
32373
+
32374
+ el.repositionFurniture = function() {
32375
+ resize(ext);
32376
+ repositionFurniture(svg, ext);
32377
+ };
32378
+
31081
32379
  function reposition(lyr, type, ext) {
31082
32380
  var container = el.findChild('.' + lyr.gui.svg_id);
31083
32381
  if (!container || !container.node()) {
@@ -31091,8 +32389,6 @@
31091
32389
  // a symbol group's transform absorbs panning, and zooming too when a
31092
32390
  // frame is defined; anything it can't absorb needs the baselines rebuilt
31093
32391
  updateLabelPaths(container.node(), lyr.gui.displayLayer, ext);
31094
- } else if (type == 'furniture') {
31095
- repositionFurniture(container.node(), lyr.gui.displayLayer, ext);
31096
32392
  } else {
31097
32393
  // container.getElementsByTagName('text')
31098
32394
  error('Unsupported symbol type:', type);
@@ -31248,19 +32544,14 @@
31248
32544
  });
31249
32545
  };
31250
32546
 
32547
+ // Furniture is sized in frame pixels, so panning and zooming only need to
32548
+ // update its transform; its content changes only with a full redraw.
31251
32549
  this.drawFurnitureLayers = function(layers, action) {
31252
- // re-render if action == 'nav', because scalebars get resized
31253
- var noRedraw = action == 'hover';
31254
- if (!noRedraw) {
31255
- _furniture.clear();
32550
+ if (action == 'nav' || action == 'hover') {
32551
+ _furniture.repositionFurniture();
32552
+ } else {
32553
+ _furniture.drawFurniture(layers);
31256
32554
  }
31257
- layers.forEach(function(lyr) {
31258
- if (noRedraw) {
31259
- _furniture.reposition(lyr, 'furniture');
31260
- } else {
31261
- _furniture.drawLayer(lyr, 'furniture');
31262
- }
31263
- });
31264
32555
  };
31265
32556
 
31266
32557
  // kludge: skip rendering base layers if hovering, except on first hover
@@ -33111,7 +34402,7 @@
33111
34402
 
33112
34403
  function isFrameMapLayer(lyr) {
33113
34404
  var dataset = lyr && lyr.gui && lyr.gui.source && lyr.gui.source.dataset;
33114
- return !!dataset && internal.isFrameLayer(lyr, dataset.arcs);
34405
+ return !!dataset && internal.isFrameComponentLayer(lyr, dataset);
33115
34406
  }
33116
34407
 
33117
34408
  function getDrawableContentLayers() {
@@ -33121,11 +34412,18 @@
33121
34412
  });
33122
34413
  }
33123
34414
 
33124
- function getDrawableFurnitureLayers(layers) {
33125
- if (!isPreviewView()) return [];
33126
- return getVisibleMapLayers().filter(function(o) {
33127
- return internal.isFurnitureLayer(o);
34415
+ // The active frame's furniture, plus any visible standalone furniture layers
34416
+ // (e.g. from -scalebar run before a frame was created)
34417
+ function getDrawableFurnitureLayers() {
34418
+ var frame = isPreviewView() ? internal.getActiveFrame(model) : null;
34419
+ if (!frame) return [];
34420
+ var layers = internal.getFrameFurnitureLayers(frame.dataset);
34421
+ getVisibleMapLayers().forEach(function(lyr) {
34422
+ if (internal.layerIsFurniture(lyr) && !layers.includes(lyr) && !lyr.hidden) {
34423
+ layers.push(lyr);
34424
+ }
33128
34425
  });
34426
+ return layers;
33129
34427
  }
33130
34428
 
33131
34429
  function updateLayerStyles(layers) {
@@ -33177,7 +34475,6 @@
33177
34475
  var layersMayHaveChanged = action != 'nav'; // !action;
33178
34476
  var fullBounds;
33179
34477
  var contentLayers = getDrawableContentLayers();
33180
- // var furnitureLayers = getDrawableFurnitureLayers();
33181
34478
  if (!(_ext.width() > 0 && _ext.height() > 0)) {
33182
34479
  // TODO: track down source of these errors
33183
34480
  console.error("Collapsed map container, unable to draw.");
@@ -33218,8 +34515,7 @@
33218
34515
  }
33219
34516
  _renderer.drawOverlayLayers(_overlayLayers, action);
33220
34517
 
33221
- // TODO: draw furniture
33222
- // _renderer.drawFurnitureLayers(furnitureLayers, action);
34518
+ _renderer.drawFurnitureLayers(getDrawableFurnitureLayers(), action);
33223
34519
  // The action says how much was redrawn, which a listener rebuilding its own
33224
34520
  // DOM overlays needs: a 'hover' draw leaves the SVG markup and its
33225
34521
  // transforms alone, so anything anchored to them is still good.
@@ -33808,10 +35104,7 @@
33808
35104
  return;
33809
35105
  }
33810
35106
  if (!getSourceLayer()) {
33811
- showPopupAlert(
33812
- 'Add one or more layers before creating a map frame.',
33813
- 'Map frame'
33814
- );
35107
+ showPopupAlert(getNoSourceMessage(), 'Map frame');
33815
35108
  return;
33816
35109
  }
33817
35110
  var popup = showPopupAlert('', 'Add map frame', {classname: 'frame-create-box'});
@@ -33956,7 +35249,7 @@
33956
35249
  function createFromVisibleLayers(width, aspect, margin) {
33957
35250
  var entries = getCompositionEntries();
33958
35251
  if (!entries.length) {
33959
- showPopupAlert('No visible geographic layers are available.', 'Map frame');
35252
+ showPopupAlert(getNoSourceMessage(), 'Map frame');
33960
35253
  return;
33961
35254
  }
33962
35255
  var ids = entries.map(function(o) {
@@ -33979,6 +35272,15 @@
33979
35272
  });
33980
35273
  }
33981
35274
 
35275
+ function getNoSourceMessage() {
35276
+ var active = gui.model.getActiveLayer();
35277
+ if (active && active.layer && !internal.layerHasGeometry(active.layer)) {
35278
+ return 'A map frame is fitted to layers with shapes, and this layer ' +
35279
+ 'contains only attribute data. Select or show a map layer to create a frame.';
35280
+ }
35281
+ return 'Add one or more layers before creating a map frame.';
35282
+ }
35283
+
33982
35284
  function getFrameTarget() {
33983
35285
  return internal.getActiveFrame(gui.model);
33984
35286
  }
@@ -33998,10 +35300,11 @@
33998
35300
  return entries.length ? entries[0].layer : null;
33999
35301
  }
34000
35302
 
35303
+ // A frame is fitted to shapes, so a data-only layer on show doesn't count
34001
35304
  function getCompositionEntries() {
34002
35305
  var layers = gui.map.getCompositionLayers();
34003
35306
  return gui.model.getLayers().filter(function(o) {
34004
- return layers.includes(o.layer);
35307
+ return layers.includes(o.layer) && internal.layerHasGeometry(o.layer);
34005
35308
  });
34006
35309
  }
34007
35310
 
@@ -34038,16 +35341,6 @@
34038
35341
  .text(label).on('click', action);
34039
35342
  }
34040
35343
 
34041
- // The "?" the rest of the app uses for field help (see .tip-button in
34042
- // elements.css and the static ones in index.html). The bubble is white-space:
34043
- // pre, so the line breaks in the text are the ones it gets.
34044
- function makeFieldTip(parent, text) {
34045
- var btn = El('div').addClass('tip-button').appendTo(parent).text('?');
34046
- var anchor = El('div').addClass('tip-anchor').appendTo(btn);
34047
- El('div').addClass('tip').appendTo(anchor).text(text);
34048
- return btn;
34049
- }
34050
-
34051
35344
  function getUnitFactor$1(units) {
34052
35345
  return units == 'in' ? 72 : units == 'cm' ? 28.3465 : 1;
34053
35346
  }
@@ -34067,7 +35360,7 @@
34067
35360
  function FrameProperties(gui) {
34068
35361
  var target, form, widthInput, heightInput, unitsSelect, aspectValue;
34069
35362
  var backgroundControl, neatlineControl, neatlineWidthInput;
34070
- var boundsValue, crsValue;
35363
+ var boundsValue, crsValue, scalebarButton;
34071
35364
 
34072
35365
  gui.frameProperties = this;
34073
35366
 
@@ -34139,6 +35432,14 @@
34139
35432
  .appendTo(appearance);
34140
35433
  makeActionButton(clearRow, 'Clear appearance', clearFrameStyle);
34141
35434
 
35435
+ var elements = makePanelSection(form, 'Map elements');
35436
+ var elementsRow = El('div')
35437
+ .addClass('label-style-row label-panel-button-row')
35438
+ .appendTo(elements);
35439
+ scalebarButton = makeActionButton(elementsRow, '', function() {
35440
+ if (gui.scalebarProperties) gui.scalebarProperties.open(target);
35441
+ }).addClass('frame-scalebar-btn');
35442
+
34142
35443
  var details = makePanelSection(form, 'Details');
34143
35444
  boundsValue = makeReadOnlyRow(details, 'Bounds');
34144
35445
  crsValue = makeReadOnlyRow(details, 'CRS');
@@ -34156,11 +35457,13 @@
34156
35457
  unitsSelect.node().value = units;
34157
35458
  aspectValue.text(getAspectText(frame));
34158
35459
  backgroundControl.showColor(rec.fill || '');
34159
- backgroundControl.opacity.node().value = formatOpacity(rec['fill-opacity'], rec.fill);
35460
+ backgroundControl.opacity.node().value = formatColorOpacityPct(rec['fill-opacity'], !!rec.fill);
34160
35461
  neatlineControl.showColor(rec.stroke || '');
34161
- neatlineControl.opacity.node().value = formatOpacity(rec['stroke-opacity'], rec.stroke);
35462
+ neatlineControl.opacity.node().value = formatColorOpacityPct(rec['stroke-opacity'], !!rec.stroke);
34162
35463
  neatlineWidthInput.node().value =
34163
35464
  rec['stroke-width'] === undefined ? '' : rec['stroke-width'];
35465
+ scalebarButton.text(internal.findFrameFurnitureLayer(target.dataset, 'scalebar') ?
35466
+ 'Edit scale bar' : 'Add scale bar');
34164
35467
  boundsValue.text(frame.bbox.map(formatCoordinate).join(', '));
34165
35468
  crsValue.text(info.proj4 || '[unknown]');
34166
35469
  }
@@ -34284,15 +35587,6 @@
34284
35587
  return String(Number(value.toPrecision(12)));
34285
35588
  }
34286
35589
 
34287
- // A colour with no opacity of its own is drawn opaque, and says so; with no
34288
- // colour either, there is nothing for an opacity to apply to.
34289
- function formatOpacity(value, color) {
34290
- if (value === undefined || value === null || value === '') {
34291
- return color ? '100%' : '';
34292
- }
34293
- return String(Math.round(Number(value) * 100)) + '%';
34294
- }
34295
-
34296
35590
  // A fixed ratio holds when the frame is rescaled; one taken from the extent
34297
35591
  // changes whenever the extent does. That difference is the reason to show the
34298
35592
  // ratio at all, so it is said rather than implied.
@@ -34306,6 +35600,434 @@
34306
35600
  return formatFrameAspectRatio(ratio) + ' (from extent)';
34307
35601
  }
34308
35602
 
35603
+ // Record fields of a scalebar, in the order they are written as options
35604
+ var scalebarOptions = ['units', 'style', 'position', 'label_position',
35605
+ 'font_size', 'font_family', 'font_style', 'font_weight', 'color', 'bar_width',
35606
+ 'tic_length', 'label_offset', 'margin'];
35607
+
35608
+ // Returns a -scalebar command that recreates a scalebar with the given
35609
+ // settings (a scalebar data record, with any edits applied). -scalebar
35610
+ // replaces the frame's scalebar, so every setting has to be written.
35611
+ function getScalebarCommand(settings) {
35612
+ var parts = ['-scalebar'];
35613
+ var d = settings || {};
35614
+ if (isSet(d.label)) {
35615
+ parts.push(quoteCommandValue(d.label));
35616
+ }
35617
+ scalebarOptions.forEach(function(k) {
35618
+ if (isSet(d[k])) {
35619
+ parts.push(k.replace(/_/g, '-') + '=' + formatValue$1(d[k]));
35620
+ }
35621
+ });
35622
+ if (d.dual_units) {
35623
+ parts.push('dual-units');
35624
+ }
35625
+ return parts.join(' ');
35626
+ }
35627
+
35628
+ function isSet(val) {
35629
+ return val !== undefined && val !== null && val !== '';
35630
+ }
35631
+
35632
+ function formatValue$1(val) {
35633
+ var str = String(val);
35634
+ return /^[\w.#-]+$/.test(str) ? str : quoteCommandValue(str);
35635
+ }
35636
+
35637
+ var positionOptions = [
35638
+ ['top-left', 'Top left'],
35639
+ ['top-right', 'Top right'],
35640
+ ['bottom-left', 'Bottom left'],
35641
+ ['bottom-right', 'Bottom right']
35642
+ ];
35643
+
35644
+ // What the scalebar is drawn in when it has no color of its own
35645
+ var defaultColor = '#000000';
35646
+
35647
+ // Settings of the frame's scalebar. Every edit sends a complete -scalebar
35648
+ // command, which replaces the scalebar, so edits are undoable and replayable
35649
+ // like any other command.
35650
+ function ScalebarProperties(gui) {
35651
+ var popup, form, note;
35652
+ var labelInput, unitsSelect, styleSelect, dualUnitsBox, positionSelect,
35653
+ labelPositionSelect, fontSizeInput, barWidthInput, marginInput,
35654
+ fontSelect, fontStyleSelect, colorChit, colorInput, colorPicker;
35655
+
35656
+ gui.scalebarProperties = this;
35657
+
35658
+ // The panel doesn't block the map, so anything can change the scalebar or
35659
+ // the frame while it is open: an undo, a frame edit, a console command.
35660
+ gui.model.on('update', function(e) {
35661
+ var flags = e.flags || {};
35662
+ if (!popup || flags.simplify_amount || flags.redraw_only) return;
35663
+ if (getScalebarRecord()) {
35664
+ updateControls();
35665
+ } else {
35666
+ closePanel();
35667
+ }
35668
+ });
35669
+
35670
+ // Adds a scalebar to the frame if it doesn't have one
35671
+ this.open = function(frameTarget) {
35672
+ var frame = frameTarget || internal.getActiveFrame(gui.model);
35673
+ if (!frame) return;
35674
+ if (getScalebarRecord()) {
35675
+ showPanel();
35676
+ } else {
35677
+ runCommand('-scalebar', 'Add scale bar', showPanel);
35678
+ }
35679
+ };
35680
+
35681
+ this.isOpen = function() {
35682
+ return !!popup;
35683
+ };
35684
+
35685
+ // The map stays live, so the panel is placed where it is least likely to
35686
+ // hide the scalebar: at the top of the map, on the side away from it
35687
+ function showPanel() {
35688
+ var d = getScalebarRecord();
35689
+ if (!d) return;
35690
+ closePanel();
35691
+ popup = showPopupAlert('', 'Scale bar', {
35692
+ non_blocking: true,
35693
+ classname: 'scalebar-properties-box'
35694
+ });
35695
+ popup.onClose(function() {
35696
+ popup = form = null;
35697
+ });
35698
+ popup.container().addClass('scalebar-properties-popup');
35699
+ form = El('div')
35700
+ .addClass('label-style-panel scalebar-properties-form')
35701
+ .appendTo(popup.container());
35702
+ initForm();
35703
+ updateControls();
35704
+ placePanel(/right/.test(d.position || '') ? 'left' : 'right');
35705
+ }
35706
+
35707
+ function closePanel() {
35708
+ if (popup) popup.close();
35709
+ }
35710
+
35711
+ function placePanel(side) {
35712
+ var box = popup.container().node().parentNode;
35713
+ var map = gui.container.findChild('.map-layers').node().getBoundingClientRect();
35714
+ // clears the zoom buttons on the right edge of the map
35715
+ var rightInset = 58;
35716
+ var leftInset = 16;
35717
+ box.style.position = 'absolute';
35718
+ box.style.marginTop = '0';
35719
+ box.style.top = Math.round(map.top + 12) + 'px';
35720
+ if (side == 'left') {
35721
+ box.style.left = Math.round(map.left + leftInset) + 'px';
35722
+ } else {
35723
+ box.style.right = Math.round(window.innerWidth - map.right + rightInset) + 'px';
35724
+ }
35725
+ }
35726
+
35727
+ function initForm() {
35728
+ var row;
35729
+ note = El('div').addClass('scalebar-properties-note').appendTo(form).hide();
35730
+
35731
+ row = makeRow(form);
35732
+ labelInput = makeInput(makeCell(row, 'Distance'), 'scalebar-label-input')
35733
+ .attr('placeholder', 'Automatic')
35734
+ .on('change', function() {
35735
+ update({label: labelInput.node().value.trim()});
35736
+ });
35737
+ unitsSelect = makeSelect(makeCell(row, 'Units'), 'scalebar-units-select',
35738
+ [['miles', 'Miles'], ['km', 'Kilometers']])
35739
+ .on('change', function() {
35740
+ var changes = {units: unitsSelect.node().value};
35741
+ var bare = getBareDistance(getScalebarRecord().label);
35742
+ if (bare) changes.label = bare;
35743
+ update(changes);
35744
+ });
35745
+
35746
+ row = makeRow(form);
35747
+ styleSelect = makeSelect(makeCell(row, 'Style'), 'scalebar-style-select',
35748
+ [['a', 'Bar'], ['b', 'Ticks']])
35749
+ .on('change', function() {
35750
+ update({style: styleSelect.node().value});
35751
+ });
35752
+ dualUnitsBox = makeCheckbox(row, 'Metric + imperial', 'scalebar-dual-units')
35753
+ .on('change', function() {
35754
+ update({dual_units: dualUnitsBox.node().checked});
35755
+ });
35756
+
35757
+ row = makeRow(form);
35758
+ positionSelect = makeSelect(makeCell(row, 'Position'), 'scalebar-position-select',
35759
+ positionOptions)
35760
+ .on('change', function() {
35761
+ update({position: positionSelect.node().value});
35762
+ });
35763
+ labelPositionSelect = makeSelect(makeCell(row, 'Labels'),
35764
+ 'scalebar-label-position-select', [['top', 'Above'], ['bottom', 'Below']])
35765
+ .on('change', function() {
35766
+ update({label_position: labelPositionSelect.node().value});
35767
+ });
35768
+
35769
+ var appearance = makePanelSection(form, 'Appearance');
35770
+
35771
+ // Font and face menus, as in the label panel
35772
+ row = makeRow(appearance);
35773
+ fontSelect = El('select').attr('title', 'Font').addClass('scalebar-font-select')
35774
+ .appendTo(makeCell(row, 'Font'))
35775
+ .on('change', function() {
35776
+ if (fontSelect.node().value) applyFont(fontSelect.node().value);
35777
+ });
35778
+ renderFontOptions();
35779
+ fontStyleSelect = El('select').attr('title', 'Font style')
35780
+ .addClass('scalebar-font-style-select')
35781
+ .appendTo(makeCell(row, 'Font style'))
35782
+ .on('change', function() {
35783
+ if (fontStyleSelect.node().value) applyFontStyle(fontStyleSelect.node().value);
35784
+ });
35785
+
35786
+ // The picker lines up with the right edge of its parent, so the row is its
35787
+ // parent rather than the narrow color cell
35788
+ row = makeRow(appearance);
35789
+ var colorCell = El('div').addClass('frame-input-cell scalebar-color-cell').appendTo(row);
35790
+ El('span').appendTo(colorCell).text('Color');
35791
+ colorChit = El('div').addClass('label-color-chit').attr('role', 'button')
35792
+ .on('click', function() { colorPicker.toggle(); });
35793
+ colorInput = El('input').attr('type', 'text').attr('title', 'Scale bar color')
35794
+ .on('change', function() {
35795
+ var color = colorInput.node().value.trim();
35796
+ if (!color) {
35797
+ updateControls();
35798
+ return;
35799
+ }
35800
+ if (isHexColor(color)) colorPicker.setColor(color);
35801
+ update({color: color});
35802
+ });
35803
+ makeColorField(colorCell, colorChit, colorInput);
35804
+ colorPicker = new ColorPicker(row, {
35805
+ onPreview: showColor,
35806
+ onChange: function(hex) {
35807
+ showColor(hex);
35808
+ update({color: hex});
35809
+ }
35810
+ });
35811
+ fontSizeInput = makeNumberInput(makeCell(row, 'Font size'), 'font_size',
35812
+ 'scalebar-font-size');
35813
+
35814
+ row = makeRow(appearance);
35815
+ barWidthInput = makeNumberInput(makeCell(row, 'Bar width'), 'bar_width',
35816
+ 'scalebar-bar-width');
35817
+ marginInput = makeNumberInput(makeCell(row, 'Inset'), 'margin',
35818
+ 'scalebar-margin')
35819
+ .attr('title', 'Distance from the edges of the frame, in pixels');
35820
+
35821
+ row = El('div').addClass('label-style-row label-panel-button-row').appendTo(form);
35822
+ makePanelActionButton(row, 'Remove scale bar', function() {
35823
+ runCommand('-scalebar remove', 'Remove scale bar', function() {
35824
+ if (popup) popup.close();
35825
+ });
35826
+ });
35827
+ }
35828
+
35829
+ function updateControls() {
35830
+ var d = getScalebarRecord();
35831
+ var problem;
35832
+ if (!d || !form) return;
35833
+ labelInput.node().value = d.label || '';
35834
+ setSelectValue(unitsSelect, getUnitsMenuValue(d.units ||
35835
+ internal.parseScalebarUnits(d.label || '')));
35836
+ setSelectValue(styleSelect, d.style == 'b' || d.style == 'B' ||
35837
+ !d.style && d.dual_units ? 'b' : 'a');
35838
+ dualUnitsBox.node().checked = !!d.dual_units;
35839
+ setSelectValue(positionSelect, d.position || 'top-left');
35840
+ setSelectValue(labelPositionSelect, d.label_position || 'top');
35841
+ fontSizeInput.node().value = formatValue(d.font_size);
35842
+ barWidthInput.node().value = formatValue(d.bar_width);
35843
+ marginInput.node().value = formatValue(d.margin);
35844
+ updateFontControls(d);
35845
+ showColor(d.color || defaultColor);
35846
+ if (isHexColor(d.color || defaultColor)) colorPicker.setColor(d.color || defaultColor);
35847
+ problem = getFrameProblem();
35848
+ note.text(problem ? 'The scale bar is hidden: ' + problem + '.' : '');
35849
+ if (problem) note.show(); else note.hide();
35850
+ }
35851
+
35852
+ function showColor(color) {
35853
+ colorInput.node().value = color || '';
35854
+ colorChit.css('background-color', color || 'transparent');
35855
+ }
35856
+
35857
+ function renderFontOptions() {
35858
+ getInstalledFonts().forEach(function(group) {
35859
+ var optgroup = El('optgroup').attr('label', group.name).appendTo(fontSelect);
35860
+ group.fonts.forEach(function(fontName) {
35861
+ El('option').attr('value', fontName).appendTo(optgroup).text(fontName);
35862
+ });
35863
+ });
35864
+ }
35865
+
35866
+ // A scalebar with no font of its own is shown in the font it is drawn in
35867
+ function getShownFont(d) {
35868
+ return d.font_family || getDefaultFontName();
35869
+ }
35870
+
35871
+ function updateFontControls(d) {
35872
+ var font = getShownFont(d);
35873
+ var shown = font ? getNearestFontStyleVariant(font, d.font_style, d.font_weight) : null;
35874
+ if (font) {
35875
+ setSelectValue(fontSelect, font);
35876
+ } else {
35877
+ fontSelect.node().selectedIndex = -1;
35878
+ }
35879
+ fontStyleSelect.empty();
35880
+ getFontStyleVariants(font).forEach(function(variant) {
35881
+ El('option').attr('value', variant.value).appendTo(fontStyleSelect).text(variant.label);
35882
+ });
35883
+ fontStyleSelect.node().disabled = !font;
35884
+ if (shown) {
35885
+ fontStyleSelect.node().value = shown.value;
35886
+ } else {
35887
+ fontStyleSelect.node().selectedIndex = -1;
35888
+ }
35889
+ }
35890
+
35891
+ // Keeps the current face, or the nearest one the new font has
35892
+ function applyFont(fontName) {
35893
+ var d = getScalebarRecord();
35894
+ var changes = {font_family: fontName};
35895
+ var variant = getNearestFontStyleVariant(fontName, d.font_style, d.font_weight);
35896
+ if (variant) Object.assign(changes, getFontStyleValues(variant));
35897
+ update(changes);
35898
+ }
35899
+
35900
+ // A face belongs to a font, so a scalebar given one names the font it is
35901
+ // drawn in
35902
+ function applyFontStyle(value) {
35903
+ var parts = value.split('|');
35904
+ var changes = getFontStyleValues({style: parts[0], weight: parts[1]});
35905
+ var d = getScalebarRecord();
35906
+ if (!d.font_family && getDefaultFontName()) {
35907
+ changes.font_family = getDefaultFontName();
35908
+ }
35909
+ update(changes);
35910
+ }
35911
+
35912
+ // Regular is stored as no face at all
35913
+ function getFontStyleValues(variant) {
35914
+ if (variantIsRegular(variant)) {
35915
+ return {font_style: '', font_weight: ''};
35916
+ }
35917
+ return {font_style: variant.style, font_weight: variant.weight};
35918
+ }
35919
+
35920
+ // Applies edits to the current settings and replaces the scalebar. The
35921
+ // units menu always applies, so it is written even when it shows the default.
35922
+ function update(changes) {
35923
+ var d = Object.assign({}, getScalebarRecord(), changes);
35924
+ if (changes.style == 'a') d.dual_units = false;
35925
+ if (changes.dual_units) d.style = 'b';
35926
+ if (!d.units) d.units = unitsSelect.node().value;
35927
+ runCommand(getScalebarCommand(d), 'Update scale bar', updateControls);
35928
+ }
35929
+
35930
+ function makeNumberInput(cell, field, className) {
35931
+ var input = makeInput(cell, className).attr('placeholder', 'auto');
35932
+ input.on('change', function() {
35933
+ var str = input.node().value.trim();
35934
+ var o = {};
35935
+ if (str !== '' && !(Number(str) >= 0)) {
35936
+ updateControls();
35937
+ return;
35938
+ }
35939
+ o[field] = str === '' ? '' : Number(str);
35940
+ update(o);
35941
+ });
35942
+ return input;
35943
+ }
35944
+
35945
+ function getFrameProblem() {
35946
+ var frame = internal.getActiveFrame(gui.model);
35947
+ var crs, data;
35948
+ if (!frame) return 'there is no map frame';
35949
+ crs = frame.layer.gui && frame.layer.gui.dynamic_crs ||
35950
+ internal.getDatasetCRS(frame.dataset);
35951
+ data = internal.getFrameLayerData(frame.layer, frame.dataset.arcs, crs);
35952
+ return internal.getFurnitureFrameProblem(data);
35953
+ }
35954
+
35955
+ function getScalebarRecord() {
35956
+ var frame = internal.getActiveFrame(gui.model);
35957
+ var lyr = frame && internal.findFrameFurnitureLayer(frame.dataset, 'scalebar');
35958
+ return lyr ? lyr.data.getReadOnlyRecordAt(0) : null;
35959
+ }
35960
+
35961
+ function runCommand(cmd, title, done) {
35962
+ runGuiEditCommand(gui, cmd, {
35963
+ title: title,
35964
+ onSuccess: done,
35965
+ onError: updateControls
35966
+ });
35967
+ }
35968
+ }
35969
+
35970
+ function makeRow(parent, className) {
35971
+ return El('div').addClass('label-style-row scalebar-row')
35972
+ .addClass(className || 'scalebar-two-col').appendTo(parent);
35973
+ }
35974
+
35975
+ function makeCell(row, label) {
35976
+ var cell = El('label').addClass('frame-input-cell').appendTo(row);
35977
+ El('span').appendTo(cell).text(label);
35978
+ return cell;
35979
+ }
35980
+
35981
+ function makeInput(parent, className) {
35982
+ return El('input').attr('type', 'text').addClass(className).appendTo(parent);
35983
+ }
35984
+
35985
+ function makeSelect(parent, className, options) {
35986
+ var select = El('select').addClass(className).appendTo(parent);
35987
+ options.forEach(function(o) {
35988
+ El('option').attr('value', o[0]).appendTo(select).text(o[1]);
35989
+ });
35990
+ return select;
35991
+ }
35992
+
35993
+ function makeCheckbox(parent, label, className) {
35994
+ var box = El('input').attr('type', 'checkbox').addClass(className);
35995
+ var wrapper = El('label').addClass('scalebar-checkbox').appendTo(parent);
35996
+ box.appendTo(wrapper);
35997
+ El('span').appendTo(wrapper).text(label);
35998
+ return box;
35999
+ }
36000
+
36001
+ // Shows a value that came from the command line even if the menu lacks it
36002
+ function setSelectValue(select, value) {
36003
+ var node = select.node();
36004
+ var found = Array.prototype.some.call(node.options, function(o) {
36005
+ return o.value == value;
36006
+ });
36007
+ if (!found) {
36008
+ El('option').attr('value', value).appendTo(select).text(value);
36009
+ }
36010
+ node.value = value;
36011
+ }
36012
+
36013
+ // The number of a single-distance label, e.g. "100" from "100 km", so the
36014
+ // units menu can apply to it
36015
+ function getBareDistance(label) {
36016
+ var match = /^([\d\s.,/]*\d)\s*[^\d\s.,/]+$/.exec(String(label || '').trim());
36017
+ return match ? match[1].trim() : '';
36018
+ }
36019
+
36020
+ function getUnitsMenuValue(units) {
36021
+ var parsed = internal.parseLabelUnits(units);
36022
+ return parsed == 'mile' || !parsed ? 'miles' :
36023
+ parsed == 'm' ? 'meters' :
36024
+ parsed == 'ft' ? 'feet' : 'km';
36025
+ }
36026
+
36027
+ function formatValue(val) {
36028
+ return val === undefined || val === null ? '' : String(val);
36029
+ }
36030
+
34309
36031
  // This is a new way to handle compatibility problems between
34310
36032
  // interactive editing modes and other interface modes
34311
36033
  // (by default, interactive modes stay on when, e.g., the user clicks
@@ -35108,6 +36830,7 @@
35108
36830
  new PreviewMode(gui);
35109
36831
  new FrameResizeTool(gui);
35110
36832
  new FrameProperties(gui);
36833
+ new ScalebarProperties(gui);
35111
36834
  gui.editToolbar = new EditToolbar(gui);
35112
36835
  gui.labelTool = new LabelTool(gui);
35113
36836
  gui.layerStyleTool = new LayerStyleTool(gui);