mapshaper 0.7.66 → 0.7.68

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.
@@ -1031,98 +1031,6 @@
1031
1031
  return _el;
1032
1032
  }
1033
1033
 
1034
- function filterLayerByIds(lyr, ids) {
1035
- var shapes;
1036
- if (lyr.shapes) {
1037
- shapes = ids.map(function(id) {
1038
- return lyr.shapes[id];
1039
- });
1040
- return utils$1.defaults({shapes: shapes, data: null}, lyr);
1041
- }
1042
- return lyr;
1043
- }
1044
-
1045
- function formatLayerNameForDisplay(name) {
1046
- return name || '[unnamed]';
1047
- }
1048
-
1049
- function cleanLayerName(raw) {
1050
- return raw.replace(/[\n\t/\\]/g, '')
1051
- .replace(/^[.\s]+/, '').replace(/[.\s]+$/, '');
1052
- }
1053
-
1054
- function updateLayerStackOrder(layers) {
1055
- // 1. assign ascending ids to unassigned layers above the range of other layers
1056
- layers.forEach(function(o, i) {
1057
- if (!o.layer.menu_order) o.layer.menu_order = 1e6 + i;
1058
- });
1059
- // 2. sort in ascending order
1060
- layers.sort(function(a, b) {
1061
- return a.layer.menu_order - b.layer.menu_order;
1062
- });
1063
- // 3. assign consecutve ids
1064
- layers.forEach(function(o, i) {
1065
- o.layer.menu_order = i + 1;
1066
- });
1067
- return layers;
1068
- }
1069
-
1070
- function sortLayersForMenuDisplay(layers) {
1071
- layers = updateLayerStackOrder(layers);
1072
- return layers.reverse();
1073
- }
1074
-
1075
- function setLayerPinning(lyr, pinned) {
1076
- lyr.pinned = !!pinned;
1077
- }
1078
-
1079
-
1080
- function calcDotScale(layers, ext) {
1081
- var bbox = ext.getBounds().scale(1.3).toArray(); // add buffer
1082
- // var topTier = 50000; // can be a bottleneck
1083
- var topTier = 10000; // short-circuit counting here
1084
- var count = 0;
1085
- layers = layers.filter(function(lyr) {
1086
- return lyr.geometry_type == 'point' && lyr.gui.style?.dotSize > 0;
1087
- });
1088
- layers.forEach(function(lyr) {
1089
- count += countPoints(lyr.gui.displayLayer.shapes, topTier, bbox);
1090
- });
1091
- count = Math.min(topTier, count) || 1;
1092
- var k = Math.pow(5 - utils$1.clamp(Math.log10(count), 1, 4), 1.25);
1093
-
1094
- // zoom adjustments
1095
- var mapScale = ext.scale();
1096
- if (mapScale < 0.5) {
1097
- k *= Math.pow(mapScale + 0.5, 0.35);
1098
- } else if (mapScale > 1) {
1099
- // scale faster at first
1100
- k *= Math.pow(Math.min(mapScale, 4), 0.25);
1101
- k *= Math.pow(mapScale, 0.02);
1102
- }
1103
-
1104
- // scale down when map is small
1105
- var smallSide = Math.min(ext.width(), ext.height());
1106
- k *= utils$1.clamp(smallSide / 500, 0.5, 1);
1107
- return k;
1108
- }
1109
-
1110
- function countPoints(shapes, max, bbox) {
1111
- var count = 0;
1112
- var shp, p;
1113
- // short-circuit point counting above top threshold
1114
- for (var i=0, n=shapes.length; i<n && count<max; i++) {
1115
- shp = shapes[i];
1116
- for (var j=0, m=(shp ? shp.length : 0); j<m; j++) {
1117
- p = shp[j];
1118
- if (p[0] > bbox[0] && p[0] < bbox[2] && p[1] > bbox[1] && p[1] < bbox[3]) {
1119
- count ++;
1120
- }
1121
- }
1122
- }
1123
- return count;
1124
- }
1125
-
1126
1034
  async function showPrompt(msg, title) {
1127
1035
  var popup = showPopupAlert(msg, title);
1128
1036
  return new Promise(function(resolve) {
@@ -2669,7 +2577,7 @@
2669
2577
  return obj === Object(obj); // via underscore
2670
2578
  }
2671
2579
 
2672
- function clamp$4(val, min, max) {
2580
+ function clamp$5(val, min, max) {
2673
2581
  return val < min ? min : (val > max ? max : val);
2674
2582
  }
2675
2583
 
@@ -3145,12 +3053,12 @@
3145
3053
  return decimals >= 0 ? num.toFixed(decimals) : String(num);
3146
3054
  }
3147
3055
 
3148
- function formatNumber$2(val) {
3056
+ function formatNumber$3(val) {
3149
3057
  return val + '';
3150
3058
  }
3151
3059
 
3152
3060
  function formatIntlNumber(val) {
3153
- var str = formatNumber$2(val);
3061
+ var str = formatNumber$3(val);
3154
3062
  return '"' + str.replace('.', ',') + '"'; // need to quote if comma-delimited
3155
3063
  }
3156
3064
 
@@ -3320,7 +3228,7 @@
3320
3228
  function findValueByRank(arr, rank) {
3321
3229
  if (!arr.length || rank < 1 || rank > arr.length) error$1("[findValueByRank()] invalid input");
3322
3230
 
3323
- rank = clamp$4(rank | 0, 1, arr.length);
3231
+ rank = clamp$5(rank | 0, 1, arr.length);
3324
3232
  var k = rank - 1, // conv. rank to array index
3325
3233
  n = arr.length,
3326
3234
  l = 0,
@@ -3694,12 +3602,12 @@
3694
3602
  // self-import and the resulting Rollup circular-dependency warning.
3695
3603
  var utils = {
3696
3604
  addThousandsSep, addslashes, arrayToIndex,
3697
- clamp: clamp$4, cleanNumericString, contains, copyElements, countValues, createBuffer,
3605
+ clamp: clamp$5, cleanNumericString, contains, copyElements, countValues, createBuffer,
3698
3606
  defaults, difference,
3699
3607
  endsWith, every, expandoBuffer, extend, extendBuffer,
3700
3608
  find, findMedian, findQuantile, findRankByValue, findStringPrefix,
3701
3609
  findValueByPct, findValueByRank, forEach, forEachProperty, format,
3702
- formatDateISO, formatIntlNumber, formatNumber: formatNumber$2, formatNumberForDisplay,
3610
+ formatDateISO, formatIntlNumber, formatNumber: formatNumber$3, formatNumberForDisplay,
3703
3611
  formatVersionedName, formatter,
3704
3612
  genericSort, getArrayBounds, getGenericComparator, getKeyComparator,
3705
3613
  getSortedIds, getUniqueName, groupBy,
@@ -7433,11 +7341,6 @@
7433
7341
  model.setDefaultTarget([target.layers[0]], target.dataset);
7434
7342
  }
7435
7343
  }
7436
- if (opts.display_all && importTotal === 0) {
7437
- model.getLayers().forEach(function(o) {
7438
- setLayerPinning(o.layer, true);
7439
- });
7440
- }
7441
7344
  model.updated({select: true}); // trigger redraw
7442
7345
  }
7443
7346
 
@@ -8674,6 +8577,123 @@
8674
8577
  }
8675
8578
  }
8676
8579
 
8580
+ function filterLayerByIds(lyr, ids) {
8581
+ var shapes;
8582
+ if (lyr.shapes) {
8583
+ shapes = ids.map(function(id) {
8584
+ return lyr.shapes[id];
8585
+ });
8586
+ return utils$1.defaults({shapes: shapes, data: null}, lyr);
8587
+ }
8588
+ return lyr;
8589
+ }
8590
+
8591
+ function formatLayerNameForDisplay(name) {
8592
+ return name || '[unnamed]';
8593
+ }
8594
+
8595
+ function cleanLayerName(raw) {
8596
+ return raw.replace(/[\n\t/\\]/g, '')
8597
+ .replace(/^[.\s]+/, '').replace(/[.\s]+$/, '');
8598
+ }
8599
+
8600
+ function updateLayerStackOrder(layers) {
8601
+ // 1. assign ascending ids to unassigned layers above the range of other layers
8602
+ layers.forEach(function(o, i) {
8603
+ if (!o.layer.menu_order) o.layer.menu_order = 1e6 + i;
8604
+ });
8605
+ // 2. sort in ascending order
8606
+ layers.sort(function(a, b) {
8607
+ return a.layer.menu_order - b.layer.menu_order;
8608
+ });
8609
+ // 3. assign consecutve ids
8610
+ layers.forEach(function(o, i) {
8611
+ o.layer.menu_order = i + 1;
8612
+ });
8613
+ return layers;
8614
+ }
8615
+
8616
+ function sortLayersForMenuDisplay(layers) {
8617
+ layers = updateLayerStackOrder(layers);
8618
+ return layers.reverse();
8619
+ }
8620
+
8621
+ function setLayerPinning(lyr, pinned) {
8622
+ lyr.pinned = !!pinned;
8623
+ }
8624
+
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.
8628
+ function layerIsPinnable(lyr, dataset) {
8629
+ if (dataset && internal.isFrameLayer(lyr, dataset.arcs)) return false;
8630
+ return internal.layerIsGeometric(lyr) || internal.layerHasRaster(lyr) ||
8631
+ internal.layerHasFurniture(lyr);
8632
+ }
8633
+
8634
+ // Every layer is visible until something says otherwise, so that a layer
8635
+ // made or imported next to another does not hide it -- a reference layer used
8636
+ // as a guide for drawing or labelling would otherwise vanish the moment the
8637
+ // new layer became the active one. Only layers whose visibility has never
8638
+ // been set are touched: a snapshot stores every layer's, and a layer hidden
8639
+ // with its eye icon stays hidden.
8640
+ //
8641
+ // layers: [{layer, dataset}], as from model.getLayers()
8642
+ function showNewLayers(layers) {
8643
+ layers.forEach(function(o) {
8644
+ if (o.layer.pinned === undefined && layerIsPinnable(o.layer, o.dataset)) {
8645
+ setLayerPinning(o.layer, true);
8646
+ }
8647
+ });
8648
+ }
8649
+
8650
+
8651
+ function calcDotScale(layers, ext) {
8652
+ var bbox = ext.getBounds().scale(1.3).toArray(); // add buffer
8653
+ // var topTier = 50000; // can be a bottleneck
8654
+ var topTier = 10000; // short-circuit counting here
8655
+ var count = 0;
8656
+ layers = layers.filter(function(lyr) {
8657
+ return lyr.geometry_type == 'point' && lyr.gui.style?.dotSize > 0;
8658
+ });
8659
+ layers.forEach(function(lyr) {
8660
+ count += countPoints(lyr.gui.displayLayer.shapes, topTier, bbox);
8661
+ });
8662
+ count = Math.min(topTier, count) || 1;
8663
+ var k = Math.pow(5 - utils$1.clamp(Math.log10(count), 1, 4), 1.25);
8664
+
8665
+ // zoom adjustments
8666
+ var mapScale = ext.scale();
8667
+ if (mapScale < 0.5) {
8668
+ k *= Math.pow(mapScale + 0.5, 0.35);
8669
+ } else if (mapScale > 1) {
8670
+ // scale faster at first
8671
+ k *= Math.pow(Math.min(mapScale, 4), 0.25);
8672
+ k *= Math.pow(mapScale, 0.02);
8673
+ }
8674
+
8675
+ // scale down when map is small
8676
+ var smallSide = Math.min(ext.width(), ext.height());
8677
+ k *= utils$1.clamp(smallSide / 500, 0.5, 1);
8678
+ return k;
8679
+ }
8680
+
8681
+ function countPoints(shapes, max, bbox) {
8682
+ var count = 0;
8683
+ var shp, p;
8684
+ // short-circuit point counting above top threshold
8685
+ for (var i=0, n=shapes.length; i<n && count<max; i++) {
8686
+ shp = shapes[i];
8687
+ for (var j=0, m=(shp ? shp.length : 0); j<m; j++) {
8688
+ p = shp[j];
8689
+ if (p[0] > bbox[0] && p[0] < bbox[2] && p[1] > bbox[1] && p[1] < bbox[3]) {
8690
+ count ++;
8691
+ }
8692
+ }
8693
+ }
8694
+ return count;
8695
+ }
8696
+
8677
8697
  // lyr: a map layer with gui property
8678
8698
  // displayCRS: CRS to use for display, or null (which clears any current display CRS)
8679
8699
  function projectLayerForDisplay(lyr, displayCRS) {
@@ -9511,7 +9531,7 @@
9511
9531
 
9512
9532
  function Console(gui) {
9513
9533
  var model = gui.model;
9514
- var CURSOR = '$ ';
9534
+ var CURSOR = '> ';
9515
9535
  var PROMPT = 'Enter mapshaper commands or type "tips" for console help.';
9516
9536
  var el = gui.container.findChild('.console').hide();
9517
9537
  var content = el.findChild('.console-buffer');
@@ -9528,15 +9548,6 @@
9528
9548
  var history = [];
9529
9549
  var historyId = 0;
9530
9550
  var _isOpen = false;
9531
- var btn = gui.container.findChild('.console-btn')
9532
- .on('click', toggle)
9533
- .on('keydown', function(e) {
9534
- if (e.key == 'Enter' || e.key == ' ') {
9535
- e.preventDefault();
9536
- e.stopPropagation();
9537
- toggle();
9538
- }
9539
- });
9540
9551
  var globals = {}; // share user-defined globals (job.defs) between runs
9541
9552
  var sharedVars = {}; // share -vars / -defaults templating scope between runs
9542
9553
 
@@ -9568,10 +9579,6 @@
9568
9579
  }
9569
9580
  });
9570
9581
 
9571
- function toggle() {
9572
- gui.toggleSidebarPanel('console');
9573
- }
9574
-
9575
9582
  gui.on('sidebar', function(e) {
9576
9583
  if (e.panels.includes('console')) {
9577
9584
  turnOn();
@@ -9622,7 +9629,6 @@
9622
9629
  function turnOn() {
9623
9630
  // if (!_isOpen && !model.isEmpty()) {
9624
9631
  if (!_isOpen) {
9625
- btn.addClass('active').attr('aria-expanded', 'true');
9626
9632
  _isOpen = true;
9627
9633
  // Route logging output to the in-app console while it's open, so a
9628
9634
  // user typing CLI commands here sees the results inline -- the same
@@ -9643,7 +9649,6 @@
9643
9649
 
9644
9650
  function turnOff() {
9645
9651
  if (_isOpen) {
9646
- btn.removeClass('active').attr('aria-expanded', 'false');
9647
9652
  _isOpen = false;
9648
9653
  if (GUI.isActiveInstance(gui)) {
9649
9654
  setLoggingForGUI(gui); // reset stop, message and error functions
@@ -10217,14 +10222,14 @@
10217
10222
  }
10218
10223
 
10219
10224
  function printExamples() {
10220
- printExample("See a list of all console commands", "$ help");
10221
- printExample("Get help using a single command", "$ help innerlines");
10222
- printExample("Get information about imported datasets", "$ info");
10223
- printExample("Print bot/debug runtime context as JSON", "$ context");
10224
- printExample("Display browser session as shell commands", "$ history");
10225
- printExample("Delete one state from a national dataset","$ filter 'STATE != \"Alaska\"'");
10226
- printExample("Aggregate counties to states by dissolving shared edges" ,"$ dissolve 'STATE'");
10227
- printExample("Clear the console", "$ clear");
10225
+ printExample("See a list of all console commands", CURSOR + "help");
10226
+ printExample("Get help using a single command", CURSOR + "help innerlines");
10227
+ printExample("Get information about imported datasets", CURSOR + "info");
10228
+ printExample("Print bot/debug runtime context as JSON", CURSOR + "context");
10229
+ printExample("Display browser session as shell commands", CURSOR + "history");
10230
+ printExample("Delete one state from a national dataset", CURSOR + "filter 'STATE != \"Alaska\"'");
10231
+ printExample("Aggregate counties to states by dissolving shared edges" , CURSOR + "dissolve 'STATE'");
10232
+ printExample("Clear the console", CURSOR + "clear");
10228
10233
  }
10229
10234
 
10230
10235
  function renderStructuredConsoleMessage(msg) {
@@ -10392,6 +10397,20 @@
10392
10397
  return false;
10393
10398
  }
10394
10399
 
10400
+ // Test if the map should be re-framed when the content it was showing had no
10401
+ // area -- a single point, or points in a line -- such as the first label
10402
+ // placed on an otherwise empty map. Such bounds are padded by a token amount,
10403
+ // so mapNeedsReset()'s area-change rule would compare the new bounds against
10404
+ // almost nothing: a second label anywhere in view grows the area by more than
10405
+ // its threshold, and the map zooms in to the two labels. The view is the
10406
+ // only real scale left to compare against, and new content smaller than the
10407
+ // view is no reason to zoom in to it.
10408
+ function mapNeedsResetFromCollapsedBounds(newBounds, viewportBounds, flags) {
10409
+ var chgThreshold = flags.proj ? 1e3 : 1e8;
10410
+ if (!newBounds.intersects(viewportBounds)) return true;
10411
+ return newBounds.area() / viewportBounds.area() > chgThreshold;
10412
+ }
10413
+
10395
10414
  // Test if an update may have affected the visible shape of arcs
10396
10415
  // @flags Flags from update event
10397
10416
  function arcsMayHaveChanged(flags) {
@@ -11185,6 +11204,92 @@
11185
11204
  };
11186
11205
  }
11187
11206
 
11207
+ // Short descriptions of what a layer holds, for the contents column of the
11208
+ // layer panel: "6 polygons", "1 data record", "12 labels".
11209
+ //
11210
+ // A point layer is described by how its features are drawn, in the terms of
11211
+ // the point style panel: labels, symbols, circles and plain points. A layer
11212
+ // that mixes them lists each kind it has, e.g. "12 labels, 30 points".
11213
+ //
11214
+ // Features with null geometry are left out of the counts, which say what is on
11215
+ // the map. The column is too narrow to list them as well, so they go in a
11216
+ // tooltip with the full description.
11217
+
11218
+ var POINT_KINDS = ['label', 'symbol', 'circle', 'point'];
11219
+
11220
+ // How a point feature is drawn, given its data record.
11221
+ // A label wins over any symbol or circle drawn with it.
11222
+ function getPointKind(d) {
11223
+ if (!d) return 'point';
11224
+ if ('label-text' in d) return 'label';
11225
+ if (d['svg-symbol'] || d.icon) return 'symbol';
11226
+ if (d.r > 0) return 'circle';
11227
+ return 'point';
11228
+ }
11229
+
11230
+ // The kind of feature a point layer is set up for, from its field names:
11231
+ // names the features of an empty layer, e.g. "0 labels".
11232
+ function getPointLayerKind(fields) {
11233
+ if (fields.includes('label-text')) return 'label';
11234
+ if (fields.includes('svg-symbol') || fields.includes('icon')) return 'symbol';
11235
+ if (fields.includes('r')) return 'circle';
11236
+ return 'point';
11237
+ }
11238
+
11239
+ // Same test as the -info command's null shape count
11240
+ function isNullShape(shp) {
11241
+ return !shp || shp.length === 0;
11242
+ }
11243
+
11244
+ // @geometryType: 'point', 'polygon' or 'polyline'
11245
+ // @shapes: the layer's shapes
11246
+ // @records: data records, or null for a layer without attribute data
11247
+ // @fields: names of the layer's data fields
11248
+ // Returns {text, nulls, title}; title is the tooltip, or null if there are no nulls.
11249
+ function describeFeatureContents(geometryType, shapes, records, fields) {
11250
+ var nulls = shapes.length - countNonNull(shapes);
11251
+ var text = geometryType == 'point' ?
11252
+ describePoints(shapes, records, fields || []) :
11253
+ formatCount(shapes.length - nulls, geometryType);
11254
+ return {
11255
+ text: text,
11256
+ nulls: nulls,
11257
+ title: nulls > 0 ? text + ', ' + formatInteger(nulls) + ' without geometry' : null
11258
+ };
11259
+ }
11260
+
11261
+ function describePoints(shapes, records, fields) {
11262
+ var counts = {label: 0, symbol: 0, circle: 0, point: 0};
11263
+ var parts, i;
11264
+ for (i=0; i<shapes.length; i++) {
11265
+ if (isNullShape(shapes[i])) continue;
11266
+ counts[getPointKind(records ? records[i] : null)]++;
11267
+ }
11268
+ parts = POINT_KINDS.filter(function(kind) {
11269
+ return counts[kind] > 0;
11270
+ }).map(function(kind) {
11271
+ return formatCount(counts[kind], kind);
11272
+ });
11273
+ return parts.length > 0 ? parts.join(', ') :
11274
+ formatCount(0, getPointLayerKind(fields));
11275
+ }
11276
+
11277
+ function countNonNull(shapes) {
11278
+ var n = 0;
11279
+ for (var i=0; i<shapes.length; i++) {
11280
+ if (!isNullShape(shapes[i])) n++;
11281
+ }
11282
+ return n;
11283
+ }
11284
+
11285
+ function formatCount(n, noun) {
11286
+ return formatInteger(n) + ' ' + noun + (n == 1 ? '' : 's');
11287
+ }
11288
+
11289
+ function formatInteger(n) {
11290
+ return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
11291
+ }
11292
+
11188
11293
  var openMenu$1;
11189
11294
  var openMenuId;
11190
11295
  // One menu element per parent element, created on demand.
@@ -11578,7 +11683,6 @@
11578
11683
  var el = gui.container.findChild(".layer-control").hide();
11579
11684
  var btn = gui.container.findChild('.layer-control-btn');
11580
11685
  var headerBtn = btn.findChild('.active-layer-label');
11581
- var tab = gui.container.findChild('.layer-tab');
11582
11686
  var isOpen = false;
11583
11687
  var cache = new DomCache();
11584
11688
  var pinAll = el.findChild('.pin-all'); // button for toggling layer visibility
@@ -11613,17 +11717,6 @@
11613
11717
  gui.toggleSidebarPanel('console');
11614
11718
  }
11615
11719
  });
11616
- tab.on('click', toggle).on('keydown', function(e) {
11617
- if (e.key == 'Enter') {
11618
- e.preventDefault();
11619
- e.stopPropagation();
11620
- toggle();
11621
- } else if (e.key == ' ') {
11622
- e.preventDefault();
11623
- e.stopPropagation();
11624
- gui.toggleSidebarPanel('console');
11625
- }
11626
- });
11627
11720
 
11628
11721
  function toggle() {
11629
11722
  gui.toggleSidebarPanel('layers');
@@ -11729,7 +11822,6 @@
11729
11822
  function turnOn() {
11730
11823
  if (isOpen) return;
11731
11824
  isOpen = true;
11732
- tab.addClass('active').attr('aria-expanded', 'true');
11733
11825
  render();
11734
11826
  el.addClass('open');
11735
11827
  el.show();
@@ -11739,7 +11831,6 @@
11739
11831
  if (!isOpen) return;
11740
11832
  stopDragging();
11741
11833
  isOpen = false;
11742
- tab.removeClass('active').attr('aria-expanded', 'false');
11743
11834
  el.removeClass('open');
11744
11835
  el.hide();
11745
11836
  }
@@ -11780,7 +11871,9 @@
11780
11871
  layerCount++;
11781
11872
  });
11782
11873
 
11783
- if (pinnableCount < 2) {
11874
+ // Shown for a single layer too: it says whether the next layer made or
11875
+ // imported will be drawn over this one or replace it on the map.
11876
+ if (pinnableCount < 1) {
11784
11877
  pinAll.hide();
11785
11878
  } else {
11786
11879
  pinAll.show();
@@ -11818,8 +11911,7 @@
11818
11911
 
11819
11912
  function renderLayer(lyr, dataset, opts) {
11820
11913
  var classes = 'layer-item';
11821
- var isFrame = internal.isFrameLayer(lyr, dataset.arcs);
11822
- var entry, html;
11914
+ var html;
11823
11915
 
11824
11916
  if (opts.pinnable) classes += ' pinnable';
11825
11917
  if (map.isActiveLayer(lyr)) classes += ' active';
@@ -11827,8 +11919,9 @@
11827
11919
  if (lyr.pinned) classes += ' pinned';
11828
11920
 
11829
11921
  html = '<!-- ' + lyr.menu_id + '--><div class="' + classes + '">';
11830
- html += rowHTML('name', '<span class="layer-name colored-text dot-underline">' + formatLayerNameForDisplay(lyr.name) + '</span>', 'row1');
11831
- html += rowHTML(isFrame ? 'size' : 'contents', describeLyr(lyr, dataset));
11922
+ html += '<div class="layer-row"><div class="layer-name-col">' +
11923
+ '<span class="layer-name colored-text dot-underline">' + formatLayerNameForDisplay(lyr.name) + '</span></div>' +
11924
+ renderContents(lyr, dataset) + '</div>';
11832
11925
  html += '<span class="more-btn layer-btn" role="button" aria-label="More layer options"></span>';
11833
11926
  if (opts.pinnable) {
11834
11927
  html += '<img class="eye-btn black-eye layer-btn" draggable="false" src="images/eye.png">';
@@ -12078,29 +12171,34 @@
12078
12171
 
12079
12172
  }
12080
12173
 
12174
+ // Returns {text, title}; title is a tooltip, or null
12081
12175
  function describeLyr(lyr, dataset) {
12082
- var n = internal.getFeatureCount(lyr),
12083
- isFrame = internal.isFrameLayer(lyr, dataset.arcs),
12084
- str, type;
12085
- if (lyr.data && !lyr.shapes) {
12086
- type = 'data record';
12087
- } else if (lyr.geometry_type) {
12088
- type = lyr.geometry_type + ' feature';
12089
- } else if (internal.layerHasRaster(lyr)) {
12090
- type = 'raster layer';
12091
- }
12092
- if (isFrame) {
12093
- str = internal.formatFrameSizeForDisplay(
12176
+ if (internal.isFrameLayer(lyr, dataset.arcs)) {
12177
+ return {text: internal.formatFrameSizeForDisplay(
12094
12178
  internal.getFrameLayerData(lyr, dataset.arcs)
12095
- );
12096
- } else if (internal.layerHasRaster(lyr)) {
12097
- str = utils$1.format('%,d x %,d %s', internal.getRasterWidth(lyr.raster), internal.getRasterHeight(lyr.raster), type);
12098
- } else if (type) {
12099
- str = utils$1.format('%,d %s%s', n, type, utils$1.pluralSuffix(n));
12100
- } else {
12101
- str = "[empty]";
12179
+ ), title: null};
12102
12180
  }
12103
- return str;
12181
+ if (internal.layerHasRaster(lyr)) {
12182
+ return {text: utils$1.format('%,d x %,d raster', internal.getRasterWidth(lyr.raster),
12183
+ internal.getRasterHeight(lyr.raster)), title: null};
12184
+ }
12185
+ if (lyr.data && !lyr.shapes) {
12186
+ return {text: formatCount(internal.getFeatureCount(lyr), 'data record'), title: null};
12187
+ }
12188
+ if (lyr.geometry_type) {
12189
+ return describeFeatureContents(lyr.geometry_type, lyr.shapes,
12190
+ lyr.data ? lyr.data.getRecords() : null, lyr.data ? lyr.data.getFields() : []);
12191
+ }
12192
+ return {text: '[empty]', title: null};
12193
+ }
12194
+
12195
+ function renderContents(lyr, dataset) {
12196
+ var o = describeLyr(lyr, dataset);
12197
+ if (!o.title) {
12198
+ return '<div class="layer-contents">' + o.text + '</div>';
12199
+ }
12200
+ return '<div class="layer-contents has-nulls"><span title="' + o.title + '">' +
12201
+ o.text + '</span></div>';
12104
12202
  }
12105
12203
 
12106
12204
  function renderLayerInfo(info) {
@@ -12222,32 +12320,70 @@
12222
12320
  }
12223
12321
 
12224
12322
  function isPinnable(lyr, dataset) {
12225
- if (dataset && internal.isFrameLayer(lyr, dataset.arcs)) return false;
12226
- return internal.layerIsGeometric(lyr) || internal.layerHasRaster(lyr) || internal.layerHasFurniture(lyr);
12323
+ return layerIsPinnable(lyr, dataset);
12227
12324
  }
12228
12325
 
12229
12326
  function layerCanBeStyled(lyr) {
12230
12327
  return !!(lyr && (lyr.geometry_type == 'point' || lyr.geometry_type == 'polyline' || lyr.geometry_type == 'polygon'));
12231
12328
  }
12232
12329
 
12233
- function rowHTML(c1, c2, cname) {
12234
- return utils$1.format('<div class="row%s"><div class="col1">%s</div>' +
12235
- '<div class="col2">%s</div></div>', cname ? ' ' + cname : '', c1, c2);
12236
- }
12237
12330
  }
12238
12331
 
12239
- // Quotes a value for use in a mapshaper command string.
12240
- //
12241
- // Single quotes with backslash escaping is what the command parser expects;
12242
- // this round-trips text containing apostrophes, double quotes, spaces and the
12243
- // empty string. Extracted here because three style tools had defined it
12244
- // privately and the label tool needed a fourth copy.
12245
- function quoteCommandValue(str) {
12246
- return "'" + String(str).replace(/'/g, "\\'") + "'";
12247
- }
12332
+ // The buttons that open and close the sidebar panels: an icon strip on the
12333
+ // map while the sidebar is closed, and a strip of toggles at the top of the
12334
+ // sidebar while it is open. Each button toggles its own panel, so both panels
12335
+ // can be open at once.
12336
+ function SidebarTabs(gui) {
12337
+ var buttons = gui.container.findChildren('.sidebar-tab, .sidebar-strip-btn');
12338
+ var hideBtn = gui.container.findChild('.sidebar-hide-btn');
12248
12339
 
12249
- // Turns the layer panel's "Draw" links into the commands they run, as pure
12250
- // functions so that they can be tested without a DOM. Layer creation goes
12340
+ buttons.forEach(function(btn) {
12341
+ var panel = btn.attr('data-panel');
12342
+ btn.on('click', function() {
12343
+ gui.toggleSidebarPanel(panel);
12344
+ });
12345
+ onKeyboardActivate(btn, function() {
12346
+ gui.toggleSidebarPanel(panel);
12347
+ });
12348
+ });
12349
+
12350
+ hideBtn.on('click', hideSidebar);
12351
+ onKeyboardActivate(hideBtn, hideSidebar);
12352
+
12353
+ gui.on('sidebar', function(e) {
12354
+ buttons.forEach(function(btn) {
12355
+ var open = e.panels.includes(btn.attr('data-panel'));
12356
+ btn.classed('active', open).attr('aria-expanded', String(open));
12357
+ });
12358
+ });
12359
+
12360
+ function hideSidebar() {
12361
+ gui.setSidebarPanels(null);
12362
+ }
12363
+
12364
+ function onKeyboardActivate(btn, action) {
12365
+ btn.on('keydown', function(e) {
12366
+ if (e.key == 'Enter' || e.key == ' ') {
12367
+ e.preventDefault();
12368
+ e.stopPropagation();
12369
+ action();
12370
+ }
12371
+ });
12372
+ }
12373
+ }
12374
+
12375
+ // Quotes a value for use in a mapshaper command string.
12376
+ //
12377
+ // Single quotes with backslash escaping is what the command parser expects;
12378
+ // this round-trips text containing apostrophes, double quotes, spaces and the
12379
+ // empty string. Extracted here because three style tools had defined it
12380
+ // privately and the label tool needed a fourth copy.
12381
+ function quoteCommandValue(str) {
12382
+ return "'" + String(str).replace(/'/g, "\\'") + "'";
12383
+ }
12384
+
12385
+ // Turns the layer panel's "Draw" links into the commands they run, as pure
12386
+ // functions so that they can be tested without a DOM. Layer creation goes
12251
12387
  // through -add-layer for the same reason every label edit goes through a
12252
12388
  // command: undo/redo and session history come with it.
12253
12389
 
@@ -12365,7 +12501,9 @@
12365
12501
  if (!container.node()) return;
12366
12502
  DRAW_KINDS.forEach(function(kind, i) {
12367
12503
  if (i > 0) {
12368
- El('span').addClass('layer-menu-link-separator').html('&nbsp;·&nbsp;')
12504
+ // A breaking space after the dot, so that a sidebar too narrow for the
12505
+ // whole list wraps it between links, and never inside one.
12506
+ El('span').addClass('layer-menu-link-separator').html('&nbsp;· ')
12369
12507
  .appendTo(container);
12370
12508
  }
12371
12509
  El('span').addClass('layer-menu-link').attr('data-kind', kind.kind)
@@ -13729,6 +13867,13 @@
13729
13867
  // keeps them out of a character class so that "\\n" is matched as a pair.
13730
13868
  var ANY_NEWLINE = /\r\n|\\n|<br>|\n|\r/gi;
13731
13869
 
13870
+ // A line break the wrapper inserted rather than one the user typed. It consumes
13871
+ // no character -- the space the line broke at stays before it -- so removing
13872
+ // every one gives back the text as typed. See
13873
+ // docs/development/text-annotation-design.md.
13874
+ var SOFT_BREAK = '<wbr>';
13875
+ var SOFT_BREAK_RXP = /<wbr>/gi;
13876
+
13732
13877
  // How a label lays out its line breaks, which is not the same for the two kinds.
13733
13878
  // An anchored label stacks its lines with <tspan>. A path label joins them with
13734
13879
  // a space, because a <tspan> inside a <textPath> advances along the curve
@@ -13743,10 +13888,72 @@
13743
13888
  // all, and a line the user has just opened with Enter and not yet typed into.
13744
13889
  var TEXT_PLACEHOLDER = '\u200b';
13745
13890
 
13746
- // Data value -> the string the user edits, with real newlines.
13891
+ // Data value -> the string the user edits, with real newlines and without the
13892
+ // wrapper's soft breaks, which are rewrapped rather than edited.
13747
13893
  function decodeLabelText(val) {
13748
13894
  if (val === null || val === undefined || val === '') return '';
13749
- return String(val).replace(ANY_NEWLINE, '\n');
13895
+ return String(val).replace(ANY_NEWLINE, '\n').replace(SOFT_BREAK_RXP, '');
13896
+ }
13897
+
13898
+ // Data value -> {text, breaks}: the string the user edits, and the offsets in
13899
+ // it where the stored value has a soft break.
13900
+ function readSoftBreaks(val) {
13901
+ var str = val === null || val === undefined ? '' : String(val).replace(ANY_NEWLINE, '\n');
13902
+ var parts = str.split(SOFT_BREAK_RXP);
13903
+ var breaks = [];
13904
+ var text = parts[0];
13905
+ for (var i = 1; i < parts.length; i++) {
13906
+ breaks.push(text.length);
13907
+ text += parts[i];
13908
+ }
13909
+ return {text: text, breaks: breaks};
13910
+ }
13911
+
13912
+ // The string the user edits, plus soft breaks at @breaks (ascending offsets
13913
+ // into it) -> the same string with the markers written in.
13914
+ function insertSoftBreaks(text, breaks) {
13915
+ var out = '', prev = 0;
13916
+ if (!breaks || breaks.length === 0) return text;
13917
+ for (var i = 0; i < breaks.length; i++) {
13918
+ out += text.substring(prev, breaks[i]) + SOFT_BREAK;
13919
+ prev = breaks[i];
13920
+ }
13921
+ return out + text.substring(prev);
13922
+ }
13923
+
13924
+ // Where a laid-out text's lines start because they were wrapped, given each
13925
+ // character's top edge (see gui-label-wrap.mjs).
13926
+ //
13927
+ // @getTop(i) returns the top of character i in px, or null for one with no box.
13928
+ // A line that starts after a typed newline is not a soft break, and neither is
13929
+ // a character with no box -- the break lands on the next one that has a box.
13930
+ // The low half of a surrogate pair is never asked about.
13931
+ function findSoftBreaks(text, getTop, threshold) {
13932
+ var breaks = [], lastTop = null, afterNewline = false, i, c, top;
13933
+ for (i = 0; i < text.length; i++) {
13934
+ c = text.charCodeAt(i);
13935
+ if (c == 10) {
13936
+ afterNewline = true;
13937
+ continue;
13938
+ }
13939
+ if (c >= 0xDC00 && c <= 0xDFFF) continue;
13940
+ top = getTop(i);
13941
+ if (top === null) continue;
13942
+ if (lastTop !== null && !afterNewline && top > lastTop + threshold) {
13943
+ breaks.push(i);
13944
+ }
13945
+ lastTop = top;
13946
+ afterNewline = false;
13947
+ }
13948
+ return breaks;
13949
+ }
13950
+
13951
+ function sameSoftBreaks(a, b) {
13952
+ if (a.length != b.length) return false;
13953
+ for (var i = 0; i < a.length; i++) {
13954
+ if (a[i] !== b[i]) return false;
13955
+ }
13956
+ return true;
13750
13957
  }
13751
13958
 
13752
13959
  // The string the user edits -> the data value. Newlines become the escape,
@@ -13763,12 +13970,28 @@
13763
13970
  // break that started it. That does two things: an empty line gets something to
13764
13971
  // lay out, so a line the user has just opened appears and can hold the caret,
13765
13972
  // and the rendered characters stay aligned one-for-one with the edited ones.
13766
- function getRenderedLines(str) {
13973
+ //
13974
+ // @breaks: offsets of soft breaks, which also start a line. A soft break
13975
+ // consumes no character, so its line gets no placeholder and the alignment
13976
+ // holds.
13977
+ function getRenderedLines(str, breaks) {
13767
13978
  var text = decodeLabelText(str);
13979
+ var lines = [], start = 0, lead = '', b = 0, i;
13768
13980
  if (text === '') return [TEXT_PLACEHOLDER];
13769
- return text.split('\n').map(function(line, i) {
13770
- return i === 0 ? line : TEXT_PLACEHOLDER + line;
13771
- });
13981
+ breaks = breaks || [];
13982
+ for (i = 0; i <= text.length; i++) {
13983
+ while (b < breaks.length && breaks[b] < i) b++;
13984
+ if (i == text.length || text.charAt(i) == '\n') {
13985
+ lines.push(lead + text.substring(start, i));
13986
+ start = i + 1;
13987
+ lead = TEXT_PLACEHOLDER;
13988
+ } else if (breaks[b] === i && i > start) {
13989
+ lines.push(lead + text.substring(start, i));
13990
+ start = i;
13991
+ lead = '';
13992
+ }
13993
+ }
13994
+ return lines;
13772
13995
  }
13773
13996
 
13774
13997
  // Whether the text would draw nothing at all: empty, or made only of
@@ -13959,10 +14182,11 @@
13959
14182
  //
13960
14183
  // label-pos places text relative to an anchor point, which a path label does
13961
14184
  // not have -- its text runs along the curve. Writing it there would put a
13962
- // property on the feature that nothing reads.
14185
+ // property on the feature that nothing reads. label-width is the same: a path
14186
+ // label is one line.
13963
14187
  function getStyleFields$1(style, isPathLabel) {
13964
14188
  return Object.keys(style || {}).filter(function(name) {
13965
- return !(isPathLabel && name == 'label-pos');
14189
+ return !(isPathLabel && (name == 'label-pos' || name == 'label-width'));
13966
14190
  });
13967
14191
  }
13968
14192
 
@@ -14024,18 +14248,45 @@
14024
14248
  // anchor: the value for text-anchor
14025
14249
  // id: feature id of the label
14026
14250
  // target: layer name, or null to use the current target
14251
+ // via: (optional) the callout-via the text carried with it
14027
14252
  function getLabelOffsetCommand(opts) {
14028
14253
  var parts = ['-style', 'dx=' + opts.dx, 'dy=' + opts.dy,
14029
14254
  'text-anchor=' + opts.anchor, 'label-pos='];
14255
+ if (opts.via) parts.push('callout-via=' + quoteCommandValue(opts.via));
14030
14256
  parts.push('ids=' + opts.id);
14031
14257
  if (opts.target) parts.push('target=' + quoteCommandValue(opts.target));
14032
14258
  return parts.join(' ');
14033
14259
  }
14034
14260
 
14261
+ // The command a drag on one of a label's handles writes, run once on release.
14262
+ //
14263
+ // 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
14265
+ // id: feature id of the label
14266
+ // opts:
14267
+ // target: layer name, or null to use the current target
14268
+ // 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
14270
+ // the lines it breaks into are one undo step.
14271
+ function getLabelStyleCommand(values, id, opts) {
14272
+ var o = opts || {};
14273
+ var parts = ['-style'];
14274
+ Object.keys(values).forEach(function(name) {
14275
+ var val = values[name];
14276
+ parts.push(name + '=' + (val === '' ? '' : quoteCommandValue(String(val))));
14277
+ });
14278
+ parts.push('ids=' + id);
14279
+ if (o.target) parts.push('target=' + quoteCommandValue(o.target));
14280
+ if (typeof o.text == 'string') {
14281
+ parts.push(getLabelTextCommand(o.text, id, o.target));
14282
+ }
14283
+ return parts.join(' ');
14284
+ }
14285
+
14035
14286
  // The command that saves edited text, run once when an editing session ends so
14036
14287
  // that a session is one undo step rather than one per keystroke.
14037
14288
  //
14038
- // text: the edited string, with real newlines
14289
+ // text: the edited string, with real newlines and any soft breaks
14039
14290
  // id: feature id of the label
14040
14291
  // target: layer name, or null to use the current target
14041
14292
  function getLabelTextCommand(text, id, target) {
@@ -14086,25 +14337,25 @@
14086
14337
  // labelModeIsAvailable().
14087
14338
  var menus = {
14088
14339
  standard: ['info', 'selection', 'box', 'ruler'],
14089
- empty: ['edit_polygons', 'edit_lines', 'edit_points', 'box', 'ruler'],
14090
- polygons: ['info', 'selection', 'box', 'polygon_style', 'edit_polygons', 'ruler'],
14091
- rectangles: ['info', 'selection', 'box', 'polygon_style', 'rectangles', 'edit_polygons', 'ruler'],
14092
- lines: ['info', 'selection', 'box', 'line_style', 'edit_lines', 'snip_lines', 'ruler'],
14340
+ empty: [ 'label','edit_points', 'edit_lines', 'edit_polygons','box', 'ruler'],
14341
+ polygons: ['info', 'selection', 'polygon_style', 'edit_polygons', 'box', 'ruler'],
14342
+ rectangles: ['info', 'selection', 'polygon_style', 'edit_polygons', 'rectangles', 'box', 'ruler'],
14343
+ lines: ['info', 'selection', 'line_style', 'edit_lines', 'snip_lines', 'box', 'ruler'],
14093
14344
  table: ['info', 'selection'],
14094
- raster: ['ruler', 'box'],
14095
- // A label layer has no entry for styling labels, for adding and dragging
14096
- // points, or for positioning labels: the label tool does all three, along
14097
- // with creating and retyping labels, so each of them offered a subset of
14098
- // what sat next to it in the menu.
14099
- labels: ['info', 'selection', 'box', 'label', 'ruler'],
14100
- points: ['info', 'selection', 'box', 'point_style', 'edit_points', 'ruler'], // , 'add-points'
14345
+ raster: ['box', 'ruler'],
14346
+ labels: ['info', 'selection', 'label', 'box', 'ruler'],
14347
+ points: ['info', 'selection', 'edit_points', 'point_style', 'box', 'ruler'], // , 'add-points'
14101
14348
  // An empty point layer is the layer the "Draw: labels" link creates,
14102
14349
  // and a label goes into it rather than beside it (see labelWouldJoin), so
14103
14350
  // the label tool belongs in its menu as well as the point tools: it is the
14104
14351
  // way back into a labels layer that has no label in it yet.
14105
- emptyPoints: ['info', 'selection', 'box', 'point_style', 'label', 'edit_points', 'ruler']
14352
+ emptyPoints: ['info', 'selection', 'label', 'point_style', 'edit_points', 'box', 'ruler']
14106
14353
  };
14107
14354
 
14355
+ // Tools that work the same whatever the active layer is. They go below a
14356
+ // divider, so the layer-name heading visibly covers only the tools above it.
14357
+ var layerIndependentModes = ['box', 'ruler'];
14358
+
14108
14359
  var prompts = {
14109
14360
  box: 'Shift-drag to draw a box',
14110
14361
  data: 'Click-select features to edit their attributes',
@@ -14200,7 +14451,7 @@
14200
14451
  };
14201
14452
 
14202
14453
  this.modeUsesPopup = function(mode) {
14203
- return ['info', 'selection', 'data', 'box', 'edit_points', 'rectangles'].includes(mode);
14454
+ return ['info', 'selection', 'data', 'box', 'edit_points'].includes(mode);
14204
14455
  };
14205
14456
 
14206
14457
  this.modeSupportsUndo = function(mode) {
@@ -14271,27 +14522,54 @@
14271
14522
 
14272
14523
  function renderMenu() {
14273
14524
  if (!menu) return;
14274
- var modes = getAvailableModes();
14275
- menu.empty();
14276
- modes.forEach(function(mode) {
14525
+ var modes = getAvailableModes().filter(function(mode) {
14277
14526
  // don't show "turn off" link if not currently editing
14278
- if (_editMode == 'off' && mode == 'off') return;
14279
- var link = El('div').addClass('nav-menu-item').attr('data-name', mode).text(getModeLabel(mode)).appendTo(menu);
14280
- link.on('click', function(e) {
14281
- if (_editMode == mode) {
14282
- // closeMenu();
14283
- setMode('off');
14284
- } else if (_editMode != mode) {
14285
- setMode(mode);
14286
- if (mode == 'off') closeMenu(120); // only close if turning off
14287
- // closeMenu(mode == 'off' ? 120 : 400); // close after selecting
14288
- }
14289
- e.stopPropagation();
14290
- });
14527
+ return !(_editMode == 'off' && mode == 'off');
14528
+ });
14529
+ var layerModes = modes.filter(function(mode) {
14530
+ return !layerIndependentModes.includes(mode);
14531
+ });
14532
+ var otherModes = modes.filter(function(mode) {
14533
+ return layerIndependentModes.includes(mode);
14291
14534
  });
14535
+ var lyr = gui.model.getActiveLayer()?.layer;
14536
+ menu.empty();
14537
+ if (lyr && layerModes.length > 0) {
14538
+ renderLayerHeading(lyr);
14539
+ }
14540
+ layerModes.forEach(renderMenuItem);
14541
+ if (layerModes.length > 0 && otherModes.length > 0) {
14542
+ El('div').addClass('nav-menu-divider').appendTo(menu);
14543
+ }
14544
+ otherModes.forEach(renderMenuItem);
14292
14545
  updateSelectionHighlight();
14293
14546
  }
14294
14547
 
14548
+ // Names the layer the tools above the divider act on.
14549
+ function renderLayerHeading(lyr) {
14550
+ var name = formatLayerNameForDisplay(lyr.name);
14551
+ var heading = El('div').addClass('nav-menu-heading').attr('title', name).text(name).appendTo(menu);
14552
+ // A click on the heading would reach the button, which picks a tool.
14553
+ heading.on('click', function(e) {
14554
+ e.stopPropagation();
14555
+ });
14556
+ }
14557
+
14558
+ function renderMenuItem(mode) {
14559
+ var link = El('div').addClass('nav-menu-item').attr('data-name', mode).text(getModeLabel(mode)).appendTo(menu);
14560
+ link.on('click', function(e) {
14561
+ if (_editMode == mode) {
14562
+ // closeMenu();
14563
+ setMode('off');
14564
+ } else if (_editMode != mode) {
14565
+ setMode(mode);
14566
+ if (mode == 'off') closeMenu(120); // only close if turning off
14567
+ // closeMenu(mode == 'off' ? 120 : 400); // close after selecting
14568
+ }
14569
+ e.stopPropagation();
14570
+ });
14571
+ }
14572
+
14295
14573
  function getModeLabel(mode) {
14296
14574
  return labels[mode];
14297
14575
  }
@@ -15631,16 +15909,16 @@
15631
15909
  function getCanvasPoint(canvas, evt) {
15632
15910
  var rect = canvas.getBoundingClientRect();
15633
15911
  return {
15634
- x: clamp$3(Math.round((evt.clientX - rect.left) / rect.width * (canvas.width - 1)), 0, canvas.width - 1),
15635
- y: clamp$3(Math.round((evt.clientY - rect.top) / rect.height * (canvas.height - 1)), 0, canvas.height - 1)
15912
+ x: clamp$4(Math.round((evt.clientX - rect.left) / rect.width * (canvas.width - 1)), 0, canvas.width - 1),
15913
+ y: clamp$4(Math.round((evt.clientY - rect.top) / rect.height * (canvas.height - 1)), 0, canvas.height - 1)
15636
15914
  };
15637
15915
  }
15638
15916
 
15639
15917
  function setPickerColor(hsb) {
15640
15918
  pickerColor = {
15641
- h: clamp$3(Math.round(hsb.h), 0, 255),
15642
- s: clamp$3(Math.round(hsb.s), 0, 255),
15643
- b: clamp$3(Math.round(hsb.b), 0, 255)
15919
+ h: clamp$4(Math.round(hsb.h), 0, 255),
15920
+ s: clamp$4(Math.round(hsb.s), 0, 255),
15921
+ b: clamp$4(Math.round(hsb.b), 0, 255)
15644
15922
  };
15645
15923
  drawColorPicker();
15646
15924
  updatePickerFields();
@@ -15781,7 +16059,7 @@
15781
16059
  }
15782
16060
 
15783
16061
  function pctToByte(val) {
15784
- return Math.round(clamp$3(val, 0, 100) / 100 * 255);
16062
+ return Math.round(clamp$4(val, 0, 100) / 100 * 255);
15785
16063
  }
15786
16064
 
15787
16065
  function byteToDegrees(val) {
@@ -15789,14 +16067,14 @@
15789
16067
  }
15790
16068
 
15791
16069
  function degreesToByte(val) {
15792
- return Math.round(clamp$3(val, 0, 360) / 360 * 255);
16070
+ return Math.round(clamp$4(val, 0, 360) / 360 * 255);
15793
16071
  }
15794
16072
 
15795
16073
  function parseNumberField(str) {
15796
16074
  return Number(String(str).replace(/[°%]/g, '').trim());
15797
16075
  }
15798
16076
 
15799
- function clamp$3(val, min, max) {
16077
+ function clamp$4(val, min, max) {
15800
16078
  return isFinite(val) ? Math.max(min, Math.min(max, val)) : min;
15801
16079
  }
15802
16080
 
@@ -16184,9 +16462,12 @@
16184
16462
  setDisplay(shown);
16185
16463
  return;
16186
16464
  }
16465
+ var changed = String(val) !== shown;
16187
16466
  setDisplay(String(val));
16188
- if (String(val) !== shown && o.onSet) o.onSet(val);
16467
+ // Before onSet, which can take focus away: the blur fires 'change', and
16468
+ // the commit it re-enters has to find this value already shown.
16189
16469
  shown = String(val);
16470
+ if (changed && o.onSet) o.onSet(val);
16190
16471
  }
16191
16472
 
16192
16473
  function setDisplay(str) {
@@ -16414,23 +16695,38 @@
16414
16695
  function makeColorField(parent, chit, input) {
16415
16696
  var box = El('div').addClass('label-color-field').appendTo(parent);
16416
16697
  chit.appendTo(box);
16417
- input.appendTo(box);
16698
+ input.addClass('label-color-input').appendTo(box);
16418
16699
  return box;
16419
16700
  }
16420
16701
 
16421
- // A colour and its opacity on one line: the swatch inside the field with the
16422
- // hex value, and the opacity in the narrow column beside it. Fill and Stroke
16423
- // are each one of these, and so are a label's text and icon colours.
16702
+ // A colour field with its opacity at the right-hand end, behind a divider:
16703
+ // opacity qualifies the colour, and one border says so where two fields side
16704
+ // by side said they were separate settings. It also leaves the narrow column
16705
+ // of the row free for a field that needs it. The opacity has no caption; its
16706
+ // percent sign and its tooltip say what it is.
16707
+ //
16708
+ // Returns the field's box; the opacity input is made by makeOpacityInput().
16709
+ function makeColorOpacityField(parent, chit, input, opacityOpts) {
16710
+ var box = makeColorField(parent, chit, input).addClass('label-color-opacity-field');
16711
+ var opacity = makeOpacityInput(box, opacityOpts);
16712
+ return {box: box, opacity: opacity};
16713
+ }
16714
+
16715
+ // A colour and its opacity in one field, captioned, in the wide column of a
16716
+ // split row. Fill and Stroke are each one of these.
16424
16717
  //
16425
- // opts.label the caption over the colour field
16718
+ // opts.label the caption over the field
16426
16719
  // opts.onColor(hex) a colour was typed, picked or previewed to a finish
16427
16720
  // opts.onOpacity(frac) a usable percentage was typed
16428
16721
  // opts.revert() one that was not, so put the row back as it was
16722
+ //
16723
+ // control.aside is the row's narrow column, empty, for a field that belongs
16724
+ // beside the colour -- a stroke's width, say.
16429
16725
  function makeColorRow(parent, opts) {
16430
16726
  var row = El('div').addClass('label-style-row label-split-row').appendTo(parent);
16431
16727
  var colorCell = El('div').addClass('label-split-cell label-color-row').appendTo(row);
16432
- var opacityCell = El('div').addClass('label-split-cell').appendTo(row);
16433
- var control = {row: row, chit: null, input: null, opacity: null, picker: null};
16728
+ var aside = El('div').addClass('label-split-cell').appendTo(row);
16729
+ var control = {row: row, aside: aside, chit: null, input: null, opacity: null, picker: null};
16434
16730
 
16435
16731
  control.setColor = function(color) {
16436
16732
  control.input.node().value = color || '';
@@ -16460,7 +16756,10 @@
16460
16756
  if (isHexColor(color)) control.picker.setColor(color);
16461
16757
  opts.onColor(color);
16462
16758
  });
16463
- makeColorField(colorCell, control.chit, control.input);
16759
+ control.opacity = makeColorOpacityField(colorCell, control.chit, control.input, {
16760
+ onSet: opts.onOpacity,
16761
+ revert: opts.revert
16762
+ }).opacity;
16464
16763
  control.picker = new ColorPicker(colorCell, {
16465
16764
  presetRows: layerColorPresetRows,
16466
16765
  // A drag in the picker shows on the map's own terms -- the swatch and the
@@ -16471,12 +16770,6 @@
16471
16770
  opts.onColor(hex);
16472
16771
  }
16473
16772
  });
16474
-
16475
- El('span').appendTo(opacityCell).text('Opacity');
16476
- control.opacity = makeOpacityInput(opacityCell, {
16477
- onSet: opts.onOpacity,
16478
- revert: opts.revert
16479
- });
16480
16773
  return control;
16481
16774
  }
16482
16775
 
@@ -16521,7 +16814,12 @@
16521
16814
  'dominant-baseline',
16522
16815
  'label-pos', 'label-side', 'label-start-offset', 'dx', 'dy',
16523
16816
  'fill', 'opacity', 'css', 'class',
16524
- 'icon', 'icon-size', 'icon-color', 'icon-opacity'
16817
+ 'halo-width', 'halo-color', 'halo-opacity',
16818
+ 'icon', 'icon-size', 'icon-color', 'icon-opacity',
16819
+ // not callout-via or callout-attach, which are placed for one label's
16820
+ // surroundings rather than chosen as a style
16821
+ 'callout', 'callout-end', 'callout-end-size', 'callout-color', 'callout-opacity', 'callout-width',
16822
+ 'callout-gap'
16525
16823
  ];
16526
16824
 
16527
16825
  // values: [[field, value], ...], the form the panel's controls produce
@@ -16610,23 +16908,49 @@
16610
16908
  //
16611
16909
  // Fixed on entry, so that a stray drag cannot displace text in a session that
16612
16910
  // never asked for it, and remembered while the tool stays on.
16911
+ //
16912
+ // Text blocks keep a mode of their own, which starts out Draggable. A block is
16913
+ // usually an annotation set off from the point it is about, and is placed by
16914
+ // dragging it away from its anchor; point text more often sits where its
16915
+ // position puts it and is moved as a whole.
16916
+ //
16917
+ // @kind: 'point' (the default) or 'block' -- see getLabelPositionKind()
16613
16918
  var LABEL_POSITION_MODES = ['fixed', 'draggable'];
16614
16919
 
16615
- function getLabelPositionMode(gui) {
16616
- return gui.state.label_position_mode || 'fixed';
16920
+ var DEFAULT_POSITION_MODES = {point: 'fixed', block: 'draggable'};
16921
+
16922
+ function getLabelPositionMode(gui, kind) {
16923
+ var k = kind == 'block' ? 'block' : 'point';
16924
+ var modes = gui.state.label_position_modes || {};
16925
+ return modes[k] || DEFAULT_POSITION_MODES[k];
16617
16926
  }
16618
16927
 
16619
- function setLabelPositionMode(gui, mode) {
16620
- gui.state.label_position_mode =
16621
- LABEL_POSITION_MODES.indexOf(mode) > -1 ? mode : 'fixed';
16928
+ // With no @kind, sets both kinds: 'fixed' puts point text in Fixed mode and
16929
+ // blocks back to their own default, which is what the tool does on entry.
16930
+ function setLabelPositionMode(gui, mode, kind) {
16931
+ var modes = gui.state.label_position_modes || {};
16932
+ var val = LABEL_POSITION_MODES.indexOf(mode) > -1 ? mode : 'fixed';
16933
+ if (kind) {
16934
+ modes[kind == 'block' ? 'block' : 'point'] = val;
16935
+ } else {
16936
+ modes = {point: val, block: DEFAULT_POSITION_MODES.block};
16937
+ }
16938
+ gui.state.label_position_modes = modes;
16622
16939
  // The panel's toggle and the tool's drag both read this, and neither owns the
16623
16940
  // other; the panel also has to relight the grid, whose cells mean something
16624
16941
  // different in each mode.
16625
16942
  gui.dispatchEvent('label_position_mode_change');
16626
16943
  }
16627
16944
 
16628
- function labelTextIsDraggable(gui) {
16629
- return getLabelPositionMode(gui) == 'draggable';
16945
+ function labelTextIsDraggable(gui, kind) {
16946
+ return getLabelPositionMode(gui, kind) == 'draggable';
16947
+ }
16948
+
16949
+ // Which position mode governs a label: 'block' for a text block (a label with
16950
+ // a wrap width), 'point' for any other.
16951
+ function getLabelPositionKind(rec) {
16952
+ var w = rec ? Number(rec['label-width']) : 0;
16953
+ return w > 0 && isFinite(w) ? 'block' : 'point';
16630
16954
  }
16631
16955
 
16632
16956
  // The label currently open for text editing, or null: {id, refocus}.
@@ -16704,6 +17028,9 @@
16704
17028
  // anchor: the justification it is drawn with
16705
17029
  // width: the width of its text, in the same space; 0 if unmeasurable
16706
17030
  // aligned: whether it carries a label-align (see below)
17031
+ // keepAnchor: (optional) leave the justification alone, for a text block:
17032
+ // its lines are laid out within a column, and re-justifying them as the
17033
+ // block passes its anchor would rearrange the text rather than move it
16707
17034
  // delta: {dx, dy} -- how far the pointer has moved, in the same space.
16708
17035
  //
16709
17036
  // Returns two descriptions of the same placement:
@@ -16731,7 +17058,7 @@
16731
17058
  // that correction, 'start' is the one text-anchor whose dx is the left edge
16732
17059
  // itself -- which is also the value that would leave the label where it is
16733
17060
  // if the alignment were later removed.
16734
- var anchor = start.aligned ? 'start' :
17061
+ var anchor = start.aligned ? 'start' : start.keepAnchor ? drawnAnchor :
16735
17062
  width > 0 ? getAnchorForCentre(left + width / 2, width) : drawnAnchor;
16736
17063
  // An aligned label goes on being drawn with the anchor its alignment gives
16737
17064
  // it, whatever the drag writes: the alignment is unchanged, so the renderer
@@ -16799,6 +17126,159 @@
16799
17126
  return Math.round(px * PRECISION) / PRECISION;
16800
17127
  }
16801
17128
 
17129
+ // Wrapping a text block to its label-width, with the browser's own line
17130
+ // breaker.
17131
+ //
17132
+ // There is no API that reports where a browser breaks lines, so the text is
17133
+ // laid out in an offscreen <div> at the label's width and font, and read back:
17134
+ // Range.getClientRects() gives each character's line box, and a character
17135
+ // sitting lower than the one before it starts a line. Everything the browser
17136
+ // knows about line breaking -- UAX #14 classes, breaks after hyphens, none at
17137
+ // no-break spaces, CJK, dictionary breaking for Thai -- comes with it.
17138
+ //
17139
+ // The font is read back from a rendered sample of the label rather than
17140
+ // assembled from the record, so that the layer's defaults and any inline css
17141
+ // apply exactly as they do on the map.
17142
+ //
17143
+ // See docs/development/text-annotation-design.md.
17144
+
17145
+ // Properties that change where lines break, and so the ones a change to which
17146
+ // has to rewrap a text block.
17147
+ var WRAP_FIELDS = ['label-width', 'font-family', 'font-size',
17148
+ 'font-weight', 'font-style', 'font-stretch', 'letter-spacing', 'css'];
17149
+
17150
+ var COPIED_STYLES = ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle',
17151
+ 'fontStretch', 'fontVariant', 'fontKerning', 'fontFeatureSettings',
17152
+ 'fontVariationSettings', 'letterSpacing', 'wordSpacing', 'textTransform'];
17153
+
17154
+ // Lines are laid out far apart, so that a character in a fallback font sitting
17155
+ // a little higher or lower than its neighbours is not mistaken for a new line.
17156
+ var LINE_HEIGHT = 3;
17157
+
17158
+ var CACHE_LIMIT = 2000;
17159
+ var cache$2 = new Map();
17160
+ var wrapper = null;
17161
+
17162
+ // The label's width in px, or 0 for a label that does not wrap.
17163
+ function getLabelWrapWidth(rec) {
17164
+ var w = rec ? Number(rec['label-width']) : 0;
17165
+ return w > 0 && isFinite(w) ? w : 0;
17166
+ }
17167
+
17168
+ // Offsets into @text (real newlines, no soft breaks) where a line starts
17169
+ // because it was wrapped, in ascending order.
17170
+ function getSoftBreaks(text, rec) {
17171
+ var width = getLabelWrapWidth(rec);
17172
+ var key, breaks;
17173
+ if (!width || !text) return [];
17174
+ key = getWrapKey(text, rec);
17175
+ if (cache$2.has(key)) return cache$2.get(key);
17176
+ breaks = measureSoftBreaks(text, rec, width);
17177
+ if (cache$2.size >= CACHE_LIMIT) cache$2.clear();
17178
+ cache$2.set(key, breaks);
17179
+ return breaks;
17180
+ }
17181
+
17182
+ // A stored label-text value, rewrapped for @rec's width and font. Returned
17183
+ // unchanged when neither the old value nor the new one has a soft break, so
17184
+ // that a label that does not wrap keeps the line breaks it was written with.
17185
+ function rewrapLabelValue(value, rec) {
17186
+ var o = readSoftBreaks(value);
17187
+ var breaks = getSoftBreaks(o.text, rec);
17188
+ if (sameSoftBreaks(o.breaks, breaks)) return value;
17189
+ return encodeLabelText(insertSoftBreaks(o.text, breaks));
17190
+ }
17191
+
17192
+ function clearWrapCache() {
17193
+ cache$2.clear();
17194
+ }
17195
+
17196
+ function getWrapKey(text, rec) {
17197
+ var parts = [text];
17198
+ WRAP_FIELDS.forEach(function(name) {
17199
+ var val = rec[name];
17200
+ parts.push(val === null || val === undefined ? '' : String(val));
17201
+ });
17202
+ parts.push(rec['class'] || '');
17203
+ return parts.join('\u0000');
17204
+ }
17205
+
17206
+ function measureSoftBreaks(text, rec, width) {
17207
+ var w = getWrapper();
17208
+ var style, fontSize, node, range;
17209
+ if (!w) return [];
17210
+ style = getLabelFontStyle(w, rec);
17211
+ if (!style) return [];
17212
+ COPIED_STYLES.forEach(function(name) {
17213
+ w.div.style[name] = style[name] || '';
17214
+ });
17215
+ w.div.style.width = width + 'px';
17216
+ w.div.textContent = text;
17217
+ node = w.div.firstChild;
17218
+ fontSize = parseFloat(style.fontSize) || 12;
17219
+ range = document.createRange();
17220
+ var breaks = findSoftBreaks(text, function(i) {
17221
+ var end = isHighSurrogate(text.charCodeAt(i)) ? i + 2 : i + 1;
17222
+ var r;
17223
+ range.setStart(node, i);
17224
+ range.setEnd(node, Math.min(end, text.length));
17225
+ r = range.getBoundingClientRect();
17226
+ return r.height > 0 ? r.top : null;
17227
+ }, fontSize * LINE_HEIGHT / 2);
17228
+ w.div.textContent = '';
17229
+ return breaks;
17230
+ }
17231
+
17232
+ function isHighSurrogate(c) {
17233
+ return c >= 0xD800 && c <= 0xDBFF;
17234
+ }
17235
+
17236
+ // The computed font of @rec's label, from a sample rendered by the same code
17237
+ // as the map, inside the defaults a label inherits from its layer's group.
17238
+ function getLabelFontStyle(w, rec) {
17239
+ var sample = Object.assign({}, rec, {'label-text': 'x'});
17240
+ var node, cs, out;
17241
+ delete sample['label-align'];
17242
+ w.svg.innerHTML = internal.svg.stringify({
17243
+ tag: 'g',
17244
+ properties: internal.getLabelTextDefaults(),
17245
+ children: [internal.svg.renderStyledLabel(sample)]
17246
+ });
17247
+ node = w.svg.querySelector('text');
17248
+ if (!node) return null;
17249
+ cs = window.getComputedStyle(node);
17250
+ out = {};
17251
+ COPIED_STYLES.forEach(function(name) {
17252
+ out[name] = cs[name];
17253
+ });
17254
+ w.svg.innerHTML = '';
17255
+ return out;
17256
+ }
17257
+
17258
+ // Positioned off the page rather than hidden with display:none, which gives an
17259
+ // element no layout and its text no lines.
17260
+ function getWrapper() {
17261
+ var box, div, svg;
17262
+ if (wrapper) return wrapper;
17263
+ if (typeof document == 'undefined') return null;
17264
+ box = document.createElement('div');
17265
+ box.setAttribute('class', 'label-wrap-measure');
17266
+ box.setAttribute('aria-hidden', 'true');
17267
+ div = document.createElement('div');
17268
+ div.style.whiteSpace = 'pre-wrap';
17269
+ div.style.overflowWrap = 'break-word';
17270
+ div.style.lineHeight = String(LINE_HEIGHT);
17271
+ div.style.padding = '0';
17272
+ div.style.border = '0';
17273
+ div.style.boxSizing = 'content-box';
17274
+ svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
17275
+ box.appendChild(div);
17276
+ box.appendChild(svg);
17277
+ document.body.appendChild(box);
17278
+ wrapper = {div: div, svg: svg};
17279
+ return wrapper;
17280
+ }
17281
+
16802
17282
  var fontField = 'font-family';
16803
17283
  var fontSizeField = 'font-size';
16804
17284
  var fontStyleField = 'font-style';
@@ -16810,21 +17290,37 @@
16810
17290
  var textAnchorField = 'text-anchor';
16811
17291
  var labelAlignField = 'label-align';
16812
17292
  var cssField = 'css';
17293
+ var haloWidthField = 'halo-width';
17294
+ var haloColorField = 'halo-color';
17295
+ var haloOpacityField = 'halo-opacity';
16813
17296
  var iconField = 'icon';
16814
17297
  var iconSizeField = 'icon-size';
16815
17298
  var iconColorField = 'icon-color';
16816
17299
  var iconOpacityField = 'icon-opacity';
17300
+ var calloutField = 'callout';
17301
+ var calloutEndField = 'callout-end';
17302
+ var calloutEndSizeField = 'callout-end-size';
17303
+ var calloutColorField = 'callout-color';
17304
+ var calloutOpacityField = 'callout-opacity';
17305
+ var calloutWidthField = 'callout-width';
17306
+ var calloutGapField = 'callout-gap';
17307
+ var defaultCalloutShape = 'line';
17308
+ var defaultCalloutWidth = 1;
16817
17309
  var defaultFontSize = 12;
16818
17310
  var defaultFontStyle = 'normal';
16819
17311
  var defaultFontWeight = '400';
16820
17312
  var defaultLabelColor = '#000000';
16821
17313
  var defaultIconColor = '#000000';
16822
17314
  var defaultIconSize = 5;
16823
- // The line height field shows this rather than renderLabel()'s 1.1em default,
16824
- // and shows it as a placeholder rather than a value, so that a label carries
16825
- // no line-height until one is chosen. "auto" is the honest description of a
16826
- // blank field: something else decides.
16827
- var lineHeightPlaceholder = 'auto';
17315
+ // The width a halo is switched on at: past the edge of the glyphs, so a
17316
+ // stroke of twice this.
17317
+ var defaultHaloWidth = 2;
17318
+ var defaultHaloColor = internal.svg.DEFAULT_HALO_COLOR;
17319
+ // The line height a label without one is drawn with, shown as a placeholder
17320
+ // rather than a value so that a label carries no line-height until one is
17321
+ // chosen. Shown as a bare multiple, which also says what a bare number typed
17322
+ // here means: a multiple of the font size, as in CSS, not px.
17323
+ var lineHeightPlaceholder = String(internal.svg.DEFAULT_LINE_HEIGHT);
16828
17324
  // What a control shows for a selection whose labels disagree about it. One
16829
17325
  // word, used in every field and menu that can be in that state: the panel
16830
17326
  // otherwise says it by showing nothing, which is also what an unset property
@@ -16846,10 +17342,20 @@
16846
17342
  labelAlignField,
16847
17343
  cssField,
16848
17344
  'label-pos',
17345
+ haloWidthField,
17346
+ haloColorField,
17347
+ haloOpacityField,
16849
17348
  iconField,
16850
17349
  iconSizeField,
16851
17350
  iconColorField,
16852
- iconOpacityField
17351
+ iconOpacityField,
17352
+ calloutField,
17353
+ calloutEndField,
17354
+ calloutEndSizeField,
17355
+ calloutColorField,
17356
+ calloutOpacityField,
17357
+ calloutWidthField,
17358
+ calloutGapField
16853
17359
  ];
16854
17360
  var labelPositions = ['nw', 'n', 'ne', 'w', 'c', 'e', 'sw', 's', 'se'];
16855
17361
  // The position an icon moves a centred label to when it is switched on: upper
@@ -16911,6 +17417,41 @@
16911
17417
  center: '<line x1="3" y1="4.5" x2="13" y2="4.5"></line><line x1="5.5" y1="8" x2="10.5" y2="8"></line><line x1="4" y1="11.5" x2="12" y2="11.5"></line>',
16912
17418
  right: '<line x1="3" y1="4.5" x2="13" y2="4.5"></line><line x1="8" y1="8" x2="13" y2="8"></line><line x1="5" y1="11.5" x2="13" y2="11.5"></line>'
16913
17419
  };
17420
+ // As with icons, no "none" among the shapes: the section's switch says whether
17421
+ // there is a callout. The ends do have one, since a line with no marker is a
17422
+ // shape of its own rather than the absence of a callout.
17423
+ var calloutShapes = [{
17424
+ name: 'line',
17425
+ title: 'straight'
17426
+ }, {
17427
+ name: 'elbow',
17428
+ title: 'elbow'
17429
+ }, {
17430
+ name: 'curve',
17431
+ title: 'curve'
17432
+ }];
17433
+ var calloutEnds = [{
17434
+ name: 'none',
17435
+ title: 'no marker at the anchor'
17436
+ }, {
17437
+ name: 'arrow',
17438
+ title: 'solid arrowhead at the anchor'
17439
+ }, {
17440
+ name: 'open-arrow',
17441
+ title: 'open arrowhead at the anchor'
17442
+ }];
17443
+ // The anchor is at the lower left of the shapes and at the left of the ends,
17444
+ // which is where the markers go. The arrowheads are drawn to the renderer's
17445
+ // angles, with sides the same visible length -- the open one's stroke
17446
+ // included -- so that they compare as the drawn arrows do.
17447
+ var calloutButtonSymbols = {
17448
+ line: '<path d="M3.5 12.5L12.5 3.5"></path>',
17449
+ elbow: '<path d="M3.5 12.5L8 4.5H13"></path>',
17450
+ curve: '<path d="M3.5 12.5Q4.5 4.5 12.5 4"></path>',
17451
+ none: '<path d="M3 8H13"></path>',
17452
+ arrow: '<path d="M6.7 8H13"></path><path class="fill" d="M2.5 8L8.53 5.57V10.43Z"></path>',
17453
+ 'open-arrow': '<path d="M3.2 8H13"></path><path d="M7.38 5.07L3.2 8L7.38 10.93"></path>'
17454
+ };
16914
17455
  var iconButtonSymbols = {
16915
17456
  circle: '<circle cx="8" cy="8" r="4.25"></circle>',
16916
17457
  square: '<rect x="4" y="4" width="8" height="8"></rect>',
@@ -16925,11 +17466,15 @@
16925
17466
  // label-style-panel carries the styling the point and layer panels share; the
16926
17467
  // second class is this panel's own, as theirs are
16927
17468
  var panel = El('div').addClass('label-style-panel text-style-panel rollover').appendTo(parent).hide();
16928
- var presetControl, fontSelect, fontStyleSelect, fontSizeInput, colorFieldBox, colorChit, colorInput, colorPicker, opacityInput, letterSpacingInput, lineHeightInput, alignBtns, cssInput, posBtns, dragModeBtns, iconToggle, iconGroupEl, iconBtns, iconSizeInput, iconColorFieldBox, iconColorChit, iconColorInput, iconColorPicker, iconOpacityInput, editingStatus, clearLink, closeBtn, hit;
17469
+ var presetControl, fontSelect, fontStyleSelect, fontSizeInput, colorFieldBox, colorChit, colorInput, colorPicker, opacityInput, letterSpacingInput, lineHeightInput, alignBtns, cssInput, posBtns, dragModeBtns, haloToggle, haloWidthInput, haloColorFieldBox, haloColorChit, haloColorInput, haloColorPicker, haloOpacityInput, iconToggle, iconGroupEl, iconBtns, iconSizeInput, iconColorFieldBox, iconColorChit, iconColorInput, iconColorPicker, iconOpacityInput, haloSection, iconSection, calloutSection, calloutToggle, calloutShapeGroupEl, calloutShapeBtns, calloutEndGroupEl, calloutEndBtns, calloutColorFieldBox, calloutColorChit, calloutColorInput, calloutColorPicker, calloutOpacityInput, calloutWidthInput, calloutEndSizeInput, calloutGapInput, editingStatus, clearLink, closeBtn, hit;
16929
17470
  var fontOptionsRendered = false;
16930
17471
  // The shape the toggle turns back on, so that switching a symbol off and on
16931
17472
  // again does not silently change a star into a circle.
16932
17473
  var lastIconShape = defaultIconShape;
17474
+ // Likewise the halo's width, which is what switching one off removes.
17475
+ var lastHaloWidth = defaultHaloWidth;
17476
+ // And the callout's shape.
17477
+ var lastCalloutShape = defaultCalloutShape;
16933
17478
 
16934
17479
  initPanel();
16935
17480
  gui.addMode(labelStylePanelMode, turnOn, turnOff);
@@ -17029,8 +17574,18 @@
17029
17574
  });
17030
17575
 
17031
17576
  var header = El('div').addClass('label-style-panel-title').appendTo(panel).text('Label styles');
17577
+ // In label mode the panel belongs to the mode, so closing it leaves the
17578
+ // mode: a panel that shut while the map stayed armed for placing labels
17579
+ // would leave the tool half on, with no panel to turn it off from. Every
17580
+ // other style panel's × closes a panel and nothing else, and this one
17581
+ // reads as the same button, which is the argument for it doing the whole
17582
+ // of what the user asked for rather than part.
17032
17583
  closeBtn = El('button').addClass('label-style-close').appendTo(header).text('×').on('click', function() {
17033
- gui.clearMode();
17584
+ if (labelModeIsOn()) {
17585
+ gui.interaction.turnOff(); // the tool's own turnOff() takes the panel
17586
+ } else {
17587
+ gui.clearMode();
17588
+ }
17034
17589
  });
17035
17590
 
17036
17591
  var selectRow = El('div').addClass('label-style-selection-row').appendTo(panel);
@@ -17038,15 +17593,16 @@
17038
17593
  clearLink = El('span').addClass('label-editing-clear colored-text').appendTo(selectRow).text('deselect').on('click', clearSelection);
17039
17594
 
17040
17595
  // Three sections rather than one flat stack: the text, the symbol beside
17041
- // it, and where the two sit relative to each other. Text and Icon are
17042
- // deliberately the same shape -- a colour and its opacity on one line, a
17043
- // size on the line above -- so that the second reads as a variation on the
17044
- // first rather than as a different kind of control.
17596
+ // it, and where the two sit relative to each other. Every section's colour
17597
+ // row is the same shape -- the colour with its opacity in one field, and
17598
+ // the value that qualifies it most closely beside it -- so that each reads
17599
+ // as a variation on the first rather than as a different kind of control.
17045
17600
  var textSection = addSection('Text');
17046
17601
 
17047
- // The controls whose own contents say what they are -- a font name, a hex
17048
- // colour, a percentage, a size beside a font style -- carry no label. The
17049
- // ones that would be a bare number otherwise keep theirs.
17602
+ // The controls whose own contents say what they are -- a font name, a
17603
+ // size beside a font style -- carry no label. The ones that would be a bare
17604
+ // number otherwise keep theirs, and so does a colour field, because the
17605
+ // field beside it has one and "Color" says the percentage is its opacity.
17050
17606
  var fontRow = El('div').addClass('label-style-row').appendTo(textSection);
17051
17607
  fontSelect = El('select').attr('title', 'Font').appendTo(fontRow).on('change', function() {
17052
17608
  if (fontSelect.node().value) {
@@ -17072,11 +17628,20 @@
17072
17628
  onDone: releaseFocus
17073
17629
  });
17074
17630
 
17631
+ // Letter spacing beside the colour, above line height: the two spacing
17632
+ // values read as a pair in the narrow column, and the left of the row
17633
+ // below is where the alignment buttons go.
17634
+ //
17635
+ // A text block's width is not here: it is set by dragging the block's
17636
+ // handle, and a field for it would be clutter on every other label.
17075
17637
  var colorRow = El('div').addClass('label-style-row label-split-row').appendTo(textSection);
17076
- var textColorCell = El('div').addClass('label-split-cell label-color-row').appendTo(colorRow);
17638
+ var textColorCell = El('div').addClass('label-split-cell label-color-row label-text-color-row').appendTo(colorRow);
17639
+ El('span').appendTo(textColorCell).text('Color');
17077
17640
  colorChit = El('div').addClass('label-color-chit').attr('role', 'button');
17078
17641
  colorInput = El('input').attr('type', 'text').attr('title', 'Text color');
17079
- colorFieldBox = makeColorField(textColorCell, colorChit, colorInput);
17642
+ var textColorField = addColorOpacityField(textColorCell, colorChit, colorInput, applyLabelOpacity);
17643
+ colorFieldBox = textColorField.box;
17644
+ opacityInput = textColorField.opacity;
17080
17645
  colorChit.on('click', function() {
17081
17646
  if (this.classList.contains('disabled')) return;
17082
17647
  toggleColorPicker();
@@ -17092,15 +17657,7 @@
17092
17657
  });
17093
17658
  colorPicker = initColorPicker(textColorCell, colorChit, colorInput, applyLabelColor);
17094
17659
 
17095
- var opacityCell = El('div').addClass('label-split-cell label-opacity-row label-text-opacity-row').appendTo(colorRow);
17096
- opacityInput = addOpacityInput(opacityCell, applyLabelOpacity);
17097
-
17098
- // Letter spacing takes the right-hand column on its own, above line
17099
- // height: the two spacing values read as a pair there, and the left of the
17100
- // row is where the alignment buttons go.
17101
- var letterRow = El('div').addClass('label-style-row label-split-row').appendTo(textSection);
17102
- El('div').addClass('label-split-cell').appendTo(letterRow);
17103
- var letterCell = El('div').addClass('label-split-cell label-spacing-row').appendTo(letterRow);
17660
+ var letterCell = El('div').addClass('label-split-cell label-spacing-row').appendTo(colorRow);
17104
17661
  El('span').appendTo(letterCell).text('Letter spacing');
17105
17662
  letterSpacingInput = makeMeasureInput(letterCell, letterSpacingField, '0');
17106
17663
 
@@ -17136,25 +17693,72 @@
17136
17693
  var positionRow = El('label').addClass('label-style-row').appendTo(textSection);
17137
17694
  El('span').appendTo(positionRow).text('Offset from anchor');
17138
17695
 
17696
+ // A halo is a yes/no that its three values then qualify, as a symbol is, so
17697
+ // it is switched the same way and its controls are inert while it is off.
17698
+ // Its row is the shape of the Text section's colour row: a colour and its
17699
+ // opacity, and a width in the narrow column beside them, where letter
17700
+ // spacing sits above.
17701
+ haloSection = addSection('Halo');
17702
+ var haloTitle = haloSection.findChild('.label-style-section-title');
17703
+ haloToggle = makeToggle(haloTitle, {
17704
+ title: 'Draw a halo around the text',
17705
+ className: 'label-halo-toggle',
17706
+ onChange: setHaloOn
17707
+ });
17708
+
17709
+ var haloColorRow = El('div').addClass('label-style-row label-split-row').appendTo(haloSection);
17710
+ var haloColorCell = El('div').addClass('label-split-cell label-color-row label-halo-color-row').appendTo(haloColorRow);
17711
+ El('span').appendTo(haloColorCell).text('Color');
17712
+ haloColorChit = El('div').addClass('label-color-chit').attr('role', 'button');
17713
+ haloColorInput = El('input').attr('type', 'text').attr('title', 'Halo color');
17714
+ var haloColorField = addColorOpacityField(haloColorCell, haloColorChit, haloColorInput, applyHaloOpacity);
17715
+ haloColorFieldBox = haloColorField.box;
17716
+ haloOpacityInput = haloColorField.opacity;
17717
+ haloColorChit.on('click', function() {
17718
+ if (this.classList.contains('disabled')) return;
17719
+ haloColorPicker.toggle();
17720
+ });
17721
+ haloColorInput.on('change', function() {
17722
+ var color = haloColorInput.node().value.trim();
17723
+ if (color) {
17724
+ if (isHexColor(color)) {
17725
+ haloColorPicker.setColor(color);
17726
+ }
17727
+ applyHaloColor(color);
17728
+ }
17729
+ });
17730
+ haloColorPicker = initColorPicker(haloColorCell, haloColorChit, haloColorInput, applyHaloColor);
17731
+
17732
+ var haloWidthCell = El('div').addClass('label-split-cell label-spacing-row').appendTo(haloColorRow);
17733
+ El('span').appendTo(haloWidthCell).text('Width');
17734
+ // Halves as well as whole pixels: a halo is usually 1 to 2px, and the
17735
+ // step between those two is most of the range anyone uses.
17736
+ haloWidthInput = new SizeField(haloWidthCell, {
17737
+ title: 'How far the halo extends past the text, in px',
17738
+ min: 0.1,
17739
+ max: 50,
17740
+ step: 0.5,
17741
+ bigStep: 2,
17742
+ onSet: applyHaloWidth,
17743
+ onStep: nudgeHaloWidth,
17744
+ onDone: releaseFocus
17745
+ });
17746
+
17139
17747
  // Whether the label has a symbol is one question and which symbol it has is
17140
17748
  // another, so the first is a switch on the section's heading rather than a
17141
17749
  // fifth shape button reading "none". Everything below it is inert while it
17142
17750
  // is off, which is also the honest reading of an icon-size or icon-color on
17143
17751
  // a label with no icon: nothing to apply it to.
17144
- var iconSection = addSection('Icon');
17752
+ iconSection = addSection('Icon');
17145
17753
  var iconTitle = iconSection.findChild('.label-style-section-title');
17146
17754
  iconToggle = makeToggle(iconTitle, {
17147
17755
  title: 'Draw a symbol at the label anchor',
17756
+ className: 'label-icon-toggle',
17148
17757
  onChange: setIconOn
17149
17758
  });
17150
- // The size's caption sits on the heading line, over its own column. It
17151
- // belongs to the field in the row below, but the shapes beside that field
17152
- // have no caption of their own, and a caption over one control of a pair
17153
- // pushes it out of line with the other.
17154
- El('div').addClass('label-icon-size-caption').appendTo(iconTitle).text('Size');
17155
-
17156
- var iconSizeRow = El('div').addClass('label-style-row label-split-row label-icon-shapes-row').appendTo(iconSection);
17157
- var iconRow = El('div').addClass('label-split-cell').appendTo(iconSizeRow);
17759
+ // The shapes have the whole row, so there is room for more of them, and
17760
+ // the size sits beside the colour below, as the halo's width does.
17761
+ var iconRow = El('div').addClass('label-style-row label-icon-shapes-row').appendTo(iconSection);
17158
17762
  var iconGroup = iconGroupEl = El('div').addClass('label-btn-group label-icon-buttons').appendTo(iconRow);
17159
17763
  iconBtns = {};
17160
17764
  iconTypes.forEach(function(icon) {
@@ -17167,21 +17771,14 @@
17167
17771
  appendIconButtonSymbol(btn, icon.name);
17168
17772
  });
17169
17773
 
17170
- var sizeRow = El('div').addClass('label-split-cell label-icon-size-row').appendTo(iconSizeRow);
17171
- iconSizeInput = new SizeField(sizeRow, {
17172
- title: 'Symbol size in px',
17173
- onSet: function(value) {
17174
- applyIconSize(value);
17175
- },
17176
- onStep: nudgeIconSize,
17177
- onDone: releaseFocus
17178
- });
17179
-
17180
17774
  var iconColorRow = El('div').addClass('label-style-row label-split-row').appendTo(iconSection);
17181
17775
  var iconColorCell = El('div').addClass('label-split-cell label-color-row label-icon-color-row').appendTo(iconColorRow);
17776
+ El('span').appendTo(iconColorCell).text('Color');
17182
17777
  iconColorChit = El('div').addClass('label-color-chit').attr('role', 'button');
17183
17778
  iconColorInput = El('input').attr('type', 'text').attr('title', 'Symbol color');
17184
- iconColorFieldBox = makeColorField(iconColorCell, iconColorChit, iconColorInput);
17779
+ var iconColorField = addColorOpacityField(iconColorCell, iconColorChit, iconColorInput, applyIconOpacity);
17780
+ iconColorFieldBox = iconColorField.box;
17781
+ iconOpacityInput = iconColorField.opacity;
17185
17782
  iconColorChit.on('click', function() {
17186
17783
  if (this.classList.contains('disabled')) return;
17187
17784
  iconColorPicker.toggle();
@@ -17197,8 +17794,18 @@
17197
17794
  });
17198
17795
  iconColorPicker = initColorPicker(iconColorCell, iconColorChit, iconColorInput, applyIconColor);
17199
17796
 
17200
- var iconOpacityCell = El('div').addClass('label-split-cell label-opacity-row label-icon-opacity-row').appendTo(iconColorRow);
17201
- iconOpacityInput = addOpacityInput(iconOpacityCell, applyIconOpacity);
17797
+ var sizeRow = El('div').addClass('label-split-cell label-icon-size-row').appendTo(iconColorRow);
17798
+ El('span').appendTo(sizeRow).text('Size');
17799
+ iconSizeInput = new SizeField(sizeRow, {
17800
+ title: 'Symbol size in px',
17801
+ onSet: function(value) {
17802
+ applyIconSize(value);
17803
+ },
17804
+ onStep: nudgeIconSize,
17805
+ onDone: releaseFocus
17806
+ });
17807
+
17808
+ initCalloutSection();
17202
17809
 
17203
17810
  var posRow = El('div').addClass('label-style-row label-position-row').appendTo(textSection);
17204
17811
 
@@ -17223,7 +17830,7 @@
17223
17830
  dragModeBtns = {};
17224
17831
  labelDragModes.forEach(function(item) {
17225
17832
  dragModeBtns[item.name] = makePanelButton(dragModeGroup, item.label, function() {
17226
- setLabelPositionMode(gui, item.name);
17833
+ setLabelPositionMode(gui, item.name, getPositionModeKind());
17227
17834
  })
17228
17835
  .attr('data-drag-mode', item.name)
17229
17836
  .attr('title', item.title);
@@ -17244,6 +17851,115 @@
17244
17851
  });
17245
17852
  }
17246
17853
 
17854
+ // Switched like the halo and the symbol, and shaped like them: the choices on
17855
+ // the first line, a colour and its opacity on the next, then the sizes. The
17856
+ // line's geometry -- its corner or bend, where it meets the text, how far it
17857
+ // stops short of the anchor -- is a matter of positions, which are dragged on
17858
+ // the map rather than typed here. See docs/development/text-annotation-design.md.
17859
+ function initCalloutSection() {
17860
+ calloutSection = addSection('Callout');
17861
+ calloutToggle = makeToggle(calloutSection.findChild('.label-style-section-title'), {
17862
+ title: 'Draw a line from the label anchor to its text',
17863
+ className: 'label-callout-toggle',
17864
+ onChange: setCalloutOn
17865
+ });
17866
+
17867
+ // Each choice in the wide column with the size that qualifies it beside
17868
+ // it, as Alignment has Line height: the line's shape and its width, then
17869
+ // the marker and its size.
17870
+ var shapeRow = El('div').addClass('label-style-row label-split-row').appendTo(calloutSection);
17871
+ var shapeCell = El('div').addClass('label-split-cell label-align-row').appendTo(shapeRow);
17872
+ El('span').appendTo(shapeCell).text('Line');
17873
+ calloutShapeGroupEl = El('div').addClass('label-btn-group label-callout-buttons').appendTo(shapeCell);
17874
+ calloutShapeBtns = {};
17875
+ calloutShapes.forEach(function(item) {
17876
+ calloutShapeBtns[item.name] = makeCalloutButton(calloutShapeGroupEl, item, function() {
17877
+ applyCalloutShape(item.name);
17878
+ });
17879
+ });
17880
+ var widthCell = El('div').addClass('label-split-cell label-spacing-row label-callout-width-row').appendTo(shapeRow);
17881
+ El('span').appendTo(widthCell).text('Width');
17882
+ calloutWidthInput = new SizeField(widthCell, {
17883
+ title: 'Callout line width in px',
17884
+ min: 0.25,
17885
+ max: 20,
17886
+ step: 0.5,
17887
+ bigStep: 2,
17888
+ decimals: 2,
17889
+ onSet: applyCalloutWidth,
17890
+ onStep: nudgeCalloutWidth,
17891
+ onDone: releaseFocus
17892
+ });
17893
+
17894
+ var endRow = El('div').addClass('label-style-row label-split-row').appendTo(calloutSection);
17895
+ var endCell = El('div').addClass('label-split-cell label-align-row').appendTo(endRow);
17896
+ El('span').appendTo(endCell).text('End');
17897
+ calloutEndGroupEl = El('div').addClass('label-btn-group label-callout-buttons').appendTo(endCell);
17898
+ calloutEndBtns = {};
17899
+ calloutEnds.forEach(function(item) {
17900
+ calloutEndBtns[item.name] = makeCalloutButton(calloutEndGroupEl, item, function() {
17901
+ applyCalloutEnd(item.name);
17902
+ });
17903
+ });
17904
+ var endSizeCell = El('div').addClass('label-split-cell label-spacing-row label-callout-end-size-row').appendTo(endRow);
17905
+ El('span').appendTo(endSizeCell).text('Size');
17906
+ calloutEndSizeInput = new SizeField(endSizeCell, {
17907
+ title: 'Arrowhead size in px, the length of its sides',
17908
+ min: 1,
17909
+ max: 60,
17910
+ step: 1,
17911
+ bigStep: 5,
17912
+ onSet: applyCalloutEndSize,
17913
+ onStep: nudgeCalloutEndSize,
17914
+ onDone: releaseFocus
17915
+ });
17916
+
17917
+ var colorRow = El('div').addClass('label-style-row label-split-row').appendTo(calloutSection);
17918
+ var colorCell = El('div').addClass('label-split-cell label-color-row label-callout-color-row').appendTo(colorRow);
17919
+ El('span').appendTo(colorCell).text('Color');
17920
+ calloutColorChit = El('div').addClass('label-color-chit').attr('role', 'button');
17921
+ calloutColorInput = El('input').attr('type', 'text').attr('title', 'Callout color');
17922
+ var calloutColorField = addColorOpacityField(colorCell, calloutColorChit, calloutColorInput, applyCalloutOpacity);
17923
+ calloutColorFieldBox = calloutColorField.box;
17924
+ calloutOpacityInput = calloutColorField.opacity;
17925
+ calloutColorChit.on('click', function() {
17926
+ if (this.classList.contains('disabled')) return;
17927
+ calloutColorPicker.toggle();
17928
+ });
17929
+ calloutColorInput.on('change', function() {
17930
+ var color = calloutColorInput.node().value.trim();
17931
+ if (color) {
17932
+ if (isHexColor(color)) {
17933
+ calloutColorPicker.setColor(color);
17934
+ }
17935
+ applyCalloutColor(color);
17936
+ }
17937
+ });
17938
+ calloutColorPicker = initColorPicker(colorCell, calloutColorChit, calloutColorInput, applyCalloutColor);
17939
+
17940
+ // The gap in the narrow column, under the sizes: it has a blank state,
17941
+ // "auto", which clears the anchor's symbol, so it is not a size field.
17942
+ var gapCell = El('div').addClass('label-split-cell label-spacing-row label-callout-gap-row').appendTo(colorRow);
17943
+ El('span').appendTo(gapCell).text('Gap');
17944
+ calloutGapInput = El('input').attr('type', 'text').addClass('label-measure-input')
17945
+ .attr('title', 'Space between the callout and the anchor, in px')
17946
+ .attr('placeholder', 'auto')
17947
+ .attr('data-placeholder', 'auto')
17948
+ .appendTo(gapCell)
17949
+ .on('change', function() {
17950
+ applyCalloutGap(calloutGapInput.node().value.trim());
17951
+ });
17952
+ }
17953
+
17954
+ function makeCalloutButton(parent, item, action) {
17955
+ var btn = makePanelButton(parent, '', action)
17956
+ .attr('data-callout', item.name)
17957
+ .attr('title', item.title);
17958
+ El('<svg class="label-callout-symbol" viewBox="0 0 16 16" aria-hidden="true">' +
17959
+ calloutButtonSymbols[item.name] + '</svg>').appendTo(btn);
17960
+ return btn;
17961
+ }
17962
+
17247
17963
  function appendIconButtonSymbol(btn, iconName) {
17248
17964
  var svg = '<svg class="label-icon-symbol" viewBox="0 0 16 16" aria-hidden="true">' +
17249
17965
  iconButtonSymbols[iconName] + '</svg>';
@@ -17277,6 +17993,7 @@
17277
17993
  function makeToggle(parent, opts) {
17278
17994
  var track = El('div').addClass('label-toggle').attr('role', 'checkbox').appendTo(parent);
17279
17995
  var state = 'off';
17996
+ if (opts.className) track.addClass(opts.className);
17280
17997
  var disabled = false;
17281
17998
  El('div').addClass('label-toggle-knob').appendTo(track);
17282
17999
  if (opts.title) track.attr('title', opts.title);
@@ -17304,9 +18021,9 @@
17304
18021
  };
17305
18022
  }
17306
18023
 
17307
- function addOpacityInput(parent, action) {
17308
- return makeOpacityInput(parent, {
17309
- onSet: action,
18024
+ function addColorOpacityField(parent, chit, input, onOpacity) {
18025
+ return makeColorOpacityField(parent, chit, input, {
18026
+ onSet: onOpacity,
17310
18027
  revert: updateControls
17311
18028
  });
17312
18029
  }
@@ -17362,9 +18079,7 @@
17362
18079
  renderFontOptions();
17363
18080
  gui.state.label_style_panel_open = true;
17364
18081
  panel.show();
17365
- // In label mode the panel belongs to the mode rather than being a thing the
17366
- // user opened, and closing it would leave the tool half on.
17367
- closeBtn[labelModeIsOn() ? 'hide' : 'show']();
18082
+ closeBtn.show();
17368
18083
  textBtn.addClass('selected');
17369
18084
  updateControls();
17370
18085
  updateSelectionDisplay();
@@ -17511,6 +18226,10 @@
17511
18226
  var iconSize = getShownValue(iconIds, iconSizeField, {useDefault: true, defaultValue: defaultIconSize});
17512
18227
  var iconColor = getShownValue(iconIds, iconColorField, {useDefault: true, defaultValue: defaultIconColor});
17513
18228
  var iconOpacity = getShownValue(iconIds, iconOpacityField, {useDefault: true, defaultValue: 1});
18229
+ var haloIds = getHaloValueIds();
18230
+ var haloWidth = getShownValue(haloIds, haloWidthField, {useDefault: true, defaultValue: lastHaloWidth});
18231
+ var haloColor = getShownValue(haloIds, haloColorField, {useDefault: true, defaultValue: defaultHaloColor});
18232
+ var haloOpacity = getShownValue(haloIds, haloOpacityField, {useDefault: true, defaultValue: 1});
17514
18233
  updateEditingStatus(manualIds.length, !!getLabelTextSession(gui));
17515
18234
  updateSavedStyleControls();
17516
18235
  fontSelect.node().disabled = !showValues;
@@ -17524,12 +18243,34 @@
17524
18243
  updateAlignButtons(showValues ? alignVal : '');
17525
18244
  updateCssControl(css);
17526
18245
  updatePositionButtons(showValues ? posVal : '', ids);
18246
+ var haloOff = updateHaloToggle();
18247
+ updateHaloWidthControl(haloWidth, haloOff);
18248
+ updateSwatchField(haloColorInput, haloColorFieldBox, haloColorChit,
18249
+ haloColorPicker, haloColor, haloOff);
18250
+ updateOpacityControl(haloOpacityInput, haloOpacity, haloOff);
17527
18251
  // The symbol's controls keep showing their values while the switch is off,
17528
18252
  // greyed: what they show is what the symbol comes back as.
17529
18253
  var iconOff = updateIconControls(showValues ? iconVal : '');
17530
18254
  updateIconSizeControls(iconSize, iconOff);
17531
18255
  updateIconColorControls(iconColor, iconOff);
17532
18256
  updateOpacityControl(iconOpacityInput, iconOpacity, iconOff);
18257
+ var calloutOff = updateCalloutValueControls();
18258
+ setSectionCollapsed(haloSection, haloOff, haloColorPicker);
18259
+ setSectionCollapsed(iconSection, iconOff, iconColorPicker);
18260
+ setSectionCollapsed(calloutSection, calloutOff, calloutColorPicker);
18261
+ }
18262
+
18263
+ // A section whose switch is off shows only its heading. What is under it is
18264
+ // inert while it is off, so hiding it loses nothing, and the panel stays short
18265
+ // enough to hold all of them. The values are kept, greyed, for the moment it
18266
+ // is switched back on, which is what they come back as.
18267
+ //
18268
+ // An open picker is closed with its section: it hangs from the colour field,
18269
+ // and a picker left open over a hidden field would change a colour nobody
18270
+ // can see.
18271
+ function setSectionCollapsed(section, collapsed, picker) {
18272
+ section.classed('collapsed', collapsed);
18273
+ if (collapsed && picker.visible()) picker.hide();
17533
18274
  }
17534
18275
 
17535
18276
  // Every field that can show a value can also show nothing, which is why each
@@ -17632,11 +18373,12 @@
17632
18373
  }));
17633
18374
  }
17634
18375
 
17635
- // The toggle is the tool's state and not the selection's, so nothing here
17636
- // reads a record. It is inert outside the label tool, where there is no drag
17637
- // on a label for it to describe.
18376
+ // The toggle is the tool's state and not the selection's, but there is one
18377
+ // for point text and one for text blocks, and it shows the one for the kind
18378
+ // of label selected. It is inert outside the label tool, where there is no
18379
+ // drag on a label for it to describe.
17638
18380
  function updateDragModeButtons() {
17639
- var mode = getLabelPositionMode(gui);
18381
+ var mode = getLabelPositionMode(gui, getPositionModeKind());
17640
18382
  var disabled = !labelModeIsOn();
17641
18383
  labelDragModes.forEach(function(item) {
17642
18384
  dragModeBtns[item.name].classed('selected', !disabled && item.name == mode);
@@ -17644,6 +18386,16 @@
17644
18386
  });
17645
18387
  }
17646
18388
 
18389
+ // 'block' when the labels the panel is pointed at are all text blocks, and
18390
+ // otherwise 'point', including for the next label to be made.
18391
+ function getPositionModeKind() {
18392
+ var table = getActiveTable();
18393
+ var ids = getTargetIds();
18394
+ return ids.length > 0 && table && ids.every(function(id) {
18395
+ return getLabelPositionKind(table.getRecordAt(id)) == 'block';
18396
+ }) ? 'block' : 'point';
18397
+ }
18398
+
17647
18399
  function everyLabelIsOnAPath(ids) {
17648
18400
  return someLabelIsOnAPath(ids, 'every');
17649
18401
  }
@@ -17781,10 +18533,12 @@
17781
18533
  if (colorPicker.visible()) {
17782
18534
  return; // avoid HSB -> RGB -> HSB rounding jumps after picker commits
17783
18535
  }
18536
+ // Only this field's own picker: the halo's or the symbol's may be the one
18537
+ // open, and a text fill like "blue" says nothing about either.
17784
18538
  if (isHexColor(colorVal)) {
17785
18539
  colorPicker.setColor(colorVal);
17786
18540
  } else {
17787
- hideColorPicker();
18541
+ colorPicker.hide();
17788
18542
  }
17789
18543
  }
17790
18544
 
@@ -17833,23 +18587,305 @@
17833
18587
  }
17834
18588
 
17835
18589
  function updateIconColorControls(shown, iconOff) {
18590
+ updateSwatchField(iconColorInput, iconColorFieldBox, iconColorChit,
18591
+ iconColorPicker, shown, iconOff);
18592
+ }
18593
+
18594
+ // A colour field belonging to a section with a switch: the symbol's and the
18595
+ // halo's, which both go on showing their colour, greyed, while switched off.
18596
+ function updateSwatchField(input, fieldBox, chit, picker, shown, sectionOff) {
17836
18597
  var colorVal = shown.value;
17837
- var disabled = iconOff || !controlsEnabled();
17838
- iconColorInput.node().disabled = disabled;
17839
- iconColorInput.node().value = colorVal || '';
17840
- setMixedPlaceholder(iconColorInput, shown.mixed);
17841
- iconColorFieldBox.classed('disabled', disabled);
17842
- setPanelButtonDisabled(iconColorChit, disabled);
17843
- iconColorChit.classed('mixed', shown.mixed);
17844
- iconColorChit.css('background-color', isHexColor(colorVal) ? colorVal : 'transparent');
17845
- if (iconColorPicker.visible()) {
18598
+ var disabled = sectionOff || !controlsEnabled();
18599
+ input.node().disabled = disabled;
18600
+ input.node().value = colorVal || '';
18601
+ setMixedPlaceholder(input, shown.mixed);
18602
+ fieldBox.classed('disabled', disabled);
18603
+ setPanelButtonDisabled(chit, disabled);
18604
+ chit.classed('mixed', shown.mixed);
18605
+ chit.css('background-color', isHexColor(colorVal) ? colorVal : 'transparent');
18606
+ if (picker.visible()) {
17846
18607
  return; // avoid HSB -> RGB -> HSB rounding jumps after picker commits
17847
18608
  }
17848
18609
  if (isHexColor(colorVal)) {
17849
- iconColorPicker.setColor(colorVal);
18610
+ picker.setColor(colorVal);
17850
18611
  } else {
17851
- iconColorPicker.hide();
18612
+ picker.hide();
18613
+ }
18614
+ }
18615
+
18616
+ // The switch reads the data, as the symbol's does, so that it follows an
18617
+ // undo. A halo is on for a label whose halo-width is above 0.
18618
+ //
18619
+ // The width, colour and opacity go on showing while it is off, greyed: the
18620
+ // colour and opacity stay on the label, and the width is the one the halo
18621
+ // comes back at.
18622
+ function updateHaloToggle() {
18623
+ var enabled = controlsEnabled();
18624
+ var state = enabled ? getHaloState() : 'off';
18625
+ haloToggle.setState(state);
18626
+ haloToggle.setDisabled(!enabled);
18627
+ return state == 'off';
18628
+ }
18629
+
18630
+ function updateHaloWidthControl(shown, haloOff) {
18631
+ if (!haloOff && Number(shown.value) > 0) lastHaloWidth = Number(shown.value);
18632
+ haloWidthInput.setValue(shown.value || '');
18633
+ haloWidthInput.setPlaceholder(shown.mixed ? MIXED_TEXT : '');
18634
+ haloWidthInput.setDisabled(haloOff || !controlsEnabled());
18635
+ }
18636
+
18637
+ function getHaloState() {
18638
+ var ids = getTargetIds();
18639
+ var table = getActiveTable();
18640
+ if (ids.length === 0) {
18641
+ return internal.svg.labelHasHalo(labelModeIsOn() ? getNewLabelStyle(gui) : null) ? 'on' : 'off';
18642
+ }
18643
+ return getToggleState(ids.map(function(id) {
18644
+ return internal.svg.labelHasHalo(table && table.getRecordAt(id));
18645
+ }));
18646
+ }
18647
+
18648
+ // Switching a halo on gives every target the same width: the one the
18649
+ // selection's halos already share, where they share one, so that a mixed
18650
+ // selection is brought into line with the labels that had a halo rather
18651
+ // than reset. Switching it off removes the width and leaves the colour and
18652
+ // opacity, which is how a halo switched off and on again comes back as it
18653
+ // was.
18654
+ function setHaloOn(on) {
18655
+ var width = on ? getNumericSize(getHaloValueIds(), haloWidthField, lastHaloWidth) : '';
18656
+ applyStyleValues([[haloWidthField, width]]);
18657
+ }
18658
+
18659
+ function applyHaloWidth(value) {
18660
+ applyStyleValues([[haloWidthField, value]], getHaloTargetIds());
18661
+ }
18662
+
18663
+ function nudgeHaloWidth(delta) {
18664
+ var width = getNumericSize(getHaloTargetIds(), haloWidthField, lastHaloWidth);
18665
+ if (!controlsEnabled() || getHaloState() == 'off') return;
18666
+ width = Math.max(0.5, Math.round((width + delta) * 10) / 10);
18667
+ applyHaloWidth(width);
18668
+ }
18669
+
18670
+ function applyHaloColor(color) {
18671
+ applyStyleValues([[haloColorField, color]], getHaloTargetIds());
18672
+ }
18673
+
18674
+ // Full opacity is stored as no halo-opacity, as it is for the text's own.
18675
+ function applyHaloOpacity(value) {
18676
+ applyStyleValues([[haloOpacityField, value >= 1 ? '' : value]], getHaloTargetIds());
18677
+ }
18678
+
18679
+ // The labels a halo's width, colour or opacity goes to: the targets that
18680
+ // have a halo, for the reason the symbol's values go only to labels with a
18681
+ // symbol. Empty for "new labels", as there.
18682
+ function getHaloTargetIds() {
18683
+ var table = getActiveTable();
18684
+ return getTargetIds().filter(function(id) {
18685
+ return internal.svg.labelHasHalo(table && table.getRecordAt(id));
18686
+ });
18687
+ }
18688
+
18689
+ // As getIconValueIds(): the labels the section shows the values of.
18690
+ function getHaloValueIds() {
18691
+ var ids = getHaloTargetIds();
18692
+ return ids.length > 0 ? ids : getTargetIds();
18693
+ }
18694
+
18695
+ // The switch reads the data, as the halo's does. A callout is on for a label
18696
+ // whose callout names a shape.
18697
+ function getCalloutState() {
18698
+ var ids = getTargetIds();
18699
+ var table = getActiveTable();
18700
+ if (ids.length === 0) {
18701
+ return internal.svg.labelHasCallout(labelModeIsOn() ? getNewLabelStyle(gui) : null) ? 'on' : 'off';
18702
+ }
18703
+ return getToggleState(ids.map(function(id) {
18704
+ return internal.svg.labelHasCallout(table && table.getRecordAt(id));
18705
+ }));
18706
+ }
18707
+
18708
+ // The labels a callout's end, colour, width and gap go to: the targets that
18709
+ // have a callout, for the reason the symbol's values go only to labels with a
18710
+ // symbol.
18711
+ function getCalloutTargetIds() {
18712
+ var table = getActiveTable();
18713
+ return getTargetIds().filter(function(id) {
18714
+ return internal.svg.labelHasCallout(table && table.getRecordAt(id));
18715
+ });
18716
+ }
18717
+
18718
+ function getCalloutValueIds() {
18719
+ var ids = getCalloutTargetIds();
18720
+ return ids.length > 0 ? ids : getTargetIds();
18721
+ }
18722
+
18723
+ // Switching a callout on gives every target the shape the selection's
18724
+ // callouts share, or the last one used. Switching it off removes the shape
18725
+ // alone, so that a callout switched off and on again comes back with the
18726
+ // corner, the attachment and the look it had.
18727
+ function setCalloutOn(on) {
18728
+ var shape = on ? getCommonValue(getCalloutTargetIds(), calloutField) || lastCalloutShape : '';
18729
+ applyStyleValues([[calloutField, shape]]);
18730
+ }
18731
+
18732
+ // A shape is a plain statement about every selected label, as a symbol's is.
18733
+ function applyCalloutShape(shape) {
18734
+ applyStyleValues([[calloutField, shape]]);
18735
+ }
18736
+
18737
+ // No marker is stored as no callout-end.
18738
+ function applyCalloutEnd(end) {
18739
+ applyStyleValues([[calloutEndField, end == 'none' ? '' : end]], getCalloutTargetIds());
18740
+ }
18741
+
18742
+ function applyCalloutColor(color) {
18743
+ applyStyleValues([[calloutColorField, color]], getCalloutTargetIds());
18744
+ }
18745
+
18746
+ function applyCalloutOpacity(value) {
18747
+ applyStyleValues([[calloutOpacityField, value >= 1 ? '' : value]], getCalloutTargetIds());
18748
+ }
18749
+
18750
+ function applyCalloutWidth(value) {
18751
+ applyStyleValues([[calloutWidthField, value]], getCalloutTargetIds());
18752
+ }
18753
+
18754
+ function nudgeCalloutWidth(delta) {
18755
+ var width = getNumericSize(getCalloutTargetIds(), calloutWidthField, defaultCalloutWidth);
18756
+ if (!controlsEnabled() || getCalloutState() == 'off') return;
18757
+ applyCalloutWidth(Math.max(0.25, Math.round((width + delta) * 4) / 4));
18758
+ }
18759
+
18760
+ // A marker size goes to the callouts that have a marker to size, as a
18761
+ // symbol's size goes only to labels with a symbol.
18762
+ function applyCalloutEndSize(value) {
18763
+ applyStyleValues([[calloutEndSizeField, value]], getCalloutEndTargetIds());
18764
+ }
18765
+
18766
+ // Stepped from the size each marker is drawn at, which for one with no
18767
+ // callout-end-size is the default its line width gives it.
18768
+ function nudgeCalloutEndSize(delta) {
18769
+ var shown = getCalloutEndSizeShown(getCalloutEndTargetIds());
18770
+ var size = Number(shown.value);
18771
+ if (!controlsEnabled() || !(size > 0)) return;
18772
+ applyCalloutEndSize(Math.max(1, Math.round(size + delta)));
18773
+ }
18774
+
18775
+ function getCalloutEndTargetIds() {
18776
+ var table = getActiveTable();
18777
+ return getCalloutTargetIds().filter(function(id) {
18778
+ return getCalloutEnd(table && table.getRecordAt(id)) != 'none';
18779
+ });
18780
+ }
18781
+
18782
+ function getCalloutEnd(rec) {
18783
+ return internal.svg.getCalloutEndType(rec);
18784
+ }
18785
+
18786
+ // The size a marker is drawn at: its own, or the default for its kind and
18787
+ // line width.
18788
+ function getCalloutEndSizeShown(ids) {
18789
+ return getShownRecordValues(ids, function(rec) {
18790
+ var size = rec ? Number(rec[calloutEndSizeField]) : 0;
18791
+ var width = rec ? Number(rec[calloutWidthField]) : 0;
18792
+ if (size > 0) return size;
18793
+ return internal.svg.getDefaultCalloutEndSize(getCalloutEnd(rec),
18794
+ width > 0 ? width : defaultCalloutWidth);
18795
+ });
18796
+ }
18797
+
18798
+ // Blank is "auto", which clears the anchor's symbol, and 0 is no gap at all:
18799
+ // the two are different, which is why this is not a size field.
18800
+ function applyCalloutGap(str) {
18801
+ var val = Number(str);
18802
+ if (str !== '' && !(isFinite(val) && val >= 0)) {
18803
+ updateControls();
18804
+ return;
17852
18805
  }
18806
+ applyStyleValues([[calloutGapField, str === '' ? '' : val]], getCalloutTargetIds());
18807
+ }
18808
+
18809
+ // Returns whether the section is off.
18810
+ function updateCalloutValueControls() {
18811
+ var enabled = controlsEnabled();
18812
+ var state = enabled ? getCalloutState() : 'off';
18813
+ var off = state == 'off';
18814
+ var ids = getCalloutValueIds();
18815
+ var shape = enabled ? getCommonValue(ids, calloutField) : '';
18816
+ var end = enabled ? getCommonValue(ids, calloutEndField, {useDefault: true, defaultValue: 'none'}) : '';
18817
+ var width = getShownValue(ids, calloutWidthField, {useDefault: true, defaultValue: defaultCalloutWidth});
18818
+ var gap = getCalloutGapShown(ids);
18819
+ if (shape && shape != 'none') lastCalloutShape = shape;
18820
+ calloutToggle.setState(state);
18821
+ calloutToggle.setDisabled(!enabled);
18822
+ updateButtonGroup(calloutShapeGroupEl, calloutShapeBtns, off ? '' : shape, off);
18823
+ updateButtonGroup(calloutEndGroupEl, calloutEndBtns, off ? '' : end, off);
18824
+ updateSwatchField(calloutColorInput, calloutColorFieldBox, calloutColorChit,
18825
+ calloutColorPicker, getCalloutColorShown(ids), off);
18826
+ updateOpacityControl(calloutOpacityInput,
18827
+ getShownValue(ids, calloutOpacityField, {useDefault: true, defaultValue: 1}), off);
18828
+ calloutWidthInput.setValue(width.value || '');
18829
+ calloutWidthInput.setPlaceholder(width.mixed ? MIXED_TEXT : '');
18830
+ calloutWidthInput.setDisabled(off || !enabled);
18831
+ updateCalloutEndSizeControl(off || !enabled);
18832
+ calloutGapInput.node().disabled = off || !enabled;
18833
+ calloutGapInput.node().value = gap.value;
18834
+ setMixedPlaceholder(calloutGapInput, gap.mixed);
18835
+ return off;
18836
+ }
18837
+
18838
+ // Inert with no marker to size. With nothing selected it describes the next
18839
+ // label's marker, which the new-label style holds.
18840
+ function updateCalloutEndSizeControl(off) {
18841
+ var ids = getTargetIds().length > 0 ? getCalloutEndTargetIds() : [];
18842
+ var noMarker = getTargetIds().length > 0 ? ids.length === 0 :
18843
+ getCalloutEnd(labelModeIsOn() ? getNewLabelStyle(gui) : null) == 'none';
18844
+ var shown = off || noMarker ? {value: '', mixed: false} : getCalloutEndSizeShown(ids);
18845
+ calloutEndSizeInput.setValue(shown.value);
18846
+ calloutEndSizeInput.setPlaceholder(shown.mixed ? MIXED_TEXT : '');
18847
+ calloutEndSizeInput.setDisabled(off || noMarker);
18848
+ }
18849
+
18850
+ function updateButtonGroup(groupEl, btns, selected, off) {
18851
+ groupEl.classed('disabled', off);
18852
+ Object.keys(btns).forEach(function(name) {
18853
+ btns[name].classed('selected', name == selected);
18854
+ setPanelButtonDisabled(btns[name], off);
18855
+ });
18856
+ }
18857
+
18858
+ // The colour a callout is drawn in, which is the text's unless it has its
18859
+ // own -- so a label with red text shows red here, not the black that
18860
+ // getCommonValueInfo() would supply for an unset callout-color.
18861
+ function getCalloutColorShown(ids) {
18862
+ return getShownRecordValues(ids, function(rec) {
18863
+ return rec && (rec[calloutColorField] || rec[fillField]) || defaultLabelColor;
18864
+ });
18865
+ }
18866
+
18867
+ // getCommonValueInfo() reads 0 as unset, and a gap of 0 is a value.
18868
+ function getCalloutGapShown(ids) {
18869
+ return getShownRecordValues(ids, function(rec) {
18870
+ var val = rec ? rec[calloutGapField] : null;
18871
+ return val || val === 0 ? String(val) : '';
18872
+ });
18873
+ }
18874
+
18875
+ // {value, mixed} for a value worked out from each target's record, or from
18876
+ // the new-label style when there is no target.
18877
+ function getShownRecordValues(ids, fn) {
18878
+ var table = getActiveTable();
18879
+ var vals;
18880
+ if (!controlsEnabled()) return {value: '', mixed: false};
18881
+ if (ids.length === 0) {
18882
+ return {value: fn(labelModeIsOn() ? getNewLabelStyle(gui) : null), mixed: false};
18883
+ }
18884
+ vals = ids.map(function(id) {
18885
+ return fn(table && table.getRecordAt(id));
18886
+ });
18887
+ return vals.every(function(val) { return val === vals[0]; }) ?
18888
+ {value: vals[0], mixed: false} : {value: '', mixed: true};
17853
18889
  }
17854
18890
 
17855
18891
  function updateSavedStyleControls() {
@@ -18063,15 +19099,44 @@
18063
19099
  addStyleValue(style, labelAlignField, getSelectedAlignment());
18064
19100
  addStyleValue(style, cssField, cssInput.node().value.trim());
18065
19101
  addStyleValue(style, 'label-pos', getSelectedLabelPosition());
19102
+ // A style saved without a halo carries no halo-width, so applying it
19103
+ // leaves a halo alone rather than removing one; the same is true of icons.
19104
+ if (getHaloState() == 'on') {
19105
+ addStyleValue(style, haloWidthField, haloWidthInput.getValue());
19106
+ addStyleValue(style, haloColorField, haloColorInput.node().value.trim());
19107
+ addStyleValue(style, haloOpacityField, getOpacityBelowFull(haloOpacityInput));
19108
+ }
18066
19109
  addStyleValue(style, iconField, icon);
18067
19110
  if (icon) {
18068
19111
  addStyleValue(style, iconSizeField, iconSizeInput.getValue());
18069
19112
  addStyleValue(style, iconColorField, iconColorInput.node().value.trim());
18070
19113
  addStyleValue(style, iconOpacityField, getIconOpacityToWrite());
18071
19114
  }
19115
+ // A callout's shape and look, but not its geometry: a corner or an
19116
+ // attachment point is placed for one label's surroundings, and means
19117
+ // nothing on another.
19118
+ if (getCalloutState() == 'on') {
19119
+ addStyleValue(style, calloutField, getSelectedButton(calloutShapeBtns));
19120
+ addStyleValue(style, calloutEndField, getSelectedButton(calloutEndBtns));
19121
+ addStyleValue(style, calloutEndSizeField,
19122
+ getCommonValue(getCalloutEndTargetIds(), calloutEndSizeField));
19123
+ addStyleValue(style, calloutColorField,
19124
+ getCommonValue(getCalloutTargetIds(), calloutColorField));
19125
+ addStyleValue(style, calloutOpacityField, getOpacityBelowFull(calloutOpacityInput));
19126
+ addStyleValue(style, calloutWidthField, calloutWidthInput.getValue());
19127
+ addStyleValue(style, calloutGapField, getCommonValue(getCalloutTargetIds(), calloutGapField));
19128
+ }
18072
19129
  return style;
18073
19130
  }
18074
19131
 
19132
+ function getSelectedButton(btns) {
19133
+ var out = '';
19134
+ Object.keys(btns).forEach(function(name) {
19135
+ if (btns[name].hasClass('selected')) out = name;
19136
+ });
19137
+ return out;
19138
+ }
19139
+
18075
19140
  // A saved style carries an opacity only if it has one to carry: saving at
18076
19141
  // 100% and applying it should not write a property the label did not have.
18077
19142
  function getOpacityBelowFull(input) {
@@ -18274,7 +19339,9 @@
18274
19339
 
18275
19340
  function hideColorPicker() {
18276
19341
  colorPicker.hide();
19342
+ haloColorPicker.hide();
18277
19343
  iconColorPicker.hide();
19344
+ calloutColorPicker.hide();
18278
19345
  }
18279
19346
 
18280
19347
  function isFormElement(node) {
@@ -18339,6 +19406,7 @@
18339
19406
  if (ids.length < internal.getFeatureCount(lyr)) {
18340
19407
  parts.push('ids=' + ids.join(','));
18341
19408
  }
19409
+ parts = parts.concat(getRewrapCommands(styles, ids, lyr));
18342
19410
  runGuiEditCommand(gui, parts.join(' '), {
18343
19411
  title: 'Label styles',
18344
19412
  onDone: function() {
@@ -18349,6 +19417,29 @@
18349
19417
  });
18350
19418
  }
18351
19419
 
19420
+ // A text block's breaks are only right for the width and font they were
19421
+ // found with, so a change to either rewraps it in the same command: one undo
19422
+ // 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.
19424
+ function getRewrapCommands(styles, ids, lyr) {
19425
+ var table = lyr.data;
19426
+ var changes = {};
19427
+ var out = [];
19428
+ styles.forEach(function(style) { changes[style[0]] = style[1]; });
19429
+ if (!table || !WRAP_FIELDS.some(function(name) { return name in changes; })) {
19430
+ return out;
19431
+ }
19432
+ ids.forEach(function(id) {
19433
+ var rec = table.getRecordAt(id);
19434
+ var value = rec && rec['label-text'];
19435
+ var wrapped;
19436
+ if (!value || everyLabelIsOnAPath([id])) return;
19437
+ wrapped = rewrapLabelValue(value, Object.assign({}, rec, changes));
19438
+ if (wrapped !== value) out.push(getLabelTextCommand(wrapped, id, null));
19439
+ });
19440
+ return out;
19441
+ }
19442
+
18352
19443
  // The yellow halo, which belongs to the older label_style mode. The label
18353
19444
  // tool draws its selection as an outline instead (gui-label-selection.mjs),
18354
19445
  // because a halo says "this text is marked" where an outline says "this is
@@ -18554,7 +19645,7 @@
18554
19645
 
18555
19646
  fillControl = addColorControl(panel, 'Fill', 'fill', '');
18556
19647
  strokeControl = addColorControl(panel, 'Stroke', 'stroke', '#000000');
18557
- strokeWidthField = addStrokeWidthControl(panel);
19648
+ strokeWidthField = addStrokeWidthControl(strokeControl.aside);
18558
19649
 
18559
19650
  var buttonRow = El('div').addClass('label-style-row label-panel-button-row').appendTo(panel);
18560
19651
  randomFillBtn = makePanelActionButton(buttonRow, 'Random fills', applyRandomFillColors);
@@ -18596,14 +19687,11 @@
18596
19687
  return control;
18597
19688
  }
18598
19689
 
18599
- // In the narrow column, under the stroke's opacity. Stepping runs up a ladder
19690
+ // In the narrow column beside the stroke's colour. Stepping runs up a ladder
18600
19691
  // of widths rather than by a fixed amount, because the useful ones are close
18601
19692
  // together at the hairline end and far apart above 2px.
18602
- function addStrokeWidthControl(parent) {
18603
- var row = El('div').addClass('label-style-row label-split-row').appendTo(parent);
18604
- El('div').addClass('label-split-cell').appendTo(row);
18605
- var cell = El('div').addClass('label-split-cell').appendTo(row);
18606
- El('span').appendTo(cell).text('Stroke width');
19693
+ function addStrokeWidthControl(cell) {
19694
+ El('span').appendTo(cell).text('Width');
18607
19695
  return new SizeField(cell, {
18608
19696
  min: 0,
18609
19697
  // Quarter-pixel widths are the useful hairlines, and the ladder below
@@ -19050,14 +20138,11 @@
19050
20138
  circleStrokeControl = addCircleColorControl('Stroke', 'stroke');
19051
20139
  circleControlRows.push(circleFillControl.row, circleStrokeControl.row);
19052
20140
 
19053
- // The two sizes of a circle side by side, in the shape the panel uses for
19054
- // every other pair.
19055
- var sizeRow = El('div').addClass('label-style-row label-split-row').appendTo(circlesSection);
19056
- // Radius in the wide column, stroke width in the narrow one under the
19057
- // stroke's opacity, matching the line and polygon panels.
19058
- var radiusCell = El('div').addClass('label-split-cell').appendTo(sizeRow);
19059
- var widthCell = El('div').addClass('label-split-cell').appendTo(sizeRow);
19060
- El('span').appendTo(widthCell).text('Stroke width');
20141
+ // Each size beside the colour it goes with: the radius beside the fill,
20142
+ // the stroke's width beside its colour, as in the line and polygon panels.
20143
+ var radiusCell = circleFillControl.aside;
20144
+ var widthCell = circleStrokeControl.aside;
20145
+ El('span').appendTo(widthCell).text('Width');
19061
20146
  circleStrokeWidthField = new SizeField(widthCell, {
19062
20147
  min: 0,
19063
20148
  decimals: 2, // quarter-pixel hairlines, which the ladder below steps through
@@ -19088,7 +20173,6 @@
19088
20173
  },
19089
20174
  onDone: releaseFocus
19090
20175
  });
19091
- circleControlRows.push(sizeRow);
19092
20176
  }
19093
20177
 
19094
20178
  function initSymbolsSection() {
@@ -19544,6 +20628,9 @@
19544
20628
  self.setDefaultTarget([active.layer], active.dataset);
19545
20629
  gui.session.setTargetLayer(active.layer); // add -target command to target single layer
19546
20630
  }
20631
+ // Before anything hears of the update, so that the map draws a layer that
20632
+ // has just arrived along with the ones already showing.
20633
+ showNewLayers(self.getLayers());
19547
20634
  if (flags.select) {
19548
20635
  self.dispatchEvent('select', active);
19549
20636
  }
@@ -20140,12 +21227,7 @@
20140
21227
  var defs = [];
20141
21228
  var symbols = lyr.shapes.map(function(shp, i) {
20142
21229
  var d = records[i];
20143
- var obj = shp && d ? renderSymbol(d, shp, view, defs, idPrefix, i) : null;
20144
- if (!obj) return null;
20145
- obj.properties.class = addClass(obj.properties.class, 'mapshaper-svg-symbol');
20146
- obj.properties.transform = getSvgSymbolTransform(shp[0], ext);
20147
- obj.properties['data-id'] = i;
20148
- return obj;
21230
+ return shp && d ? renderLayerSymbol(d, shp, ext, view, defs, idPrefix, i) : null;
20149
21231
  }).filter(Boolean);
20150
21232
  var obj = internal.getEmptyLayerForSVG(lyr, {});
20151
21233
  // <defs> has to come first so the layer's own markup can reference it
@@ -20154,6 +21236,41 @@
20154
21236
  return internal.svg.stringify(obj);
20155
21237
  }
20156
21238
 
21239
+ function renderLayerSymbol(d, shp, ext, view, defs, idPrefix, i) {
21240
+ var obj = renderSymbol(d, shp, view, defs, idPrefix, i);
21241
+ if (!obj) return null;
21242
+ obj.properties.class = addClass(obj.properties.class, 'mapshaper-svg-symbol');
21243
+ obj.properties.transform = getSvgSymbolTransform(shp[0], ext);
21244
+ obj.properties['data-id'] = i;
21245
+ return obj;
21246
+ }
21247
+
21248
+ // Redraws one anchored label's symbol from @rec, which need not be its record:
21249
+ // a drag previews its edit this way, and the command it commits then redraws
21250
+ // the layer from the data. The node is rendered exactly as renderSymbols()
21251
+ // renders it, so the hit test, the selection cue and the drag's own lookups
21252
+ // find it as they found the one it replaces.
21253
+ //
21254
+ // Anchored labels only: a path label's baseline lives in the layer's <defs>,
21255
+ // which this leaves alone.
21256
+ // Returns the new node, or null if there was nothing to replace.
21257
+ function replaceAnchoredSymbol(container, id, rec, shp, ext) {
21258
+ var old = container && shp && !internal.svg.shapeIsPathLabel(shp, rec) ?
21259
+ container.querySelector('.mapshaper-svg-symbol[data-id="' + id + '"]') : null;
21260
+ var obj = old ? renderLayerSymbol(rec, shp, ext, null, null, '', id) : null;
21261
+ var tmp, node;
21262
+ if (!obj) return null;
21263
+ tmp = document.createElementNS('http://www.w3.org/2000/svg', 'g');
21264
+ tmp.innerHTML = internal.svg.stringify(obj);
21265
+ node = tmp.firstElementChild;
21266
+ if (!node) return null;
21267
+ // Off-screen symbols are hidden by repositionSymbols(), which would not know
21268
+ // to hide this one again until the map moved.
21269
+ if (old.hasAttribute('display')) node.setAttribute('display', old.getAttribute('display'));
21270
+ old.parentNode.replaceChild(node, old);
21271
+ return node;
21272
+ }
21273
+
20157
21274
  // Markup for a label that is not in any layer yet: the one being typed into
20158
21275
  // before it has been created.
20159
21276
  //
@@ -20690,7 +21807,9 @@
20690
21807
  // What the click found in the selection as it stood before the click, which
20691
21808
  // the click itself is about to replace. The label tool reads this to tell a
20692
21809
  // click that selects a label from a click on the label that was already the
20693
- // whole selection -- its gesture for editing that label's text.
21810
+ // whole selection -- its gesture for editing that label's text -- and a click
21811
+ // off a selection from one on empty map, since a click on an unlabelled
21812
+ // feature selects it before the tool hears of the click.
20694
21813
  //
20695
21814
  // An additive click never qualifies: shift-click means toggle, and it would
20696
21815
  // otherwise open a text session on the label it was removing.
@@ -20701,7 +21820,8 @@
20701
21820
  var id = hitData.ids.length > 0 ? hitData.ids[0] : -1;
20702
21821
  return {
20703
21822
  clicked_only_selection: id > -1 && !eventUsesAdditiveSelection(e) &&
20704
- selectionIds.length == 1 && selectionIds[0] === id
21823
+ selectionIds.length == 1 && selectionIds[0] === id,
21824
+ had_selection: selectionIds.length > 0
20705
21825
  };
20706
21826
  }
20707
21827
 
@@ -22109,8 +23229,11 @@
22109
23229
  gui.on('interaction_mode_change', function(e) {
22110
23230
  if (e.mode === 'selection') {
22111
23231
  gui.enterMode('selection_tool');
22112
- } else if (_on) {
22113
- turnOff();
23232
+ } else if (gui.getMode() == 'selection_tool') {
23233
+ // Leave the gui mode rather than just calling turnOff(): a mode left
23234
+ // behind makes the next enterMode('selection_tool') a no-op, so the tool
23235
+ // would never turn back on.
23236
+ gui.clearMode();
22114
23237
  }
22115
23238
  });
22116
23239
 
@@ -24165,6 +25288,222 @@
24165
25288
  return len > 0 ? geom.distance2D(x1, y1, p[0], p[1]) / len : 0;
24166
25289
  }
24167
25290
 
25291
+ // The handles on a selected anchored label, and what dragging each one
25292
+ // writes, as pure functions of the label's record and the pointer.
25293
+ //
25294
+ // - width: on a text block only, at the bottom corner of its column on the
25295
+ // side that is free to move -- the right for text that starts at its
25296
+ // anchor, the left for text that ends there, the right for centred text,
25297
+ // which grows both ways. Dragging it sets label-width. Point text has none:
25298
+ // a label stays the kind it was made as, and a handle that turned point
25299
+ // text into a block was a way to do that by accident.
25300
+ // - via: the elbow's corner, or the point the curve passes through.
25301
+ // - attach: where the line meets the text, which slides around the padded box.
25302
+ // - gap: where the line stops short of the anchor.
25303
+ //
25304
+ // A corner rather than the middle of the free side for the width handle,
25305
+ // because the middle of a side is where an elbow meets a one-line label, and
25306
+ // the two handles would sit on top of each other.
25307
+ //
25308
+ // Everything is in the label's own space -- origin at the anchor, y down, px
25309
+ // -- which is the space the callout is worked out in. See
25310
+ // docs/development/text-annotation-design.md.
25311
+
25312
+ // The narrowest a text block can be dragged to, px. Narrower than a character
25313
+ // or two, and every character gets a line of its own.
25314
+ var MIN_LABEL_WIDTH = 10;
25315
+
25316
+ // How far out from the anchor the line has to stop before its gap handle is
25317
+ // drawn, in screen px. Nearer than that the handle sits on the anchor's own,
25318
+ // and the anchor is the one that matters more.
25319
+ var MIN_GAP_HANDLE = 6;
25320
+
25321
+ // rec: the label's record
25322
+ // textBox: the rendered text's bbox, {x, y, width, height}, or null
25323
+ // opts:
25324
+ // symbolRadius: radius of the symbol at the anchor, which the default gap
25325
+ // clears
25326
+ // scale: screen px per label px
25327
+ // padding: how far outside the text the selection box is drawn
25328
+ // Returns {handles: [{kind, point}], column}, where column is the x-range
25329
+ // [xmin, xmax] a text block wraps within, or null for point text. The
25330
+ // width handle sits on the column's corner, not the text's.
25331
+ function getAnchoredLabelHandles(rec, textBox, opts) {
25332
+ var o = opts || {};
25333
+ var handles = [];
25334
+ var column = getLabelColumn(rec);
25335
+ var shape = internal.svg.getLabelCalloutShape(rec, o.symbolRadius || 0);
25336
+ var width = textBox && column ?
25337
+ getWidthHandlePoint(rec, textBox, column, o.padding || 0) : null;
25338
+ // Nearer-first matters only for ties, which go to the earlier handle: the
25339
+ // callout's own handles are smaller and sit on the line, so they win.
25340
+ if (shape) {
25341
+ if (shape.via) handles.push({kind: 'via', point: shape.via});
25342
+ handles.push({kind: 'attach', point: shape.attach});
25343
+ if (length(shape.tip) * (o.scale || 1) >= MIN_GAP_HANDLE) {
25344
+ handles.push({kind: 'gap', point: shape.tip});
25345
+ }
25346
+ }
25347
+ if (width) handles.push({kind: 'width', point: width});
25348
+ return {handles: handles, column: column};
25349
+ }
25350
+
25351
+ // The x-range a text block's lines wrap within, from where its drawn anchor
25352
+ // puts it, or null for a label with no width.
25353
+ function getLabelColumn(rec) {
25354
+ var w = Number(rec && rec['label-width']);
25355
+ var drawn, anchor;
25356
+ if (!(w > 0 && isFinite(w))) return null;
25357
+ drawn = internal.svg.getDrawnLabelOffset(rec);
25358
+ anchor = getDrawnAnchor(drawn);
25359
+ if (anchor == 'end') return [drawn.dx - w, drawn.dx];
25360
+ if (anchor == 'middle') return [drawn.dx - w / 2, drawn.dx + w / 2];
25361
+ return [drawn.dx, drawn.dx + w];
25362
+ }
25363
+
25364
+ // An anchor the record does not set is inherited from the layer's group,
25365
+ // which is 'middle' -- see getLabelTextDefaults().
25366
+ function getDrawnAnchor(drawn) {
25367
+ return drawn && drawn['text-anchor'] || 'middle';
25368
+ }
25369
+
25370
+ function getWidthHandlePoint(rec, box, column, pad) {
25371
+ var anchor = getDrawnAnchor(internal.svg.getDrawnLabelOffset(rec));
25372
+ var xmin = Math.min(column[0], box.x);
25373
+ var xmax = Math.max(column[1], box.x + box.width);
25374
+ var y = box.y + box.height + pad;
25375
+ return anchor == 'end' ? [xmin - pad, y] : [xmax + pad, y];
25376
+ }
25377
+
25378
+ // The label-width a drag on the width handle sets, in whole px.
25379
+ // anchor: the drawn text-anchor; dx: the drawn dx, which is the edge (or for
25380
+ // centred text the middle) that stays put
25381
+ // x: where the handle has been dragged to
25382
+ // pad: how far outside the text the handle is drawn
25383
+ function getDraggedWidth(anchor, dx, x, pad) {
25384
+ var w;
25385
+ if (anchor == 'end') w = dx - x - pad;
25386
+ else if (anchor == 'middle') w = 2 * (Math.abs(x - dx) - pad);
25387
+ else w = x - pad - dx;
25388
+ return Math.max(MIN_LABEL_WIDTH, Math.round(w));
25389
+ }
25390
+
25391
+ // Where a dragged via point lands: on a line level with, or plumb with, the
25392
+ // anchor or the attachment point @t when it comes within @tol of one. Those
25393
+ // are the alignments that make an elbow's legs horizontal or vertical.
25394
+ function snapCalloutVia(p, t, tol) {
25395
+ return [snapTo(p[0], [0, t[0]], tol), snapTo(p[1], [0, t[1]], tol)];
25396
+ }
25397
+
25398
+ // Where on the padded @box a dragged attachment point lands, as the
25399
+ // [fx, fy] fractions callout-attach stores: on the edge nearest @p, and at the
25400
+ // middle of that edge or at a corner when within @tol of one. A side also
25401
+ // snaps to the first line's midline, where an automatic elbow meets it.
25402
+ function getAttachFraction(box, p, tol) {
25403
+ var w = box.xmax - box.xmin;
25404
+ var h = box.ymax - box.ymin;
25405
+ var x = clamp$3(p[0], box.xmin, box.xmax);
25406
+ var y = clamp$3(p[1], box.ymin, box.ymax);
25407
+ var edge = getNearestEdge(box, x, y);
25408
+ var fx, fy, midline;
25409
+ if (edge == 'left' || edge == 'right') {
25410
+ fx = edge == 'left' ? 0 : 1;
25411
+ midline = h > 0 && box.midline > box.ymin && box.midline < box.ymax ?
25412
+ (box.midline - box.ymin) / h : 0.5;
25413
+ fy = h > 0 ? snapTo((y - box.ymin) / h, [0, midline, 0.5, 1], tol / h) : 0.5;
25414
+ } else {
25415
+ fy = edge == 'top' ? 0 : 1;
25416
+ fx = w > 0 ? snapTo((x - box.xmin) / w, [0, 0.5, 1], tol / w) : 0.5;
25417
+ }
25418
+ return [roundTo(fx, 1000), roundTo(fy, 1000)];
25419
+ }
25420
+
25421
+ function getNearestEdge(box, x, y) {
25422
+ var d = {
25423
+ left: x - box.xmin,
25424
+ right: box.xmax - x,
25425
+ top: y - box.ymin,
25426
+ bottom: box.ymax - y
25427
+ };
25428
+ return Object.keys(d).reduce(function(memo, key) {
25429
+ return d[key] < d[memo] ? key : memo;
25430
+ }, 'left');
25431
+ }
25432
+
25433
+ // The callout-gap a drag on the gap handle sets: how far from the anchor the
25434
+ // line now stops, px.
25435
+ function getDraggedGap(p) {
25436
+ return roundTo(length(p), 10);
25437
+ }
25438
+
25439
+ // Where a callout's via point goes when its text moves, which takes the
25440
+ // attachment point from @t0 to @t1.
25441
+ //
25442
+ // A curve keeps its shape: the via point is rotated and scaled about the
25443
+ // anchor by whatever takes @t0 to @t1. An elbow keeps its corner in
25444
+ // proportion across the span from anchor to text, and a leg that was level
25445
+ // with the text (or plumb with it) stays so, since that is almost always what
25446
+ // a corner was put there for.
25447
+ //
25448
+ // type: 'elbow' or 'curve'
25449
+ function followCalloutVia(type, via, t0, t1) {
25450
+ var d2, re, im;
25451
+ if (type == 'curve') {
25452
+ d2 = t0[0] * t0[0] + t0[1] * t0[1];
25453
+ if (!(d2 > 0)) return via.slice();
25454
+ re = (t1[0] * t0[0] + t1[1] * t0[1]) / d2;
25455
+ im = (t1[1] * t0[0] - t1[0] * t0[1]) / d2;
25456
+ return [via[0] * re - via[1] * im, via[0] * im + via[1] * re];
25457
+ }
25458
+ return [followElbowCoord(via[0], t0[0], t1[0]), followElbowCoord(via[1], t0[1], t1[1])];
25459
+ }
25460
+
25461
+ // Close enough to count as level, in px: the values are stored in tenths.
25462
+ var LEVEL_TOLERANCE = 0.05;
25463
+
25464
+ function followElbowCoord(v, t0, t1) {
25465
+ if (Math.abs(v - t0) < LEVEL_TOLERANCE) return t1;
25466
+ // Scaling by a span of almost nothing throws the corner miles away, so a
25467
+ // text that started nearly in line with its anchor moves the corner with it
25468
+ // instead.
25469
+ if (Math.abs(t0) < 1) return v + t1 - t0;
25470
+ return v * t1 / t0;
25471
+ }
25472
+
25473
+ // 'x,y', as callout-via and callout-attach store a pair
25474
+ function formatPointPair(p) {
25475
+ return formatNumber$2(p[0]) + ',' + formatNumber$2(p[1]);
25476
+ }
25477
+
25478
+ function formatNumber$2(n) {
25479
+ var val = roundTo(n, 10);
25480
+ return String(val === 0 ? 0 : val); // no "-0"
25481
+ }
25482
+
25483
+ function snapTo(val, targets, tol) {
25484
+ var best = val, bestDist = tol;
25485
+ targets.forEach(function(t) {
25486
+ var d = Math.abs(val - t);
25487
+ if (d <= bestDist) {
25488
+ bestDist = d;
25489
+ best = t;
25490
+ }
25491
+ });
25492
+ return best;
25493
+ }
25494
+
25495
+ function clamp$3(val, min, max) {
25496
+ return val < min ? min : val > max ? max : val;
25497
+ }
25498
+
25499
+ function length(p) {
25500
+ return Math.sqrt(p[0] * p[0] + p[1] * p[1]);
25501
+ }
25502
+
25503
+ function roundTo(n, k) {
25504
+ return Math.round(n * k) / k;
25505
+ }
25506
+
24168
25507
  // Sliding a path label's text along its curve, and flipping it across.
24169
25508
  //
24170
25509
  // Both are one gesture -- a drag on the glyphs, Illustrator's model -- and both
@@ -24757,6 +26096,7 @@
24757
26096
  created: false,
24758
26097
  onClose: opts && opts.onClose || null,
24759
26098
  startText: decodeLabelText(rec['label-text']),
26099
+ startBreaks: readSoftBreaks(rec['label-text']).breaks,
24760
26100
  nodes: null
24761
26101
  };
24762
26102
  startSession();
@@ -24801,6 +26141,7 @@
24801
26141
  created: true,
24802
26142
  onClose: opts.onClose || null,
24803
26143
  startText: '',
26144
+ startBreaks: [],
24804
26145
  nodes: null
24805
26146
  };
24806
26147
  startSession();
@@ -24938,10 +26279,15 @@
24938
26279
  // there is no session to abandon -- it was created before this one, perhaps
24939
26280
  // by the script the file was built by.
24940
26281
  // - An existing label whose text changed has that text **saved**.
26282
+ //
26283
+ // A text block is saved with the soft breaks it was last drawn with, and is
26284
+ // saved even when the typed text did not change if those breaks did: a font
26285
+ // installed since, or a width set from the console, moves them.
24941
26286
  function commit(o) {
24942
26287
  var blank = textHasNoGlyphs(o.text);
26288
+ var breaks = getBreaks(o);
24943
26289
  if (o.pending) {
24944
- if (!blank) o.pending.create(o.text);
26290
+ if (!blank) o.pending.create(insertSoftBreaks(o.text, breaks));
24945
26291
  return;
24946
26292
  }
24947
26293
  if (blank) {
@@ -24949,12 +26295,23 @@
24949
26295
  removeLabel(o);
24950
26296
  return;
24951
26297
  }
24952
- if (o.text === o.startText) return;
24953
- runGuiEditCommand(gui, getLabelTextCommand(o.text, o.id, o.target.name), {
26298
+ if (o.text === o.startText && sameSoftBreaks(breaks, o.startBreaks)) return;
26299
+ runGuiEditCommand(gui, getLabelTextCommand(insertSoftBreaks(o.text, breaks),
26300
+ o.id, o.target.name), {
24954
26301
  title: 'Label text'
24955
26302
  });
24956
26303
  }
24957
26304
 
26305
+ // Where the session's text wraps, as offsets into it. Only an anchored label
26306
+ // wraps: a path label's text runs along its curve on one line.
26307
+ function getBreaks(o) {
26308
+ var shp = o.pending ? o.pending.coords : o.target.shapes && o.target.shapes[o.id];
26309
+ var rec;
26310
+ if (!shp || shp.length > 1) return [];
26311
+ rec = o.pending ? o.pending.getStyle() : getRecord(o.target, o.id);
26312
+ return getSoftBreaks(o.text, rec);
26313
+ }
26314
+
24958
26315
  // Deletes the label's feature, geometry and all.
24959
26316
  //
24960
26317
  // -filter is the command for this: dropping a feature is not a label
@@ -25037,7 +26394,9 @@
25037
26394
  // when there is no text, exactly as it does for a committed empty label.
25038
26395
  function getPendingRecord(o) {
25039
26396
  var rec = Object.assign({}, o.pending.getStyle());
25040
- rec['label-text'] = encodeLabelText(o.text);
26397
+ // With its soft breaks, so that what is aligned and what a callout meets
26398
+ // is the block as wrapped
26399
+ rec['label-text'] = encodeLabelText(insertSoftBreaks(o.text, getBreaks(o)));
25041
26400
  // No need to expand label-pos here, or to measure its text: the renderer
25042
26401
  // resolves the position and asks for the width it needs, so a pending
25043
26402
  // label is laid out by exactly the same code as a committed one.
@@ -25087,12 +26446,14 @@
25087
26446
  // getRenderedLines() gives it one.
25088
26447
  function writeText(o) {
25089
26448
  var content = o.nodes.content;
26449
+ var breaks = getBreaks(o);
26450
+ var written = o.text + '\u0000' + breaks.join(',');
25090
26451
  var lines, i, tspan;
25091
26452
  // refresh() runs on every map render, which during a pan is every frame;
25092
26453
  // rebuilding text nodes that already say the right thing is pure waste
25093
- if (o.writtenTo === content && o.writtenText === o.text) return;
26454
+ if (o.writtenTo === content && o.writtenText === written) return;
25094
26455
  o.writtenTo = content;
25095
- o.writtenText = o.text;
26456
+ o.writtenText = written;
25096
26457
  o.nodes.text.setAttributeNS(XML_NS, 'space', 'preserve');
25097
26458
  while (content.firstChild) content.removeChild(content.firstChild);
25098
26459
  // A <textPath> runs along its baseline, so a second line would advance
@@ -25104,7 +26465,10 @@
25104
26465
  content.appendChild(document.createTextNode(getRenderedContent(o.text)));
25105
26466
  return;
25106
26467
  }
25107
- lines = getRenderedLines(o.text);
26468
+ // A soft break keeps the space it broke at at the end of its line, where
26469
+ // the rendered label drops it: here every typed character has to be one
26470
+ // the caret can sit beside.
26471
+ lines = getRenderedLines(o.text, breaks);
25108
26472
  content.appendChild(document.createTextNode(lines[0]));
25109
26473
  for (i = 1; i < lines.length; i++) {
25110
26474
  tspan = document.createElementNS(SVG_NS$3, 'tspan');
@@ -25127,7 +26491,7 @@
25127
26491
 
25128
26492
  function getLineHeight(o) {
25129
26493
  var rec = o.pending ? o.pending.getStyle() : getRecord(o.target, o.id);
25130
- return rec && rec['line-height'] || '1.1em';
26494
+ return internal.svg.getLineHeightDy(rec && rec['line-height']);
25131
26495
  }
25132
26496
 
25133
26497
  function getProvider(nodes) {
@@ -25190,6 +26554,7 @@
25190
26554
  if (pathId) {
25191
26555
  groups.back.appendChild(ghostPath(pathId));
25192
26556
  } else if (box) {
26557
+ appendColumn(o, groups.back, box);
25193
26558
  groups.back.appendChild(rect(box, 'label-edit-box'));
25194
26559
  }
25195
26560
  getSelectionRects(provider, bands[0], bands[1], rendered).forEach(function(r) {
@@ -25200,6 +26565,22 @@
25200
26565
  syncTransform(o, groups);
25201
26566
  }
25202
26567
 
26568
+ // A text block's column -- the width it wraps to -- as a faint dashed box
26569
+ // behind the box around the text, which is usually narrower. Without it a
26570
+ // text block just placed looks like any other label: a caret in a box the
26571
+ // size of nothing.
26572
+ function appendColumn(o, g, box) {
26573
+ var rec = o.pending ? getPendingRecord(o) : getRecord(o.target, o.id);
26574
+ var column = rec ? getLabelColumn(rec) : null;
26575
+ if (!column) return;
26576
+ g.appendChild(rect({
26577
+ x: column[0] - BOX_PADDING$1,
26578
+ y: box.y,
26579
+ width: column[1] - column[0] + BOX_PADDING$1 * 2,
26580
+ height: box.height
26581
+ }, 'label-edit-column'));
26582
+ }
26583
+
25203
26584
  // Keeps clicks near the label inside the session instead of ending it.
25204
26585
  //
25205
26586
  // Glyph-precise hit testing is not enough for this: a curved label's bounding
@@ -25503,6 +26884,8 @@
25503
26884
  var BOX_PADDING = 3;
25504
26885
  var ANCHOR_RADIUS = 3.5;
25505
26886
  var KNOT_RADIUS = 3;
26887
+ var WIDTH_HANDLE_SIZE = 6;
26888
+ var GAP_HANDLE_RADIUS = 2.5;
25506
26889
 
25507
26890
  // How many labels get an outline before the cue falls back to the halo.
25508
26891
  //
@@ -25522,7 +26905,12 @@
25522
26905
 
25523
26906
  // getEditingId: returns the feature id of an open text editing session, or -1.
25524
26907
  // A label being typed into draws its own box and does not want a second one.
25525
- function LabelSelection(gui, ext, hit, getEditingId) {
26908
+ // getHandles: (optional) function(target, id, textBox) returning
26909
+ // {handles, column} for a selected anchored label, as from
26910
+ // getAnchoredLabelHandles(), or null. The tool decides when a label has
26911
+ // handles, and gives a text block's column from what is on screen, which
26912
+ // during a drag is not the data.
26913
+ function LabelSelection(gui, ext, hit, getEditingId, getHandles) {
25526
26914
  var self = {};
25527
26915
  var groups = []; // one <g> per drawn cue, in the layer's markup
25528
26916
  var marked = []; // text nodes wearing the halo, when there are too many to outline
@@ -25617,7 +27005,7 @@
25617
27005
  g.appendChild(curve(nodes.pathId));
25618
27006
  if (withHandles) appendKnotHandles(g, target, id);
25619
27007
  } else {
25620
- appendAnchoredCue(g, nodes, rec, id);
27008
+ appendAnchoredCue(g, nodes, rec, id, withHandles ? target : null);
25621
27009
  }
25622
27010
  groups.push(g);
25623
27011
  }
@@ -25633,14 +27021,76 @@
25633
27021
  // ring inside it would. What is left -- offset text with nothing at its
25634
27022
  // anchor -- is the case where the ring is the only thing that says what the
25635
27023
  // text hangs off.
25636
- function appendAnchoredCue(g, nodes, rec, id) {
27024
+ //
27025
+ // A selected label's handles go in a group of their own, after the symbol
27026
+ // rather than before it: they sit on the callout and on the edge of the box,
27027
+ // and painted beneath the line they would be hidden by it.
27028
+ //
27029
+ // @handleTarget: the layer, when the label is selected and so may have
27030
+ // handles, or null
27031
+ //
27032
+ // A selected text block also shows its column, the width it wraps to, as a
27033
+ // fainter dashed box behind the solid one. The solid box is the label itself
27034
+ // -- the wrapped text, which is what a callout meets -- and is usually
27035
+ // narrower than its column; the column is what the width handle drags.
27036
+ function appendAnchoredCue(g, nodes, rec, id, handleTarget) {
25637
27037
  var box = measure(nodes.content);
27038
+ var o = box && handleTarget && getHandles ? getHandles(handleTarget, id, box) : null;
27039
+ var column = o ? o.column : null;
25638
27040
  if (!box) return;
27041
+ if (column) g.appendChild(columnRect(box, column, BOX_PADDING));
25639
27042
  if (box.width || box.height) g.appendChild(rect(box, BOX_PADDING));
25640
27043
  if (id === tetherId) g.appendChild(tether(box));
25641
27044
  if (!internal.featureHasSvgSymbol(rec) && !boxHoldsOrigin(box)) {
25642
27045
  g.appendChild(anchorMarker());
25643
27046
  }
27047
+ if (o && o.handles.length > 0) appendHandles(nodes, o.handles);
27048
+ }
27049
+
27050
+ // The column's own extent across, and the text's up and down: a column has
27051
+ // no height of its own.
27052
+ function columnRect(box, column, pad) {
27053
+ var el = rect({x: column[0], y: box.y, width: column[1] - column[0],
27054
+ height: box.height}, pad);
27055
+ el.setAttribute('class', 'label-cue-column');
27056
+ return el;
27057
+ }
27058
+
27059
+ function appendHandles(nodes, handles) {
27060
+ var g = document.createElementNS(SVG_NS$2, 'g');
27061
+ var transform = nodes.symbol.getAttribute('transform');
27062
+ var display = nodes.symbol.getAttribute('display');
27063
+ g.setAttribute('class', 'label-cue label-cue-handles');
27064
+ if (transform) g.setAttribute('transform', transform);
27065
+ if (display) g.setAttribute('display', display);
27066
+ handles.forEach(function(h) {
27067
+ g.appendChild(labelHandle(h));
27068
+ });
27069
+ nodes.symbol.parentNode.insertBefore(g, nodes.symbol.nextSibling);
27070
+ groups.push(g);
27071
+ }
27072
+
27073
+ // A square for the width handle, which is a corner of the box, and a ring
27074
+ // for the callout's, which are points on its line -- filled for the end
27075
+ // that meets the text, so that the two ends of the line read differently.
27076
+ function labelHandle(h) {
27077
+ var p = h.point;
27078
+ var el;
27079
+ if (h.kind == 'width') {
27080
+ el = document.createElementNS(SVG_NS$2, 'rect');
27081
+ el.setAttribute('x', p[0] - WIDTH_HANDLE_SIZE / 2);
27082
+ el.setAttribute('y', p[1] - WIDTH_HANDLE_SIZE / 2);
27083
+ el.setAttribute('width', WIDTH_HANDLE_SIZE);
27084
+ el.setAttribute('height', WIDTH_HANDLE_SIZE);
27085
+ } else {
27086
+ el = document.createElementNS(SVG_NS$2, 'circle');
27087
+ el.setAttribute('cx', p[0]);
27088
+ el.setAttribute('cy', p[1]);
27089
+ el.setAttribute('r', h.kind == 'gap' ? GAP_HANDLE_RADIUS : KNOT_RADIUS);
27090
+ }
27091
+ el.setAttribute('class', 'label-cue-handle label-cue-' + h.kind);
27092
+ el.setAttribute('data-handle', h.kind);
27093
+ return el;
25644
27094
  }
25645
27095
 
25646
27096
  // Whether the label's anchor point is inside the box drawn around its text.
@@ -26107,8 +27557,17 @@
26107
27557
  // resolve and still leaves a short polyline to walk per mouse move.
26108
27558
  var FLATTEN_PX = 0.5;
26109
27559
 
27560
+ // How near a dragged handle has to come to an alignment to snap to it, px on
27561
+ // screen
27562
+ var SNAP_PX = 5;
27563
+
27564
+ // The width a text block placed with a click wraps to, px, when the panel does
27565
+ // not set one
27566
+ var DEFAULT_BLOCK_WIDTH = 160;
27567
+
26110
27568
  function initLabelTool(gui, ext, hit) {
26111
- // which kind of label a click creates: null, 'anchor' or 'path'
27569
+ // which kind of label a click creates: null, 'anchor', 'block' or 'path'.
27570
+ // A block is an anchored label with a wrap width.
26112
27571
  var armed = null;
26113
27572
  var curve = createCurveState();
26114
27573
  // The pointer's position while a curve is being drawn, in display
@@ -26131,11 +27590,27 @@
26131
27590
  // coordinates. A drag on a label's text starts from here rather than from
26132
27591
  // the dragstart event -- see beginTextDrag().
26133
27592
  var hoverPoint = null;
27593
+ // A drag on one of a selected anchored label's own handles -- its width or
27594
+ // its callout -- or null. See gui-label-handles.mjs.
27595
+ var handleDrag = null;
27596
+ // The handles last drawn, {id, handles}, which is what the pointer is tested
27597
+ // against: they are worked out when the cue is drawn rather than on every
27598
+ // mouse move, since placing them measures the label.
27599
+ var shownHandles = null;
27600
+ // A drag on the map that is setting a new text block's width, or null
27601
+ var blockDrag = null;
27602
+ // A drag that has been released and whose command has not redrawn the map
27603
+ // yet, or null. Its preview is still what is on screen, so the cue goes on
27604
+ // being drawn from it rather than from the data, which has not changed yet.
27605
+ var releasedDrag = null;
27606
+ // The mouseup that ended the last block drag, or null. A short one is
27607
+ // followed by a click from the same mouseup, which it has already handled.
27608
+ var blockRelease = null;
26134
27609
  var editor = new LabelEditor(gui, ext);
26135
27610
  var selection = new LabelSelection(gui, ext, hit, function() {
26136
27611
  return editor.isOpen() ? editor.getFeatureId() : -1;
26137
- });
26138
- var toolbar, anchorBtn, pathBtn, alert;
27612
+ }, getLabelHandles);
27613
+ var toolbar, anchorBtn, blockBtn, pathBtn, alert;
26139
27614
 
26140
27615
  gui.addMode('label_tool', turnOn, turnOff);
26141
27616
 
@@ -26207,6 +27682,11 @@
26207
27682
  hideInstructions();
26208
27683
  drag = null;
26209
27684
  textDrag = null;
27685
+ handleDrag = null;
27686
+ releasedDrag = null;
27687
+ blockRelease = null;
27688
+ shownHandles = null;
27689
+ clearBlockDrag();
26210
27690
  hoverHandle = null;
26211
27691
  hoverTextId = -1;
26212
27692
  hoverPoint = null;
@@ -26214,6 +27694,8 @@
26214
27694
  // go back whether or not it was a crosshair.
26215
27695
  gui.container.findChild('.map-layers').classed('label-tool', false);
26216
27696
  gui.container.findChild('.map-layers').classed('label-handle', false);
27697
+ gui.container.findChild('.map-layers').classed('label-text-drag', false);
27698
+ gui.container.findChild('.map-layers').classed('label-resize', false);
26217
27699
  if (toolbar) toolbar.hide();
26218
27700
  if (gui.interaction.getMode() == 'label') {
26219
27701
  // the mode change came from somewhere other than the mode menu
@@ -26239,6 +27721,14 @@
26239
27721
  }).on('click', function() {
26240
27722
  setArmed(armed == 'anchor' ? null : 'anchor');
26241
27723
  });
27724
+ // A tool of its own, although a text block is only a label with a width,
27725
+ // because otherwise the width is something found by dragging a handle on a
27726
+ // label that already exists -- and nothing on screen says it is there.
27727
+ blockBtn = toolbar.addButton('#text-block-icon', {
27728
+ tooltip: 'Add a text block'
27729
+ }).on('click', function() {
27730
+ setArmed(armed == 'block' ? null : 'block');
27731
+ });
26242
27732
  pathBtn = toolbar.addButton('#curved-text-icon', {
26243
27733
  tooltip: 'Add a label along a path'
26244
27734
  }).on('click', function() {
@@ -26258,6 +27748,7 @@
26258
27748
  function updateButtons() {
26259
27749
  if (!toolbar) return;
26260
27750
  anchorBtn.setSelected(armed == 'anchor');
27751
+ blockBtn.setSelected(armed == 'block');
26261
27752
  pathBtn.setSelected(armed == 'path');
26262
27753
  updateCursor();
26263
27754
  }
@@ -26271,22 +27762,34 @@
26271
27762
  // wrong even with a tool armed.
26272
27763
  function updateCursor() {
26273
27764
  var el = gui.container.findChild('.map-layers');
26274
- // A selected path label's glyphs are a handle as much as its knots are: a
26275
- // drag there slides the text along its curve.
26276
- var onHandle = active() && (!!hoverHandle || hoverTextId > -1);
26277
- var placing = active() && !!armed && hit.getHitId() == -1 && !onHandle;
27765
+ // The text and the control points get different cursors, because they are
27766
+ // often close together -- a callout's attachment point is on the text's
27767
+ // box, and a path label's knots sit under its glyphs -- and a drag on one
27768
+ // does something quite different from a drag on the other.
27769
+ var onText = active() && hoverTextId > -1;
27770
+ var onHandle = active() && !onText && !!hoverHandle;
27771
+ var resizing = onHandle && hoverHandle.kind == 'width';
27772
+ var placing = active() && !!armed && hit.getHitId() == -1 && !onText && !onHandle;
26278
27773
  el.classed('label-tool', placing);
26279
- el.classed('label-handle', onHandle);
27774
+ el.classed('label-text-drag', onText);
27775
+ el.classed('label-handle', onHandle && !resizing);
27776
+ el.classed('label-resize', resizing);
26280
27777
  }
26281
27778
 
26282
27779
  function showInstructions() {
27780
+ var msg;
26283
27781
  hideInstructions();
26284
27782
  if (!armed) return;
26285
- alert = showPopupAlert(armed == 'anchor' ?
26286
- 'Click on the map to place a label.' :
26287
- 'Click to draw a curved path. Type Esc or double-click ' +
26288
- 'to finish. Backspace removes the last point.',
26289
- null, {non_blocking: true, max_width: '330px'});
27783
+ if (armed == 'anchor') {
27784
+ msg = 'Click on the map to place a label.';
27785
+ } else if (armed == 'block') {
27786
+ msg = 'Click on the map to place a text block, or drag across it ' +
27787
+ 'to set the width the text wraps to.';
27788
+ } else {
27789
+ msg = 'Click to draw a curved path. Type Esc or double-click ' +
27790
+ 'to finish. Backspace removes the last point.';
27791
+ }
27792
+ alert = showPopupAlert(msg, null, {non_blocking: true, max_width: '330px'});
26290
27793
  }
26291
27794
 
26292
27795
  function hideInstructions() {
@@ -26302,10 +27805,20 @@
26302
27805
  // editing acts on exactly one and has nothing to do with the panel.
26303
27806
  hit.on('click', function(e) {
26304
27807
  if (!active()) return;
27808
+ // A press that moved a little is both a block drag and a click, released
27809
+ // by the same mouseup; the drag has answered it already.
27810
+ if (blockRelease && getDomEvent(e) == blockRelease) {
27811
+ blockRelease = null;
27812
+ return;
27813
+ }
26305
27814
  if (armed == 'path' && drawingCurve()) {
26306
27815
  extendCurve(pixToMapCoords(e.x, e.y));
26307
27816
  return;
26308
27817
  }
27818
+ // A label's handles sit on its callout and its box, which the hit test
27819
+ // counts as the label, and a second click there would open its text. A
27820
+ // click on a handle is half of the double-click that resets it.
27821
+ if (hoverHandle && hoverHandle.kind && !editor.isOpen()) return;
26309
27822
  // A click away from the text finishes the label being typed into, and does
26310
27823
  // nothing else: leaving one label is its own gesture, and placing the next
26311
27824
  // one takes another click. This is also what makes one undo step cover one
@@ -26330,10 +27843,18 @@
26330
27843
  if (e.id > -1 && clickLabel(e.id, e)) return;
26331
27844
  if (editor.isOpen()) return; // a click within the text moved the caret
26332
27845
  // Nothing was hit. Any selection is now over with, and the click means
26333
- // whatever the armed tool says it means.
27846
+ // whatever the armed tool says it means -- except with the text block
27847
+ // tool, where clicking off a selected block is how it is let go of, and
27848
+ // a block dropped at the same time would be one more thing to delete.
27849
+ if (armed == 'block' && e.had_selection) {
27850
+ deselectLabels();
27851
+ return;
27852
+ }
26334
27853
  deselectLabels();
26335
27854
  if (armed == 'anchor') {
26336
- beginLabel([pixToMapCoords(e.x, e.y)], []);
27855
+ beginLabel([pixToMapCoords(e.x, e.y)]);
27856
+ } else if (armed == 'block') {
27857
+ beginLabel([pixToMapCoords(e.x, e.y)], getBlockStyle);
26337
27858
  } else if (armed == 'path') {
26338
27859
  extendCurve(pixToMapCoords(e.x, e.y));
26339
27860
  }
@@ -26531,15 +28052,19 @@
26531
28052
  return editor.ownsNode(getEventNode(e));
26532
28053
  }
26533
28054
 
26534
- // The DOM node a hit event came from. A hit event's originalEvent is the
26535
- // mouse event, whose own originalEvent is the DOM one, so the node is two
26536
- // levels down.
28055
+ // The DOM node a hit event came from.
26537
28056
  function getEventNode(e) {
26538
- var mouseEvt = e && e.originalEvent;
26539
- var domEvt = mouseEvt && mouseEvt.originalEvent || mouseEvt;
28057
+ var domEvt = getDomEvent(e);
26540
28058
  return domEvt && domEvt.target || null;
26541
28059
  }
26542
28060
 
28061
+ // The DOM event behind a hit event: its originalEvent is the mouse event,
28062
+ // whose own originalEvent is the DOM one.
28063
+ function getDomEvent(e) {
28064
+ var mouseEvt = e && e.originalEvent;
28065
+ return mouseEvt && mouseEvt.originalEvent || mouseEvt || null;
28066
+ }
28067
+
26543
28068
  function deselectLabels() {
26544
28069
  if (hit.getSelectionIds().length > 0) hit.clearSelection();
26545
28070
  }
@@ -26574,6 +28099,7 @@
26574
28099
  // find its nodes again and write the text being edited back into them, and
26575
28100
  // the selection cues have to be drawn onto the new markup.
26576
28101
  gui.on('map_rendered', function(e) {
28102
+ if (!e || e.action != 'hover') releasedDrag = null;
26577
28103
  editor.refresh();
26578
28104
  // The cues went with the old markup, so they have to be drawn again --
26579
28105
  // except after a 'hover' draw, which leaves the SVG alone. That draw now
@@ -26628,7 +28154,14 @@
26628
28154
  if (beginTextDrag(e)) consumeDrag(e);
26629
28155
  return;
26630
28156
  }
26631
- if (!hoverHandle) return;
28157
+ if (!hoverHandle) {
28158
+ if (armed == 'block' && beginBlockDrag(e)) consumeDrag(e);
28159
+ return;
28160
+ }
28161
+ if (hoverHandle.kind) {
28162
+ if (beginHandleDrag(hoverHandle)) consumeDrag(e);
28163
+ return;
28164
+ }
26632
28165
  beginKnotDrag(hoverHandle);
26633
28166
  consumeDrag(e);
26634
28167
  });
@@ -26663,6 +28196,16 @@
26663
28196
  updateTextDrag(e);
26664
28197
  return;
26665
28198
  }
28199
+ if (handleDrag) {
28200
+ consumeDrag(e);
28201
+ updateHandleDrag(e);
28202
+ return;
28203
+ }
28204
+ if (blockDrag) {
28205
+ consumeDrag(e);
28206
+ updateBlockDrag(e);
28207
+ return;
28208
+ }
26666
28209
  if (!drag) return;
26667
28210
  consumeDrag(e);
26668
28211
  shp = getDisplayShapes(drag.target)[drag.id];
@@ -26685,6 +28228,7 @@
26685
28228
  if (textDrag) {
26686
28229
  o = textDrag;
26687
28230
  textDrag = null;
28231
+ if (o.moved) releasedDrag = o;
26688
28232
  consumeDrag(e);
26689
28233
  selection.setTether(-1);
26690
28234
  // A press that never moved is a click, which the click handler has
@@ -26692,6 +28236,22 @@
26692
28236
  if (o.moved) commitTextDrag(o);
26693
28237
  return;
26694
28238
  }
28239
+ if (handleDrag) {
28240
+ o = handleDrag;
28241
+ handleDrag = null;
28242
+ if (o.values) releasedDrag = o;
28243
+ consumeDrag(e);
28244
+ if (o.values) commitHandleDrag(o);
28245
+ return;
28246
+ }
28247
+ if (blockDrag) {
28248
+ o = blockDrag;
28249
+ clearBlockDrag();
28250
+ blockRelease = getDomEvent(e);
28251
+ consumeDrag(e);
28252
+ commitBlockDrag(o);
28253
+ return;
28254
+ }
26695
28255
  if (!o) return;
26696
28256
  consumeDrag(e);
26697
28257
  drag = null;
@@ -26726,6 +28286,301 @@
26726
28286
  });
26727
28287
  }
26728
28288
 
28289
+ // The handles a selected anchored label draws, for LabelSelection: only on a
28290
+ // label that is the whole selection, since a handle edits one label, and
28291
+ // not while anything else is going on. Kept as shownHandles for the hover
28292
+ // test, which then tests the pointer against exactly what is on screen.
28293
+ //
28294
+ // During a drag they are worked out from the record being previewed, so that
28295
+ // the handle follows the pointer and the others follow the line.
28296
+ //
28297
+ // Every selected text block gets its column, handles or not, and that too
28298
+ // comes from the preview: the text moves by its attributes during an offset
28299
+ // drag, and a column read from the data stayed where the text had been.
28300
+ function getLabelHandles(target, id, textBox) {
28301
+ var ids = hit.getSelectionIds();
28302
+ var rec = getPreviewRecord(id) || getRecord(target, id);
28303
+ var o;
28304
+ if (!rec || isPathLabel(target, id)) return null;
28305
+ if (!active() || editor.isOpen() || drawingCurve() || textDrag ||
28306
+ ids.length != 1 || ids[0] != id) {
28307
+ shownHandles = null;
28308
+ return {handles: [], column: getLabelColumn(rec)};
28309
+ }
28310
+ o = getAnchoredLabelHandles(rec, textBox, {
28311
+ symbolRadius: internal.svg.getAnchorSymbolRadius(rec),
28312
+ scale: ext.getSymbolScale() || 1,
28313
+ padding: BOX_PADDING
28314
+ });
28315
+ shownHandles = {id: id, target: target, handles: o.handles};
28316
+ return o;
28317
+ }
28318
+
28319
+ function getPreviewRecord(id) {
28320
+ var o = handleDrag || textDrag || releasedDrag;
28321
+ return o && o.id == id && o.previewRec || null;
28322
+ }
28323
+
28324
+ // The label handle nearest the pointer, within reach of it, or null.
28325
+ // Returns a handle shaped like findNearestKnot()'s, plus its kind, its point
28326
+ // in label space and the pointer's, and its distance in screen px.
28327
+ function findLabelHandle(target, e) {
28328
+ var o = shownHandles;
28329
+ var ids = hit.getSelectionIds();
28330
+ var scale = ext.getSymbolScale() || 1;
28331
+ var shp, q, best = null;
28332
+ if (!o || o.target != target || ids.length != 1 || ids[0] != o.id) return null;
28333
+ shp = getDisplayShapes(target)[o.id];
28334
+ if (!shp) return null;
28335
+ q = getLabelSpacePoint(shp[0], e);
28336
+ o.handles.forEach(function(h) {
28337
+ var dist = Math.sqrt(Math.pow(h.point[0] - q.x, 2) +
28338
+ Math.pow(h.point[1] - q.y, 2)) * scale;
28339
+ if (dist <= KNOT_HIT_THRESHOLD && (!best || dist < best.dist)) {
28340
+ best = {id: o.id, kind: h.kind, point: h.point, pointer: [q.x, q.y], dist: dist};
28341
+ }
28342
+ });
28343
+ return best;
28344
+ }
28345
+
28346
+ function beginHandleDrag(handle) {
28347
+ var target = hit.getHitTarget();
28348
+ var rec = getRecord(target, handle.id);
28349
+ var shp = getDisplayShapes(target)[handle.id];
28350
+ var drawn, shape;
28351
+ if (!rec || !shp) return false;
28352
+ drawn = internal.svg.getDrawnLabelOffset(rec);
28353
+ shape = internal.svg.getLabelCalloutShape(rec, internal.svg.getAnchorSymbolRadius(rec));
28354
+ if (handle.kind != 'width' && !shape) return false;
28355
+ handleDrag = {
28356
+ kind: handle.kind,
28357
+ id: handle.id,
28358
+ target: target,
28359
+ rec: rec,
28360
+ anchor: shp[0],
28361
+ // Where within the handle it was grabbed, so that it tracks the pointer
28362
+ // rather than jumping to it -- measured from the last hover, as a knot's
28363
+ // is, since dragstart arrives after the first move.
28364
+ grab: [handle.point[0] - handle.pointer[0], handle.point[1] - handle.pointer[1]],
28365
+ textAnchor: getDrawnAnchor(drawn),
28366
+ dx: drawn.dx,
28367
+ // The padded box the attachment point slides around. It does not move
28368
+ // while the point does: callout-attach is where on the box, not where
28369
+ // the box is.
28370
+ box: shape ? shape.box : null,
28371
+ values: null,
28372
+ previewRec: null
28373
+ };
28374
+ return true;
28375
+ }
28376
+
28377
+ function updateHandleDrag(e) {
28378
+ var o = handleDrag;
28379
+ var q = getLabelSpacePoint(o.anchor, e);
28380
+ var values = getHandleDragValues(o, [q.x + o.grab[0], q.y + o.grab[1]]);
28381
+ if (!values || o.values && sameValues(values, o.values)) return;
28382
+ o.values = values;
28383
+ o.previewRec = Object.assign({}, o.rec, values);
28384
+ previewLabel(o.target, o.id, o.previewRec);
28385
+ }
28386
+
28387
+ // The fields a handle dragged to @p writes, all of them in their stored form.
28388
+ function getHandleDragValues(o, p) {
28389
+ var tol = SNAP_PX / (ext.getSymbolScale() || 1);
28390
+ var width, shape, via;
28391
+ if (o.kind == 'width') {
28392
+ width = getDraggedWidth(o.textAnchor, o.dx, p[0], BOX_PADDING);
28393
+ return {
28394
+ 'label-width': width,
28395
+ 'label-text': rewrapLabelValue(o.rec['label-text'],
28396
+ Object.assign({}, o.rec, {'label-width': width}))
28397
+ };
28398
+ }
28399
+ if (o.kind == 'via') {
28400
+ // Snapped against where the line meets the text with the via point
28401
+ // where the pointer is, because an automatic attachment faces the via
28402
+ // point and moves with it. Only an elbow's corner snaps: level and plumb
28403
+ // legs are what an elbow is for, but a curve's midpoint has no reason to
28404
+ // line up with anything, and snapping it made the curve hard to place.
28405
+ shape = internal.svg.getLabelCalloutShape(
28406
+ Object.assign({}, o.rec, {'callout-via': formatPointPair(p)}),
28407
+ internal.svg.getAnchorSymbolRadius(o.rec));
28408
+ via = shape && shape.kind != 'bezier' ? snapCalloutVia(p, shape.attach, tol) : p;
28409
+ return {'callout-via': formatPointPair(via)};
28410
+ }
28411
+ if (o.kind == 'attach') {
28412
+ return {'callout-attach': formatPointPair(getAttachFraction(o.box, p, tol))};
28413
+ }
28414
+ if (o.kind == 'gap') {
28415
+ return {'callout-gap': getDraggedGap(p)};
28416
+ }
28417
+ return null;
28418
+ }
28419
+
28420
+ function sameValues(a, b) {
28421
+ return Object.keys(a).every(function(k) { return a[k] === b[k]; });
28422
+ }
28423
+
28424
+ // Redraws one label from @rec without touching its data, then redraws its
28425
+ // cue around the result. The command the drag ends with redraws the layer
28426
+ // from the data, which replaces this.
28427
+ function previewLabel(target, id, rec) {
28428
+ var shp = getDisplayShapes(target)[id];
28429
+ var container = target.gui && target.gui.svg_container;
28430
+ if (!replaceAnchoredSymbol(container, id, rec, shp, ext)) return;
28431
+ selection.refresh(true);
28432
+ }
28433
+
28434
+ // As with an offset drag, the preview is left in place for the command's own
28435
+ // redraw to replace, and is only taken back if the command fails.
28436
+ function commitHandleDrag(o) {
28437
+ var values = Object.assign({}, o.values);
28438
+ var text = values['label-text'];
28439
+ delete values['label-text'];
28440
+ runHandleCommand(o.target, o.id, values,
28441
+ text !== undefined && text !== o.rec['label-text'] ? text : null,
28442
+ handleTitles[o.kind]);
28443
+ }
28444
+
28445
+ var handleTitles = {
28446
+ width: 'Set label width',
28447
+ via: 'Move callout bend',
28448
+ attach: 'Move callout end',
28449
+ gap: 'Set callout gap'
28450
+ };
28451
+
28452
+ // text: rewrapped label-text to write with the values, or null
28453
+ function runHandleCommand(target, id, values, text, title) {
28454
+ var ids = hit.getSelectionIds();
28455
+ runGuiEditCommand(gui, getLabelStyleCommand(values, id, {
28456
+ target: target.name,
28457
+ text: text === null ? undefined : text
28458
+ }), {
28459
+ title: title,
28460
+ // The label stays selected, so its handles are still there to be dragged
28461
+ // again. Re-running the command rebuilds the layer, which drops the
28462
+ // selection.
28463
+ onSuccess: function() { hit.setSelectionIds(ids); },
28464
+ onError: function() { gui.dispatchEvent('map-needs-refresh'); }
28465
+ });
28466
+ }
28467
+
28468
+ // Double-clicking a callout handle gives the label back the callout's own
28469
+ // choice of bend, attachment or gap. The width handle has no automatic value
28470
+ // to go back to: taking the width away would turn the block into point text.
28471
+ function resetHandle(handle) {
28472
+ var target = hit.getHitTarget();
28473
+ var rec = getRecord(target, handle.id);
28474
+ var field = {via: 'callout-via', attach: 'callout-attach',
28475
+ gap: 'callout-gap'}[handle.kind];
28476
+ var values = {};
28477
+ if (!rec || !field || isBlank(rec[field])) return;
28478
+ values[field] = '';
28479
+ runHandleCommand(target, handle.id, values, null, handleTitles[handle.kind]);
28480
+ }
28481
+
28482
+ function isBlank(val) {
28483
+ return val === undefined || val === null || val === '';
28484
+ }
28485
+
28486
+ // Dragging across the map with the text block tool armed sets the new
28487
+ // block's width, which runs between the points pressed and released.
28488
+ function beginBlockDrag(e) {
28489
+ var target = hit.getHitTarget();
28490
+ // Not from a label, whose text a drag there belongs to
28491
+ if (!target || e.id > -1 && isLabel(target, e.id)) return false;
28492
+ if (editor.isOpen()) return false;
28493
+ blockDrag = {
28494
+ target: target,
28495
+ from: getPointerPixels(e),
28496
+ start: hoverPoint || pixToMapCoords(e.x, e.y),
28497
+ width: 0,
28498
+ guide: null
28499
+ };
28500
+ return true;
28501
+ }
28502
+
28503
+ function updateBlockDrag(e) {
28504
+ var o = blockDrag;
28505
+ var scale = ext.getSymbolScale() || 1;
28506
+ o.width = (e.x - o.from[0]) / scale;
28507
+ drawBlockGuide(o, scale);
28508
+ }
28509
+
28510
+ // A dashed box the height of a line, from the press to the pointer, drawn
28511
+ // with a symbol's transform so that it scales as the label will. In the
28512
+ // map's shared <svg> rather than the layer's group, like a pending label,
28513
+ // since a layer with no labels in it yet has no group to draw into.
28514
+ function drawBlockGuide(o, scale) {
28515
+ var container = gui.map.getSvgRoot();
28516
+ var style = getStyleForNewLabel();
28517
+ var h = getNewFontSize(style) * 1.2;
28518
+ var p = ext.translateCoords(o.start[0], o.start[1]);
28519
+ var el = o.guide;
28520
+ if (!container) return;
28521
+ if (!el) {
28522
+ el = o.guide = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
28523
+ el.setAttribute('class', 'label-block-guide');
28524
+ container.appendChild(el);
28525
+ }
28526
+ el.setAttribute('transform', internal.svg.getTransform(p, scale));
28527
+ el.setAttribute('x', Math.min(0, o.width));
28528
+ el.setAttribute('y', 0);
28529
+ el.setAttribute('width', Math.abs(o.width));
28530
+ el.setAttribute('height', h);
28531
+ }
28532
+
28533
+ function clearBlockDrag() {
28534
+ var el = blockDrag && blockDrag.guide;
28535
+ if (el && el.parentNode) el.parentNode.removeChild(el);
28536
+ blockDrag = null;
28537
+ }
28538
+
28539
+ // A drag too short to mean a width is a click, and does what a click would:
28540
+ // lets go of the selection if there is one, and otherwise places a block at
28541
+ // the default width. A drag to the left places the block the same as a drag
28542
+ // to the right from where it ended, so that its text is left-aligned like
28543
+ // any other block's.
28544
+ function commitBlockDrag(o) {
28545
+ var width = Math.round(Math.abs(o.width));
28546
+ var start = o.start;
28547
+ var p;
28548
+ if (width < MIN_LABEL_WIDTH) {
28549
+ if (hit.getSelectionIds().length > 0) {
28550
+ deselectLabels();
28551
+ } else {
28552
+ beginLabel([start], getBlockStyle);
28553
+ }
28554
+ return;
28555
+ }
28556
+ deselectLabels();
28557
+ if (o.width < 0) {
28558
+ p = ext.translateCoords(start[0], start[1]);
28559
+ start = pixToMapCoords(p[0] + o.width * (ext.getSymbolScale() || 1), p[1]);
28560
+ }
28561
+ beginLabel([start], function() { return getBlockStyle(width); });
28562
+ }
28563
+
28564
+ // A text block's style: the new label's, with a wrap width -- @width or a
28565
+ // default -- and placed with the top left of its column at the anchor, so
28566
+ // that its text is left-aligned and fills the box a drag drew. The offsets
28567
+ // that do that replace the position the panel names.
28568
+ function getBlockStyle(width) {
28569
+ var style = Object.assign({}, getStyleForNewLabel());
28570
+ delete style['label-pos'];
28571
+ return Object.assign(style, {
28572
+ 'label-width': width > 0 ? width : DEFAULT_BLOCK_WIDTH,
28573
+ 'text-anchor': 'start',
28574
+ dx: 0,
28575
+ dy: Math.round(getNewFontSize(style) * 0.8)
28576
+ });
28577
+ }
28578
+
28579
+ function getNewFontSize(style) {
28580
+ var px = parseFloat(style['font-size']);
28581
+ return px > 0 ? px : internal.svg.DEFAULT_LABEL_FONT_SIZE || 12;
28582
+ }
28583
+
26729
28584
  // What a drag on a selected label's glyphs means, which depends on the kind
26730
28585
  // of label and -- for an anchored one -- on the tool's position mode.
26731
28586
  //
@@ -26738,7 +28593,7 @@
26738
28593
  var shp = id > -1 ? getDisplayShapes(target)[id] : null;
26739
28594
  if (id < 0 || !shp) return false;
26740
28595
  if (isPathLabel(target, id)) return beginPathTextDrag(e, target, id);
26741
- if (labelTextIsDraggable(gui)) return beginOffsetDrag(e, target, id);
28596
+ if (textIsDraggable(target, id)) return beginOffsetDrag(e, target, id);
26742
28597
  beginKnotDrag({
26743
28598
  id: id,
26744
28599
  index: 0,
@@ -26873,6 +28728,8 @@
26873
28728
  kind: 'offset',
26874
28729
  id: id,
26875
28730
  target: target,
28731
+ rec: rec,
28732
+ previewRec: null,
26876
28733
  // dx and dy are in the space inside the label's symbol group, so pointer
26877
28734
  // movement is divided by the scale that group wears.
26878
28735
  scale: ext.getSymbolScale() || 1,
@@ -26889,7 +28746,8 @@
26889
28746
  // multi-line label -- which is the width its justification is
26890
28747
  // measured against.
26891
28748
  width: box ? box.width : 0,
26892
- aligned: !!internal.svg.getAlignmentAnchor(rec['label-align'])
28749
+ aligned: !!internal.svg.getAlignmentAnchor(rec['label-align']),
28750
+ keepAnchor: getLabelPositionKind(rec) == 'block'
26893
28751
  },
26894
28752
  // What the attributes said before the preview wrote over them, so that
26895
28753
  // the command is what changes the label rather than the drag.
@@ -26898,14 +28756,32 @@
26898
28756
  y: nodes.text.getAttribute('y'),
26899
28757
  anchor: nodes.text.getAttribute('text-anchor')
26900
28758
  },
28759
+ callout: getOffsetDragCallout(rec),
26901
28760
  moved: false
26902
28761
  };
26903
28762
  // The hairline to the anchor, which is what the drag is measured from and
26904
- // often the only thing on screen that says so.
26905
- selection.setTether(id);
28763
+ // often the only thing on screen that says so. A callout says it already.
28764
+ if (!textDrag.callout) selection.setTether(id);
26906
28765
  return true;
26907
28766
  }
26908
28767
 
28768
+ // What an offset drag needs to carry a label's callout along, or null for a
28769
+ // label without one: the record to redraw it from, and for a callout bent
28770
+ // by hand, its via point and where the line met the text at the start.
28771
+ function getOffsetDragCallout(rec) {
28772
+ var shape;
28773
+ if (!internal.svg.labelHasCallout(rec)) return null;
28774
+ shape = internal.svg.getLabelCalloutShape(rec, internal.svg.getAnchorSymbolRadius(rec));
28775
+ return {
28776
+ rec: rec,
28777
+ box: internal.svg.getLabelTextBox(rec),
28778
+ // the shape's via point is the stored one when there is one
28779
+ via: rec['callout-via'] && shape && shape.via ? shape.via.slice() : null,
28780
+ attach: shape ? shape.attach : null,
28781
+ type: shape && shape.kind == 'bezier' ? 'curve' : 'elbow'
28782
+ };
28783
+ }
28784
+
26909
28785
  function updateOffsetDrag(e) {
26910
28786
  var o = textDrag;
26911
28787
  o.values = getOffsetDragValues(o.start, {
@@ -26913,7 +28789,41 @@
26913
28789
  dy: (e.y - o.from[1]) / o.scale
26914
28790
  });
26915
28791
  o.moved = true;
26916
- previewOffset(o);
28792
+ // The record as the drag will leave it, which the selection cue reads a
28793
+ // text block's column from while the drag is on
28794
+ o.previewRec = Object.assign({}, o.rec, {
28795
+ dx: o.values.dx,
28796
+ dy: o.values.dy,
28797
+ 'text-anchor': o.values['text-anchor']
28798
+ });
28799
+ delete o.previewRec['label-pos'];
28800
+ if (o.callout) {
28801
+ previewCalloutOffset(o, getDomEvent(e));
28802
+ } else {
28803
+ previewOffset(o);
28804
+ }
28805
+ }
28806
+
28807
+ // A label with a callout is redrawn rather than moved by its attributes, as
28808
+ // previewOffset() does, because the line has to be worked out again for
28809
+ // where the text now is. Redrawn from the fields the drag will write, which
28810
+ // is also what makes the preview the same as the result.
28811
+ //
28812
+ // A via point placed by hand goes with the text, unless Alt is held: a bend
28813
+ // put there to clear something usually still has to, and one left behind by
28814
+ // a long drag doubles the line back on itself.
28815
+ function previewCalloutOffset(o, evt) {
28816
+ var c = o.callout;
28817
+ var rec = o.previewRec;
28818
+ var box, t1;
28819
+ o.via = null;
28820
+ if (c.via && c.attach && !(evt && evt.altKey)) {
28821
+ box = internal.svg.getLabelTextBox(rec);
28822
+ t1 = [c.attach[0] + box.xmin - c.box.xmin, c.attach[1] + box.ymin - c.box.ymin];
28823
+ o.via = formatPointPair(followCalloutVia(c.type, c.via, c.attach, t1));
28824
+ rec['callout-via'] = o.via;
28825
+ }
28826
+ previewLabel(o.target, o.id, rec);
26917
28827
  }
26918
28828
 
26919
28829
  // Shows the offset by writing it onto the rendered text, as the path drag
@@ -26939,6 +28849,12 @@
26939
28849
 
26940
28850
  function restoreOffset(o) {
26941
28851
  var nodes = findLabelNodes(o.target, o.id);
28852
+ releasedDrag = null;
28853
+ if (o.callout) {
28854
+ // redrawn rather than written on, so redrawn back
28855
+ gui.dispatchEvent('map-needs-refresh');
28856
+ return;
28857
+ }
26942
28858
  if (!nodes) return;
26943
28859
  // The renderer always writes x and y on an anchored label, so the removal
26944
28860
  // case is for markup this tool did not draw.
@@ -26960,6 +28876,7 @@
26960
28876
  dx: o.values.dx,
26961
28877
  dy: o.values.dy,
26962
28878
  anchor: o.values['text-anchor'],
28879
+ via: o.via || null,
26963
28880
  id: o.id,
26964
28881
  target: o.target.name
26965
28882
  }), {
@@ -27062,8 +28979,9 @@
27062
28979
  function updateHoverHandle(e) {
27063
28980
  var found = drawingCurve() || editor.isOpen() || !e.overMap ? null :
27064
28981
  findHandle(hit.getHitTarget(), e);
27065
- var changed = !!found != !!hoverHandle;
27066
- if (drag || textDrag) return;
28982
+ var changed = !!found != !!hoverHandle ||
28983
+ !!found && found.kind != hoverHandle.kind;
28984
+ if (dragging()) return;
27067
28985
  hoverHandle = found;
27068
28986
  if (changed) {
27069
28987
  updateHoverText(); // a knot handle takes the glyphs' turn away
@@ -27082,16 +29000,30 @@
27082
29000
  // text, and letting the handle win there would leave the text ungrabbable.
27083
29001
  function updateHoverText() {
27084
29002
  var id = findDraggableText();
27085
- if (drag || textDrag) return;
29003
+ if (dragging()) return;
27086
29004
  hoverTextId = id > -1 && (!hoverHandle || glyphsOutrankHandle(id)) ? id : -1;
27087
29005
  }
27088
29006
 
29007
+ // Point text and text blocks each have a position mode -- see
29008
+ // getLabelPositionMode().
29009
+ function textIsDraggable(target, id) {
29010
+ return labelTextIsDraggable(gui, getLabelPositionKind(getRecord(target, id)));
29011
+ }
29012
+
29013
+ function dragging() {
29014
+ return !!(drag || textDrag || handleDrag || blockDrag);
29015
+ }
29016
+
27089
29017
  // The exception is about the pointer being on the glyphs, not about the mode:
27090
29018
  // an offset label's anchor is out from under its text, and taking the handle
27091
29019
  // away there left it with no anchor handle at all -- dragging the symbol of a
27092
29020
  // label positioned ne slid its text instead of moving the label.
29021
+ //
29022
+ // Only the anchor: the width and callout handles sit on the edge of the box
29023
+ // and would otherwise never be grabbable in Draggable mode.
27093
29024
  function glyphsOutrankHandle(id) {
27094
- return hoverHandle.id == id && labelTextIsDraggable(gui) &&
29025
+ return !hoverHandle.kind && hoverHandle.id == id &&
29026
+ textIsDraggable(hit.getHitTarget(), id) &&
27095
29027
  !isPathLabel(hit.getHitTarget(), id) && pointerIsOverText(id);
27096
29028
  }
27097
29029
 
@@ -27134,14 +29066,24 @@
27134
29066
  }
27135
29067
 
27136
29068
  // The handle under the pointer, or null. Only a selected label's handles are
27137
- // grabbable -- see gui-label-knots.mjs.
29069
+ // grabbable -- see gui-label-knots.mjs. A knot and a label handle within
29070
+ // reach of the pointer go to whichever is nearer: a callout's gap handle can
29071
+ // sit close to the anchor.
27138
29072
  function findHandle(target, e) {
27139
29073
  var shapes = target && getDisplayShapes(target);
27140
29074
  var ids = hit.getSelectionIds();
27141
29075
  var p = pixToMapCoords(e.x, e.y);
27142
- var handle;
29076
+ var handle, labelHandle, knotPix;
27143
29077
  if (!shapes || ids.length === 0) return null;
27144
29078
  handle = findNearestKnot(shapes, ids, p, scaleThreshold(KNOT_HIT_THRESHOLD));
29079
+ labelHandle = findLabelHandle(target, e);
29080
+ if (handle && labelHandle) {
29081
+ knotPix = ext.translateCoords(handle.point[0], handle.point[1]);
29082
+ if (Math.hypot(knotPix[0] - e.x, knotPix[1] - e.y) < labelHandle.dist) {
29083
+ labelHandle = null;
29084
+ }
29085
+ }
29086
+ if (labelHandle) return labelHandle;
27145
29087
  if (handle) handle.pointer = p;
27146
29088
  return handle;
27147
29089
  }
@@ -27183,6 +29125,10 @@
27183
29125
  }
27184
29126
  return;
27185
29127
  }
29128
+ if (hoverHandle && hoverHandle.kind && !editor.isOpen()) {
29129
+ resetHandle(hoverHandle);
29130
+ return;
29131
+ }
27186
29132
  // Double-clicking a label reaches straight into its text, which is the
27187
29133
  // gesture most editors use and saves selecting it first. The single-click
27188
29134
  // route got there already if the label was the selection; this covers the
@@ -27295,8 +29241,12 @@
27295
29241
  // So the click opens a *pending* session, which draws the label and holds its
27296
29242
  // text, and the label becomes a feature only when the session ends with
27297
29243
  // something in it. See LabelEditor.openPending().
27298
- function beginLabel(displayCoords) {
29244
+ //
29245
+ // getStyle: (optional) the new label's style, when it is not simply the
29246
+ // panel's -- a text block's adds a width
29247
+ function beginLabel(displayCoords, getStyle) {
27299
29248
  var target = hit.getHitTarget();
29249
+ var styleFn = getStyle || getStyleForNewLabel;
27300
29250
  if (!target) return;
27301
29251
  hideInstructions();
27302
29252
  editor.openPending(target, {
@@ -27305,15 +29255,15 @@
27305
29255
  // or a position while the caret is sitting there is visible immediately.
27306
29256
  // The same style the label will be created with, down to the font it is
27307
29257
  // named in, so that committing it changes nothing on screen.
27308
- getStyle: getStyleForNewLabel,
27309
- create: function(text) { createLabel(displayCoords, text); }
29258
+ getStyle: styleFn,
29259
+ create: function(text) { createLabel(displayCoords, text, styleFn()); }
27310
29260
  });
27311
29261
  }
27312
29262
 
27313
29263
  // Creates the label being typed into. One command carries its geometry, its
27314
29264
  // style and its text, so a new label is a single entry in the session history
27315
29265
  // and a single step to undo.
27316
- function createLabel(displayCoords, text) {
29266
+ function createLabel(displayCoords, text, style) {
27317
29267
  var target = hit.getHitTarget();
27318
29268
  var coords = displayCoords.map(function(p) {
27319
29269
  return target ? translateDisplayPoint(target, p) : p;
@@ -27323,7 +29273,7 @@
27323
29273
  text: text,
27324
29274
  // whatever the style panel was set to while nothing was selected, so that
27325
29275
  // a font can be chosen before the first label exists
27326
- style: getStyleForNewLabel()
29276
+ style: style
27327
29277
  }), {title: 'Add label'});
27328
29278
  }
27329
29279
 
@@ -27390,7 +29340,7 @@
27390
29340
  },
27391
29341
  referenceStyle = { // outline style for reference layers
27392
29342
  type: 'outline',
27393
- strokeColors: [null, '#78c110'], // upped saturation from #86c927
29343
+ strokeColors: [null, '#87b73b'], // was 78c110
27394
29344
  strokeWidth: 0.85,
27395
29345
  dotColor: "#73ba20",
27396
29346
  dotSize: 1
@@ -29669,50 +31619,73 @@
29669
31619
  gui.on('interaction_mode_change', function(e) {
29670
31620
  if (e.mode === 'rectangles') {
29671
31621
  gui.enterMode('rectangle_tool');
29672
- } else if (_on) {
29673
- turnOff();
31622
+ } else if (gui.getMode() == 'rectangle_tool') {
31623
+ // Leave the gui mode rather than just calling turnOff(): a mode left
31624
+ // behind makes the next enterMode('rectangle_tool') a no-op, so the tool
31625
+ // would never turn back on.
31626
+ gui.clearMode();
29674
31627
  }
29675
31628
  });
29676
31629
 
29677
31630
  hit.on('change', function(e) {
29678
31631
  if (!_on) return;
29679
31632
  // TODO: handle multiple hits (see gui-inspection-control)
29680
- var id = e.id;
29681
31633
  if (e.id > -1 && e.pinned) {
29682
- var target = hit.getHitTarget();
29683
- var path = target.shapes[e.id][0];
29684
- var bbox = target.gui.displayArcs.getSimpleShapeBounds(path).toArray();
29685
- box.setDataCoords(bbox);
29686
- dragInfo = {
29687
- id: e.id,
29688
- target: target,
29689
- ids: [],
29690
- points: []
29691
- };
29692
- var iter = target.gui.displayArcs.getShapeIter(path);
29693
- while (iter.hasNext()) {
29694
- dragInfo.points.push([iter.x, iter.y]);
29695
- dragInfo.ids.push(iter._arc.i);
31634
+ if (!dragInfo || dragInfo.id != e.id) {
31635
+ selectRectangle(e.id);
29696
31636
  }
29697
- gui.container.findChild('.map-layers').classed('dragging', true);
29698
-
29699
- } else if (dragInfo) {
29700
- gui.dispatchEvent('rectangle_dragend', dragInfo); // save undo state
29701
- gui.container.findChild('.map-layers').classed('dragging', false);
29702
- reset();
29703
31637
  } else {
29704
- box.hide();
31638
+ reset();
29705
31639
  }
29706
-
29707
31640
  });
29708
31641
 
29709
31642
  box.on('handle_drag', function(e) {
29710
31643
  if (!_on || !dragInfo) return;
29711
31644
  var coords = internal.bboxToCoords(box.getDataCoords());
29712
31645
  setRectangleCoords(dragInfo.target, dragInfo.ids, coords);
31646
+ dragInfo.moved = true;
29713
31647
  gui.dispatchEvent('map-needs-refresh');
29714
31648
  });
29715
31649
 
31650
+ // Each handle drag is one undo step, saved when it ends -- waiting until the
31651
+ // rectangle is deselected would lose the edit if the mode changes first.
31652
+ box.on('handle_up', function() {
31653
+ if (!_on || !dragInfo || !dragInfo.moved) return;
31654
+ gui.dispatchEvent('rectangle_dragend', {
31655
+ target: dragInfo.target,
31656
+ ids: dragInfo.ids,
31657
+ points: dragInfo.points
31658
+ });
31659
+ dragInfo.points = getDataPoints(dragInfo.target, dragInfo.ids);
31660
+ dragInfo.moved = false;
31661
+ });
31662
+
31663
+ function selectRectangle(id) {
31664
+ var target = hit.getHitTarget();
31665
+ var path = target.shapes[id][0];
31666
+ var bbox = target.gui.displayArcs.getSimpleShapeBounds(path).toArray();
31667
+ var ids = [];
31668
+ var iter = target.gui.displayArcs.getShapeIter(path);
31669
+ while (iter.hasNext()) {
31670
+ ids.push(iter._arc.i);
31671
+ }
31672
+ box.setDataCoords(bbox);
31673
+ dragInfo = {
31674
+ id: id,
31675
+ target: target,
31676
+ ids: ids,
31677
+ points: getDataPoints(target, ids), // for undo
31678
+ moved: false
31679
+ };
31680
+ gui.container.findChild('.map-layers').classed('dragging', true);
31681
+ }
31682
+
31683
+ function getDataPoints(target, ids) {
31684
+ return ids.map(function(id) {
31685
+ return getVertexCoords(target, id);
31686
+ });
31687
+ }
31688
+
29716
31689
  function turnOn() {
29717
31690
  box.turnOn();
29718
31691
  _on = true;
@@ -29730,6 +31703,9 @@
29730
31703
 
29731
31704
  function reset() {
29732
31705
  box.hide();
31706
+ if (dragInfo) {
31707
+ gui.container.findChild('.map-layers').classed('dragging', false);
31708
+ }
29733
31709
  dragInfo = null;
29734
31710
  }
29735
31711
  }
@@ -30612,6 +32588,9 @@
30612
32588
  // getContentLayerBounds() instead. Recorded because the difference matters
30613
32589
  // when deciding whether an update should reset the view.
30614
32590
  var _boundsArePlaceholder = false;
32591
+ // Whether the content's bounds had no width or no height before they were
32592
+ // padded in calcFullBounds() -- see mapNeedsResetFromCollapsedBounds().
32593
+ var _boundsAreCollapsed = false;
30615
32594
 
30616
32595
  var RESIZE_REDRAW_DELAY = 200;
30617
32596
 
@@ -30878,6 +32857,7 @@
30878
32857
  // read before calcFullBounds() below, which describes the map as it is
30879
32858
  // after this update
30880
32859
  var prevBoundsWerePlaceholder = _boundsArePlaceholder;
32860
+ var prevBoundsWereCollapsed = _boundsAreCollapsed;
30881
32861
  var fullBounds;
30882
32862
  var needReset;
30883
32863
 
@@ -30960,6 +32940,8 @@
30960
32940
  // screen, so the view they chose it in is the one to keep, unless what
30961
32941
  // arrived is not in it.
30962
32942
  needReset = !fullBounds.intersects(_ext.getBounds());
32943
+ } else if (prevBoundsWereCollapsed) {
32944
+ needReset = mapNeedsResetFromCollapsedBounds(fullBounds, _ext.getBounds(), e.flags);
30963
32945
  } else {
30964
32946
  needReset = mapNeedsReset(fullBounds, _ext.getFullBounds(), _ext.getBounds(), e.flags);
30965
32947
  }
@@ -31040,7 +33022,8 @@
31040
33022
  b.scale(1 + marginPct / 100 * 2);
31041
33023
 
31042
33024
  // Inflate display bounding box of single-point layers and collapsed shapes a bit
31043
- if (b.width() === 0 || b.height() === 0) {
33025
+ _boundsAreCollapsed = b.width() === 0 || b.height() === 0;
33026
+ if (_boundsAreCollapsed) {
31044
33027
  b.padBounds(1e-4, 1e-4, 1e-4, 1e-4);
31045
33028
  }
31046
33029
  return b;
@@ -31786,22 +33769,20 @@
31786
33769
 
31787
33770
  // Fitting obeys the same mode as a handle drag: holding the scale grows the
31788
33771
  // output to cover the new extent, holding the output rescales onto it. The
31789
- // command works out both the padding and the held-scale width, so the
31790
- // margin's unit handling lives in one place.
33772
+ // command works out the extent, which has to leave room for symbols and
33773
+ // labels at the scale it ends up at, along with the padding and the
33774
+ // held-scale width, so the margin's unit handling lives in one place.
31791
33775
  function fitVisibleLayers() {
31792
33776
  var target = getFrameTarget();
31793
33777
  var entries = getCompositionEntries();
31794
33778
  var margin = getMargin();
31795
- var parts, bounds;
33779
+ var parts;
31796
33780
  if (!target || !entries.length) return;
31797
- bounds = entries.reduce(function(memo, o) {
31798
- return memo.mergeBounds(
31799
- internal.getLayerBounds(o.layer, o.dataset.arcs)
31800
- );
31801
- }, new internal.Bounds());
31802
33781
  parts = [
31803
33782
  '-update-frame',
31804
- 'bbox=' + quoteCommandValue(bounds.toArray().join(','))
33783
+ 'fit=' + internal.formatOptionValue(entries.map(function(o) {
33784
+ return internal.getLayerTargetId(gui.model, o.layer);
33785
+ }).join(','))
31805
33786
  ];
31806
33787
  if (margin) parts.push('offset=' + quoteCommandValue(margin));
31807
33788
  if (!lockSize) parts.push('fix-scale');
@@ -32148,9 +34129,9 @@
32148
34129
  revert: updateControls
32149
34130
  });
32150
34131
  // The width belongs to the same property as the colour and opacity beside
32151
- // it, and all three fit on the row. The background row leaves the column
32152
- // empty rather than putting the width on a line of its own.
32153
- neatlineWidthInput = makeRowField(neatlineControl.row, 'Width')
34132
+ // it. The background row leaves the column empty rather than putting the
34133
+ // width on a line of its own.
34134
+ neatlineWidthInput = makeCellField(neatlineControl.aside, 'Width')
32154
34135
  .addClass('frame-neatline-width')
32155
34136
  .on('change', updateNeatlineWidth);
32156
34137
  var clearRow = El('div')
@@ -32175,9 +34156,9 @@
32175
34156
  unitsSelect.node().value = units;
32176
34157
  aspectValue.text(getAspectText(frame));
32177
34158
  backgroundControl.showColor(rec.fill || '');
32178
- backgroundControl.opacity.node().value = formatOpacity(rec['fill-opacity']);
34159
+ backgroundControl.opacity.node().value = formatOpacity(rec['fill-opacity'], rec.fill);
32179
34160
  neatlineControl.showColor(rec.stroke || '');
32180
- neatlineControl.opacity.node().value = formatOpacity(rec['stroke-opacity']);
34161
+ neatlineControl.opacity.node().value = formatOpacity(rec['stroke-opacity'], rec.stroke);
32181
34162
  neatlineWidthInput.node().value =
32182
34163
  rec['stroke-width'] === undefined ? '' : rec['stroke-width'];
32183
34164
  boundsValue.text(frame.bbox.map(formatCoordinate).join(', '));
@@ -32265,8 +34246,7 @@
32265
34246
  }
32266
34247
 
32267
34248
  // An extra captioned cell on an existing colour row.
32268
- function makeRowField(row, label) {
32269
- var cell = El('div').addClass('label-split-cell').appendTo(row);
34249
+ function makeCellField(cell, label) {
32270
34250
  El('span').appendTo(cell).text(label);
32271
34251
  return makeTextInput(cell);
32272
34252
  }
@@ -32304,9 +34284,13 @@
32304
34284
  return String(Number(value.toPrecision(12)));
32305
34285
  }
32306
34286
 
32307
- function formatOpacity(value) {
32308
- return value === undefined ? '' :
32309
- String(Math.round(Number(value) * 100)) + '%';
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)) + '%';
32310
34294
  }
32311
34295
 
32312
34296
  // A fixed ratio holds when the frame is rescaled; one taken from the extent
@@ -33324,7 +35308,8 @@
33324
35308
  }
33325
35309
 
33326
35310
  function clampSidebarPanelsSeparatorPosition(pageY) {
33327
- var pct = 100 * (pageY - 29) / (window.innerHeight - 29);
35311
+ var rect = gui.container.findChild('.sidebar-panels').node().getBoundingClientRect();
35312
+ var pct = 100 * (pageY - rect.top) / rect.height;
33328
35313
  return Math.max(15, Math.min(85, pct));
33329
35314
  }
33330
35315
  }
@@ -33728,7 +35713,6 @@
33728
35713
  if (manifest.catalog) {
33729
35714
  opts.catalog = manifest.catalog;
33730
35715
  }
33731
- opts.display_all = vars['display-all'] || vars.a || !!manifest.display_all;
33732
35716
  opts.quick_view = vars['quick-view'] || vars.q || !!manifest.quick_view;
33733
35717
  opts.target = vars.target || manifest.target || null;
33734
35718
  opts.name = vars.name || manifest.name || null;
@@ -33756,6 +35740,7 @@
33756
35740
  new ImportControl(gui, importOpts);
33757
35741
  new ExportControl(gui);
33758
35742
  new LayerControl(gui);
35743
+ new SidebarTabs(gui);
33759
35744
  new AddLayerLinks(gui);
33760
35745
  HeaderMenu();
33761
35746
  gui.console = new Console(gui);
@@ -33818,6 +35803,24 @@
33818
35803
  El('body').addClass('map-view');
33819
35804
  gui.console.runInitialCommands(getInitialConsoleCommands());
33820
35805
  });
35806
+
35807
+ // The layer list, opened beside the first data so that the user sees what
35808
+ // was imported and where to go next -- once, and only for data: dismissing
35809
+ // the splash popup without importing anything leaves the sidebar closed.
35810
+ // Waits for any menu mode (the import dialog, while files are still loading)
35811
+ // to close, since opening a panel would close it.
35812
+ var layersShownForData = false;
35813
+ gui.model.on('update', showLayersForFirstData);
35814
+ gui.on('mode', showLayersForFirstData);
35815
+ function showLayersForFirstData() {
35816
+ var panels;
35817
+ if (layersShownForData || gui.model.isEmpty() || gui.getMode()) return;
35818
+ layersShownForData = true;
35819
+ panels = gui.getSidebarPanels();
35820
+ if (!panels.includes('layers')) {
35821
+ gui.setSidebarPanels(panels.concat('layers').sort());
35822
+ }
35823
+ }
33821
35824
  };
33822
35825
 
33823
35826
  })();