mapshaper 0.7.67 → 0.7.69

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.
@@ -3324,7 +3324,7 @@
3324
3324
  return str;
3325
3325
  }
3326
3326
 
3327
- function formatValue(val, matches) {
3327
+ function formatValue$2(val, matches) {
3328
3328
  var flags = matches[1];
3329
3329
  var padding = matches[2];
3330
3330
  var decimals = matches[3] ? parseInt(matches[3].substr(1)) : void 0;
@@ -3420,7 +3420,7 @@
3420
3420
  error$1("[format()] Data does not match format string; format:", fmt, "data:", arguments);
3421
3421
  }
3422
3422
  for (var i=0; i<n; i++) {
3423
- str += formatValue(arguments[i], formatCodes[i]) + literals[i+1];
3423
+ str += formatValue$2(arguments[i], formatCodes[i]) + literals[i+1];
3424
3424
  }
3425
3425
  return str;
3426
3426
  };
@@ -8623,10 +8623,10 @@
8623
8623
  }
8624
8624
 
8625
8625
  // Whether a layer can be shown on the map alongside the active layer, and so
8626
- // has an eye icon in the layers panel. A frame is drawn by preview mode, and a
8627
- // data-only layer has nothing to draw.
8626
+ // has an eye icon in the layers panel. A frame and its furniture are drawn by
8627
+ // preview mode, and a data-only layer has nothing to draw.
8628
8628
  function layerIsPinnable(lyr, dataset) {
8629
- if (dataset && internal.isFrameLayer(lyr, dataset.arcs)) return false;
8629
+ if (internal.isFrameComponentLayer(lyr, dataset)) return false;
8630
8630
  return internal.layerIsGeometric(lyr) || internal.layerHasRaster(lyr) ||
8631
8631
  internal.layerHasFurniture(lyr);
8632
8632
  }
@@ -9531,7 +9531,7 @@
9531
9531
 
9532
9532
  function Console(gui) {
9533
9533
  var model = gui.model;
9534
- var CURSOR = '$ ';
9534
+ var CURSOR = '> ';
9535
9535
  var PROMPT = 'Enter mapshaper commands or type "tips" for console help.';
9536
9536
  var el = gui.container.findChild('.console').hide();
9537
9537
  var content = el.findChild('.console-buffer');
@@ -9548,15 +9548,6 @@
9548
9548
  var history = [];
9549
9549
  var historyId = 0;
9550
9550
  var _isOpen = false;
9551
- var btn = gui.container.findChild('.console-btn')
9552
- .on('click', toggle)
9553
- .on('keydown', function(e) {
9554
- if (e.key == 'Enter' || e.key == ' ') {
9555
- e.preventDefault();
9556
- e.stopPropagation();
9557
- toggle();
9558
- }
9559
- });
9560
9551
  var globals = {}; // share user-defined globals (job.defs) between runs
9561
9552
  var sharedVars = {}; // share -vars / -defaults templating scope between runs
9562
9553
 
@@ -9588,10 +9579,6 @@
9588
9579
  }
9589
9580
  });
9590
9581
 
9591
- function toggle() {
9592
- gui.toggleSidebarPanel('console');
9593
- }
9594
-
9595
9582
  gui.on('sidebar', function(e) {
9596
9583
  if (e.panels.includes('console')) {
9597
9584
  turnOn();
@@ -9642,7 +9629,6 @@
9642
9629
  function turnOn() {
9643
9630
  // if (!_isOpen && !model.isEmpty()) {
9644
9631
  if (!_isOpen) {
9645
- btn.addClass('active').attr('aria-expanded', 'true');
9646
9632
  _isOpen = true;
9647
9633
  // Route logging output to the in-app console while it's open, so a
9648
9634
  // user typing CLI commands here sees the results inline -- the same
@@ -9663,7 +9649,6 @@
9663
9649
 
9664
9650
  function turnOff() {
9665
9651
  if (_isOpen) {
9666
- btn.removeClass('active').attr('aria-expanded', 'false');
9667
9652
  _isOpen = false;
9668
9653
  if (GUI.isActiveInstance(gui)) {
9669
9654
  setLoggingForGUI(gui); // reset stop, message and error functions
@@ -10237,14 +10222,14 @@
10237
10222
  }
10238
10223
 
10239
10224
  function printExamples() {
10240
- printExample("See a list of all console commands", "$ help");
10241
- printExample("Get help using a single command", "$ help innerlines");
10242
- printExample("Get information about imported datasets", "$ info");
10243
- printExample("Print bot/debug runtime context as JSON", "$ context");
10244
- printExample("Display browser session as shell commands", "$ history");
10245
- printExample("Delete one state from a national dataset","$ filter 'STATE != \"Alaska\"'");
10246
- printExample("Aggregate counties to states by dissolving shared edges" ,"$ dissolve 'STATE'");
10247
- 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");
10248
10233
  }
10249
10234
 
10250
10235
  function renderStructuredConsoleMessage(msg) {
@@ -10903,6 +10888,9 @@
10903
10888
  if (opts.format == 'svg' || opts.format == 'topojson') {
10904
10889
  opts.gui_frame = getGuiFrameContext();
10905
10890
  }
10891
+ if (opts.format == 'svg') {
10892
+ targets = addFrameFurnitureTargets(targets, opts.gui_frame);
10893
+ }
10906
10894
  try {
10907
10895
  var files = await internal.exportTargetLayers(model, targets, opts);
10908
10896
  } catch(e) {
@@ -10968,7 +10956,7 @@
10968
10956
  function initLayerMenu() {
10969
10957
  var list = menu.findChild('.export-layer-list').empty();
10970
10958
  var layers = model.getLayers().filter(function(o) {
10971
- return !internal.isFrameLayer(o.layer, o.dataset.arcs);
10959
+ return !internal.isFrameComponentLayer(o.layer, o.dataset);
10972
10960
  });
10973
10961
  sortLayersForMenuDisplay(layers);
10974
10962
 
@@ -11162,21 +11150,42 @@
11162
11150
  };
11163
11151
  }
11164
11152
 
11153
+ // Snapshots keep the frame and its furniture
11165
11154
  function addFrameTarget(targets) {
11166
11155
  var frame = internal.getActiveFrame(model);
11167
- var group;
11168
11156
  if (!frame) return targets;
11157
+ return addDatasetTargets(targets, frame.dataset,
11158
+ [frame.layer].concat(internal.getFrameFurnitureLayers(frame.dataset)));
11159
+ }
11160
+
11161
+ // The frame's furniture is drawn in SVG output, the way it is in preview.
11162
+ // An unprojected frame can't show a scalebar, so it doesn't block the export.
11163
+ function addFrameFurnitureTargets(targets, frameContext) {
11164
+ var frame = internal.getActiveFrame(model);
11165
+ var furniture = frame ? internal.getFrameFurnitureLayers(frame.dataset) : [];
11166
+ if (furniture.length === 0) return targets;
11167
+ if (internal.getFurnitureFrameProblem(frameContext && frameContext.data)) {
11168
+ console.warn('Map furniture was not exported: the map frame is unprojected');
11169
+ return targets;
11170
+ }
11171
+ return addDatasetTargets(targets, frame.dataset, furniture);
11172
+ }
11173
+
11174
+ function addDatasetTargets(targets, dataset, layers) {
11175
+ var group;
11169
11176
  targets = targets.map(function(target) {
11170
11177
  return Object.assign({}, target, {layers: target.layers.slice()});
11171
11178
  });
11172
11179
  group = targets.find(function(target) {
11173
- return target.dataset == frame.dataset;
11180
+ return target.dataset == dataset;
11174
11181
  });
11175
- if (group) {
11176
- if (!group.layers.includes(frame.layer)) group.layers.push(frame.layer);
11177
- } else {
11178
- targets.push({dataset: frame.dataset, layers: [frame.layer]});
11182
+ if (!group) {
11183
+ group = {dataset: dataset, layers: []};
11184
+ targets.push(group);
11179
11185
  }
11186
+ layers.forEach(function(lyr) {
11187
+ if (!group.layers.includes(lyr)) group.layers.push(lyr);
11188
+ });
11180
11189
  return targets;
11181
11190
  }
11182
11191
 
@@ -11219,6 +11228,92 @@
11219
11228
  };
11220
11229
  }
11221
11230
 
11231
+ // Short descriptions of what a layer holds, for the contents column of the
11232
+ // layer panel: "6 polygons", "1 data record", "12 labels".
11233
+ //
11234
+ // A point layer is described by how its features are drawn, in the terms of
11235
+ // the point style panel: labels, symbols, circles and plain points. A layer
11236
+ // that mixes them lists each kind it has, e.g. "12 labels, 30 points".
11237
+ //
11238
+ // Features with null geometry are left out of the counts, which say what is on
11239
+ // the map. The column is too narrow to list them as well, so they go in a
11240
+ // tooltip with the full description.
11241
+
11242
+ var POINT_KINDS = ['label', 'symbol', 'circle', 'point'];
11243
+
11244
+ // How a point feature is drawn, given its data record.
11245
+ // A label wins over any symbol or circle drawn with it.
11246
+ function getPointKind(d) {
11247
+ if (!d) return 'point';
11248
+ if ('label-text' in d) return 'label';
11249
+ if (d['svg-symbol'] || d.icon) return 'symbol';
11250
+ if (d.r > 0) return 'circle';
11251
+ return 'point';
11252
+ }
11253
+
11254
+ // The kind of feature a point layer is set up for, from its field names:
11255
+ // names the features of an empty layer, e.g. "0 labels".
11256
+ function getPointLayerKind(fields) {
11257
+ if (fields.includes('label-text')) return 'label';
11258
+ if (fields.includes('svg-symbol') || fields.includes('icon')) return 'symbol';
11259
+ if (fields.includes('r')) return 'circle';
11260
+ return 'point';
11261
+ }
11262
+
11263
+ // Same test as the -info command's null shape count
11264
+ function isNullShape(shp) {
11265
+ return !shp || shp.length === 0;
11266
+ }
11267
+
11268
+ // @geometryType: 'point', 'polygon' or 'polyline'
11269
+ // @shapes: the layer's shapes
11270
+ // @records: data records, or null for a layer without attribute data
11271
+ // @fields: names of the layer's data fields
11272
+ // Returns {text, nulls, title}; title is the tooltip, or null if there are no nulls.
11273
+ function describeFeatureContents(geometryType, shapes, records, fields) {
11274
+ var nulls = shapes.length - countNonNull(shapes);
11275
+ var text = geometryType == 'point' ?
11276
+ describePoints(shapes, records, fields || []) :
11277
+ formatCount(shapes.length - nulls, geometryType);
11278
+ return {
11279
+ text: text,
11280
+ nulls: nulls,
11281
+ title: nulls > 0 ? text + ', ' + formatInteger(nulls) + ' without geometry' : null
11282
+ };
11283
+ }
11284
+
11285
+ function describePoints(shapes, records, fields) {
11286
+ var counts = {label: 0, symbol: 0, circle: 0, point: 0};
11287
+ var parts, i;
11288
+ for (i=0; i<shapes.length; i++) {
11289
+ if (isNullShape(shapes[i])) continue;
11290
+ counts[getPointKind(records ? records[i] : null)]++;
11291
+ }
11292
+ parts = POINT_KINDS.filter(function(kind) {
11293
+ return counts[kind] > 0;
11294
+ }).map(function(kind) {
11295
+ return formatCount(counts[kind], kind);
11296
+ });
11297
+ return parts.length > 0 ? parts.join(', ') :
11298
+ formatCount(0, getPointLayerKind(fields));
11299
+ }
11300
+
11301
+ function countNonNull(shapes) {
11302
+ var n = 0;
11303
+ for (var i=0; i<shapes.length; i++) {
11304
+ if (!isNullShape(shapes[i])) n++;
11305
+ }
11306
+ return n;
11307
+ }
11308
+
11309
+ function formatCount(n, noun) {
11310
+ return formatInteger(n) + ' ' + noun + (n == 1 ? '' : 's');
11311
+ }
11312
+
11313
+ function formatInteger(n) {
11314
+ return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
11315
+ }
11316
+
11222
11317
  var openMenu$1;
11223
11318
  var openMenuId;
11224
11319
  // One menu element per parent element, created on demand.
@@ -11385,6 +11480,9 @@
11385
11480
  if (e.frameProperties) {
11386
11481
  addMenuItem('frame properties', e.frameProperties, '');
11387
11482
  }
11483
+ if (e.scalebarProperties) {
11484
+ addMenuItem(e.scalebarLabel || 'scale bar', e.scalebarProperties, '');
11485
+ }
11388
11486
  if (e.resizeFrame) {
11389
11487
  addMenuItem('resize frame', e.resizeFrame, '');
11390
11488
  }
@@ -11612,7 +11710,6 @@
11612
11710
  var el = gui.container.findChild(".layer-control").hide();
11613
11711
  var btn = gui.container.findChild('.layer-control-btn');
11614
11712
  var headerBtn = btn.findChild('.active-layer-label');
11615
- var tab = gui.container.findChild('.layer-tab');
11616
11713
  var isOpen = false;
11617
11714
  var cache = new DomCache();
11618
11715
  var pinAll = el.findChild('.pin-all'); // button for toggling layer visibility
@@ -11647,17 +11744,6 @@
11647
11744
  gui.toggleSidebarPanel('console');
11648
11745
  }
11649
11746
  });
11650
- tab.on('click', toggle).on('keydown', function(e) {
11651
- if (e.key == 'Enter') {
11652
- e.preventDefault();
11653
- e.stopPropagation();
11654
- toggle();
11655
- } else if (e.key == ' ') {
11656
- e.preventDefault();
11657
- e.stopPropagation();
11658
- gui.toggleSidebarPanel('console');
11659
- }
11660
- });
11661
11747
 
11662
11748
  function toggle() {
11663
11749
  gui.toggleSidebarPanel('layers');
@@ -11763,7 +11849,6 @@
11763
11849
  function turnOn() {
11764
11850
  if (isOpen) return;
11765
11851
  isOpen = true;
11766
- tab.addClass('active').attr('aria-expanded', 'true');
11767
11852
  render();
11768
11853
  el.addClass('open');
11769
11854
  el.show();
@@ -11773,7 +11858,6 @@
11773
11858
  if (!isOpen) return;
11774
11859
  stopDragging();
11775
11860
  isOpen = false;
11776
- tab.removeClass('active').attr('aria-expanded', 'false');
11777
11861
  el.removeClass('open');
11778
11862
  el.hide();
11779
11863
  }
@@ -11826,6 +11910,8 @@
11826
11910
  sortLayersForMenuDisplay(model.getLayers()).forEach(function(o) {
11827
11911
  var lyr = o.layer;
11828
11912
  var isFrame = internal.isFrameLayer(lyr, o.dataset.arcs);
11913
+ // furniture is shown as part of its frame's entry
11914
+ if (!isFrame && internal.isFrameComponentLayer(lyr, o.dataset)) return;
11829
11915
  var opts = {
11830
11916
  show_source: layerCount < 5,
11831
11917
  pinnable: pinnableCount > 0 && isPinnable(lyr, o.dataset)
@@ -11854,8 +11940,7 @@
11854
11940
 
11855
11941
  function renderLayer(lyr, dataset, opts) {
11856
11942
  var classes = 'layer-item';
11857
- var isFrame = internal.isFrameLayer(lyr, dataset.arcs);
11858
- var entry, html;
11943
+ var html;
11859
11944
 
11860
11945
  if (opts.pinnable) classes += ' pinnable';
11861
11946
  if (map.isActiveLayer(lyr)) classes += ' active';
@@ -11863,8 +11948,9 @@
11863
11948
  if (lyr.pinned) classes += ' pinned';
11864
11949
 
11865
11950
  html = '<!-- ' + lyr.menu_id + '--><div class="' + classes + '">';
11866
- html += rowHTML('name', '<span class="layer-name colored-text dot-underline">' + formatLayerNameForDisplay(lyr.name) + '</span>', 'row1');
11867
- html += rowHTML(isFrame ? 'size' : 'contents', describeLyr(lyr, dataset));
11951
+ html += '<div class="layer-row"><div class="layer-name-col">' +
11952
+ '<span class="layer-name colored-text dot-underline">' + formatLayerNameForDisplay(lyr.name) + '</span></div>' +
11953
+ renderContents(lyr, dataset) + '</div>';
11868
11954
  html += '<span class="more-btn layer-btn" role="button" aria-label="More layer options"></span>';
11869
11955
  if (opts.pinnable) {
11870
11956
  html += '<img class="eye-btn black-eye layer-btn" draggable="false" src="images/eye.png">';
@@ -11925,25 +12011,39 @@
11925
12011
  function deleteLayer() {
11926
12012
  var target = findLayerById(id);
11927
12013
  var undoTransaction;
12014
+ var furniture;
11928
12015
  if (!target) return;
12016
+ // a frame's furniture goes with it, rather than becoming a stray layer
12017
+ furniture = internal.isFrameLayer(target.layer, target.dataset.arcs) ?
12018
+ internal.getFrameFurnitureLayers(target.dataset) : [];
11929
12019
  undoTransaction = createUndoTransaction(gui, 'delete layer');
11930
12020
  if (map.isVisibleLayer(target.layer)) {
11931
12021
  // TODO: check for double map refresh after model.deleteLayer() below
11932
12022
  setLayerPinning(target.layer, false);
11933
12023
  }
11934
12024
  if (undoTransaction) {
11935
- undoTransaction.run(function() {
11936
- model.deleteLayer(target.layer, target.dataset);
11937
- });
12025
+ undoTransaction.run(deleteLayers);
11938
12026
  addUndoTransactionToHistory(gui, undoTransaction, {
11939
12027
  flags: {select: true, arc_count: true},
11940
12028
  entryPrefix: 'delete-layer'
11941
12029
  });
11942
12030
  } else {
12031
+ deleteLayers();
12032
+ }
12033
+
12034
+ function deleteLayers() {
12035
+ furniture.forEach(function(lyr) {
12036
+ model.deleteLayer(lyr, target.dataset);
12037
+ });
11943
12038
  model.deleteLayer(target.layer, target.dataset);
11944
12039
  }
11945
12040
  }
11946
12041
 
12042
+ function openScalebarProperties() {
12043
+ var target = findLayerById(id);
12044
+ if (target && gui.scalebarProperties) gui.scalebarProperties.open(target);
12045
+ }
12046
+
11947
12047
  // Duplicate a layer by running an equivalent command, so the copy is
11948
12048
  // recorded in session history and can be undone. Uses '-filter true +'
11949
12049
  // to append a copy without replacing the source, keeps the same name
@@ -12031,6 +12131,10 @@
12031
12131
  internal.isFrameLayer(target.layer, target.dataset.arcs);
12032
12132
  if (isFrame) {
12033
12133
  menuEvent.frameProperties = openFrameProperties;
12134
+ menuEvent.scalebarProperties = openScalebarProperties;
12135
+ menuEvent.scalebarLabel =
12136
+ internal.findFrameFurnitureLayer(target.dataset, 'scalebar') ?
12137
+ 'scale bar' : 'add scale bar';
12034
12138
  menuEvent.resizeFrame = resizeFrame;
12035
12139
  menuEvent.deleteFrame = deleteLayer;
12036
12140
  } else {
@@ -12114,29 +12218,34 @@
12114
12218
 
12115
12219
  }
12116
12220
 
12221
+ // Returns {text, title}; title is a tooltip, or null
12117
12222
  function describeLyr(lyr, dataset) {
12118
- var n = internal.getFeatureCount(lyr),
12119
- isFrame = internal.isFrameLayer(lyr, dataset.arcs),
12120
- str, type;
12121
- if (lyr.data && !lyr.shapes) {
12122
- type = 'data record';
12123
- } else if (lyr.geometry_type) {
12124
- type = lyr.geometry_type + ' feature';
12125
- } else if (internal.layerHasRaster(lyr)) {
12126
- type = 'raster layer';
12127
- }
12128
- if (isFrame) {
12129
- str = internal.formatFrameSizeForDisplay(
12223
+ if (internal.isFrameLayer(lyr, dataset.arcs)) {
12224
+ return {text: internal.formatFrameSizeForDisplay(
12130
12225
  internal.getFrameLayerData(lyr, dataset.arcs)
12131
- );
12132
- } else if (internal.layerHasRaster(lyr)) {
12133
- str = utils$1.format('%,d x %,d %s', internal.getRasterWidth(lyr.raster), internal.getRasterHeight(lyr.raster), type);
12134
- } else if (type) {
12135
- str = utils$1.format('%,d %s%s', n, type, utils$1.pluralSuffix(n));
12136
- } else {
12137
- str = "[empty]";
12226
+ ), title: null};
12138
12227
  }
12139
- return str;
12228
+ if (internal.layerHasRaster(lyr)) {
12229
+ return {text: utils$1.format('%,d x %,d raster', internal.getRasterWidth(lyr.raster),
12230
+ internal.getRasterHeight(lyr.raster)), title: null};
12231
+ }
12232
+ if (lyr.data && !lyr.shapes) {
12233
+ return {text: formatCount(internal.getFeatureCount(lyr), 'data record'), title: null};
12234
+ }
12235
+ if (lyr.geometry_type) {
12236
+ return describeFeatureContents(lyr.geometry_type, lyr.shapes,
12237
+ lyr.data ? lyr.data.getRecords() : null, lyr.data ? lyr.data.getFields() : []);
12238
+ }
12239
+ return {text: '[empty]', title: null};
12240
+ }
12241
+
12242
+ function renderContents(lyr, dataset) {
12243
+ var o = describeLyr(lyr, dataset);
12244
+ if (!o.title) {
12245
+ return '<div class="layer-contents">' + o.text + '</div>';
12246
+ }
12247
+ return '<div class="layer-contents has-nulls"><span title="' + o.title + '">' +
12248
+ o.text + '</span></div>';
12140
12249
  }
12141
12250
 
12142
12251
  function renderLayerInfo(info) {
@@ -12265,9 +12374,48 @@
12265
12374
  return !!(lyr && (lyr.geometry_type == 'point' || lyr.geometry_type == 'polyline' || lyr.geometry_type == 'polygon'));
12266
12375
  }
12267
12376
 
12268
- function rowHTML(c1, c2, cname) {
12269
- return utils$1.format('<div class="row%s"><div class="col1">%s</div>' +
12270
- '<div class="col2">%s</div></div>', cname ? ' ' + cname : '', c1, c2);
12377
+ }
12378
+
12379
+ // The buttons that open and close the sidebar panels: an icon strip on the
12380
+ // map while the sidebar is closed, and a strip of toggles at the top of the
12381
+ // sidebar while it is open. Each button toggles its own panel, so both panels
12382
+ // can be open at once.
12383
+ function SidebarTabs(gui) {
12384
+ var buttons = gui.container.findChildren('.sidebar-tab, .sidebar-strip-btn');
12385
+ var hideBtn = gui.container.findChild('.sidebar-hide-btn');
12386
+
12387
+ buttons.forEach(function(btn) {
12388
+ var panel = btn.attr('data-panel');
12389
+ btn.on('click', function() {
12390
+ gui.toggleSidebarPanel(panel);
12391
+ });
12392
+ onKeyboardActivate(btn, function() {
12393
+ gui.toggleSidebarPanel(panel);
12394
+ });
12395
+ });
12396
+
12397
+ hideBtn.on('click', hideSidebar);
12398
+ onKeyboardActivate(hideBtn, hideSidebar);
12399
+
12400
+ gui.on('sidebar', function(e) {
12401
+ buttons.forEach(function(btn) {
12402
+ var open = e.panels.includes(btn.attr('data-panel'));
12403
+ btn.classed('active', open).attr('aria-expanded', String(open));
12404
+ });
12405
+ });
12406
+
12407
+ function hideSidebar() {
12408
+ gui.setSidebarPanels(null);
12409
+ }
12410
+
12411
+ function onKeyboardActivate(btn, action) {
12412
+ btn.on('keydown', function(e) {
12413
+ if (e.key == 'Enter' || e.key == ' ') {
12414
+ e.preventDefault();
12415
+ e.stopPropagation();
12416
+ action();
12417
+ }
12418
+ });
12271
12419
  }
12272
12420
  }
12273
12421
 
@@ -12400,7 +12548,9 @@
12400
12548
  if (!container.node()) return;
12401
12549
  DRAW_KINDS.forEach(function(kind, i) {
12402
12550
  if (i > 0) {
12403
- El('span').addClass('layer-menu-link-separator').html('&nbsp;·&nbsp;')
12551
+ // A breaking space after the dot, so that a sidebar too narrow for the
12552
+ // whole list wraps it between links, and never inside one.
12553
+ El('span').addClass('layer-menu-link-separator').html('&nbsp;· ')
12404
12554
  .appendTo(container);
12405
12555
  }
12406
12556
  El('span').addClass('layer-menu-link').attr('data-kind', kind.kind)
@@ -14234,25 +14384,25 @@
14234
14384
  // labelModeIsAvailable().
14235
14385
  var menus = {
14236
14386
  standard: ['info', 'selection', 'box', 'ruler'],
14237
- empty: ['edit_polygons', 'edit_lines', 'edit_points', 'box', 'ruler'],
14238
- polygons: ['info', 'selection', 'box', 'polygon_style', 'edit_polygons', 'ruler'],
14239
- rectangles: ['info', 'selection', 'box', 'polygon_style', 'rectangles', 'edit_polygons', 'ruler'],
14240
- lines: ['info', 'selection', 'box', 'line_style', 'edit_lines', 'snip_lines', 'ruler'],
14387
+ empty: [ 'label','edit_points', 'edit_lines', 'edit_polygons','box', 'ruler'],
14388
+ polygons: ['info', 'selection', 'polygon_style', 'edit_polygons', 'box', 'ruler'],
14389
+ rectangles: ['info', 'selection', 'polygon_style', 'edit_polygons', 'rectangles', 'box', 'ruler'],
14390
+ lines: ['info', 'selection', 'line_style', 'edit_lines', 'snip_lines', 'box', 'ruler'],
14241
14391
  table: ['info', 'selection'],
14242
- raster: ['ruler', 'box'],
14243
- // A label layer has no entry for styling labels, for adding and dragging
14244
- // points, or for positioning labels: the label tool does all three, along
14245
- // with creating and retyping labels, so each of them offered a subset of
14246
- // what sat next to it in the menu.
14247
- labels: ['info', 'selection', 'box', 'label', 'ruler'],
14248
- points: ['info', 'selection', 'box', 'point_style', 'edit_points', 'ruler'], // , 'add-points'
14392
+ raster: ['box', 'ruler'],
14393
+ labels: ['info', 'selection', 'label', 'box', 'ruler'],
14394
+ points: ['info', 'selection', 'edit_points', 'point_style', 'box', 'ruler'], // , 'add-points'
14249
14395
  // An empty point layer is the layer the "Draw: labels" link creates,
14250
14396
  // and a label goes into it rather than beside it (see labelWouldJoin), so
14251
14397
  // the label tool belongs in its menu as well as the point tools: it is the
14252
14398
  // way back into a labels layer that has no label in it yet.
14253
- emptyPoints: ['info', 'selection', 'box', 'point_style', 'label', 'edit_points', 'ruler']
14399
+ emptyPoints: ['info', 'selection', 'label', 'point_style', 'edit_points', 'box', 'ruler']
14254
14400
  };
14255
14401
 
14402
+ // Tools that work the same whatever the active layer is. They go below a
14403
+ // divider, so the layer-name heading visibly covers only the tools above it.
14404
+ var layerIndependentModes = ['box', 'ruler'];
14405
+
14256
14406
  var prompts = {
14257
14407
  box: 'Shift-drag to draw a box',
14258
14408
  data: 'Click-select features to edit their attributes',
@@ -14348,7 +14498,7 @@
14348
14498
  };
14349
14499
 
14350
14500
  this.modeUsesPopup = function(mode) {
14351
- return ['info', 'selection', 'data', 'box', 'edit_points', 'rectangles'].includes(mode);
14501
+ return ['info', 'selection', 'data', 'box', 'edit_points'].includes(mode);
14352
14502
  };
14353
14503
 
14354
14504
  this.modeSupportsUndo = function(mode) {
@@ -14419,27 +14569,54 @@
14419
14569
 
14420
14570
  function renderMenu() {
14421
14571
  if (!menu) return;
14422
- var modes = getAvailableModes();
14423
- menu.empty();
14424
- modes.forEach(function(mode) {
14572
+ var modes = getAvailableModes().filter(function(mode) {
14425
14573
  // don't show "turn off" link if not currently editing
14426
- if (_editMode == 'off' && mode == 'off') return;
14427
- var link = El('div').addClass('nav-menu-item').attr('data-name', mode).text(getModeLabel(mode)).appendTo(menu);
14428
- link.on('click', function(e) {
14429
- if (_editMode == mode) {
14430
- // closeMenu();
14431
- setMode('off');
14432
- } else if (_editMode != mode) {
14433
- setMode(mode);
14434
- if (mode == 'off') closeMenu(120); // only close if turning off
14435
- // closeMenu(mode == 'off' ? 120 : 400); // close after selecting
14436
- }
14437
- e.stopPropagation();
14438
- });
14574
+ return !(_editMode == 'off' && mode == 'off');
14575
+ });
14576
+ var layerModes = modes.filter(function(mode) {
14577
+ return !layerIndependentModes.includes(mode);
14578
+ });
14579
+ var otherModes = modes.filter(function(mode) {
14580
+ return layerIndependentModes.includes(mode);
14439
14581
  });
14582
+ var lyr = gui.model.getActiveLayer()?.layer;
14583
+ menu.empty();
14584
+ if (lyr && layerModes.length > 0) {
14585
+ renderLayerHeading(lyr);
14586
+ }
14587
+ layerModes.forEach(renderMenuItem);
14588
+ if (layerModes.length > 0 && otherModes.length > 0) {
14589
+ El('div').addClass('nav-menu-divider').appendTo(menu);
14590
+ }
14591
+ otherModes.forEach(renderMenuItem);
14440
14592
  updateSelectionHighlight();
14441
14593
  }
14442
14594
 
14595
+ // Names the layer the tools above the divider act on.
14596
+ function renderLayerHeading(lyr) {
14597
+ var name = formatLayerNameForDisplay(lyr.name);
14598
+ var heading = El('div').addClass('nav-menu-heading').attr('title', name).text(name).appendTo(menu);
14599
+ // A click on the heading would reach the button, which picks a tool.
14600
+ heading.on('click', function(e) {
14601
+ e.stopPropagation();
14602
+ });
14603
+ }
14604
+
14605
+ function renderMenuItem(mode) {
14606
+ var link = El('div').addClass('nav-menu-item').attr('data-name', mode).text(getModeLabel(mode)).appendTo(menu);
14607
+ link.on('click', function(e) {
14608
+ if (_editMode == mode) {
14609
+ // closeMenu();
14610
+ setMode('off');
14611
+ } else if (_editMode != mode) {
14612
+ setMode(mode);
14613
+ if (mode == 'off') closeMenu(120); // only close if turning off
14614
+ // closeMenu(mode == 'off' ? 120 : 400); // close after selecting
14615
+ }
14616
+ e.stopPropagation();
14617
+ });
14618
+ }
14619
+
14443
14620
  function getModeLabel(mode) {
14444
14621
  return labels[mode];
14445
14622
  }
@@ -16482,20 +16659,43 @@
16482
16659
 
16483
16660
  // The fraction an opacity control's contents mean ("50%", " 50 " -> 0.5), or
16484
16661
  // null if it is not holding a number. Out-of-range values are clamped rather
16485
- // than refused: a pasted 150% is an intent, not a mistake.
16662
+ // than refused: a pasted 150% is an intent, not a mistake. A blank field is
16663
+ // null, not zero: it is what a colour with no value shows beside it.
16486
16664
  function parseOpacityValue(str) {
16487
- var pct = Number(String(str).replace('%', '').trim());
16488
- if (!isFinite(pct)) return null;
16665
+ var txt = String(str).replace('%', '').trim();
16666
+ var pct = Number(txt);
16667
+ if (txt === '' || !isFinite(pct)) return null;
16489
16668
  return Math.max(0, Math.min(100, pct)) / 100;
16490
16669
  }
16491
16670
 
16671
+ // A typed dash pattern in the form -style stroke-dasharray= takes: lengths
16672
+ // separated by single spaces. Commas and runs of whitespace are accepted
16673
+ // because SVG accepts them, and a pasted "4, 2" means the same as "4 2".
16674
+ // Returns '' for a blank field. Does not check that the lengths are numbers.
16675
+ function normalizeDashArrayInput(str) {
16676
+ return String(str).trim().split(/[\s,]+/).filter(Boolean).join(' ');
16677
+ }
16678
+
16492
16679
  // A stored fraction as the percentage a control shows, or '' for no value --
16493
16680
  // which is how a control over a selection that does not agree shows.
16494
16681
  function formatOpacityPct(val) {
16682
+ if (isUnsetValue(val)) return '';
16495
16683
  val = Number(val);
16496
16684
  return isFinite(val) ? Math.round(Math.max(0, Math.min(1, val)) * 100) + '%' : '';
16497
16685
  }
16498
16686
 
16687
+ // The opacity shown beside a colour. A colour with no opacity of its own is
16688
+ // drawn opaque, and says so; with no colour either, there is nothing for an
16689
+ // opacity to apply to, and the field is blank like the colour.
16690
+ function formatColorOpacityPct(opacity, hasColor) {
16691
+ if (isUnsetValue(opacity)) return hasColor ? '100%' : '';
16692
+ return formatOpacityPct(opacity);
16693
+ }
16694
+
16695
+ function isUnsetValue(val) {
16696
+ return val === undefined || val === null || val === '';
16697
+ }
16698
+
16499
16699
  // The parts the style panels are built from. They were the label panel's, and
16500
16700
  // three panels drawing the same control three ways is how they came to look
16501
16701
  // like three programs: a colour was a swatch inside a field in one and a
@@ -16555,6 +16755,16 @@
16555
16755
  return btn;
16556
16756
  }
16557
16757
 
16758
+ // The "?" the rest of the app uses for field help (see .tip-button in
16759
+ // elements.css and the static ones in index.html). The bubble is white-space:
16760
+ // pre, so the line breaks in the text are the ones it gets.
16761
+ function makeFieldTip(parent, text) {
16762
+ var btn = El('div').addClass('tip-button').appendTo(parent).text('?');
16763
+ var anchor = El('div').addClass('tip-anchor').appendTo(btn);
16764
+ El('div').addClass('tip').appendTo(anchor).text(text);
16765
+ return btn;
16766
+ }
16767
+
16558
16768
  function setPanelButtonDisabled(el, disabled) {
16559
16769
  el.classed('disabled', !!disabled)
16560
16770
  .attr('aria-disabled', disabled ? 'true' : 'false');
@@ -16565,23 +16775,38 @@
16565
16775
  function makeColorField(parent, chit, input) {
16566
16776
  var box = El('div').addClass('label-color-field').appendTo(parent);
16567
16777
  chit.appendTo(box);
16568
- input.appendTo(box);
16778
+ input.addClass('label-color-input').appendTo(box);
16569
16779
  return box;
16570
16780
  }
16571
16781
 
16572
- // A colour and its opacity on one line: the swatch inside the field with the
16573
- // hex value, and the opacity in the narrow column beside it. Fill and Stroke
16574
- // are each one of these, and so are a label's text and icon colours.
16782
+ // A colour field with its opacity at the right-hand end, behind a divider:
16783
+ // opacity qualifies the colour, and one border says so where two fields side
16784
+ // by side said they were separate settings. It also leaves the narrow column
16785
+ // of the row free for a field that needs it. The opacity has no caption; its
16786
+ // percent sign and its tooltip say what it is.
16787
+ //
16788
+ // Returns the field's box; the opacity input is made by makeOpacityInput().
16789
+ function makeColorOpacityField(parent, chit, input, opacityOpts) {
16790
+ var box = makeColorField(parent, chit, input).addClass('label-color-opacity-field');
16791
+ var opacity = makeOpacityInput(box, opacityOpts);
16792
+ return {box: box, opacity: opacity};
16793
+ }
16794
+
16795
+ // A colour and its opacity in one field, captioned, in the wide column of a
16796
+ // split row. Fill and Stroke are each one of these.
16575
16797
  //
16576
- // opts.label the caption over the colour field
16798
+ // opts.label the caption over the field
16577
16799
  // opts.onColor(hex) a colour was typed, picked or previewed to a finish
16578
16800
  // opts.onOpacity(frac) a usable percentage was typed
16579
16801
  // opts.revert() one that was not, so put the row back as it was
16802
+ //
16803
+ // control.aside is the row's narrow column, empty, for a field that belongs
16804
+ // beside the colour -- a stroke's width, say.
16580
16805
  function makeColorRow(parent, opts) {
16581
16806
  var row = El('div').addClass('label-style-row label-split-row').appendTo(parent);
16582
16807
  var colorCell = El('div').addClass('label-split-cell label-color-row').appendTo(row);
16583
- var opacityCell = El('div').addClass('label-split-cell').appendTo(row);
16584
- var control = {row: row, chit: null, input: null, opacity: null, picker: null};
16808
+ var aside = El('div').addClass('label-split-cell').appendTo(row);
16809
+ var control = {row: row, aside: aside, chit: null, input: null, opacity: null, picker: null};
16585
16810
 
16586
16811
  control.setColor = function(color) {
16587
16812
  control.input.node().value = color || '';
@@ -16611,7 +16836,10 @@
16611
16836
  if (isHexColor(color)) control.picker.setColor(color);
16612
16837
  opts.onColor(color);
16613
16838
  });
16614
- makeColorField(colorCell, control.chit, control.input);
16839
+ control.opacity = makeColorOpacityField(colorCell, control.chit, control.input, {
16840
+ onSet: opts.onOpacity,
16841
+ revert: opts.revert
16842
+ }).opacity;
16615
16843
  control.picker = new ColorPicker(colorCell, {
16616
16844
  presetRows: layerColorPresetRows,
16617
16845
  // A drag in the picker shows on the map's own terms -- the swatch and the
@@ -16622,12 +16850,6 @@
16622
16850
  opts.onColor(hex);
16623
16851
  }
16624
16852
  });
16625
-
16626
- El('span').appendTo(opacityCell).text('Opacity');
16627
- control.opacity = makeOpacityInput(opacityCell, {
16628
- onSet: opts.onOpacity,
16629
- revert: opts.revert
16630
- });
16631
16853
  return control;
16632
16854
  }
16633
16855
 
@@ -17174,11 +17396,11 @@
17174
17396
  // stroke of twice this.
17175
17397
  var defaultHaloWidth = 2;
17176
17398
  var defaultHaloColor = internal.svg.DEFAULT_HALO_COLOR;
17177
- // The line height field shows this rather than renderLabel()'s 1.1em default,
17178
- // and shows it as a placeholder rather than a value, so that a label carries
17179
- // no line-height until one is chosen. "auto" is the honest description of a
17180
- // blank field: something else decides.
17181
- var lineHeightPlaceholder = 'auto';
17399
+ // The line height a label without one is drawn with, shown as a placeholder
17400
+ // rather than a value so that a label carries no line-height until one is
17401
+ // chosen. Shown as a bare multiple, which also says what a bare number typed
17402
+ // here means: a multiple of the font size, as in CSS, not px.
17403
+ var lineHeightPlaceholder = String(internal.svg.DEFAULT_LINE_HEIGHT);
17182
17404
  // What a control shows for a selection whose labels disagree about it. One
17183
17405
  // word, used in every field and menu that can be in that state: the panel
17184
17406
  // otherwise says it by showing nothing, which is also what an unset property
@@ -17451,15 +17673,16 @@
17451
17673
  clearLink = El('span').addClass('label-editing-clear colored-text').appendTo(selectRow).text('deselect').on('click', clearSelection);
17452
17674
 
17453
17675
  // Three sections rather than one flat stack: the text, the symbol beside
17454
- // it, and where the two sit relative to each other. Text and Icon are
17455
- // deliberately the same shape -- a colour and its opacity on one line, a
17456
- // size on the line above -- so that the second reads as a variation on the
17457
- // first rather than as a different kind of control.
17676
+ // it, and where the two sit relative to each other. Every section's colour
17677
+ // row is the same shape -- the colour with its opacity in one field, and
17678
+ // the value that qualifies it most closely beside it -- so that each reads
17679
+ // as a variation on the first rather than as a different kind of control.
17458
17680
  var textSection = addSection('Text');
17459
17681
 
17460
- // The controls whose own contents say what they are -- a font name, a hex
17461
- // colour, a percentage, a size beside a font style -- carry no label. The
17462
- // ones that would be a bare number otherwise keep theirs.
17682
+ // The controls whose own contents say what they are -- a font name, a
17683
+ // size beside a font style -- carry no label. The ones that would be a bare
17684
+ // number otherwise keep theirs, and so does a colour field, because the
17685
+ // field beside it has one and "Color" says the percentage is its opacity.
17463
17686
  var fontRow = El('div').addClass('label-style-row').appendTo(textSection);
17464
17687
  fontSelect = El('select').attr('title', 'Font').appendTo(fontRow).on('change', function() {
17465
17688
  if (fontSelect.node().value) {
@@ -17485,11 +17708,20 @@
17485
17708
  onDone: releaseFocus
17486
17709
  });
17487
17710
 
17711
+ // Letter spacing beside the colour, above line height: the two spacing
17712
+ // values read as a pair in the narrow column, and the left of the row
17713
+ // below is where the alignment buttons go.
17714
+ //
17715
+ // A text block's width is not here: it is set by dragging the block's
17716
+ // handle, and a field for it would be clutter on every other label.
17488
17717
  var colorRow = El('div').addClass('label-style-row label-split-row').appendTo(textSection);
17489
- var textColorCell = El('div').addClass('label-split-cell label-color-row').appendTo(colorRow);
17718
+ var textColorCell = El('div').addClass('label-split-cell label-color-row label-text-color-row').appendTo(colorRow);
17719
+ El('span').appendTo(textColorCell).text('Color');
17490
17720
  colorChit = El('div').addClass('label-color-chit').attr('role', 'button');
17491
17721
  colorInput = El('input').attr('type', 'text').attr('title', 'Text color');
17492
- colorFieldBox = makeColorField(textColorCell, colorChit, colorInput);
17722
+ var textColorField = addColorOpacityField(textColorCell, colorChit, colorInput, applyLabelOpacity);
17723
+ colorFieldBox = textColorField.box;
17724
+ opacityInput = textColorField.opacity;
17493
17725
  colorChit.on('click', function() {
17494
17726
  if (this.classList.contains('disabled')) return;
17495
17727
  toggleColorPicker();
@@ -17505,18 +17737,7 @@
17505
17737
  });
17506
17738
  colorPicker = initColorPicker(textColorCell, colorChit, colorInput, applyLabelColor);
17507
17739
 
17508
- var opacityCell = El('div').addClass('label-split-cell label-opacity-row label-text-opacity-row').appendTo(colorRow);
17509
- opacityInput = addOpacityInput(opacityCell, applyLabelOpacity);
17510
-
17511
- // Letter spacing takes the right-hand column on its own, above line
17512
- // height: the two spacing values read as a pair there, and the left of the
17513
- // row is where the alignment buttons go.
17514
- //
17515
- // A text block's width is not here: it is set by dragging the block's
17516
- // handle, and a field for it would be clutter on every other label.
17517
- var letterRow = El('div').addClass('label-style-row label-split-row').appendTo(textSection);
17518
- El('div').addClass('label-split-cell').appendTo(letterRow);
17519
- var letterCell = El('div').addClass('label-split-cell label-spacing-row').appendTo(letterRow);
17740
+ var letterCell = El('div').addClass('label-split-cell label-spacing-row').appendTo(colorRow);
17520
17741
  El('span').appendTo(letterCell).text('Letter spacing');
17521
17742
  letterSpacingInput = makeMeasureInput(letterCell, letterSpacingField, '0');
17522
17743
 
@@ -17554,8 +17775,8 @@
17554
17775
 
17555
17776
  // A halo is a yes/no that its three values then qualify, as a symbol is, so
17556
17777
  // it is switched the same way and its controls are inert while it is off.
17557
- // Its rows are ones the Text section already has: a colour and its opacity
17558
- // on one line, and a width in the narrow column beneath, where letter
17778
+ // Its row is the shape of the Text section's colour row: a colour and its
17779
+ // opacity, and a width in the narrow column beside them, where letter
17559
17780
  // spacing sits above.
17560
17781
  haloSection = addSection('Halo');
17561
17782
  var haloTitle = haloSection.findChild('.label-style-section-title');
@@ -17566,10 +17787,13 @@
17566
17787
  });
17567
17788
 
17568
17789
  var haloColorRow = El('div').addClass('label-style-row label-split-row').appendTo(haloSection);
17569
- var haloColorCell = El('div').addClass('label-split-cell label-color-row').appendTo(haloColorRow);
17790
+ var haloColorCell = El('div').addClass('label-split-cell label-color-row label-halo-color-row').appendTo(haloColorRow);
17791
+ El('span').appendTo(haloColorCell).text('Color');
17570
17792
  haloColorChit = El('div').addClass('label-color-chit').attr('role', 'button');
17571
17793
  haloColorInput = El('input').attr('type', 'text').attr('title', 'Halo color');
17572
- haloColorFieldBox = makeColorField(haloColorCell, haloColorChit, haloColorInput);
17794
+ var haloColorField = addColorOpacityField(haloColorCell, haloColorChit, haloColorInput, applyHaloOpacity);
17795
+ haloColorFieldBox = haloColorField.box;
17796
+ haloOpacityInput = haloColorField.opacity;
17573
17797
  haloColorChit.on('click', function() {
17574
17798
  if (this.classList.contains('disabled')) return;
17575
17799
  haloColorPicker.toggle();
@@ -17585,12 +17809,7 @@
17585
17809
  });
17586
17810
  haloColorPicker = initColorPicker(haloColorCell, haloColorChit, haloColorInput, applyHaloColor);
17587
17811
 
17588
- var haloOpacityCell = El('div').addClass('label-split-cell label-opacity-row').appendTo(haloColorRow);
17589
- haloOpacityInput = addOpacityInput(haloOpacityCell, applyHaloOpacity);
17590
-
17591
- var haloWidthRow = El('div').addClass('label-style-row label-split-row').appendTo(haloSection);
17592
- El('div').addClass('label-split-cell').appendTo(haloWidthRow);
17593
- var haloWidthCell = El('div').addClass('label-split-cell label-spacing-row').appendTo(haloWidthRow);
17812
+ var haloWidthCell = El('div').addClass('label-split-cell label-spacing-row').appendTo(haloColorRow);
17594
17813
  El('span').appendTo(haloWidthCell).text('Width');
17595
17814
  // Halves as well as whole pixels: a halo is usually 1 to 2px, and the
17596
17815
  // step between those two is most of the range anyone uses.
@@ -17617,14 +17836,9 @@
17617
17836
  className: 'label-icon-toggle',
17618
17837
  onChange: setIconOn
17619
17838
  });
17620
- // The size's caption sits on the heading line, over its own column. It
17621
- // belongs to the field in the row below, but the shapes beside that field
17622
- // have no caption of their own, and a caption over one control of a pair
17623
- // pushes it out of line with the other.
17624
- El('div').addClass('label-icon-size-caption').appendTo(iconTitle).text('Size');
17625
-
17626
- var iconSizeRow = El('div').addClass('label-style-row label-split-row label-icon-shapes-row').appendTo(iconSection);
17627
- var iconRow = El('div').addClass('label-split-cell').appendTo(iconSizeRow);
17839
+ // The shapes have the whole row, so there is room for more of them, and
17840
+ // the size sits beside the colour below, as the halo's width does.
17841
+ var iconRow = El('div').addClass('label-style-row label-icon-shapes-row').appendTo(iconSection);
17628
17842
  var iconGroup = iconGroupEl = El('div').addClass('label-btn-group label-icon-buttons').appendTo(iconRow);
17629
17843
  iconBtns = {};
17630
17844
  iconTypes.forEach(function(icon) {
@@ -17637,21 +17851,14 @@
17637
17851
  appendIconButtonSymbol(btn, icon.name);
17638
17852
  });
17639
17853
 
17640
- var sizeRow = El('div').addClass('label-split-cell label-icon-size-row').appendTo(iconSizeRow);
17641
- iconSizeInput = new SizeField(sizeRow, {
17642
- title: 'Symbol size in px',
17643
- onSet: function(value) {
17644
- applyIconSize(value);
17645
- },
17646
- onStep: nudgeIconSize,
17647
- onDone: releaseFocus
17648
- });
17649
-
17650
17854
  var iconColorRow = El('div').addClass('label-style-row label-split-row').appendTo(iconSection);
17651
17855
  var iconColorCell = El('div').addClass('label-split-cell label-color-row label-icon-color-row').appendTo(iconColorRow);
17856
+ El('span').appendTo(iconColorCell).text('Color');
17652
17857
  iconColorChit = El('div').addClass('label-color-chit').attr('role', 'button');
17653
17858
  iconColorInput = El('input').attr('type', 'text').attr('title', 'Symbol color');
17654
- iconColorFieldBox = makeColorField(iconColorCell, iconColorChit, iconColorInput);
17859
+ var iconColorField = addColorOpacityField(iconColorCell, iconColorChit, iconColorInput, applyIconOpacity);
17860
+ iconColorFieldBox = iconColorField.box;
17861
+ iconOpacityInput = iconColorField.opacity;
17655
17862
  iconColorChit.on('click', function() {
17656
17863
  if (this.classList.contains('disabled')) return;
17657
17864
  iconColorPicker.toggle();
@@ -17667,8 +17874,16 @@
17667
17874
  });
17668
17875
  iconColorPicker = initColorPicker(iconColorCell, iconColorChit, iconColorInput, applyIconColor);
17669
17876
 
17670
- var iconOpacityCell = El('div').addClass('label-split-cell label-opacity-row label-icon-opacity-row').appendTo(iconColorRow);
17671
- iconOpacityInput = addOpacityInput(iconOpacityCell, applyIconOpacity);
17877
+ var sizeRow = El('div').addClass('label-split-cell label-icon-size-row').appendTo(iconColorRow);
17878
+ El('span').appendTo(sizeRow).text('Size');
17879
+ iconSizeInput = new SizeField(sizeRow, {
17880
+ title: 'Symbol size in px',
17881
+ onSet: function(value) {
17882
+ applyIconSize(value);
17883
+ },
17884
+ onStep: nudgeIconSize,
17885
+ onDone: releaseFocus
17886
+ });
17672
17887
 
17673
17888
  initCalloutSection();
17674
17889
 
@@ -17780,10 +17995,13 @@
17780
17995
  });
17781
17996
 
17782
17997
  var colorRow = El('div').addClass('label-style-row label-split-row').appendTo(calloutSection);
17783
- var colorCell = El('div').addClass('label-split-cell label-color-row').appendTo(colorRow);
17998
+ var colorCell = El('div').addClass('label-split-cell label-color-row label-callout-color-row').appendTo(colorRow);
17999
+ El('span').appendTo(colorCell).text('Color');
17784
18000
  calloutColorChit = El('div').addClass('label-color-chit').attr('role', 'button');
17785
18001
  calloutColorInput = El('input').attr('type', 'text').attr('title', 'Callout color');
17786
- calloutColorFieldBox = makeColorField(colorCell, calloutColorChit, calloutColorInput);
18002
+ var calloutColorField = addColorOpacityField(colorCell, calloutColorChit, calloutColorInput, applyCalloutOpacity);
18003
+ calloutColorFieldBox = calloutColorField.box;
18004
+ calloutOpacityInput = calloutColorField.opacity;
17787
18005
  calloutColorChit.on('click', function() {
17788
18006
  if (this.classList.contains('disabled')) return;
17789
18007
  calloutColorPicker.toggle();
@@ -17798,14 +18016,10 @@
17798
18016
  }
17799
18017
  });
17800
18018
  calloutColorPicker = initColorPicker(colorCell, calloutColorChit, calloutColorInput, applyCalloutColor);
17801
- var opacityCell = El('div').addClass('label-split-cell label-opacity-row').appendTo(colorRow);
17802
- calloutOpacityInput = addOpacityInput(opacityCell, applyCalloutOpacity);
17803
18019
 
17804
18020
  // The gap in the narrow column, under the sizes: it has a blank state,
17805
18021
  // "auto", which clears the anchor's symbol, so it is not a size field.
17806
- var gapRow = El('div').addClass('label-style-row label-split-row').appendTo(calloutSection);
17807
- El('div').addClass('label-split-cell').appendTo(gapRow);
17808
- var gapCell = El('div').addClass('label-split-cell label-spacing-row label-callout-gap-row').appendTo(gapRow);
18022
+ var gapCell = El('div').addClass('label-split-cell label-spacing-row label-callout-gap-row').appendTo(colorRow);
17809
18023
  El('span').appendTo(gapCell).text('Gap');
17810
18024
  calloutGapInput = El('input').attr('type', 'text').addClass('label-measure-input')
17811
18025
  .attr('title', 'Space between the callout and the anchor, in px')
@@ -17887,9 +18101,9 @@
17887
18101
  };
17888
18102
  }
17889
18103
 
17890
- function addOpacityInput(parent, action) {
17891
- return makeOpacityInput(parent, {
17892
- onSet: action,
18104
+ function addColorOpacityField(parent, chit, input, onOpacity) {
18105
+ return makeColorOpacityField(parent, chit, input, {
18106
+ onSet: onOpacity,
17893
18107
  revert: updateControls
17894
18108
  });
17895
18109
  }
@@ -19421,12 +19635,12 @@
19421
19635
  }
19422
19636
 
19423
19637
  var savedStylesKey = 'layer_style_presets';
19424
- var styleFields = ['stroke', 'stroke-width', 'stroke-opacity', 'fill', 'fill-opacity'];
19638
+ var styleFields = ['stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'fill', 'fill-opacity'];
19425
19639
 
19426
19640
  function LayerStyleTool(gui) {
19427
19641
  var parent = gui.container.findChild('.mshp-main-map');
19428
19642
  var panel = El('div').addClass('label-style-panel layer-style-panel rollover').appendTo(parent).hide();
19429
- var title, editingStatus, clearLink, strokeControl, fillControl, strokeWidthField, randomFillBtn, presetControl, hit;
19643
+ var title, editingStatus, clearLink, strokeControl, fillControl, strokeWidthField, dashControl, randomFillBtn, presetControl, hit;
19430
19644
  var targetLayer = null;
19431
19645
 
19432
19646
  initPanel();
@@ -19511,7 +19725,8 @@
19511
19725
 
19512
19726
  fillControl = addColorControl(panel, 'Fill', 'fill', '');
19513
19727
  strokeControl = addColorControl(panel, 'Stroke', 'stroke', '#000000');
19514
- strokeWidthField = addStrokeWidthControl(panel);
19728
+ strokeWidthField = addStrokeWidthControl(strokeControl.aside);
19729
+ dashControl = addDashArrayControl(panel);
19515
19730
 
19516
19731
  var buttonRow = El('div').addClass('label-style-row label-panel-button-row').appendTo(panel);
19517
19732
  randomFillBtn = makePanelActionButton(buttonRow, 'Random fills', applyRandomFillColors);
@@ -19553,14 +19768,11 @@
19553
19768
  return control;
19554
19769
  }
19555
19770
 
19556
- // In the narrow column, under the stroke's opacity. Stepping runs up a ladder
19771
+ // In the narrow column beside the stroke's colour. Stepping runs up a ladder
19557
19772
  // of widths rather than by a fixed amount, because the useful ones are close
19558
19773
  // together at the hairline end and far apart above 2px.
19559
- function addStrokeWidthControl(parent) {
19560
- var row = El('div').addClass('label-style-row label-split-row').appendTo(parent);
19561
- El('div').addClass('label-split-cell').appendTo(row);
19562
- var cell = El('div').addClass('label-split-cell').appendTo(row);
19563
- El('span').appendTo(cell).text('Stroke width');
19774
+ function addStrokeWidthControl(cell) {
19775
+ El('span').appendTo(cell).text('Width');
19564
19776
  return new SizeField(cell, {
19565
19777
  min: 0,
19566
19778
  // Quarter-pixel widths are the useful hairlines, and the ladder below
@@ -19575,6 +19787,29 @@
19575
19787
  });
19576
19788
  }
19577
19789
 
19790
+ // A literal stroke-dasharray, in the wide column so that it lines up with
19791
+ // the stroke's colour above it. A blank field makes the line solid again.
19792
+ function addDashArrayControl(parent) {
19793
+ var row = El('div').addClass('label-style-row label-split-row layer-dash-row').appendTo(parent);
19794
+ var cell = El('div').addClass('label-split-cell').appendTo(row);
19795
+ El('div').addClass('label-split-cell').appendTo(row);
19796
+ var caption = El('div').addClass('label-style-row-label').appendTo(cell).text('Dashes');
19797
+ makeFieldTip(caption,
19798
+ 'Dash and gap lengths in pixels, separated\n' +
19799
+ 'by spaces. "4" gives 4px dashes and 4px gaps.\n' +
19800
+ '"6 3" gives 6px dashes and 3px gaps.');
19801
+ var input = El('input').attr('type', 'text').appendTo(cell)
19802
+ .on('change', function() {
19803
+ var value = normalizeDashArrayInput(input.node().value);
19804
+ if (value && internal.parseStyleLiteral('stroke-dasharray', value) === undefined) {
19805
+ updateControls();
19806
+ return;
19807
+ }
19808
+ applyDashArrayStyle(value);
19809
+ });
19810
+ return {row: row, input: input};
19811
+ }
19812
+
19578
19813
  function releaseFocus() {
19579
19814
  releasePanelFocus(panel.node());
19580
19815
  }
@@ -19588,9 +19823,11 @@
19588
19823
  updateEditingStatus(manualIds.length);
19589
19824
  strokeControl.row.show();
19590
19825
  fillControl.row.classed('hidden', geom != 'polygon');
19826
+ dashControl.row.classed('hidden', geom != 'polyline');
19591
19827
  updateColorControl(strokeControl);
19592
19828
  updateColorControl(fillControl);
19593
19829
  updateStrokeWidthControl();
19830
+ updateDashArrayControl();
19594
19831
  randomFillBtn.classed('hidden', geom != 'polygon');
19595
19832
  presetControl.render();
19596
19833
  updateSavedStyleControls();
@@ -19604,10 +19841,13 @@
19604
19841
  if (!isHexColor(value)) control.picker.hide();
19605
19842
  }
19606
19843
 
19844
+ // A selection whose colours disagree still has colours, so an opacity unset
19845
+ // on all of it shows as full rather than blank.
19607
19846
  function updateOpacityControl(control) {
19608
- var value = getCommonStyleValue(control.field + '-opacity');
19609
- control.opacity.node().value =
19610
- formatOpacityPct(value === '' || value === undefined || value === null ? 1 : value);
19847
+ var field = control.field + '-opacity';
19848
+ control.opacity.node().value = styleFieldIsUnsetForTargets(field) ?
19849
+ formatColorOpacityPct(null, !styleFieldIsUnsetForTargets(control.field)) :
19850
+ formatOpacityPct(getCommonStyleValue(field));
19611
19851
  }
19612
19852
 
19613
19853
  function updateStrokeWidthControl() {
@@ -19617,6 +19857,11 @@
19617
19857
  getDefaultStrokeWidth() : value));
19618
19858
  }
19619
19859
 
19860
+ function updateDashArrayControl() {
19861
+ var value = getCommonStyleValue('stroke-dasharray');
19862
+ dashControl.input.node().value = value === undefined || value === null ? '' : String(value);
19863
+ }
19864
+
19620
19865
  function nudgeStrokeWidth(direction) {
19621
19866
  var value = Number(getCommonStyleValue('stroke-width'));
19622
19867
  if (!isFinite(value)) value = getDefaultStrokeWidth();
@@ -19658,6 +19903,14 @@
19658
19903
  runStyleCommand(styles);
19659
19904
  }
19660
19905
 
19906
+ function applyDashArrayStyle(value) {
19907
+ var styles = [['stroke-dasharray', value]];
19908
+ if (value && styleFieldIsUnsetForTargets('stroke')) {
19909
+ styles.push(['stroke', strokeControl.defaultColor]);
19910
+ }
19911
+ runStyleCommand(styles);
19912
+ }
19913
+
19661
19914
  function runStyleCommand(styles) {
19662
19915
  var parts = ['-style'];
19663
19916
  releaseFocus();
@@ -19719,6 +19972,9 @@
19719
19972
  addStyleValue(style, 'stroke', getControlValue(strokeControl.input));
19720
19973
  addStyleValue(style, 'stroke-width', strokeWidthField.getValue());
19721
19974
  addStyleValue(style, 'stroke-opacity', parseOpacityValue(strokeControl.opacity.node().value));
19975
+ if (targetLayer && targetLayer.geometry_type == 'polyline') {
19976
+ addStyleValue(style, 'stroke-dasharray', getControlValue(dashControl.input));
19977
+ }
19722
19978
  if (targetLayer && targetLayer.geometry_type == 'polygon') {
19723
19979
  addStyleValue(style, 'fill', getControlValue(fillControl.input));
19724
19980
  addStyleValue(style, 'fill-opacity', parseOpacityValue(fillControl.opacity.node().value));
@@ -20007,14 +20263,11 @@
20007
20263
  circleStrokeControl = addCircleColorControl('Stroke', 'stroke');
20008
20264
  circleControlRows.push(circleFillControl.row, circleStrokeControl.row);
20009
20265
 
20010
- // The two sizes of a circle side by side, in the shape the panel uses for
20011
- // every other pair.
20012
- var sizeRow = El('div').addClass('label-style-row label-split-row').appendTo(circlesSection);
20013
- // Radius in the wide column, stroke width in the narrow one under the
20014
- // stroke's opacity, matching the line and polygon panels.
20015
- var radiusCell = El('div').addClass('label-split-cell').appendTo(sizeRow);
20016
- var widthCell = El('div').addClass('label-split-cell').appendTo(sizeRow);
20017
- El('span').appendTo(widthCell).text('Stroke width');
20266
+ // Each size beside the colour it goes with: the radius beside the fill,
20267
+ // the stroke's width beside its colour, as in the line and polygon panels.
20268
+ var radiusCell = circleFillControl.aside;
20269
+ var widthCell = circleStrokeControl.aside;
20270
+ El('span').appendTo(widthCell).text('Width');
20018
20271
  circleStrokeWidthField = new SizeField(widthCell, {
20019
20272
  min: 0,
20020
20273
  decimals: 2, // quarter-pixel hairlines, which the ladder below steps through
@@ -20045,7 +20298,6 @@
20045
20298
  },
20046
20299
  onDone: releaseFocus
20047
20300
  });
20048
- circleControlRows.push(sizeRow);
20049
20301
  }
20050
20302
 
20051
20303
  function initSymbolsSection() {
@@ -20133,27 +20385,42 @@
20133
20385
  var radius = getCommonValue('r');
20134
20386
  var fill = getCommonValue('fill');
20135
20387
  var stroke = getCommonValue('stroke');
20136
- var fillOpacity = getCommonValue('fill-opacity');
20137
- var strokeOpacity = getCommonValue('stroke-opacity');
20138
20388
  var strokeWidth = getCommonValue('stroke-width');
20139
20389
  setCircleRadius(radius);
20140
20390
  setCircleColor(circleFillControl, fill);
20141
20391
  setCircleColor(circleStrokeControl, stroke);
20142
- setCircleOpacity(circleFillControl, fillOpacity);
20143
- setCircleOpacity(circleStrokeControl, strokeOpacity);
20392
+ updateCircleOpacity(circleFillControl);
20393
+ updateCircleOpacity(circleStrokeControl);
20144
20394
  setCircleStrokeWidth(strokeWidth === '' ? 0 : strokeWidth);
20145
20395
  if (representation != 'circle' && representation != 'unstyled') {
20146
20396
  setCircleRadius(defaultCircleRadius);
20147
20397
  setCircleColor(circleFillControl, '');
20148
20398
  setCircleColor(circleStrokeControl, '');
20149
- setCircleOpacity(circleFillControl, '');
20150
- setCircleOpacity(circleStrokeControl, '');
20399
+ circleFillControl.opacity.node().value = '';
20400
+ circleStrokeControl.opacity.node().value = '';
20151
20401
  setCircleStrokeWidth(0);
20152
20402
  }
20153
20403
  }
20154
20404
 
20155
- function setCircleOpacity(control, value) {
20156
- control.opacity.node().value = formatOpacityPct(value === '' ? 1 : value);
20405
+ // The opacity to put in the style command: undefined to leave the property
20406
+ // as it is, null for a field holding something that is not an opacity.
20407
+ // A blank field has nothing to say, and 100% over an unset opacity is the
20408
+ // default being shown rather than a value to store.
20409
+ function getCircleOpacityToWrite(control) {
20410
+ var str = control.opacity.node().value.trim();
20411
+ var val = parseOpacityValue(str);
20412
+ if (str === '') return undefined;
20413
+ if (val === 1 && styleFieldIsUnset(control.field + '-opacity')) return undefined;
20414
+ return val;
20415
+ }
20416
+
20417
+ // A selection whose colours disagree still has colours, so an opacity unset
20418
+ // on all of it shows as full rather than blank.
20419
+ function updateCircleOpacity(control) {
20420
+ var field = control.field + '-opacity';
20421
+ control.opacity.node().value = styleFieldIsUnset(field) ?
20422
+ formatColorOpacityPct(null, !styleFieldIsUnset(control.field)) :
20423
+ formatOpacityPct(getCommonValue(field));
20157
20424
  }
20158
20425
 
20159
20426
  function renderCreateFields() {
@@ -20204,16 +20471,16 @@
20204
20471
  function createSimpleCircles() {
20205
20472
  runCommand('-style r=' + defaultCreatedCircleRadius +
20206
20473
  ' fill=' + quoteCommandValue(defaultCircleFill) +
20207
- ' fill-opacity=1 stroke-opacity=1 stroke-width=0', 'Create circles');
20474
+ ' stroke-width=0', 'Create circles');
20208
20475
  }
20209
20476
 
20210
20477
  function applyCircleStyles() {
20211
20478
  var representation = getPointRepresentation();
20212
20479
  var radius = getCircleRadius();
20213
20480
  var fill = circleFillControl.input.node().value.trim();
20214
- var fillOpacity = parseOpacityValue(circleFillControl.opacity.node().value);
20481
+ var fillOpacity = getCircleOpacityToWrite(circleFillControl);
20215
20482
  var stroke = circleStrokeControl.input.node().value.trim();
20216
- var strokeOpacity = parseOpacityValue(circleStrokeControl.opacity.node().value);
20483
+ var strokeOpacity = getCircleOpacityToWrite(circleStrokeControl);
20217
20484
  var strokeWidth = getCircleStrokeWidth();
20218
20485
  var args;
20219
20486
  if (!gui.console || !(representation == 'unstyled' || representation == 'circle') ||
@@ -20222,11 +20489,9 @@
20222
20489
  fill = defaultCircleFill;
20223
20490
  setCircleColor(circleFillControl, fill);
20224
20491
  }
20225
- args = [
20226
- 'fill-opacity=' + fillOpacity,
20227
- 'stroke-opacity=' + strokeOpacity,
20228
- 'stroke-width=' + strokeWidth
20229
- ];
20492
+ args = ['stroke-width=' + strokeWidth];
20493
+ if (fillOpacity !== undefined) args.push('fill-opacity=' + fillOpacity);
20494
+ if (strokeOpacity !== undefined) args.push('stroke-opacity=' + strokeOpacity);
20230
20495
  if (radius !== null) args.unshift('r=' + radius);
20231
20496
  if (fill) args.push('fill=' + quoteCommandValue(fill));
20232
20497
  if (stroke) args.push('stroke=' + quoteCommandValue(stroke));
@@ -20453,7 +20718,7 @@
20453
20718
  var setDefaultTargets = self.setDefaultTargets;
20454
20719
  utils$1.extend(self, EventDispatcher.prototype);
20455
20720
 
20456
- // A map frame is composition state, not editable content. Commands may
20721
+ // A map frame (and its furniture) is composition state, not editable content. Commands may
20457
20722
  // explicitly target it, but it must not replace the GUI's active content
20458
20723
  // layer or become the implicit target of the next console command.
20459
20724
  self.setDefaultTargets = function(targets, opts) {
@@ -20520,15 +20785,25 @@
20520
20785
  };
20521
20786
 
20522
20787
  self.selectNextLayer = function() {
20523
- var next = self.findNextLayer(self.getActiveLayer().layer);
20788
+ var next = findContentLayer(self.findNextLayer);
20524
20789
  if (next) self.selectLayer(next.layer, next.dataset);
20525
20790
  };
20526
20791
 
20527
20792
  self.selectPrevLayer = function() {
20528
- var prev = self.findPrevLayer(self.getActiveLayer().layer);
20793
+ var prev = findContentLayer(self.findPrevLayer);
20529
20794
  if (prev) self.selectLayer(prev.layer, prev.dataset);
20530
20795
  };
20531
20796
 
20797
+ // step past the frame and its furniture, which can't be selected
20798
+ function findContentLayer(step) {
20799
+ var start = self.getActiveLayer().layer;
20800
+ var o = step.call(self, start);
20801
+ while (o && o.layer != start && internal.isFrameComponentLayer(o.layer, o.dataset)) {
20802
+ o = step.call(self, o.layer);
20803
+ }
20804
+ return o;
20805
+ }
20806
+
20532
20807
  return self;
20533
20808
 
20534
20809
  function getContentTargets(targets) {
@@ -20536,7 +20811,7 @@
20536
20811
  return {
20537
20812
  dataset: target.dataset,
20538
20813
  layers: target.layers.filter(function(lyr) {
20539
- return !internal.isFrameLayer(lyr, target.dataset.arcs);
20814
+ return !internal.isFrameComponentLayer(lyr, target.dataset);
20540
20815
  })
20541
20816
  };
20542
20817
  }).filter(function(target) {
@@ -20546,7 +20821,7 @@
20546
20821
 
20547
20822
  function getFirstContentTarget() {
20548
20823
  var target = self.getLayers().find(function(o) {
20549
- return !internal.isFrameLayer(o.layer, o.dataset.arcs);
20824
+ return !internal.isFrameComponentLayer(o.layer, o.dataset);
20550
20825
  });
20551
20826
  return target ? [{dataset: target.dataset, layers: [target.layer]}] : [];
20552
20827
  }
@@ -22929,15 +23204,22 @@
22929
23204
  this.zoomToBbox = zoomToBbox;
22930
23205
 
22931
23206
  if (gui.options.homeControl) {
22932
- gui.buttons.addButton("#home-icon").on('click', function() {
22933
- if (disabled()) return;
22934
- gui.dispatchEvent('map_reset');
22935
- });
23207
+ gui.buttons.addButton("#home-icon")
23208
+ .attr('data-tooltip', 'Zoom to fit')
23209
+ .attr('aria-label', 'Zoom to fit')
23210
+ .on('click', function() {
23211
+ if (disabled()) return;
23212
+ gui.dispatchEvent('map_reset');
23213
+ });
22936
23214
  }
22937
23215
 
22938
23216
  if (gui.options.zoomControl) {
22939
- inBtn = gui.buttons.addButton("#zoom-in-icon");
22940
- outBtn = gui.buttons.addButton("#zoom-out-icon");
23217
+ inBtn = gui.buttons.addButton("#zoom-in-icon")
23218
+ .attr('data-tooltip', 'Zoom in')
23219
+ .attr('aria-label', 'Zoom in');
23220
+ outBtn = gui.buttons.addButton("#zoom-out-icon")
23221
+ .attr('data-tooltip', 'Zoom out')
23222
+ .attr('aria-label', 'Zoom out');
22941
23223
  initVariableClick(inBtn.node(), zoomIn);
22942
23224
  initVariableClick(outBtn.node(), zoomOut);
22943
23225
  ext.on('change', function() {
@@ -23102,8 +23384,11 @@
23102
23384
  gui.on('interaction_mode_change', function(e) {
23103
23385
  if (e.mode === 'selection') {
23104
23386
  gui.enterMode('selection_tool');
23105
- } else if (_on) {
23106
- turnOff();
23387
+ } else if (gui.getMode() == 'selection_tool') {
23388
+ // Leave the gui mode rather than just calling turnOff(): a mode left
23389
+ // behind makes the next enterMode('selection_tool') a no-op, so the tool
23390
+ // would never turn back on.
23391
+ gui.clearMode();
23107
23392
  }
23108
23393
  });
23109
23394
 
@@ -26361,7 +26646,7 @@
26361
26646
 
26362
26647
  function getLineHeight(o) {
26363
26648
  var rec = o.pending ? o.pending.getStyle() : getRecord(o.target, o.id);
26364
- return rec && rec['line-height'] || '1.1em';
26649
+ return internal.svg.getLineHeightDy(rec && rec['line-height']);
26365
26650
  }
26366
26651
 
26367
26652
  function getProvider(nodes) {
@@ -29211,6 +29496,7 @@
29211
29496
  referenceStyle = { // outline style for reference layers
29212
29497
  type: 'outline',
29213
29498
  strokeColors: [null, '#87b73b'], // was 78c110
29499
+ // strokeColors: [null, 'rgba(79,140,0,0.67)'],
29214
29500
  strokeWidth: 0.85,
29215
29501
  dotColor: "#73ba20",
29216
29502
  dotSize: 1
@@ -30090,37 +30376,103 @@
30090
30376
  }
30091
30377
 
30092
30378
 
30093
- function getCanvasFillPattern(style) {
30094
- var fill = hatches[style.fillPattern];
30095
- if (fill === undefined) {
30096
- fill = makePatternFill(style);
30097
- hatches[style.fillPattern] = fill;
30379
+ // symbolScale: the preview symbol scale (1 outside preview). Pattern sizes are
30380
+ // in output pixels, like stroke widths, so the tile grows with the page.
30381
+ // anchor: canvas-pixel position the pattern origin is pinned to, so the tile
30382
+ // travels with the map instead of sticking to the viewport. Panning only
30383
+ // updates this matrix. The tile image is rebuilt when the scale changes,
30384
+ // which is at most once per pattern per render.
30385
+ function getCanvasFillPattern(style, symbolScale, anchor) {
30386
+ var scale = symbolScale > 0 ? symbolScale : 1;
30387
+ var entry = hatches[style.fillPattern];
30388
+ if (!entry || entry.scale != scale) {
30389
+ entry = makePatternEntry(style, scale);
30390
+ hatches[style.fillPattern] = entry;
30098
30391
  }
30099
- return fill || style.fill || '#000'; // use fill if hatches are invalid
30392
+ if (!entry || !entry.pattern) return style.fill || '#000';
30393
+ if (anchor) applyPatternAnchor(entry, anchor);
30394
+ return entry.pattern;
30100
30395
  }
30101
30396
 
30102
- function makePatternFill(style) {
30397
+ function makePatternEntry(style, scale) {
30103
30398
  var o = internal.parsePattern(style.fillPattern);
30104
30399
  if (!o) return null;
30105
30400
  var canv = document.createElement('canvas');
30106
30401
  var ctx = canv.getContext('2d');
30107
- var res = GUI.getPixelRatio();
30108
- var w = o.tileSize[0] * res;
30109
- var h = o.tileSize[1] * res;
30402
+ var k = GUI.getPixelRatio() * scale;
30403
+ var tw = o.tileSize[0], th = o.tileSize[1];
30404
+ // A canvas has whole-pixel dimensions, but the scaled tile generally does
30405
+ // not. The tile is drawn into the nearest whole-pixel canvas, and the
30406
+ // pattern transform stretches it back to its exact size so that the
30407
+ // repeat period doesn't drift across a large polygon.
30408
+ var w = Math.max(1, Math.round(tw * k));
30409
+ var h = Math.max(1, Math.round(th * k));
30110
30410
  canv.setAttribute('width', w);
30111
30411
  canv.setAttribute('height', h);
30412
+ ctx.scale(w / tw, h / th);
30112
30413
  if (o.background) {
30113
30414
  ctx.fillStyle = o.background;
30114
- ctx.fillRect(0, 0, w, h);
30115
- }
30116
- if (o.type == 'dots' || o.type == 'squares') makeDotFill(o, ctx, res);
30117
- if (o.type == 'dashes') makeDashFill(o, ctx, res);
30118
- if (o.type == 'hatches') makeHatchFill(o, ctx, res);
30119
- var pattern = ctx.createPattern(canv, 'repeat');
30120
- if (o.rotation) {
30121
- pattern.setTransform(new DOMMatrix('rotate(' + o.rotation + 'deg)'));
30415
+ ctx.fillRect(0, 0, tw, th);
30122
30416
  }
30123
- return pattern;
30417
+ if (o.type == 'dots' || o.type == 'squares') makeDotFill(o, ctx, 1);
30418
+ if (o.type == 'dashes') makeDashFill(o, ctx, 1);
30419
+ if (o.type == 'hatches') makeHatchFill(o, ctx, 1);
30420
+ return {
30421
+ scale: scale,
30422
+ pattern: ctx.createPattern(canv, 'repeat'),
30423
+ rotation: o.rotation || 0,
30424
+ sx: tw * k / w,
30425
+ sy: th * k / h,
30426
+ w: w,
30427
+ h: h
30428
+ };
30429
+ }
30430
+
30431
+ // Pins the cached pattern to @anchor. Skipped when the view has not moved,
30432
+ // so a hover redraw does not touch the pattern.
30433
+ function applyPatternAnchor(entry, anchor) {
30434
+ if (entry.ax == anchor.x && entry.ay == anchor.y) return;
30435
+ entry.ax = anchor.x;
30436
+ entry.ay = anchor.y;
30437
+ var t = getPatternTransform(entry, anchor);
30438
+ entry.pattern.setTransform(new DOMMatrix([t.a, t.b, t.c, t.d, t.e, t.f]));
30439
+ }
30440
+
30441
+ // Matrix taking pattern-image pixels to canvas pixels: translate to the
30442
+ // anchor, then rotate, then scale. The anchor is folded into one tile so a
30443
+ // map origin far outside the viewport does not blow the matrix precision.
30444
+ function getPatternTransform(entry, anchor) {
30445
+ var p = wrapPatternAnchor(anchor.x, anchor.y, entry);
30446
+ var rad = (entry.rotation || 0) * Math.PI / 180;
30447
+ var c = Math.cos(rad);
30448
+ var s = Math.sin(rad);
30449
+ return {
30450
+ a: c * entry.sx,
30451
+ b: s * entry.sx,
30452
+ c: -s * entry.sy,
30453
+ d: c * entry.sy,
30454
+ e: p.x,
30455
+ f: p.y
30456
+ };
30457
+ }
30458
+
30459
+ // @entry.sx/sy scale image pixels to canvas pixels; the image repeats every
30460
+ // @entry.w by @entry.h pixels. Returns a point in the same place on the
30461
+ // pattern, within one tile of the origin.
30462
+ function wrapPatternAnchor(x, y, entry) {
30463
+ var rad = (entry.rotation || 0) * Math.PI / 180;
30464
+ var c = Math.cos(rad);
30465
+ var s = Math.sin(rad);
30466
+ var rx = c * x + s * y;
30467
+ var ry = -s * x + c * y;
30468
+ var qx = rx / entry.sx - Math.floor(rx / entry.sx / entry.w) * entry.w;
30469
+ var qy = ry / entry.sy - Math.floor(ry / entry.sy / entry.h) * entry.h;
30470
+ var lx = qx * entry.sx;
30471
+ var ly = qy * entry.sy;
30472
+ return {
30473
+ x: c * lx - s * ly,
30474
+ y: s * lx + c * ly
30475
+ };
30124
30476
  }
30125
30477
 
30126
30478
  function makeDashFill(o, ctx, res) {
@@ -30811,27 +31163,66 @@
30811
31163
  return iter;
30812
31164
  }
30813
31165
 
31166
+ // Same multiplier as ctx.lineWidth. Retina canvases are pixRatio times the
31167
+ // CSS pixel; preview mode multiplies styled strokes by the symbol scale.
31168
+ function getCanvasStrokeScale(pixRatio, lineScale) {
31169
+ return (pixRatio > 1 ? pixRatio : 1) * (lineScale || 1);
31170
+ }
31171
+
31172
+ // Scale a stroke-dasharray into canvas pixels. Scale 1 returns the authored
31173
+ // tokens, so a 100% preview and a non-preview view keep the pattern as stored.
31174
+ function scaleCanvasLineDash(lineDash, scale) {
31175
+ var parts = String(lineDash).split(' ');
31176
+ var out, i;
31177
+ if (!(scale > 0) || scale === 1) return parts;
31178
+ out = new Array(parts.length);
31179
+ for (i = 0; i < parts.length; i++) {
31180
+ out[i] = +parts[i] * scale;
31181
+ }
31182
+ return out;
31183
+ }
31184
+
31185
+ // Where a fill pattern's origin sits, in canvas pixels. In preview that is
31186
+ // the top-left of the page; otherwise the geographic origin, so a pan (which
31187
+ // only changes the translation) carries the pattern with the shapes.
31188
+ function getPatternAnchor(ext) {
31189
+ var t = ext.getTransform(GUI.getPixelRatio());
31190
+ var frame = ext.getFrameData();
31191
+ if (frame && frame.bbox) {
31192
+ return {
31193
+ x: frame.bbox[0] * t.mx + t.bx,
31194
+ y: frame.bbox[3] * t.my + t.by
31195
+ };
31196
+ }
31197
+ return {x: t.bx, y: t.by};
31198
+ }
31199
+
30814
31200
  function getPathStart(ext, lineScale) {
30815
31201
  var pixRatio = GUI.getPixelRatio();
30816
31202
  if (!lineScale) lineScale = 1;
31203
+ // Same factor as lineWidth, so dash length and gap stay in proportion to
31204
+ // the stroke. In preview that factor includes the symbol scale; without it
31205
+ // the pattern stays a fixed screen size while the line grows.
31206
+ var strokeScale = getCanvasStrokeScale(pixRatio, lineScale);
31207
+ // The symbol scale itself, not lineScale: lineScale adds a bump to widen
31208
+ // hover outlines, which a fill has no reason to follow.
31209
+ var patternScale = ext.getSymbolScale() || 1;
31210
+ // Page corner in preview, so the pattern is fixed to the page the way the
31211
+ // exported SVG is. With no frame, the geographic origin, which is enough
31212
+ // for panning: the pattern is not scaled by the geographic zoom then.
31213
+ var patternAnchor = getPatternAnchor(ext);
30817
31214
  return function(ctx, style) {
30818
31215
  var strokeWidth;
30819
31216
  ctx.beginPath();
30820
31217
  if (style.strokeWidth > 0) {
30821
31218
  strokeWidth = style.strokeWidth;
30822
- if (pixRatio > 1) {
30823
- // bump up thin lines on retina, but not to more than 1px
30824
- // (tests on Chrome showed much faster rendering of 1px lines)
30825
- // strokeWidth = strokeWidth < 1 ? 1 : strokeWidth * pixRatio;
30826
- strokeWidth = strokeWidth * pixRatio;
30827
- }
30828
31219
  ctx.lineCap = style.lineCap || 'round';
30829
31220
  ctx.lineJoin = style.lineJoin || 'round';
30830
- ctx.lineWidth = strokeWidth * lineScale;
31221
+ ctx.lineWidth = strokeWidth * strokeScale;
30831
31222
  ctx.strokeStyle = style.strokeColor;
30832
31223
  if (style.lineDash){
30833
31224
  ctx.lineCap = 'butt';
30834
- ctx.setLineDash(style.lineDash.split(' '));
31225
+ ctx.setLineDash(scaleCanvasLineDash(style.lineDash, strokeScale));
30835
31226
  }
30836
31227
  if (style.miterLimit) {
30837
31228
  ctx.miterLimit = style.miterLimit;
@@ -30839,7 +31230,7 @@
30839
31230
  }
30840
31231
 
30841
31232
  if (style.fillPattern) {
30842
- ctx.fillStyle = getCanvasFillPattern(style);
31233
+ ctx.fillStyle = getCanvasFillPattern(style, patternScale, patternAnchor);
30843
31234
  } else if (style.fillColor) {
30844
31235
  ctx.fillStyle = style.fillColor;
30845
31236
  }
@@ -30878,20 +31269,28 @@
30878
31269
  return internal.svg.getTransform(p, scale);
30879
31270
  }
30880
31271
 
30881
- function repositionFurniture(container, layer, ext) {
30882
- var g = El.findAll('.mapshaper-svg-furniture', container)[0];
30883
- g.setAttribute('transform', getSvgFurnitureTransform(ext));
31272
+ function repositionFurniture(container, ext) {
31273
+ if (!ext.getFrameData()) return;
31274
+ El.findAll('.mapshaper-svg-furniture', container).forEach(function(g) {
31275
+ g.setAttribute('transform', getSvgFurnitureTransform(ext));
31276
+ });
30884
31277
  }
30885
31278
 
31279
+ // Returns an SVG string, or '' if the furniture can't be drawn in the current
31280
+ // frame (e.g. the map is unprojected). The scalebar panel explains why.
30886
31281
  function renderFurniture(lyr, ext) {
30887
- var frame = ext.getFrameData(); // frame should be set if we're rendering a furniture layer
30888
- var obj = internal.getEmptyLayerForSVG(lyr, {});
30889
- if (!frame) {
30890
- stop$1('Missing map frame data');
30891
- }
31282
+ var frame = ext.getFrameData();
31283
+ var obj;
31284
+ if (internal.getFurnitureFrameProblem(frame)) return '';
31285
+ obj = internal.getEmptyLayerForSVG(lyr, {});
30892
31286
  obj.properties.transform = getSvgFurnitureTransform(ext);
30893
31287
  obj.properties.class = 'mapshaper-svg-furniture';
30894
- obj.children = internal.renderFurnitureLayer(lyr, frame);
31288
+ try {
31289
+ obj.children = internal.renderFurnitureLayer(lyr, frame);
31290
+ } catch(e) {
31291
+ console.error(e);
31292
+ return '';
31293
+ }
30895
31294
  return internal.svg.stringify(obj);
30896
31295
  }
30897
31296
 
@@ -30933,8 +31332,6 @@
30933
31332
  resize(ext);
30934
31333
  if (type == 'label' || type == 'symbol') {
30935
31334
  html = renderSymbols(lyr.gui.displayLayer, ext, id);
30936
- } else if (type == 'furniture') {
30937
- html = renderFurniture(lyr.gui.displayLayer, ext);
30938
31335
  }
30939
31336
  g.innerHTML = html;
30940
31337
  if (type == 'label' || type == 'symbol') {
@@ -30948,6 +31345,26 @@
30948
31345
  }
30949
31346
  };
30950
31347
 
31348
+ // Furniture layers are dataset layers (they have no display layer)
31349
+ el.drawFurniture = function(layers) {
31350
+ el.clear();
31351
+ resize(ext);
31352
+ layers.forEach(function(lyr) {
31353
+ var html = renderFurniture(lyr, ext);
31354
+ var g;
31355
+ if (!html) return;
31356
+ g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
31357
+ g.setAttribute('class', 'mapshaper-svg-layer mapshaper-furniture-layer');
31358
+ g.innerHTML = html;
31359
+ svg.append(g);
31360
+ });
31361
+ };
31362
+
31363
+ el.repositionFurniture = function() {
31364
+ resize(ext);
31365
+ repositionFurniture(svg, ext);
31366
+ };
31367
+
30951
31368
  function reposition(lyr, type, ext) {
30952
31369
  var container = el.findChild('.' + lyr.gui.svg_id);
30953
31370
  if (!container || !container.node()) {
@@ -30961,8 +31378,6 @@
30961
31378
  // a symbol group's transform absorbs panning, and zooming too when a
30962
31379
  // frame is defined; anything it can't absorb needs the baselines rebuilt
30963
31380
  updateLabelPaths(container.node(), lyr.gui.displayLayer, ext);
30964
- } else if (type == 'furniture') {
30965
- repositionFurniture(container.node(), lyr.gui.displayLayer, ext);
30966
31381
  } else {
30967
31382
  // container.getElementsByTagName('text')
30968
31383
  error('Unsupported symbol type:', type);
@@ -31118,19 +31533,14 @@
31118
31533
  });
31119
31534
  };
31120
31535
 
31536
+ // Furniture is sized in frame pixels, so panning and zooming only need to
31537
+ // update its transform; its content changes only with a full redraw.
31121
31538
  this.drawFurnitureLayers = function(layers, action) {
31122
- // re-render if action == 'nav', because scalebars get resized
31123
- var noRedraw = action == 'hover';
31124
- if (!noRedraw) {
31125
- _furniture.clear();
31539
+ if (action == 'nav' || action == 'hover') {
31540
+ _furniture.repositionFurniture();
31541
+ } else {
31542
+ _furniture.drawFurniture(layers);
31126
31543
  }
31127
- layers.forEach(function(lyr) {
31128
- if (noRedraw) {
31129
- _furniture.reposition(lyr, 'furniture');
31130
- } else {
31131
- _furniture.drawLayer(lyr, 'furniture');
31132
- }
31133
- });
31134
31544
  };
31135
31545
 
31136
31546
  // kludge: skip rendering base layers if hovering, except on first hover
@@ -31489,50 +31899,73 @@
31489
31899
  gui.on('interaction_mode_change', function(e) {
31490
31900
  if (e.mode === 'rectangles') {
31491
31901
  gui.enterMode('rectangle_tool');
31492
- } else if (_on) {
31493
- turnOff();
31902
+ } else if (gui.getMode() == 'rectangle_tool') {
31903
+ // Leave the gui mode rather than just calling turnOff(): a mode left
31904
+ // behind makes the next enterMode('rectangle_tool') a no-op, so the tool
31905
+ // would never turn back on.
31906
+ gui.clearMode();
31494
31907
  }
31495
31908
  });
31496
31909
 
31497
31910
  hit.on('change', function(e) {
31498
31911
  if (!_on) return;
31499
31912
  // TODO: handle multiple hits (see gui-inspection-control)
31500
- var id = e.id;
31501
31913
  if (e.id > -1 && e.pinned) {
31502
- var target = hit.getHitTarget();
31503
- var path = target.shapes[e.id][0];
31504
- var bbox = target.gui.displayArcs.getSimpleShapeBounds(path).toArray();
31505
- box.setDataCoords(bbox);
31506
- dragInfo = {
31507
- id: e.id,
31508
- target: target,
31509
- ids: [],
31510
- points: []
31511
- };
31512
- var iter = target.gui.displayArcs.getShapeIter(path);
31513
- while (iter.hasNext()) {
31514
- dragInfo.points.push([iter.x, iter.y]);
31515
- dragInfo.ids.push(iter._arc.i);
31914
+ if (!dragInfo || dragInfo.id != e.id) {
31915
+ selectRectangle(e.id);
31516
31916
  }
31517
- gui.container.findChild('.map-layers').classed('dragging', true);
31518
-
31519
- } else if (dragInfo) {
31520
- gui.dispatchEvent('rectangle_dragend', dragInfo); // save undo state
31521
- gui.container.findChild('.map-layers').classed('dragging', false);
31522
- reset();
31523
31917
  } else {
31524
- box.hide();
31918
+ reset();
31525
31919
  }
31526
-
31527
31920
  });
31528
31921
 
31529
31922
  box.on('handle_drag', function(e) {
31530
31923
  if (!_on || !dragInfo) return;
31531
31924
  var coords = internal.bboxToCoords(box.getDataCoords());
31532
31925
  setRectangleCoords(dragInfo.target, dragInfo.ids, coords);
31926
+ dragInfo.moved = true;
31533
31927
  gui.dispatchEvent('map-needs-refresh');
31534
31928
  });
31535
31929
 
31930
+ // Each handle drag is one undo step, saved when it ends -- waiting until the
31931
+ // rectangle is deselected would lose the edit if the mode changes first.
31932
+ box.on('handle_up', function() {
31933
+ if (!_on || !dragInfo || !dragInfo.moved) return;
31934
+ gui.dispatchEvent('rectangle_dragend', {
31935
+ target: dragInfo.target,
31936
+ ids: dragInfo.ids,
31937
+ points: dragInfo.points
31938
+ });
31939
+ dragInfo.points = getDataPoints(dragInfo.target, dragInfo.ids);
31940
+ dragInfo.moved = false;
31941
+ });
31942
+
31943
+ function selectRectangle(id) {
31944
+ var target = hit.getHitTarget();
31945
+ var path = target.shapes[id][0];
31946
+ var bbox = target.gui.displayArcs.getSimpleShapeBounds(path).toArray();
31947
+ var ids = [];
31948
+ var iter = target.gui.displayArcs.getShapeIter(path);
31949
+ while (iter.hasNext()) {
31950
+ ids.push(iter._arc.i);
31951
+ }
31952
+ box.setDataCoords(bbox);
31953
+ dragInfo = {
31954
+ id: id,
31955
+ target: target,
31956
+ ids: ids,
31957
+ points: getDataPoints(target, ids), // for undo
31958
+ moved: false
31959
+ };
31960
+ gui.container.findChild('.map-layers').classed('dragging', true);
31961
+ }
31962
+
31963
+ function getDataPoints(target, ids) {
31964
+ return ids.map(function(id) {
31965
+ return getVertexCoords(target, id);
31966
+ });
31967
+ }
31968
+
31536
31969
  function turnOn() {
31537
31970
  box.turnOn();
31538
31971
  _on = true;
@@ -31550,6 +31983,9 @@
31550
31983
 
31551
31984
  function reset() {
31552
31985
  box.hide();
31986
+ if (dragInfo) {
31987
+ gui.container.findChild('.map-layers').classed('dragging', false);
31988
+ }
31553
31989
  dragInfo = null;
31554
31990
  }
31555
31991
  }
@@ -32955,7 +33391,7 @@
32955
33391
 
32956
33392
  function isFrameMapLayer(lyr) {
32957
33393
  var dataset = lyr && lyr.gui && lyr.gui.source && lyr.gui.source.dataset;
32958
- return !!dataset && internal.isFrameLayer(lyr, dataset.arcs);
33394
+ return !!dataset && internal.isFrameComponentLayer(lyr, dataset);
32959
33395
  }
32960
33396
 
32961
33397
  function getDrawableContentLayers() {
@@ -32965,11 +33401,18 @@
32965
33401
  });
32966
33402
  }
32967
33403
 
32968
- function getDrawableFurnitureLayers(layers) {
32969
- if (!isPreviewView()) return [];
32970
- return getVisibleMapLayers().filter(function(o) {
32971
- return internal.isFurnitureLayer(o);
33404
+ // The active frame's furniture, plus any visible standalone furniture layers
33405
+ // (e.g. from -scalebar run before a frame was created)
33406
+ function getDrawableFurnitureLayers() {
33407
+ var frame = isPreviewView() ? internal.getActiveFrame(model) : null;
33408
+ if (!frame) return [];
33409
+ var layers = internal.getFrameFurnitureLayers(frame.dataset);
33410
+ getVisibleMapLayers().forEach(function(lyr) {
33411
+ if (internal.layerIsFurniture(lyr) && !layers.includes(lyr) && !lyr.hidden) {
33412
+ layers.push(lyr);
33413
+ }
32972
33414
  });
33415
+ return layers;
32973
33416
  }
32974
33417
 
32975
33418
  function updateLayerStyles(layers) {
@@ -33021,7 +33464,6 @@
33021
33464
  var layersMayHaveChanged = action != 'nav'; // !action;
33022
33465
  var fullBounds;
33023
33466
  var contentLayers = getDrawableContentLayers();
33024
- // var furnitureLayers = getDrawableFurnitureLayers();
33025
33467
  if (!(_ext.width() > 0 && _ext.height() > 0)) {
33026
33468
  // TODO: track down source of these errors
33027
33469
  console.error("Collapsed map container, unable to draw.");
@@ -33062,8 +33504,7 @@
33062
33504
  }
33063
33505
  _renderer.drawOverlayLayers(_overlayLayers, action);
33064
33506
 
33065
- // TODO: draw furniture
33066
- // _renderer.drawFurnitureLayers(furnitureLayers, action);
33507
+ _renderer.drawFurnitureLayers(getDrawableFurnitureLayers(), action);
33067
33508
  // The action says how much was redrawn, which a listener rebuilding its own
33068
33509
  // DOM overlays needs: a 'hover' draw leaves the SVG markup and its
33069
33510
  // transforms alone, so anything anchored to them is still good.
@@ -33613,22 +34054,20 @@
33613
34054
 
33614
34055
  // Fitting obeys the same mode as a handle drag: holding the scale grows the
33615
34056
  // output to cover the new extent, holding the output rescales onto it. The
33616
- // command works out both the padding and the held-scale width, so the
33617
- // margin's unit handling lives in one place.
34057
+ // command works out the extent, which has to leave room for symbols and
34058
+ // labels at the scale it ends up at, along with the padding and the
34059
+ // held-scale width, so the margin's unit handling lives in one place.
33618
34060
  function fitVisibleLayers() {
33619
34061
  var target = getFrameTarget();
33620
34062
  var entries = getCompositionEntries();
33621
34063
  var margin = getMargin();
33622
- var parts, bounds;
34064
+ var parts;
33623
34065
  if (!target || !entries.length) return;
33624
- bounds = entries.reduce(function(memo, o) {
33625
- return memo.mergeBounds(
33626
- internal.getLayerBounds(o.layer, o.dataset.arcs)
33627
- );
33628
- }, new internal.Bounds());
33629
34066
  parts = [
33630
34067
  '-update-frame',
33631
- 'bbox=' + quoteCommandValue(bounds.toArray().join(','))
34068
+ 'fit=' + internal.formatOptionValue(entries.map(function(o) {
34069
+ return internal.getLayerTargetId(gui.model, o.layer);
34070
+ }).join(','))
33632
34071
  ];
33633
34072
  if (margin) parts.push('offset=' + quoteCommandValue(margin));
33634
34073
  if (!lockSize) parts.push('fix-scale');
@@ -33654,10 +34093,7 @@
33654
34093
  return;
33655
34094
  }
33656
34095
  if (!getSourceLayer()) {
33657
- showPopupAlert(
33658
- 'Add one or more layers before creating a map frame.',
33659
- 'Map frame'
33660
- );
34096
+ showPopupAlert(getNoSourceMessage(), 'Map frame');
33661
34097
  return;
33662
34098
  }
33663
34099
  var popup = showPopupAlert('', 'Add map frame', {classname: 'frame-create-box'});
@@ -33802,7 +34238,7 @@
33802
34238
  function createFromVisibleLayers(width, aspect, margin) {
33803
34239
  var entries = getCompositionEntries();
33804
34240
  if (!entries.length) {
33805
- showPopupAlert('No visible geographic layers are available.', 'Map frame');
34241
+ showPopupAlert(getNoSourceMessage(), 'Map frame');
33806
34242
  return;
33807
34243
  }
33808
34244
  var ids = entries.map(function(o) {
@@ -33825,6 +34261,15 @@
33825
34261
  });
33826
34262
  }
33827
34263
 
34264
+ function getNoSourceMessage() {
34265
+ var active = gui.model.getActiveLayer();
34266
+ if (active && active.layer && !internal.layerHasGeometry(active.layer)) {
34267
+ return 'A map frame is fitted to layers with shapes, and this layer ' +
34268
+ 'contains only attribute data. Select or show a map layer to create a frame.';
34269
+ }
34270
+ return 'Add one or more layers before creating a map frame.';
34271
+ }
34272
+
33828
34273
  function getFrameTarget() {
33829
34274
  return internal.getActiveFrame(gui.model);
33830
34275
  }
@@ -33844,10 +34289,11 @@
33844
34289
  return entries.length ? entries[0].layer : null;
33845
34290
  }
33846
34291
 
34292
+ // A frame is fitted to shapes, so a data-only layer on show doesn't count
33847
34293
  function getCompositionEntries() {
33848
34294
  var layers = gui.map.getCompositionLayers();
33849
34295
  return gui.model.getLayers().filter(function(o) {
33850
- return layers.includes(o.layer);
34296
+ return layers.includes(o.layer) && internal.layerHasGeometry(o.layer);
33851
34297
  });
33852
34298
  }
33853
34299
 
@@ -33884,16 +34330,6 @@
33884
34330
  .text(label).on('click', action);
33885
34331
  }
33886
34332
 
33887
- // The "?" the rest of the app uses for field help (see .tip-button in
33888
- // elements.css and the static ones in index.html). The bubble is white-space:
33889
- // pre, so the line breaks in the text are the ones it gets.
33890
- function makeFieldTip(parent, text) {
33891
- var btn = El('div').addClass('tip-button').appendTo(parent).text('?');
33892
- var anchor = El('div').addClass('tip-anchor').appendTo(btn);
33893
- El('div').addClass('tip').appendTo(anchor).text(text);
33894
- return btn;
33895
- }
33896
-
33897
34333
  function getUnitFactor$1(units) {
33898
34334
  return units == 'in' ? 72 : units == 'cm' ? 28.3465 : 1;
33899
34335
  }
@@ -33913,7 +34349,7 @@
33913
34349
  function FrameProperties(gui) {
33914
34350
  var target, form, widthInput, heightInput, unitsSelect, aspectValue;
33915
34351
  var backgroundControl, neatlineControl, neatlineWidthInput;
33916
- var boundsValue, crsValue;
34352
+ var boundsValue, crsValue, scalebarButton;
33917
34353
 
33918
34354
  gui.frameProperties = this;
33919
34355
 
@@ -33975,9 +34411,9 @@
33975
34411
  revert: updateControls
33976
34412
  });
33977
34413
  // The width belongs to the same property as the colour and opacity beside
33978
- // it, and all three fit on the row. The background row leaves the column
33979
- // empty rather than putting the width on a line of its own.
33980
- neatlineWidthInput = makeRowField(neatlineControl.row, 'Width')
34414
+ // it. The background row leaves the column empty rather than putting the
34415
+ // width on a line of its own.
34416
+ neatlineWidthInput = makeCellField(neatlineControl.aside, 'Width')
33981
34417
  .addClass('frame-neatline-width')
33982
34418
  .on('change', updateNeatlineWidth);
33983
34419
  var clearRow = El('div')
@@ -33985,6 +34421,14 @@
33985
34421
  .appendTo(appearance);
33986
34422
  makeActionButton(clearRow, 'Clear appearance', clearFrameStyle);
33987
34423
 
34424
+ var elements = makePanelSection(form, 'Map elements');
34425
+ var elementsRow = El('div')
34426
+ .addClass('label-style-row label-panel-button-row')
34427
+ .appendTo(elements);
34428
+ scalebarButton = makeActionButton(elementsRow, '', function() {
34429
+ if (gui.scalebarProperties) gui.scalebarProperties.open(target);
34430
+ }).addClass('frame-scalebar-btn');
34431
+
33988
34432
  var details = makePanelSection(form, 'Details');
33989
34433
  boundsValue = makeReadOnlyRow(details, 'Bounds');
33990
34434
  crsValue = makeReadOnlyRow(details, 'CRS');
@@ -34002,11 +34446,13 @@
34002
34446
  unitsSelect.node().value = units;
34003
34447
  aspectValue.text(getAspectText(frame));
34004
34448
  backgroundControl.showColor(rec.fill || '');
34005
- backgroundControl.opacity.node().value = formatOpacity(rec['fill-opacity']);
34449
+ backgroundControl.opacity.node().value = formatColorOpacityPct(rec['fill-opacity'], !!rec.fill);
34006
34450
  neatlineControl.showColor(rec.stroke || '');
34007
- neatlineControl.opacity.node().value = formatOpacity(rec['stroke-opacity']);
34451
+ neatlineControl.opacity.node().value = formatColorOpacityPct(rec['stroke-opacity'], !!rec.stroke);
34008
34452
  neatlineWidthInput.node().value =
34009
34453
  rec['stroke-width'] === undefined ? '' : rec['stroke-width'];
34454
+ scalebarButton.text(internal.findFrameFurnitureLayer(target.dataset, 'scalebar') ?
34455
+ 'Edit scale bar' : 'Add scale bar');
34010
34456
  boundsValue.text(frame.bbox.map(formatCoordinate).join(', '));
34011
34457
  crsValue.text(info.proj4 || '[unknown]');
34012
34458
  }
@@ -34092,8 +34538,7 @@
34092
34538
  }
34093
34539
 
34094
34540
  // An extra captioned cell on an existing colour row.
34095
- function makeRowField(row, label) {
34096
- var cell = El('div').addClass('label-split-cell').appendTo(row);
34541
+ function makeCellField(cell, label) {
34097
34542
  El('span').appendTo(cell).text(label);
34098
34543
  return makeTextInput(cell);
34099
34544
  }
@@ -34131,11 +34576,6 @@
34131
34576
  return String(Number(value.toPrecision(12)));
34132
34577
  }
34133
34578
 
34134
- function formatOpacity(value) {
34135
- return value === undefined ? '' :
34136
- String(Math.round(Number(value) * 100)) + '%';
34137
- }
34138
-
34139
34579
  // A fixed ratio holds when the frame is rescaled; one taken from the extent
34140
34580
  // changes whenever the extent does. That difference is the reason to show the
34141
34581
  // ratio at all, so it is said rather than implied.
@@ -34149,6 +34589,434 @@
34149
34589
  return formatFrameAspectRatio(ratio) + ' (from extent)';
34150
34590
  }
34151
34591
 
34592
+ // Record fields of a scalebar, in the order they are written as options
34593
+ var scalebarOptions = ['units', 'style', 'position', 'label_position',
34594
+ 'font_size', 'font_family', 'font_style', 'font_weight', 'color', 'bar_width',
34595
+ 'tic_length', 'label_offset', 'margin'];
34596
+
34597
+ // Returns a -scalebar command that recreates a scalebar with the given
34598
+ // settings (a scalebar data record, with any edits applied). -scalebar
34599
+ // replaces the frame's scalebar, so every setting has to be written.
34600
+ function getScalebarCommand(settings) {
34601
+ var parts = ['-scalebar'];
34602
+ var d = settings || {};
34603
+ if (isSet(d.label)) {
34604
+ parts.push(quoteCommandValue(d.label));
34605
+ }
34606
+ scalebarOptions.forEach(function(k) {
34607
+ if (isSet(d[k])) {
34608
+ parts.push(k.replace(/_/g, '-') + '=' + formatValue$1(d[k]));
34609
+ }
34610
+ });
34611
+ if (d.dual_units) {
34612
+ parts.push('dual-units');
34613
+ }
34614
+ return parts.join(' ');
34615
+ }
34616
+
34617
+ function isSet(val) {
34618
+ return val !== undefined && val !== null && val !== '';
34619
+ }
34620
+
34621
+ function formatValue$1(val) {
34622
+ var str = String(val);
34623
+ return /^[\w.#-]+$/.test(str) ? str : quoteCommandValue(str);
34624
+ }
34625
+
34626
+ var positionOptions = [
34627
+ ['top-left', 'Top left'],
34628
+ ['top-right', 'Top right'],
34629
+ ['bottom-left', 'Bottom left'],
34630
+ ['bottom-right', 'Bottom right']
34631
+ ];
34632
+
34633
+ // What the scalebar is drawn in when it has no color of its own
34634
+ var defaultColor = '#000000';
34635
+
34636
+ // Settings of the frame's scalebar. Every edit sends a complete -scalebar
34637
+ // command, which replaces the scalebar, so edits are undoable and replayable
34638
+ // like any other command.
34639
+ function ScalebarProperties(gui) {
34640
+ var popup, form, note;
34641
+ var labelInput, unitsSelect, styleSelect, dualUnitsBox, positionSelect,
34642
+ labelPositionSelect, fontSizeInput, barWidthInput, marginInput,
34643
+ fontSelect, fontStyleSelect, colorChit, colorInput, colorPicker;
34644
+
34645
+ gui.scalebarProperties = this;
34646
+
34647
+ // The panel doesn't block the map, so anything can change the scalebar or
34648
+ // the frame while it is open: an undo, a frame edit, a console command.
34649
+ gui.model.on('update', function(e) {
34650
+ var flags = e.flags || {};
34651
+ if (!popup || flags.simplify_amount || flags.redraw_only) return;
34652
+ if (getScalebarRecord()) {
34653
+ updateControls();
34654
+ } else {
34655
+ closePanel();
34656
+ }
34657
+ });
34658
+
34659
+ // Adds a scalebar to the frame if it doesn't have one
34660
+ this.open = function(frameTarget) {
34661
+ var frame = frameTarget || internal.getActiveFrame(gui.model);
34662
+ if (!frame) return;
34663
+ if (getScalebarRecord()) {
34664
+ showPanel();
34665
+ } else {
34666
+ runCommand('-scalebar', 'Add scale bar', showPanel);
34667
+ }
34668
+ };
34669
+
34670
+ this.isOpen = function() {
34671
+ return !!popup;
34672
+ };
34673
+
34674
+ // The map stays live, so the panel is placed where it is least likely to
34675
+ // hide the scalebar: at the top of the map, on the side away from it
34676
+ function showPanel() {
34677
+ var d = getScalebarRecord();
34678
+ if (!d) return;
34679
+ closePanel();
34680
+ popup = showPopupAlert('', 'Scale bar', {
34681
+ non_blocking: true,
34682
+ classname: 'scalebar-properties-box'
34683
+ });
34684
+ popup.onClose(function() {
34685
+ popup = form = null;
34686
+ });
34687
+ popup.container().addClass('scalebar-properties-popup');
34688
+ form = El('div')
34689
+ .addClass('label-style-panel scalebar-properties-form')
34690
+ .appendTo(popup.container());
34691
+ initForm();
34692
+ updateControls();
34693
+ placePanel(/right/.test(d.position || '') ? 'left' : 'right');
34694
+ }
34695
+
34696
+ function closePanel() {
34697
+ if (popup) popup.close();
34698
+ }
34699
+
34700
+ function placePanel(side) {
34701
+ var box = popup.container().node().parentNode;
34702
+ var map = gui.container.findChild('.map-layers').node().getBoundingClientRect();
34703
+ // clears the zoom buttons on the right edge of the map
34704
+ var rightInset = 58;
34705
+ var leftInset = 16;
34706
+ box.style.position = 'absolute';
34707
+ box.style.marginTop = '0';
34708
+ box.style.top = Math.round(map.top + 12) + 'px';
34709
+ if (side == 'left') {
34710
+ box.style.left = Math.round(map.left + leftInset) + 'px';
34711
+ } else {
34712
+ box.style.right = Math.round(window.innerWidth - map.right + rightInset) + 'px';
34713
+ }
34714
+ }
34715
+
34716
+ function initForm() {
34717
+ var row;
34718
+ note = El('div').addClass('scalebar-properties-note').appendTo(form).hide();
34719
+
34720
+ row = makeRow(form);
34721
+ labelInput = makeInput(makeCell(row, 'Distance'), 'scalebar-label-input')
34722
+ .attr('placeholder', 'Automatic')
34723
+ .on('change', function() {
34724
+ update({label: labelInput.node().value.trim()});
34725
+ });
34726
+ unitsSelect = makeSelect(makeCell(row, 'Units'), 'scalebar-units-select',
34727
+ [['miles', 'Miles'], ['km', 'Kilometers']])
34728
+ .on('change', function() {
34729
+ var changes = {units: unitsSelect.node().value};
34730
+ var bare = getBareDistance(getScalebarRecord().label);
34731
+ if (bare) changes.label = bare;
34732
+ update(changes);
34733
+ });
34734
+
34735
+ row = makeRow(form);
34736
+ styleSelect = makeSelect(makeCell(row, 'Style'), 'scalebar-style-select',
34737
+ [['a', 'Bar'], ['b', 'Ticks']])
34738
+ .on('change', function() {
34739
+ update({style: styleSelect.node().value});
34740
+ });
34741
+ dualUnitsBox = makeCheckbox(row, 'Metric + imperial', 'scalebar-dual-units')
34742
+ .on('change', function() {
34743
+ update({dual_units: dualUnitsBox.node().checked});
34744
+ });
34745
+
34746
+ row = makeRow(form);
34747
+ positionSelect = makeSelect(makeCell(row, 'Position'), 'scalebar-position-select',
34748
+ positionOptions)
34749
+ .on('change', function() {
34750
+ update({position: positionSelect.node().value});
34751
+ });
34752
+ labelPositionSelect = makeSelect(makeCell(row, 'Labels'),
34753
+ 'scalebar-label-position-select', [['top', 'Above'], ['bottom', 'Below']])
34754
+ .on('change', function() {
34755
+ update({label_position: labelPositionSelect.node().value});
34756
+ });
34757
+
34758
+ var appearance = makePanelSection(form, 'Appearance');
34759
+
34760
+ // Font and face menus, as in the label panel
34761
+ row = makeRow(appearance);
34762
+ fontSelect = El('select').attr('title', 'Font').addClass('scalebar-font-select')
34763
+ .appendTo(makeCell(row, 'Font'))
34764
+ .on('change', function() {
34765
+ if (fontSelect.node().value) applyFont(fontSelect.node().value);
34766
+ });
34767
+ renderFontOptions();
34768
+ fontStyleSelect = El('select').attr('title', 'Font style')
34769
+ .addClass('scalebar-font-style-select')
34770
+ .appendTo(makeCell(row, 'Font style'))
34771
+ .on('change', function() {
34772
+ if (fontStyleSelect.node().value) applyFontStyle(fontStyleSelect.node().value);
34773
+ });
34774
+
34775
+ // The picker lines up with the right edge of its parent, so the row is its
34776
+ // parent rather than the narrow color cell
34777
+ row = makeRow(appearance);
34778
+ var colorCell = El('div').addClass('frame-input-cell scalebar-color-cell').appendTo(row);
34779
+ El('span').appendTo(colorCell).text('Color');
34780
+ colorChit = El('div').addClass('label-color-chit').attr('role', 'button')
34781
+ .on('click', function() { colorPicker.toggle(); });
34782
+ colorInput = El('input').attr('type', 'text').attr('title', 'Scale bar color')
34783
+ .on('change', function() {
34784
+ var color = colorInput.node().value.trim();
34785
+ if (!color) {
34786
+ updateControls();
34787
+ return;
34788
+ }
34789
+ if (isHexColor(color)) colorPicker.setColor(color);
34790
+ update({color: color});
34791
+ });
34792
+ makeColorField(colorCell, colorChit, colorInput);
34793
+ colorPicker = new ColorPicker(row, {
34794
+ onPreview: showColor,
34795
+ onChange: function(hex) {
34796
+ showColor(hex);
34797
+ update({color: hex});
34798
+ }
34799
+ });
34800
+ fontSizeInput = makeNumberInput(makeCell(row, 'Font size'), 'font_size',
34801
+ 'scalebar-font-size');
34802
+
34803
+ row = makeRow(appearance);
34804
+ barWidthInput = makeNumberInput(makeCell(row, 'Bar width'), 'bar_width',
34805
+ 'scalebar-bar-width');
34806
+ marginInput = makeNumberInput(makeCell(row, 'Inset'), 'margin',
34807
+ 'scalebar-margin')
34808
+ .attr('title', 'Distance from the edges of the frame, in pixels');
34809
+
34810
+ row = El('div').addClass('label-style-row label-panel-button-row').appendTo(form);
34811
+ makePanelActionButton(row, 'Remove scale bar', function() {
34812
+ runCommand('-scalebar remove', 'Remove scale bar', function() {
34813
+ if (popup) popup.close();
34814
+ });
34815
+ });
34816
+ }
34817
+
34818
+ function updateControls() {
34819
+ var d = getScalebarRecord();
34820
+ var problem;
34821
+ if (!d || !form) return;
34822
+ labelInput.node().value = d.label || '';
34823
+ setSelectValue(unitsSelect, getUnitsMenuValue(d.units ||
34824
+ internal.parseScalebarUnits(d.label || '')));
34825
+ setSelectValue(styleSelect, d.style == 'b' || d.style == 'B' ||
34826
+ !d.style && d.dual_units ? 'b' : 'a');
34827
+ dualUnitsBox.node().checked = !!d.dual_units;
34828
+ setSelectValue(positionSelect, d.position || 'top-left');
34829
+ setSelectValue(labelPositionSelect, d.label_position || 'top');
34830
+ fontSizeInput.node().value = formatValue(d.font_size);
34831
+ barWidthInput.node().value = formatValue(d.bar_width);
34832
+ marginInput.node().value = formatValue(d.margin);
34833
+ updateFontControls(d);
34834
+ showColor(d.color || defaultColor);
34835
+ if (isHexColor(d.color || defaultColor)) colorPicker.setColor(d.color || defaultColor);
34836
+ problem = getFrameProblem();
34837
+ note.text(problem ? 'The scale bar is hidden: ' + problem + '.' : '');
34838
+ if (problem) note.show(); else note.hide();
34839
+ }
34840
+
34841
+ function showColor(color) {
34842
+ colorInput.node().value = color || '';
34843
+ colorChit.css('background-color', color || 'transparent');
34844
+ }
34845
+
34846
+ function renderFontOptions() {
34847
+ getInstalledFonts().forEach(function(group) {
34848
+ var optgroup = El('optgroup').attr('label', group.name).appendTo(fontSelect);
34849
+ group.fonts.forEach(function(fontName) {
34850
+ El('option').attr('value', fontName).appendTo(optgroup).text(fontName);
34851
+ });
34852
+ });
34853
+ }
34854
+
34855
+ // A scalebar with no font of its own is shown in the font it is drawn in
34856
+ function getShownFont(d) {
34857
+ return d.font_family || getDefaultFontName();
34858
+ }
34859
+
34860
+ function updateFontControls(d) {
34861
+ var font = getShownFont(d);
34862
+ var shown = font ? getNearestFontStyleVariant(font, d.font_style, d.font_weight) : null;
34863
+ if (font) {
34864
+ setSelectValue(fontSelect, font);
34865
+ } else {
34866
+ fontSelect.node().selectedIndex = -1;
34867
+ }
34868
+ fontStyleSelect.empty();
34869
+ getFontStyleVariants(font).forEach(function(variant) {
34870
+ El('option').attr('value', variant.value).appendTo(fontStyleSelect).text(variant.label);
34871
+ });
34872
+ fontStyleSelect.node().disabled = !font;
34873
+ if (shown) {
34874
+ fontStyleSelect.node().value = shown.value;
34875
+ } else {
34876
+ fontStyleSelect.node().selectedIndex = -1;
34877
+ }
34878
+ }
34879
+
34880
+ // Keeps the current face, or the nearest one the new font has
34881
+ function applyFont(fontName) {
34882
+ var d = getScalebarRecord();
34883
+ var changes = {font_family: fontName};
34884
+ var variant = getNearestFontStyleVariant(fontName, d.font_style, d.font_weight);
34885
+ if (variant) Object.assign(changes, getFontStyleValues(variant));
34886
+ update(changes);
34887
+ }
34888
+
34889
+ // A face belongs to a font, so a scalebar given one names the font it is
34890
+ // drawn in
34891
+ function applyFontStyle(value) {
34892
+ var parts = value.split('|');
34893
+ var changes = getFontStyleValues({style: parts[0], weight: parts[1]});
34894
+ var d = getScalebarRecord();
34895
+ if (!d.font_family && getDefaultFontName()) {
34896
+ changes.font_family = getDefaultFontName();
34897
+ }
34898
+ update(changes);
34899
+ }
34900
+
34901
+ // Regular is stored as no face at all
34902
+ function getFontStyleValues(variant) {
34903
+ if (variantIsRegular(variant)) {
34904
+ return {font_style: '', font_weight: ''};
34905
+ }
34906
+ return {font_style: variant.style, font_weight: variant.weight};
34907
+ }
34908
+
34909
+ // Applies edits to the current settings and replaces the scalebar. The
34910
+ // units menu always applies, so it is written even when it shows the default.
34911
+ function update(changes) {
34912
+ var d = Object.assign({}, getScalebarRecord(), changes);
34913
+ if (changes.style == 'a') d.dual_units = false;
34914
+ if (changes.dual_units) d.style = 'b';
34915
+ if (!d.units) d.units = unitsSelect.node().value;
34916
+ runCommand(getScalebarCommand(d), 'Update scale bar', updateControls);
34917
+ }
34918
+
34919
+ function makeNumberInput(cell, field, className) {
34920
+ var input = makeInput(cell, className).attr('placeholder', 'auto');
34921
+ input.on('change', function() {
34922
+ var str = input.node().value.trim();
34923
+ var o = {};
34924
+ if (str !== '' && !(Number(str) >= 0)) {
34925
+ updateControls();
34926
+ return;
34927
+ }
34928
+ o[field] = str === '' ? '' : Number(str);
34929
+ update(o);
34930
+ });
34931
+ return input;
34932
+ }
34933
+
34934
+ function getFrameProblem() {
34935
+ var frame = internal.getActiveFrame(gui.model);
34936
+ var crs, data;
34937
+ if (!frame) return 'there is no map frame';
34938
+ crs = frame.layer.gui && frame.layer.gui.dynamic_crs ||
34939
+ internal.getDatasetCRS(frame.dataset);
34940
+ data = internal.getFrameLayerData(frame.layer, frame.dataset.arcs, crs);
34941
+ return internal.getFurnitureFrameProblem(data);
34942
+ }
34943
+
34944
+ function getScalebarRecord() {
34945
+ var frame = internal.getActiveFrame(gui.model);
34946
+ var lyr = frame && internal.findFrameFurnitureLayer(frame.dataset, 'scalebar');
34947
+ return lyr ? lyr.data.getReadOnlyRecordAt(0) : null;
34948
+ }
34949
+
34950
+ function runCommand(cmd, title, done) {
34951
+ runGuiEditCommand(gui, cmd, {
34952
+ title: title,
34953
+ onSuccess: done,
34954
+ onError: updateControls
34955
+ });
34956
+ }
34957
+ }
34958
+
34959
+ function makeRow(parent, className) {
34960
+ return El('div').addClass('label-style-row scalebar-row')
34961
+ .addClass(className || 'scalebar-two-col').appendTo(parent);
34962
+ }
34963
+
34964
+ function makeCell(row, label) {
34965
+ var cell = El('label').addClass('frame-input-cell').appendTo(row);
34966
+ El('span').appendTo(cell).text(label);
34967
+ return cell;
34968
+ }
34969
+
34970
+ function makeInput(parent, className) {
34971
+ return El('input').attr('type', 'text').addClass(className).appendTo(parent);
34972
+ }
34973
+
34974
+ function makeSelect(parent, className, options) {
34975
+ var select = El('select').addClass(className).appendTo(parent);
34976
+ options.forEach(function(o) {
34977
+ El('option').attr('value', o[0]).appendTo(select).text(o[1]);
34978
+ });
34979
+ return select;
34980
+ }
34981
+
34982
+ function makeCheckbox(parent, label, className) {
34983
+ var box = El('input').attr('type', 'checkbox').addClass(className);
34984
+ var wrapper = El('label').addClass('scalebar-checkbox').appendTo(parent);
34985
+ box.appendTo(wrapper);
34986
+ El('span').appendTo(wrapper).text(label);
34987
+ return box;
34988
+ }
34989
+
34990
+ // Shows a value that came from the command line even if the menu lacks it
34991
+ function setSelectValue(select, value) {
34992
+ var node = select.node();
34993
+ var found = Array.prototype.some.call(node.options, function(o) {
34994
+ return o.value == value;
34995
+ });
34996
+ if (!found) {
34997
+ El('option').attr('value', value).appendTo(select).text(value);
34998
+ }
34999
+ node.value = value;
35000
+ }
35001
+
35002
+ // The number of a single-distance label, e.g. "100" from "100 km", so the
35003
+ // units menu can apply to it
35004
+ function getBareDistance(label) {
35005
+ var match = /^([\d\s.,/]*\d)\s*[^\d\s.,/]+$/.exec(String(label || '').trim());
35006
+ return match ? match[1].trim() : '';
35007
+ }
35008
+
35009
+ function getUnitsMenuValue(units) {
35010
+ var parsed = internal.parseLabelUnits(units);
35011
+ return parsed == 'mile' || !parsed ? 'miles' :
35012
+ parsed == 'm' ? 'meters' :
35013
+ parsed == 'ft' ? 'feet' : 'km';
35014
+ }
35015
+
35016
+ function formatValue(val) {
35017
+ return val === undefined || val === null ? '' : String(val);
35018
+ }
35019
+
34152
35020
  // This is a new way to handle compatibility problems between
34153
35021
  // interactive editing modes and other interface modes
34154
35022
  // (by default, interactive modes stay on when, e.g., the user clicks
@@ -34951,6 +35819,7 @@
34951
35819
  new PreviewMode(gui);
34952
35820
  new FrameResizeTool(gui);
34953
35821
  new FrameProperties(gui);
35822
+ new ScalebarProperties(gui);
34954
35823
  gui.editToolbar = new EditToolbar(gui);
34955
35824
  gui.labelTool = new LabelTool(gui);
34956
35825
  gui.layerStyleTool = new LayerStyleTool(gui);
@@ -35151,7 +36020,8 @@
35151
36020
  }
35152
36021
 
35153
36022
  function clampSidebarPanelsSeparatorPosition(pageY) {
35154
- var pct = 100 * (pageY - 29) / (window.innerHeight - 29);
36023
+ var rect = gui.container.findChild('.sidebar-panels').node().getBoundingClientRect();
36024
+ var pct = 100 * (pageY - rect.top) / rect.height;
35155
36025
  return Math.max(15, Math.min(85, pct));
35156
36026
  }
35157
36027
  }
@@ -35582,6 +36452,7 @@
35582
36452
  new ImportControl(gui, importOpts);
35583
36453
  new ExportControl(gui);
35584
36454
  new LayerControl(gui);
36455
+ new SidebarTabs(gui);
35585
36456
  new AddLayerLinks(gui);
35586
36457
  HeaderMenu();
35587
36458
  gui.console = new Console(gui);
@@ -35644,6 +36515,24 @@
35644
36515
  El('body').addClass('map-view');
35645
36516
  gui.console.runInitialCommands(getInitialConsoleCommands());
35646
36517
  });
36518
+
36519
+ // The layer list, opened beside the first data so that the user sees what
36520
+ // was imported and where to go next -- once, and only for data: dismissing
36521
+ // the splash popup without importing anything leaves the sidebar closed.
36522
+ // Waits for any menu mode (the import dialog, while files are still loading)
36523
+ // to close, since opening a panel would close it.
36524
+ var layersShownForData = false;
36525
+ gui.model.on('update', showLayersForFirstData);
36526
+ gui.on('mode', showLayersForFirstData);
36527
+ function showLayersForFirstData() {
36528
+ var panels;
36529
+ if (layersShownForData || gui.model.isEmpty() || gui.getMode()) return;
36530
+ layersShownForData = true;
36531
+ panels = gui.getSidebarPanels();
36532
+ if (!panels.includes('layers')) {
36533
+ gui.setSidebarPanels(panels.concat('layers').sort());
36534
+ }
36535
+ }
35647
36536
  };
35648
36537
 
35649
36538
  })();