mapshaper 0.7.62 → 0.7.64

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.
@@ -1153,6 +1153,11 @@
1153
1153
  if (opts.max_width) {
1154
1154
  infoBox.node().style.maxWidth = opts.max_width;
1155
1155
  }
1156
+ // For a popup that needs to style or unclip the box itself, not just its
1157
+ // content -- the box scrolls its overflow, which would cut off a tooltip.
1158
+ if (opts.classname) {
1159
+ infoBox.addClass(opts.classname);
1160
+ }
1156
1161
  var container = El('div').appendTo(infoBox);
1157
1162
  if (!title && warningRxp.test(msg)) {
1158
1163
  title = 'Warning';
@@ -3140,12 +3145,12 @@
3140
3145
  return decimals >= 0 ? num.toFixed(decimals) : String(num);
3141
3146
  }
3142
3147
 
3143
- function formatNumber(val) {
3148
+ function formatNumber$2(val) {
3144
3149
  return val + '';
3145
3150
  }
3146
3151
 
3147
3152
  function formatIntlNumber(val) {
3148
- var str = formatNumber(val);
3153
+ var str = formatNumber$2(val);
3149
3154
  return '"' + str.replace('.', ',') + '"'; // need to quote if comma-delimited
3150
3155
  }
3151
3156
 
@@ -3694,7 +3699,7 @@
3694
3699
  endsWith, every, expandoBuffer, extend, extendBuffer,
3695
3700
  find, findMedian, findQuantile, findRankByValue, findStringPrefix,
3696
3701
  findValueByPct, findValueByRank, forEach, forEachProperty, format,
3697
- formatDateISO, formatIntlNumber, formatNumber, formatNumberForDisplay,
3702
+ formatDateISO, formatIntlNumber, formatNumber: formatNumber$2, formatNumberForDisplay,
3698
3703
  formatVersionedName, formatter,
3699
3704
  genericSort, getArrayBounds, getGenericComparator, getKeyComparator,
3700
3705
  getSortedIds, getUniqueName, groupBy,
@@ -8564,6 +8569,24 @@
8564
8569
  return copy;
8565
8570
  }
8566
8571
 
8572
+ // Return the point transform used to calculate dynamic-display frame bounds.
8573
+ // This mirrors the WGS84 pivot and Web Mercator latitude clamping used above.
8574
+ function getDisplayProjectionTransform(src, dest) {
8575
+ var wgs84 = internal.parseCrsString('wgs84');
8576
+ var toWGS84 = internal.isWGS84(src) ? null :
8577
+ internal.getProjTransform2(src, wgs84);
8578
+ var fromWGS84 = internal.getProjTransform2(wgs84, dest);
8579
+ var clampLat = internal.isWebMercator(dest);
8580
+ return function(x, y) {
8581
+ var p = toWGS84 ? toWGS84(x, y) : [x, y];
8582
+ if (!p) return null;
8583
+ if (clampLat) {
8584
+ p = [p[0], Math.max(-89.9, Math.min(89.9, p[1]))];
8585
+ }
8586
+ return fromWGS84(p[0], p[1]);
8587
+ };
8588
+ }
8589
+
8567
8590
  function clampY(arcs) {
8568
8591
  var max = 89.9,
8569
8592
  min = -89.9,
@@ -8758,10 +8781,21 @@
8758
8781
  if (internal.layerHasPoints(layer)) {
8759
8782
  gui.displayLayer = projectPointsForDisplay(layer, sourceCRS, displayCRS);
8760
8783
  } else if (internal.layerHasPaths(layer)) {
8761
- emptyArcs = findEmptyArcs(displayArcs);
8762
- if (emptyArcs.length > 0) {
8763
- // Don't try to draw paths containing coordinates that failed to project
8764
- gui.displayLayer = internal.filterPathLayerByArcIds(gui.displayLayer, emptyArcs);
8784
+ if (internal.isFrameLayer(layer, dataset.arcs)) {
8785
+ var projectedFrame = internal.getProjectedFrameDisplayLayer(
8786
+ layer,
8787
+ dataset.arcs,
8788
+ getDisplayProjectionTransform(sourceCRS, displayCRS),
8789
+ internal.isLatLngCRS(sourceCRS)
8790
+ );
8791
+ gui.displayLayer = projectedFrame.layer;
8792
+ gui.displayArcs = projectedFrame.arcs;
8793
+ } else {
8794
+ emptyArcs = findEmptyArcs(displayArcs);
8795
+ if (emptyArcs.length > 0) {
8796
+ // Don't try to draw paths containing coordinates that failed to project
8797
+ gui.displayLayer = internal.filterPathLayerByArcIds(gui.displayLayer, emptyArcs);
8798
+ }
8765
8799
  }
8766
8800
  } else if (internal.layerHasRaster(layer)) {
8767
8801
  gui.bounds = getProjectedRasterDisplayBounds(layer, sourceCRS, displayCRS);
@@ -10678,6 +10712,8 @@
10678
10712
  var toggleBtn = null; // checkbox <input> for toggling layer selection
10679
10713
  var exportBtn = gui.container.findChild('.export-btn').addClass('disabled');
10680
10714
  var ofileName = gui.container.findChild('#ofile-name');
10715
+ var frameInfo = menu.findChild('.export-frame-info').hide();
10716
+ menu.findChild('.advanced-options').on('input', updateFrameInfo);
10681
10717
  new SimpleButton(menu.findChild('.close2-btn')).on('click', gui.clearMode);
10682
10718
 
10683
10719
  if (!GUI.exportIsSupported()) {
@@ -10746,6 +10782,7 @@
10746
10782
  formatPickedByUser = false;
10747
10783
  // initZipOption();
10748
10784
  initFormatMenu();
10785
+ updateFrameInfo();
10749
10786
  updateExportCheckboxes();
10750
10787
  menu.show();
10751
10788
  }
@@ -10827,6 +10864,10 @@
10827
10864
  var snapshot = gui.session.getHistorySnapshot();
10828
10865
  snapshot.savedAtIndex = snapshot.commands.length;
10829
10866
  opts.history = snapshot;
10867
+ targets = addFrameTarget(targets);
10868
+ }
10869
+ if (opts.format == 'svg' || opts.format == 'topojson') {
10870
+ opts.gui_frame = getGuiFrameContext();
10830
10871
  }
10831
10872
  try {
10832
10873
  var files = await internal.exportTargetLayers(model, targets, opts);
@@ -10892,7 +10933,9 @@
10892
10933
 
10893
10934
  function initLayerMenu() {
10894
10935
  var list = menu.findChild('.export-layer-list').empty();
10895
- var layers = model.getLayers();
10936
+ var layers = model.getLayers().filter(function(o) {
10937
+ return !internal.isFrameLayer(o.layer, o.dataset.arcs);
10938
+ });
10896
10939
  sortLayersForMenuDisplay(layers);
10897
10940
 
10898
10941
  if (layers.length > 2) {
@@ -11011,11 +11054,13 @@
11011
11054
  // changes afterwards.
11012
11055
  formatPickedByUser = true;
11013
11056
  updateExportCheckboxes();
11057
+ updateFrameInfo();
11014
11058
  }
11015
11059
 
11016
11060
  function setSelectedFormat(fmt) {
11017
11061
  var el = menu.findChild('.export-formats input[value="' + fmt + '"]');
11018
11062
  if (el) el.node().checked = true;
11063
+ updateFrameInfo();
11019
11064
  }
11020
11065
 
11021
11066
  // Which formats apply depends on what is checked for export, so unchecking
@@ -11059,6 +11104,62 @@
11059
11104
  }, []);
11060
11105
  }
11061
11106
 
11107
+ function getGuiFrameContext() {
11108
+ var target = internal.getActiveFrame(model);
11109
+ var rec, style;
11110
+ if (!target) return null;
11111
+ rec = target.layer.data.getReadOnlyRecordAt(0) || {};
11112
+ style = {};
11113
+ ['fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity',
11114
+ 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'opacity'
11115
+ ].forEach(function(field) {
11116
+ if (rec[field] !== undefined && rec[field] !== null && rec[field] !== '') {
11117
+ style[field] = rec[field];
11118
+ }
11119
+ });
11120
+ return {
11121
+ data: internal.getFrameLayerData(
11122
+ target.layer,
11123
+ target.dataset.arcs,
11124
+ internal.getDatasetCRS(target.dataset)
11125
+ ),
11126
+ name: target.layer.name || 'frame',
11127
+ style: style
11128
+ };
11129
+ }
11130
+
11131
+ function addFrameTarget(targets) {
11132
+ var frame = internal.getActiveFrame(model);
11133
+ var group;
11134
+ if (!frame) return targets;
11135
+ targets = targets.map(function(target) {
11136
+ return Object.assign({}, target, {layers: target.layers.slice()});
11137
+ });
11138
+ group = targets.find(function(target) {
11139
+ return target.dataset == frame.dataset;
11140
+ });
11141
+ if (group) {
11142
+ if (!group.layers.includes(frame.layer)) group.layers.push(frame.layer);
11143
+ } else {
11144
+ targets.push({dataset: frame.dataset, layers: [frame.layer]});
11145
+ }
11146
+ return targets;
11147
+ }
11148
+
11149
+ function updateFrameInfo() {
11150
+ if (!frameInfo) return;
11151
+ var context = getGuiFrameContext();
11152
+ var format = getSelectedFormat();
11153
+ var topoUsesPixels = format == 'topojson' &&
11154
+ /\b(?:width|height)\s*=/.test(getExportOptsAsString());
11155
+ if (context && (format == 'svg' || topoUsesPixels)) {
11156
+ frameInfo.text('Map frame: ' +
11157
+ internal.formatFrameSizeForDisplay(context.data)).show();
11158
+ } else {
11159
+ frameInfo.hide();
11160
+ }
11161
+ }
11162
+
11062
11163
  };
11063
11164
 
11064
11165
  function DomCache() {
@@ -11247,6 +11348,15 @@
11247
11348
  }
11248
11349
  openMenu$1 = this;
11249
11350
 
11351
+ if (e.frameProperties) {
11352
+ addMenuItem('frame properties', e.frameProperties, '');
11353
+ }
11354
+ if (e.resizeFrame) {
11355
+ addMenuItem('resize frame', e.resizeFrame, '');
11356
+ }
11357
+ if (e.deleteFrame) {
11358
+ addMenuItem('delete frame', e.deleteFrame, '');
11359
+ }
11250
11360
  if (e.deleteLayer) {
11251
11361
  addMenuItem('delete layer', e.deleteLayer, '');
11252
11362
  }
@@ -11454,6 +11564,18 @@
11454
11564
  var isOpen = false;
11455
11565
  var cache = new DomCache();
11456
11566
  var pinAll = el.findChild('.pin-all'); // button for toggling layer visibility
11567
+ var frameEmpty = el.findChild('.map-frame-empty')
11568
+ .on('click', openFrameCreateDialog)
11569
+ .on('keydown', function(e) {
11570
+ if (e.key == 'Enter' || e.key == ' ') {
11571
+ e.preventDefault();
11572
+ openFrameCreateDialog();
11573
+ }
11574
+ });
11575
+
11576
+ function openFrameCreateDialog() {
11577
+ if (gui.frameTool) gui.frameTool.openCreateDialog();
11578
+ }
11457
11579
 
11458
11580
  // layer repositioning
11459
11581
  var dragTargetId = null;
@@ -11537,7 +11659,7 @@
11537
11659
  function testAllLayersPinned() {
11538
11660
  var allPinned = true;
11539
11661
  model.forEachLayer(function(lyr, dataset) {
11540
- if (isPinnable(lyr) && !lyr.pinned) {
11662
+ if (isPinnable(lyr, dataset) && !lyr.pinned) {
11541
11663
  allPinned = false;
11542
11664
  }
11543
11665
  });
@@ -11620,10 +11742,14 @@
11620
11742
 
11621
11743
  function renderLayerList() {
11622
11744
  var list = el.findChild('.layer-list');
11745
+ var frameSection = el.findChild('.map-frame-section');
11746
+ var frameList = el.findChild('.map-frame-list');
11623
11747
  var uniqIds = {};
11624
11748
  var pinnableCount = 0;
11625
11749
  var layerCount = 0;
11750
+ var frameCount = 0;
11626
11751
  list.empty();
11752
+ frameList.empty();
11627
11753
  model.forEachLayer(function(lyr, dataset) {
11628
11754
  // Assign a unique id to each layer, so html strings
11629
11755
  // can be used as unique identifiers for caching rendered HTML, and as
@@ -11632,7 +11758,7 @@
11632
11758
  lyr.menu_id = utils$1.getUniqueName();
11633
11759
  }
11634
11760
  uniqIds[lyr.menu_id] = true;
11635
- if (isPinnable(lyr)) pinnableCount++;
11761
+ if (isPinnable(lyr, dataset)) pinnableCount++;
11636
11762
  layerCount++;
11637
11763
  });
11638
11764
 
@@ -11645,9 +11771,10 @@
11645
11771
 
11646
11772
  sortLayersForMenuDisplay(model.getLayers()).forEach(function(o) {
11647
11773
  var lyr = o.layer;
11774
+ var isFrame = internal.isFrameLayer(lyr, o.dataset.arcs);
11648
11775
  var opts = {
11649
11776
  show_source: layerCount < 5,
11650
- pinnable: pinnableCount > 0 && isPinnable(lyr)
11777
+ pinnable: pinnableCount > 0 && isPinnable(lyr, o.dataset)
11651
11778
  };
11652
11779
  var html, element;
11653
11780
  html = renderLayer(lyr, o.dataset, opts);
@@ -11658,14 +11785,22 @@
11658
11785
  initMouseEvents(element, lyr.menu_id, opts.pinnable);
11659
11786
  cache.add(html, element);
11660
11787
  }
11661
- list.appendChild(element);
11788
+ if (isFrame) {
11789
+ frameList.appendChild(element);
11790
+ frameCount++;
11791
+ } else {
11792
+ list.appendChild(element);
11793
+ }
11662
11794
  });
11795
+ frameSection.classed('hidden', layerCount === 0);
11796
+ frameEmpty.classed('hidden', frameCount > 0);
11663
11797
  }
11664
11798
 
11665
11799
  cache.cleanup();
11666
11800
 
11667
11801
  function renderLayer(lyr, dataset, opts) {
11668
11802
  var classes = 'layer-item';
11803
+ var isFrame = internal.isFrameLayer(lyr, dataset.arcs);
11669
11804
  var entry, html;
11670
11805
 
11671
11806
  if (opts.pinnable) classes += ' pinnable';
@@ -11675,7 +11810,7 @@
11675
11810
 
11676
11811
  html = '<!-- ' + lyr.menu_id + '--><div class="' + classes + '">';
11677
11812
  html += rowHTML('name', '<span class="layer-name colored-text dot-underline">' + formatLayerNameForDisplay(lyr.name) + '</span>', 'row1');
11678
- html += rowHTML('contents', describeLyr(lyr, dataset));
11813
+ html += rowHTML(isFrame ? 'size' : 'contents', describeLyr(lyr, dataset));
11679
11814
  html += '<span class="more-btn layer-btn" role="button" aria-label="More layer options"></span>';
11680
11815
  if (opts.pinnable) {
11681
11816
  html += '<img class="eye-btn black-eye layer-btn" draggable="false" src="images/eye.png">';
@@ -11794,7 +11929,18 @@
11794
11929
  if (!target) return;
11795
11930
  popup = showPopupAlert('', 'Layer info');
11796
11931
  content = popup.container().addClass('layer-info-popup');
11797
- content.node().appendChild(renderLayerInfo(internal.getLayerInfo(target.layer, target.dataset)));
11932
+ content.node().appendChild(renderLayerInfo(
11933
+ internal.getLayerInfo(target.layer, target.dataset)
11934
+ ));
11935
+ }
11936
+
11937
+ function openFrameProperties() {
11938
+ var target = findLayerById(id);
11939
+ if (target && gui.frameProperties) gui.frameProperties.open(target);
11940
+ }
11941
+
11942
+ function resizeFrame() {
11943
+ if (gui.frameTool) gui.frameTool.open();
11798
11944
  }
11799
11945
 
11800
11946
  function styleLayer() {
@@ -11827,10 +11973,18 @@
11827
11973
  pageY: rect.top + rect.height / 2
11828
11974
  };
11829
11975
  }
11830
- menuEvent.deleteLayer = deleteLayer;
11831
- menuEvent.duplicateLayer = duplicateLayer;
11832
- menuEvent.showLayerInfo = showLayerInfo;
11833
- if (target && layerCanBeStyled(target.layer)) {
11976
+ var isFrame = target &&
11977
+ internal.isFrameLayer(target.layer, target.dataset.arcs);
11978
+ if (isFrame) {
11979
+ menuEvent.frameProperties = openFrameProperties;
11980
+ menuEvent.resizeFrame = resizeFrame;
11981
+ menuEvent.deleteFrame = deleteLayer;
11982
+ } else {
11983
+ menuEvent.deleteLayer = deleteLayer;
11984
+ menuEvent.duplicateLayer = duplicateLayer;
11985
+ menuEvent.showLayerInfo = showLayerInfo;
11986
+ }
11987
+ if (!isFrame && target && layerCanBeStyled(target.layer)) {
11834
11988
  menuEvent.styleLayer = styleLayer;
11835
11989
  if (internal.layerHasLabels(target.layer)) {
11836
11990
  menuEvent.styleLayerName = 'edit labels';
@@ -11895,7 +12049,13 @@
11895
12049
 
11896
12050
  // init click-to-select
11897
12051
  GUI.onClick(entry, function() {
11898
- selectLayer();
12052
+ var target = findLayerById(id);
12053
+ if (target && internal.isFrameLayer(target.layer, target.dataset.arcs) &&
12054
+ gui.frameProperties) {
12055
+ gui.frameProperties.open(target);
12056
+ } else {
12057
+ selectLayer();
12058
+ }
11899
12059
  });
11900
12060
 
11901
12061
  }
@@ -11912,7 +12072,9 @@
11912
12072
  type = 'raster layer';
11913
12073
  }
11914
12074
  if (isFrame) {
11915
- str = 'map frame';
12075
+ str = internal.formatFrameSizeForDisplay(
12076
+ internal.getFrameLayerData(lyr, dataset.arcs)
12077
+ );
11916
12078
  } else if (internal.layerHasRaster(lyr)) {
11917
12079
  str = utils$1.format('%,d x %,d %s', internal.getRasterWidth(lyr.raster), internal.getRasterHeight(lyr.raster), type);
11918
12080
  } else if (type) {
@@ -11965,7 +12127,7 @@
11965
12127
  title.className = 'console-info-subtitle';
11966
12128
  title.textContent = 'Attribute data';
11967
12129
  wrapper.appendChild(title);
11968
- if (!fields) {
12130
+ if (!fields || fields.length === 0) {
11969
12131
  var none = document.createElement('div');
11970
12132
  none.className = 'console-info-empty';
11971
12133
  none.textContent = '[none]';
@@ -12041,7 +12203,8 @@
12041
12203
  }
12042
12204
  }
12043
12205
 
12044
- function isPinnable(lyr) {
12206
+ function isPinnable(lyr, dataset) {
12207
+ if (dataset && internal.isFrameLayer(lyr, dataset.arcs)) return false;
12045
12208
  return internal.layerIsGeometric(lyr) || internal.layerHasRaster(lyr) || internal.layerHasFurniture(lyr);
12046
12209
  }
12047
12210
 
@@ -13947,6 +14110,8 @@
13947
14110
  vertices: 'edit vertices',
13948
14111
  selection: 'selection tool',
13949
14112
  ruler: 'measure distance',
14113
+ frame: 'edit map frame',
14114
+ frame_draw: 'draw map frame',
13950
14115
  'add-points': 'add points',
13951
14116
  rectangles: 'drag-to-resize',
13952
14117
  off: 'turn off'
@@ -14021,7 +14186,7 @@
14021
14186
  };
14022
14187
 
14023
14188
  this.modeSupportsUndo = function(mode) {
14024
- return ['data', 'label', 'label_style', 'point_style', 'line_style', 'polygon_style', 'edit_points', 'edit_lines', 'edit_polygons', 'snip_lines', 'vertices', 'rectangles'].includes(mode);
14189
+ return ['data', 'frame', 'label', 'label_style', 'point_style', 'line_style', 'polygon_style', 'edit_points', 'edit_lines', 'edit_polygons', 'snip_lines', 'vertices', 'rectangles'].includes(mode);
14025
14190
  };
14026
14191
 
14027
14192
  this.getMode = getInteractionMode;
@@ -14048,7 +14213,8 @@
14048
14213
  // A mode with a panel of its own: hovering the button must not open the mode
14049
14214
  // menu over the panel.
14050
14215
  function stylePanelIsActive() {
14051
- return _editMode == 'label' || _editMode == 'label_style' ||
14216
+ return _editMode == 'frame' || _editMode == 'frame_draw' ||
14217
+ _editMode == 'label' || _editMode == 'label_style' ||
14052
14218
  _editMode == 'point_style' || _editMode == 'line_style' ||
14053
14219
  _editMode == 'polygon_style';
14054
14220
  }
@@ -14115,11 +14281,18 @@
14115
14281
  // if current editing mode is not available, turn off the tool
14116
14282
  function updateCurrentMode() {
14117
14283
  var modes = getAvailableModes();
14118
- if (modes.indexOf(_editMode) == -1 && !labelModeIsAvailable() && !labelStyleModeIsAvailable() && !layerStyleModeIsAvailable() && !pointStyleModeIsAvailable()) {
14284
+ if (modes.indexOf(_editMode) == -1 && !inFrameMode() && !labelModeIsAvailable() && !labelStyleModeIsAvailable() && !layerStyleModeIsAvailable() && !pointStyleModeIsAvailable()) {
14119
14285
  setMode('off');
14120
14286
  }
14121
14287
  }
14122
14288
 
14289
+ // The frame modes are reached from the layer panel and the frame menu, never
14290
+ // from this button's menu, so they neither belong to the active layer's tool
14291
+ // list nor light the button up.
14292
+ function inFrameMode() {
14293
+ return _editMode == 'frame' || _editMode == 'frame_draw';
14294
+ }
14295
+
14123
14296
  // Whether a label made now would join the active layer rather than starting a
14124
14297
  // layer of its own, which is the label tool's own rule for what a label layer
14125
14298
  // is: one with a label-text field, or an empty point layer.
@@ -14206,7 +14379,7 @@
14206
14379
  }
14207
14380
  btn.classed('hover', _menuOpen);
14208
14381
  // btn.classed('selected', active() && !_menuOpen);
14209
- btn.classed('selected', active());
14382
+ btn.classed('selected', active() && !inFrameMode());
14210
14383
  }
14211
14384
 
14212
14385
  function updateSelectionHighlight() {
@@ -14232,6 +14405,9 @@
14232
14405
  //
14233
14406
  // API:
14234
14407
  // toolbar.addButton(iconRef, opts) -> ToolbarButton
14408
+ // toolbar.addTextButton(label, opts) -> ToolbarButton
14409
+ // toolbar.addSegmentedControl(caption, items, opts) -> ToolbarSegmentedControl
14410
+ // toolbar.addTextField(caption, opts) -> ToolbarTextField
14235
14411
  // toolbar.addSeparator()
14236
14412
  // toolbar.show()
14237
14413
  // toolbar.hide()
@@ -14263,6 +14439,25 @@
14263
14439
  return new ToolbarButton(content, iconRef, btnOpts || {});
14264
14440
  };
14265
14441
 
14442
+ this.addTextButton = function(label, btnOpts) {
14443
+ var button = new ToolbarButton(content, null, btnOpts || {});
14444
+ button.setText(label);
14445
+ return button;
14446
+ };
14447
+
14448
+ // A choice between modes with every label visible and the active one lit.
14449
+ // Preferred over a button that rewrites its own label, which leaves no way
14450
+ // to tell the mode you are in from the mode a click would put you in.
14451
+ // items: [{value, label, tooltip}]
14452
+ this.addSegmentedControl = function(caption, items, ctrlOpts) {
14453
+ return new ToolbarSegmentedControl(content, caption, items, ctrlOpts || {});
14454
+ };
14455
+
14456
+ // A short typed value, committed on Enter or on leaving the field.
14457
+ this.addTextField = function(caption, fieldOpts) {
14458
+ return new ToolbarTextField(content, caption, fieldOpts || {});
14459
+ };
14460
+
14266
14461
  this.addSeparator = function() {
14267
14462
  return El('div').addClass('floating-toolbar-separator').appendTo(content);
14268
14463
  };
@@ -14311,6 +14506,111 @@
14311
14506
  }
14312
14507
  }
14313
14508
 
14509
+ function ToolbarSegmentedControl(parent, caption, items, opts) {
14510
+ var el = El('div').addClass('floating-toolbar-group').appendTo(parent);
14511
+ var changeHandlers = [];
14512
+ var value = null;
14513
+ var segments;
14514
+ if (caption) {
14515
+ El('span').addClass('floating-toolbar-caption').appendTo(el).text(caption);
14516
+ }
14517
+ if (opts.classname) el.addClass(opts.classname);
14518
+ var group = El('div').addClass('floating-toolbar-segments').appendTo(el);
14519
+ segments = items.map(function(item) {
14520
+ var button = new ToolbarButton(group, null, {tooltip: item.tooltip});
14521
+ button.setText(item.label);
14522
+ button.on('click', function() {
14523
+ if (value === item.value) return;
14524
+ setValue(item.value);
14525
+ changeHandlers.forEach(function(fn) { fn(value); });
14526
+ });
14527
+ return {value: item.value, button: button};
14528
+ });
14529
+
14530
+ function setValue(val) {
14531
+ value = val;
14532
+ segments.forEach(function(o) {
14533
+ o.button.setSelected(o.value === val);
14534
+ });
14535
+ }
14536
+
14537
+ // Sets the lit segment without firing 'change', for syncing to outside state.
14538
+ this.setValue = function(val) {
14539
+ setValue(val);
14540
+ return this;
14541
+ };
14542
+
14543
+ this.getValue = function() {
14544
+ return value;
14545
+ };
14546
+
14547
+ this.on = function(event, fn) {
14548
+ if (event == 'change') changeHandlers.push(fn);
14549
+ return this;
14550
+ };
14551
+
14552
+ this.node = function() {
14553
+ return el.node();
14554
+ };
14555
+ }
14556
+
14557
+ function ToolbarTextField(parent, caption, opts) {
14558
+ var el = El('label').addClass('floating-toolbar-group')
14559
+ .addClass('floating-toolbar-field').appendTo(parent);
14560
+ var changeHandlers = [];
14561
+ var committed = '';
14562
+ var input;
14563
+ if (caption) {
14564
+ El('span').addClass('floating-toolbar-caption').appendTo(el).text(caption);
14565
+ }
14566
+ input = El('input').attr('type', 'text').appendTo(el);
14567
+ if (opts.classname) input.addClass(opts.classname);
14568
+ if (opts.placeholder) input.attr('placeholder', opts.placeholder);
14569
+ if (opts.width) input.css('width', opts.width);
14570
+ if (opts.tooltip) el.attr('data-tooltip', opts.tooltip);
14571
+
14572
+ // Keyboard events reach gui.keyboard through the document, so map shortcuts
14573
+ // would fire while the field has focus. Enter commits, Escape abandons the
14574
+ // edit and leaves the field rather than closing the tool around it.
14575
+ input.on('keydown', function(e) {
14576
+ e.stopPropagation();
14577
+ if (e.keyCode == 13) {
14578
+ input.node().blur();
14579
+ } else if (e.keyCode == 27) {
14580
+ input.node().value = committed;
14581
+ input.node().blur();
14582
+ }
14583
+ });
14584
+
14585
+ // Native change fires on Enter and on leaving an edited field, and not when
14586
+ // Escape has put the original value back.
14587
+ input.on('change', function() {
14588
+ var value = input.node().value.trim();
14589
+ committed = value;
14590
+ changeHandlers.forEach(function(fn) { fn(value); });
14591
+ });
14592
+
14593
+ // Sets the displayed value without firing 'change'.
14594
+ this.setValue = function(value) {
14595
+ committed = value == null ? '' : String(value);
14596
+ input.node().value = committed;
14597
+ return this;
14598
+ };
14599
+
14600
+ this.getValue = function() {
14601
+ return input.node().value.trim();
14602
+ };
14603
+
14604
+ this.on = function(event, fn) {
14605
+ if (event == 'change') changeHandlers.push(fn);
14606
+ return this;
14607
+ };
14608
+
14609
+ this.node = function() {
14610
+ return input.node();
14611
+ };
14612
+ }
14613
+
14314
14614
  function ToolbarButton(parent, iconRef, opts) {
14315
14615
  var btn = El('div').addClass('floating-toolbar-btn').appendTo(parent);
14316
14616
  if (iconRef) {
@@ -14369,6 +14669,11 @@
14369
14669
 
14370
14670
  this.setTooltip = setTooltip;
14371
14671
 
14672
+ this.setText = function(text) {
14673
+ btn.addClass('text-btn').text(text);
14674
+ return this;
14675
+ };
14676
+
14372
14677
  this.node = function() {
14373
14678
  return btn.node();
14374
14679
  };
@@ -15125,8 +15430,22 @@
15125
15430
  indigoRamp.concat(purpleRamp)
15126
15431
  ];
15127
15432
 
15433
+ // The pickers that are open, so that opening one can close the rest. They
15434
+ // float over their panel and overlap each other -- in Frame properties,
15435
+ // Background and Neatline are close enough together that two open pickers just
15436
+ // cover one another. Only open pickers are listed and hide() delists them, so
15437
+ // this holds at most one entry.
15438
+ var openPickers = [];
15439
+
15440
+ function closeOpenPickers(except) {
15441
+ openPickers.slice().forEach(function(picker) {
15442
+ if (picker !== except) picker.hide();
15443
+ });
15444
+ }
15445
+
15128
15446
  function ColorPicker(parent, opts) {
15129
15447
  opts = opts || {};
15448
+ var self = this;
15130
15449
  var colorPicker = El('div').addClass('label-color-picker').appendTo(parent).hide();
15131
15450
  var sbCanvas, hueCanvas, sbMarker, hueMarker, pickerHsbInputs;
15132
15451
  var pickerColor = {h: 0, s: 0, b: 0};
@@ -15138,13 +15457,18 @@
15138
15457
  if (colorPicker.visible()) {
15139
15458
  this.hide();
15140
15459
  } else {
15460
+ closeOpenPickers(self);
15141
15461
  colorPicker.show();
15462
+ positionPicker();
15142
15463
  drawColorPicker();
15464
+ if (openPickers.indexOf(self) == -1) openPickers.push(self);
15143
15465
  }
15144
15466
  };
15145
15467
 
15146
15468
  this.hide = function() {
15147
15469
  colorPicker.hide();
15470
+ var i = openPickers.indexOf(self);
15471
+ if (i > -1) openPickers.splice(i, 1);
15148
15472
  };
15149
15473
 
15150
15474
  this.visible = function() {
@@ -15161,6 +15485,24 @@
15161
15485
  return hsbToHex(pickerColor);
15162
15486
  };
15163
15487
 
15488
+ // The picker is placed in viewport coordinates (see .label-color-picker), so
15489
+ // it has to be put against the field it belongs to each time it opens. The
15490
+ // offsets reproduce what the stylesheet used to do with `top: 48px; right: 0`
15491
+ // against that field, plus a clamp so that a panel low on the screen cannot
15492
+ // push the picker off the bottom.
15493
+ function positionPicker() {
15494
+ var node = colorPicker.node();
15495
+ var anchor = node.parentNode.getBoundingClientRect();
15496
+ var margin = 8;
15497
+ var top = anchor.top + 48;
15498
+ var left = anchor.right - node.offsetWidth;
15499
+ if (top + node.offsetHeight + margin > window.innerHeight) {
15500
+ top = window.innerHeight - margin - node.offsetHeight;
15501
+ }
15502
+ colorPicker.css('top', Math.round(Math.max(margin, top)) + 'px');
15503
+ colorPicker.css('left', Math.round(Math.max(margin, left)) + 'px');
15504
+ }
15505
+
15164
15506
  function init() {
15165
15507
  var sbWrap = El('div').addClass('label-color-canvas-wrap').appendTo(colorPicker);
15166
15508
  sbCanvas = El('canvas').attr('width', '256').attr('height', '256').appendTo(sbWrap);
@@ -16070,6 +16412,17 @@
16070
16412
  control.chit.css('background-color', isHexColor(color) ? color : 'transparent');
16071
16413
  };
16072
16414
 
16415
+ // What a panel calls when it refreshes from the data: the picker has to start
16416
+ // from the colour that is set, not from wherever it was left. A picker that
16417
+ // has never been opened is still on its default, so without this it opens on
16418
+ // black rather than on the colour beside it. Kept apart from setColor(),
16419
+ // which is also the picker's own preview callback and must not feed back into
16420
+ // it mid-drag.
16421
+ control.showColor = function(color) {
16422
+ control.setColor(color);
16423
+ if (isHexColor(color)) control.picker.setColor(color);
16424
+ };
16425
+
16073
16426
  El('span').appendTo(colorCell).text(opts.label);
16074
16427
  control.chit = El('div').addClass('label-color-chit').attr('role', 'button')
16075
16428
  .on('click', function() {
@@ -18078,13 +18431,13 @@
18078
18431
  return control;
18079
18432
  }
18080
18433
 
18081
- // In the wide column, under the stroke colour it belongs to. Stepping runs
18082
- // up a ladder of widths rather than by a fixed amount, because the useful
18083
- // ones are close together at the hairline end and far apart above 2px.
18434
+ // In the narrow column, under the stroke's opacity. Stepping runs up a ladder
18435
+ // of widths rather than by a fixed amount, because the useful ones are close
18436
+ // together at the hairline end and far apart above 2px.
18084
18437
  function addStrokeWidthControl(parent) {
18085
18438
  var row = El('div').addClass('label-style-row label-split-row').appendTo(parent);
18086
- var cell = El('div').addClass('label-split-cell').appendTo(row);
18087
18439
  El('div').addClass('label-split-cell').appendTo(row);
18440
+ var cell = El('div').addClass('label-split-cell').appendTo(row);
18088
18441
  El('span').appendTo(cell).text('Stroke width');
18089
18442
  return new SizeField(cell, {
18090
18443
  min: 0,
@@ -18123,13 +18476,10 @@
18123
18476
 
18124
18477
  function updateColorControl(control) {
18125
18478
  var value = getCommonStyleValue(control.field);
18126
- setColorControlValue(control, value);
18479
+ control.showColor(value);
18127
18480
  updateOpacityControl(control);
18128
- if (isHexColor(value)) {
18129
- control.picker.setColor(value);
18130
- } else {
18131
- control.picker.hide();
18132
- }
18481
+ // Nothing for the picker to sit on when the selection has no one colour.
18482
+ if (!isHexColor(value)) control.picker.hide();
18133
18483
  }
18134
18484
 
18135
18485
  function updateOpacityControl(control) {
@@ -18138,10 +18488,6 @@
18138
18488
  formatOpacityPct(value === '' || value === undefined || value === null ? 1 : value);
18139
18489
  }
18140
18490
 
18141
- function setColorControlValue(control, value) {
18142
- control.setColor(value);
18143
- }
18144
-
18145
18491
  function updateStrokeWidthControl() {
18146
18492
  var value = getCommonStyleValue('stroke-width');
18147
18493
  strokeWidthField.setValue(
@@ -18542,8 +18888,10 @@
18542
18888
  // The two sizes of a circle side by side, in the shape the panel uses for
18543
18889
  // every other pair.
18544
18890
  var sizeRow = El('div').addClass('label-style-row label-split-row').appendTo(circlesSection);
18545
- var widthCell = El('div').addClass('label-split-cell').appendTo(sizeRow);
18891
+ // Radius in the wide column, stroke width in the narrow one under the
18892
+ // stroke's opacity, matching the line and polygon panels.
18546
18893
  var radiusCell = El('div').addClass('label-split-cell').appendTo(sizeRow);
18894
+ var widthCell = El('div').addClass('label-split-cell').appendTo(sizeRow);
18547
18895
  El('span').appendTo(widthCell).text('Stroke width');
18548
18896
  circleStrokeWidthField = new SizeField(widthCell, {
18549
18897
  min: 0,
@@ -18933,7 +19281,7 @@
18933
19281
  }
18934
19282
 
18935
19283
  function setCircleColor(control, color) {
18936
- control.setColor(color);
19284
+ control.showColor(color);
18937
19285
  }
18938
19286
 
18939
19287
  function formatNumberValue(val) {
@@ -18980,8 +19328,28 @@
18980
19328
  function Model(gui) {
18981
19329
  var self = new internal.Catalog();
18982
19330
  var deleteLayer = self.deleteLayer;
19331
+ var setDefaultTargets = self.setDefaultTargets;
18983
19332
  utils$1.extend(self, EventDispatcher.prototype);
18984
19333
 
19334
+ // A map frame is composition state, not editable content. Commands may
19335
+ // explicitly target it, but it must not replace the GUI's active content
19336
+ // layer or become the implicit target of the next console command.
19337
+ self.setDefaultTargets = function(targets, opts) {
19338
+ var previousContent = getContentTargets(self.getDefaultTargets());
19339
+ var requestedContent = getContentTargets(targets);
19340
+ setDefaultTargets.call(self, targets, opts);
19341
+ if (countTargetLayers(requestedContent) < countTargetLayers(targets)) {
19342
+ var contentTargets = requestedContent.length ?
19343
+ requestedContent : previousContent;
19344
+ if (!contentTargets.length) {
19345
+ contentTargets = getFirstContentTarget();
19346
+ }
19347
+ if (contentTargets.length) {
19348
+ setDefaultTargets.call(self, contentTargets);
19349
+ }
19350
+ }
19351
+ };
19352
+
18985
19353
  // override Catalog method (so -drop command will work in web console)
18986
19354
  self.deleteLayer = function(lyr, dataset) {
18987
19355
  var active, flags;
@@ -19037,6 +19405,32 @@
19037
19405
  };
19038
19406
 
19039
19407
  return self;
19408
+
19409
+ function getContentTargets(targets) {
19410
+ return (targets || []).map(function(target) {
19411
+ return {
19412
+ dataset: target.dataset,
19413
+ layers: target.layers.filter(function(lyr) {
19414
+ return !internal.isFrameLayer(lyr, target.dataset.arcs);
19415
+ })
19416
+ };
19417
+ }).filter(function(target) {
19418
+ return target.layers.length > 0;
19419
+ });
19420
+ }
19421
+
19422
+ function getFirstContentTarget() {
19423
+ var target = self.getLayers().find(function(o) {
19424
+ return !internal.isFrameLayer(o.layer, o.dataset.arcs);
19425
+ });
19426
+ return target ? [{dataset: target.dataset, layers: [target.layer]}] : [];
19427
+ }
19428
+
19429
+ function countTargetLayers(targets) {
19430
+ return (targets || []).reduce(function(sum, target) {
19431
+ return sum + target.layers.length;
19432
+ }, 0);
19433
+ }
19040
19434
  }
19041
19435
 
19042
19436
  function absArcId(arcId) {
@@ -20883,6 +21277,95 @@
20883
21277
  });
20884
21278
  }
20885
21279
 
21280
+ // How an aspect ratio is read, written and held to, in one place. The Add map
21281
+ // frame dialog takes one typed into a field, the Frame properties panel shows
21282
+ // the one a frame ended up with, so a ratio entered as 3:2 reads back as 3:2,
21283
+ // and the box drawn on the map is kept to it while it is dragged.
21284
+
21285
+ // Ratios common enough to be worth naming when one is shown.
21286
+ var namedRatios = [
21287
+ [1, '1:1'],
21288
+ [4 / 3, '4:3'],
21289
+ [3 / 2, '3:2'],
21290
+ [16 / 9, '16:9']
21291
+ ];
21292
+
21293
+ // Accepts "5:4" or a bare number. Returns NaN for anything else, including a
21294
+ // ratio with a zero or negative term.
21295
+ function parseFrameAspectRatio(value) {
21296
+ var parts = String(value).trim().split(':').map(Number);
21297
+ if (parts.length == 2) {
21298
+ return parts[0] > 0 && parts[1] > 0 ? parts[0] / parts[1] : NaN;
21299
+ }
21300
+ return parts.length == 1 ? parts[0] : NaN;
21301
+ }
21302
+
21303
+ // "3:2" for a ratio that has a name, "1.62" for one that does not.
21304
+ function formatFrameAspectRatio(aspect) {
21305
+ var match = namedRatios.find(function(item) {
21306
+ return Math.abs(item[0] - aspect) < 1e-10;
21307
+ });
21308
+ return match ? match[1] : String(Math.round(aspect * 100) / 100);
21309
+ }
21310
+
21311
+ // Move the pointer-side corner of a box being dragged out from (x1, y1) so the
21312
+ // box has the given width/height ratio. Grows the short side rather than
21313
+ // trimming the long one, so the box always reaches the pointer on one axis and
21314
+ // never shrinks away from the drag. Works in screen pixels, where y runs
21315
+ // downwards; only the magnitudes matter, so the sign of each axis is kept.
21316
+ function getCornerForRatio(x1, y1, x2, y2, ratio) {
21317
+ var sx = x2 < x1 ? -1 : 1,
21318
+ sy = y2 < y1 ? -1 : 1,
21319
+ w = Math.abs(x2 - x1),
21320
+ h = Math.abs(y2 - y1);
21321
+ if (w / h > ratio) { // also catches h == 0
21322
+ h = w / ratio;
21323
+ } else if (w > 0 || h > 0) {
21324
+ w = h * ratio;
21325
+ }
21326
+ return [x1 + sx * w, y1 + sy * h];
21327
+ }
21328
+
21329
+ // Reshape a [minx, miny, maxx, maxy] box that a handle drag has pulled off
21330
+ // ratio. A corner handle grows whichever side is short, anchored on the corner
21331
+ // opposite the handle; an edge handle drives the axis it moves along and the
21332
+ // other axis grows from the centre, so the box widens in place instead of
21333
+ // walking sideways. Mutates bbox.
21334
+ function applyAspectRatio(bbox, handle, ratio) {
21335
+ var w = bbox[2] - bbox[0],
21336
+ h = bbox[3] - bbox[1],
21337
+ cx, cy;
21338
+ // Leave an inverted or empty box alone; the drag has flipped it past an edge
21339
+ // and the caller puts the corners back in order when the drag ends.
21340
+ if (!(w > 0) || !(h > 0) || handle.type == 'center') return;
21341
+ if (handle.type == 'corner') {
21342
+ if (w / h > ratio) h = w / ratio;
21343
+ else w = h * ratio;
21344
+ } else if (handle.col == 'left' || handle.col == 'right') {
21345
+ h = w / ratio;
21346
+ } else {
21347
+ w = h * ratio;
21348
+ }
21349
+ if (handle.col == 'left') {
21350
+ bbox[0] = bbox[2] - w;
21351
+ } else if (handle.col == 'right') {
21352
+ bbox[2] = bbox[0] + w;
21353
+ } else {
21354
+ cx = (bbox[0] + bbox[2]) / 2;
21355
+ bbox[0] = cx - w / 2;
21356
+ bbox[2] = cx + w / 2;
21357
+ }
21358
+ if (handle.row == 'top') {
21359
+ bbox[3] = bbox[1] + h;
21360
+ } else if (handle.row == 'bottom') {
21361
+ bbox[1] = bbox[3] - h;
21362
+ } else {
21363
+ cy = (bbox[1] + bbox[3]) / 2;
21364
+ bbox[1] = cy - h / 2;
21365
+ bbox[3] = cy + h / 2;
21366
+ }
21367
+ }
21368
+
20886
21369
  function HighlightBox(gui, optsArg) {
20887
21370
  var el = El('div').addClass('zoom-box').appendTo('body'),
20888
21371
  opts = Object.assign({
@@ -20891,11 +21374,12 @@
20891
21374
  persistent: false,
20892
21375
  draggable: false // does dragging the map draw a box
20893
21376
  }, optsArg),
20894
- clickToStart = opts.name == 'box-tool', // other versions use shift-drag
21377
+ clickToStart = opts.clickToStart || opts.name == 'box-tool',
20895
21378
  box = new EventDispatcher(),
20896
21379
  stroke = 2,
20897
21380
  activeHandle = null,
20898
21381
  prevXY = null,
21382
+ aspectRatio = null,
20899
21383
  boxCoords = null,
20900
21384
  _on = false,
20901
21385
  _visible = false,
@@ -20990,18 +21474,25 @@
20990
21474
  }
20991
21475
  prevXY = xy;
20992
21476
  redraw();
20993
- box.dispatchEvent('handle_drag');
21477
+ box.dispatchEvent('handle_drag', {
21478
+ handle: copyHandle(activeHandle),
21479
+ map_bbox: boxCoords.slice()
21480
+ });
20994
21481
  });
20995
21482
 
20996
21483
  gui.map.getMouse().on('mouseup', function(e) {
20997
21484
  if (activeHandle && _on) {
21485
+ var handle = copyHandle(activeHandle);
20998
21486
  activeHandle.el.css('background', null);
20999
21487
  activeHandle = null;
21000
21488
  prevXY = null;
21001
- box.dispatchEvent('handle_up');
21002
21489
  // reset box if it has been inverted (by dragging)
21003
21490
  fixBounds(boxCoords);
21004
21491
  redraw();
21492
+ box.dispatchEvent('handle_up', {
21493
+ handle: handle,
21494
+ map_bbox: boxCoords.slice()
21495
+ });
21005
21496
  }
21006
21497
  });
21007
21498
  }
@@ -21050,6 +21541,9 @@
21050
21541
  boxCoords[1] += dy;
21051
21542
  if (centered) boxCoords[3] -= dy;
21052
21543
  }
21544
+ if (aspectRatio) {
21545
+ applyAspectRatio(boxCoords, activeHandle, aspectRatio);
21546
+ }
21053
21547
  }
21054
21548
 
21055
21549
  function rescaleBox(x, y) {
@@ -21064,6 +21558,14 @@
21064
21558
  boxCoords = [cx - dx, cy - dy, cx + dx, cy + dy];
21065
21559
  }
21066
21560
 
21561
+ // Hold the box to a width/height ratio while it is drawn or resized. The
21562
+ // ratio is in screen pixels, which is what the user is aiming at; the map
21563
+ // draws both axes at one scale, so for a projected view it is also the ratio
21564
+ // of the coordinates the box reports back.
21565
+ box.setAspectRatio = function(ratio) {
21566
+ aspectRatio = ratio > 0 ? Number(ratio) : null;
21567
+ };
21568
+
21067
21569
  box.setDataCoords = function(bbox) {
21068
21570
  boxCoords = bbox;
21069
21571
  // Box is being placed programmatically (e.g. around a pinned rectangle);
@@ -21081,6 +21583,12 @@
21081
21583
  return dataBox;
21082
21584
  };
21083
21585
 
21586
+ // Coordinates in the map's display CRS. This avoids routing frame-tool
21587
+ // coordinates through the currently active content layer.
21588
+ box.getDisplayCoords = function() {
21589
+ return boxCoords ? boxCoords.slice() : null;
21590
+ };
21591
+
21084
21592
  box.turnOn = function() {
21085
21593
  _on = true;
21086
21594
  };
@@ -21138,34 +21646,16 @@
21138
21646
 
21139
21647
  // get bbox coords in the display CRS
21140
21648
  function getBoxCoords(p1, p2) {
21141
- if (gui.keyboard.shiftIsPressed()) {
21142
- p2 = getSquareCorner(p1[0], p1[1], p2[0], p2[1]);
21649
+ // A set ratio wins over shift, which is just the 1:1 case of the same rule.
21650
+ var ratio = aspectRatio || (gui.keyboard.shiftIsPressed() ? 1 : 0);
21651
+ if (ratio) {
21652
+ p2 = getCornerForRatio(p1[0], p1[1], p2[0], p2[1], ratio);
21143
21653
  }
21144
21654
  var bbox = pixToCoords(p1.concat(p2), gui.map.getExtent());
21145
21655
  fixBounds(bbox);
21146
21656
  return bbox;
21147
21657
  }
21148
21658
 
21149
- function getSquareCorner(x1, y1, x2, y2) {
21150
- var dx = x2 - x1;
21151
- var dy = y2 - y1;
21152
- if (dy === 0 && dx === 0) {
21153
- return [x2, y2];
21154
- }
21155
- if (dy === 0) {
21156
- dy = 1;
21157
- }
21158
- if (dx === 0) {
21159
- dx = 1;
21160
- }
21161
- if (Math.abs(dx) > Math.abs(dy)) {
21162
- dy = dy * Math.abs(dx / dy);
21163
- } else {
21164
- dx = dx * Math.abs(dy / dx);
21165
- }
21166
- return [x1 + dx, y1 + dy];
21167
- }
21168
-
21169
21659
  function redraw() {
21170
21660
  if (!boxCoords) return;
21171
21661
  var ext = gui.map.getExtent();
@@ -21179,6 +21669,14 @@
21179
21669
  return box;
21180
21670
  }
21181
21671
 
21672
+ function copyHandle(handle) {
21673
+ return handle ? {
21674
+ type: handle.type,
21675
+ col: handle.col,
21676
+ row: handle.row
21677
+ } : null;
21678
+ }
21679
+
21182
21680
  function coordsToPix(bbox, ext) {
21183
21681
  var a = ext.translateCoords(bbox[0], bbox[1]);
21184
21682
  var b = ext.translateCoords(bbox[2], bbox[3]);
@@ -21739,7 +22237,11 @@
21739
22237
  var recIds = id >= 0 ? [id] : ids;
21740
22238
  var el = content;
21741
22239
  var table = lyr.data; // table can be null (e.g. if layer has no attribute data)
21742
- var tableEl = table ? renderTable(recIds, table, editable) : null;
22240
+ var isFrame = layerIsFrame(lyr);
22241
+ var hasCustomFrameFields = isFrame && table && table.getFields().some(function(field) {
22242
+ return !internal.isFrameReservedField(field);
22243
+ });
22244
+ var tableEl = table ? renderTable(recIds, table, editable, isFrame) : null;
21743
22245
  el.empty(); // clean up if panel is already open
21744
22246
  if (tableEl) {
21745
22247
  tableEl.appendTo(el);
@@ -21753,6 +22255,8 @@
21753
22255
  e.preventDefault(); // don't copy original string with tabs
21754
22256
  }
21755
22257
  });
22258
+ } else if (isFrame) {
22259
+ el.html('<div class="note">Map frame settings are edited separately from attribute data.</div>');
21756
22260
  } else {
21757
22261
  // Some individual features can have undefined values for some or all of
21758
22262
  // their data properties (properties are set to undefined when an input JSON file
@@ -21762,13 +22266,13 @@
21762
22266
  }
21763
22267
 
21764
22268
  var footer = El('div').appendTo(el);
21765
- if (editable) {
22269
+ if (editable && !isFrame) {
21766
22270
  // render "add field" button
21767
22271
  El('span').addClass('add-field-btn').appendTo(footer).on('click', async function(e) {
21768
22272
  // show "add field" dialog
21769
22273
  openAddFieldPopup(gui, recIds, lyr);
21770
22274
  }).text('+ add field');
21771
- } else if (pinned) {
22275
+ } else if (pinned && (!isFrame || hasCustomFrameFields)) {
21772
22276
  // render "Click to edit" button
21773
22277
  El('span').addClass('edit-data-btn').appendTo(footer).on('click', async function(e) {
21774
22278
  self.show(id, ids, lyr, true, true);
@@ -21776,7 +22280,7 @@
21776
22280
  }
21777
22281
  }
21778
22282
 
21779
- function renderTable(recIds, table, editable) {
22283
+ function renderTable(recIds, table, editable, isFrame) {
21780
22284
  var tableEl = El('table').addClass('selectable');
21781
22285
  var rows = 0;
21782
22286
  var rec;
@@ -21791,6 +22295,7 @@
21791
22295
  utils$1.forEachProperty(rec, function(v, k) {
21792
22296
  // missing GeoJSON fields are set to undefined on import; skip these
21793
22297
  if (v === undefined) return;
22298
+ if (isFrame && internal.isFrameReservedField(k)) return;
21794
22299
  var rowEl = renderRow(k, v, recIds, table, editable);
21795
22300
  if (rowEl) {
21796
22301
  rowEl.appendTo(tableEl);
@@ -21800,6 +22305,11 @@
21800
22305
  return rows > 0 ? tableEl : null;
21801
22306
  }
21802
22307
 
22308
+ function layerIsFrame(lyr) {
22309
+ var dataset = lyr.gui && lyr.gui.source && lyr.gui.source.dataset;
22310
+ return !!dataset && internal.isFrameLayer(lyr, dataset.arcs);
22311
+ }
22312
+
21803
22313
  function getMultiRecord(recIds, table) {
21804
22314
  var fields = table.getFields();
21805
22315
  var rec = {};
@@ -22474,7 +22984,7 @@
22474
22984
  function showInstructions() {
22475
22985
  var isMac = navigator.userAgent.includes('Mac');
22476
22986
  var symbol = isMac ? '⌘' : '^';
22477
- var msg = `Instructions: click on the map to add points. Move points by dragging.`;
22987
+ var msg = `Click on the map to add points.<br>Move points by dragging.`;
22478
22988
  alert = showPopupAlert(msg, null, { non_blocking: true, max_width: '290px'});
22479
22989
  }
22480
22990
 
@@ -22692,7 +23202,7 @@
22692
23202
  function showInstructions() {
22693
23203
  var isMac = navigator.userAgent.includes('Mac');
22694
23204
  var undoKey = isMac ? '⌘' : '^';
22695
- var msg = `Instructions: click to start a path, click or drag to keep drawing. Drag vertices to reshape a path.`;
23205
+ var msg = `Click to add points to a path or click and drag to draw continuously. Drag vertices to reshape a path.`;
22696
23206
  alert = showPopupAlert(msg, null, {
22697
23207
  non_blocking: true, max_width: '350px'});
22698
23208
  }
@@ -23269,7 +23779,7 @@
23269
23779
  }
23270
23780
 
23271
23781
  function showInstructions() {
23272
- var msg = 'Instructions: click a line to snip it apart. Snipping a ring ' +
23782
+ var msg = 'Click a line to snip it apart. Snipping a ring ' +
23273
23783
  'takes two clicks -- the ring divides at the second one.';
23274
23784
  alert = showPopupAlert(msg, null, {non_blocking: true, max_width: '350px'});
23275
23785
  }
@@ -24052,7 +24562,7 @@
24052
24562
  //
24053
24563
  // See docs/development/label-tool-design.md.
24054
24564
 
24055
- var SVG_NS$2 = 'http://www.w3.org/2000/svg';
24565
+ var SVG_NS$3 = 'http://www.w3.org/2000/svg';
24056
24566
  var XML_NS = 'http://www.w3.org/XML/1998/namespace';
24057
24567
  var BOX_PADDING$1 = 3;
24058
24568
 
@@ -24350,7 +24860,7 @@
24350
24860
  }
24351
24861
  removePendingGroup(o);
24352
24862
  o.pending.markup = markup;
24353
- o.pending.group = document.createElementNS(SVG_NS$2, 'g');
24863
+ o.pending.group = document.createElementNS(SVG_NS$3, 'g');
24354
24864
  o.pending.group.setAttribute('class', 'label-edit-pending');
24355
24865
  o.pending.group.innerHTML = markup;
24356
24866
  container.appendChild(o.pending.group);
@@ -24432,7 +24942,7 @@
24432
24942
  lines = getRenderedLines(o.text);
24433
24943
  content.appendChild(document.createTextNode(lines[0]));
24434
24944
  for (i = 1; i < lines.length; i++) {
24435
- tspan = document.createElementNS(SVG_NS$2, 'tspan');
24945
+ tspan = document.createElementNS(SVG_NS$3, 'tspan');
24436
24946
  tspan.setAttribute('x', o.nodes.text.getAttribute('x') || 0);
24437
24947
  tspan.setAttribute('dy', getLineHeight(o));
24438
24948
  tspan.appendChild(document.createTextNode(lines[i]));
@@ -24542,7 +25052,7 @@
24542
25052
  // nothing on screen at all. Drawn from the same <defs> path the <textPath>
24543
25053
  // references, so it cannot drift from the text it belongs to.
24544
25054
  function ghostPath(pathId) {
24545
- var el = document.createElementNS(SVG_NS$2, 'use');
25055
+ var el = document.createElementNS(SVG_NS$3, 'use');
24546
25056
  el.setAttribute('href', '#' + pathId);
24547
25057
  el.setAttribute('class', 'label-edit-path');
24548
25058
  return el;
@@ -24555,7 +25065,7 @@
24555
25065
  if (!pathId) return;
24556
25066
  // A thickened, transparent copy of the baseline: about one em wide, so that
24557
25067
  // the region follows the curve rather than boxing it.
24558
- el = document.createElementNS(SVG_NS$2, 'use');
25068
+ el = document.createElementNS(SVG_NS$3, 'use');
24559
25069
  el.setAttribute('href', '#' + pathId);
24560
25070
  el.setAttribute('class', 'label-edit-hit-baseline');
24561
25071
  el.setAttribute('stroke-width', caret ? caret.ascent + caret.descent : 12);
@@ -24616,7 +25126,7 @@
24616
25126
  }
24617
25127
 
24618
25128
  function makeGroup(className) {
24619
- var g = document.createElementNS(SVG_NS$2, 'g');
25129
+ var g = document.createElementNS(SVG_NS$3, 'g');
24620
25130
  g.setAttribute('class', className);
24621
25131
  return g;
24622
25132
  }
@@ -24651,7 +25161,7 @@
24651
25161
  }
24652
25162
 
24653
25163
  function rect(box, className) {
24654
- var el = document.createElementNS(SVG_NS$2, 'rect');
25164
+ var el = document.createElementNS(SVG_NS$3, 'rect');
24655
25165
  el.setAttribute('x', box.x);
24656
25166
  el.setAttribute('y', box.y);
24657
25167
  el.setAttribute('width', box.width);
@@ -24661,7 +25171,7 @@
24661
25171
  }
24662
25172
 
24663
25173
  function caretLine(caret) {
24664
- var el = document.createElementNS(SVG_NS$2, 'line');
25174
+ var el = document.createElementNS(SVG_NS$3, 'line');
24665
25175
  el.setAttribute('x1', caret.x);
24666
25176
  el.setAttribute('y1', caret.y - caret.ascent);
24667
25177
  el.setAttribute('x2', caret.x);
@@ -24821,7 +25331,7 @@
24821
25331
  //
24822
25332
  // See docs/development/label-tool-design.md.
24823
25333
 
24824
- var SVG_NS$1 = 'http://www.w3.org/2000/svg';
25334
+ var SVG_NS$2 = 'http://www.w3.org/2000/svg';
24825
25335
  var BOX_PADDING = 3;
24826
25336
  var ANCHOR_RADIUS = 3.5;
24827
25337
  var KNOT_RADIUS = 3;
@@ -24995,7 +25505,7 @@
24995
25505
  // moves and hides with it, and inserted before it so the outline paints
24996
25506
  // beneath the glyphs.
24997
25507
  function makeGroup(nodes, className) {
24998
- var g = document.createElementNS(SVG_NS$1, 'g');
25508
+ var g = document.createElementNS(SVG_NS$2, 'g');
24999
25509
  var transform = nodes.symbol.getAttribute('transform');
25000
25510
  var display = nodes.symbol.getAttribute('display');
25001
25511
  g.setAttribute('class', 'label-cue ' + className);
@@ -25014,7 +25524,7 @@
25014
25524
  }
25015
25525
 
25016
25526
  function rect(box, pad) {
25017
- var el = document.createElementNS(SVG_NS$1, 'rect');
25527
+ var el = document.createElementNS(SVG_NS$2, 'rect');
25018
25528
  el.setAttribute('x', box.x - pad);
25019
25529
  el.setAttribute('y', box.y - pad);
25020
25530
  el.setAttribute('width', box.width + pad * 2);
@@ -25024,7 +25534,7 @@
25024
25534
  }
25025
25535
 
25026
25536
  function anchorMarker() {
25027
- var el = document.createElementNS(SVG_NS$1, 'circle');
25537
+ var el = document.createElementNS(SVG_NS$2, 'circle');
25028
25538
  // The group's own origin is the anchor point, so the marker sits at 0,0.
25029
25539
  el.setAttribute('cx', 0);
25030
25540
  el.setAttribute('cy', 0);
@@ -25037,7 +25547,7 @@
25037
25547
  // of its box rather than to the middle of it: a line to the middle would run
25038
25548
  // underneath the glyphs it is pointing at.
25039
25549
  function tether(box) {
25040
- var el = document.createElementNS(SVG_NS$1, 'line');
25550
+ var el = document.createElementNS(SVG_NS$2, 'line');
25041
25551
  el.setAttribute('x1', 0);
25042
25552
  el.setAttribute('y1', 0);
25043
25553
  el.setAttribute('x2', clamp(0, box.x - BOX_PADDING, box.x + box.width + BOX_PADDING));
@@ -25051,7 +25561,7 @@
25051
25561
  }
25052
25562
 
25053
25563
  function knotHandle(p) {
25054
- var el = document.createElementNS(SVG_NS$1, 'circle');
25564
+ var el = document.createElementNS(SVG_NS$2, 'circle');
25055
25565
  el.setAttribute('cx', p[0]);
25056
25566
  el.setAttribute('cy', p[1]);
25057
25567
  el.setAttribute('r', KNOT_RADIUS);
@@ -25060,7 +25570,7 @@
25060
25570
  }
25061
25571
 
25062
25572
  function curve(pathId) {
25063
- var el = document.createElementNS(SVG_NS$1, 'use');
25573
+ var el = document.createElementNS(SVG_NS$2, 'use');
25064
25574
  el.setAttribute('href', '#' + pathId);
25065
25575
  el.setAttribute('class', 'label-cue-curve');
25066
25576
  return el;
@@ -25407,9 +25917,9 @@
25407
25917
  hideInstructions();
25408
25918
  if (!armed) return;
25409
25919
  alert = showPopupAlert(armed == 'anchor' ?
25410
- 'Instructions: click on the map to place a label.' :
25411
- 'Instructions: click to place points along the path. Double-click, ' +
25412
- 'Enter or Escape to finish. Backspace removes the last point.',
25920
+ 'Click on the map to place a label.' :
25921
+ 'Click to draw a curved path. Type Esc or double-click ' +
25922
+ 'to finish. Backspace removes the last point.',
25413
25923
  null, {non_blocking: true, max_width: '330px'});
25414
25924
  }
25415
25925
 
@@ -26705,6 +27215,18 @@
26705
27215
  return bounds2.width() / _frame.width;
26706
27216
  };
26707
27217
 
27218
+ this.zoomToFrameMagnification = function(targetScale) {
27219
+ if (!_frame || !(targetScale > 0)) return;
27220
+ var currentScale = this.getSymbolScale();
27221
+ var frameBounds = new Bounds$1(_frame.bbox);
27222
+ var p = this.translateCoords(frameBounds.centerX(), frameBounds.centerY());
27223
+ this.zoomByPct(
27224
+ targetScale / currentScale,
27225
+ p[0] / this.width(),
27226
+ p[1] / this.height()
27227
+ );
27228
+ };
27229
+
26708
27230
  // convert pixel coords (0,0 is top left corner of map) to display CRS coords
26709
27231
  this.pixCoordsToMapCoords = function(x, y) {
26710
27232
  return this.getTransform().invert().transform(x, y);
@@ -28576,7 +29098,7 @@
28576
29098
  function showInstructions() {
28577
29099
  var isMac = navigator.userAgent.includes('Mac');
28578
29100
  var symbol = isMac ? '⌘' : '^';
28579
- var msg = `Instructions: Click to start a rectangle. Drag handles to resize. Press shift key to resize symmetrically.`;
29101
+ var msg = `Click to start a rectangle. Drag handles to resize. Press shift key to resize symmetrically.`;
28580
29102
  alert = showPopupAlert(msg, null, { non_blocking: true, max_width: '360px'});
28581
29103
  }
28582
29104
 
@@ -28590,11 +29112,12 @@
28590
29112
  return !_on && gui.getMode() != 'selection_tool';
28591
29113
  }
28592
29114
 
28593
- function runCommand(cmd) {
29115
+ function runCommand(cmd, onSuccess) {
28594
29116
  if (gui.console) {
28595
29117
  gui.console.runMapshaperCommands(cmd, function(err) {
28596
29118
  reset();
28597
29119
  gui.clearMode();
29120
+ if (!err && onSuccess) onSuccess();
28598
29121
  });
28599
29122
  }
28600
29123
  // reset(); // TODO: exit interactive mode
@@ -28662,9 +29185,7 @@
28662
29185
  var popup = showPopupAlert('', 'Add a map frame');
28663
29186
  var el = popup.container();
28664
29187
  el.addClass('option-menu');
28665
- var html = `<p>Enter a width in px, cm or inches to create a frame layer
28666
- for setting the size of the map for symbol scaling in the
28667
- GUI and setting the size and crop of SVG output.</p><div><input type="text" class="frame-width text-input" placeholder="examples: 600px 5in"></div>
29188
+ var html = `<p>Enter an output width in px, cm or inches.</p><div><input type="text" class="frame-width text-input" placeholder="examples: 600px 5in"></div>
28668
29189
  <div class="btn dialog-btn">Create</div></span>`;
28669
29190
  el.html(html);
28670
29191
  var input = el.findChild('.frame-width');
@@ -28676,8 +29197,10 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
28676
29197
  input.node().value = '';
28677
29198
  return;
28678
29199
  }
28679
- var cmd = `-rectangle + name=frame bbox='${bbox.join(',')}' width='${widthStr}'`;
28680
- runCommand(cmd);
29200
+ var cmd = `-frame name=frame bbox='${bbox.join(',')}' width='${widthStr}'`;
29201
+ runCommand(cmd, function() {
29202
+ if (gui.frameTool) gui.frameTool.open();
29203
+ });
28681
29204
  popup.close();
28682
29205
  });
28683
29206
  }
@@ -28861,7 +29384,7 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
28861
29384
  return Math.max(min, Math.min(max, val));
28862
29385
  }
28863
29386
 
28864
- var SVG_NS = 'http://www.w3.org/2000/svg';
29387
+ var SVG_NS$1 = 'http://www.w3.org/2000/svg';
28865
29388
  var OUT_OF_RANGE_MESSAGE = 'out of range';
28866
29389
  var GEODESIC_PIXEL_TOLERANCE = 1.5;
28867
29390
  var GEODESIC_MAX_DEPTH = 14;
@@ -28874,11 +29397,11 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
28874
29397
  function RulerTool(gui, ext) {
28875
29398
  var mapLayers = gui.container.findChild('.map-layers');
28876
29399
  var parent = gui.container.findChild('.mshp-main-map');
28877
- var svg = createSvgNode('svg');
28878
- var directPath = createSvgNode('path');
28879
- var geodesicPath = createSvgNode('path');
28880
- var startMarker = createSvgNode('circle');
28881
- var endMarker = createSvgNode('circle');
29400
+ var svg = createSvgNode$1('svg');
29401
+ var directPath = createSvgNode$1('path');
29402
+ var geodesicPath = createSvgNode$1('path');
29403
+ var startMarker = createSvgNode$1('circle');
29404
+ var endMarker = createSvgNode$1('circle');
28882
29405
  var vertexMarkers = [];
28883
29406
  var popup = El('div').addClass('ruler-popup rollover').appendTo(parent).hide();
28884
29407
  var content = El('div').addClass('ruler-popup-content').appendTo(popup);
@@ -29560,7 +30083,7 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
29560
30083
  function getVertexMarker(i) {
29561
30084
  var marker;
29562
30085
  while (vertexMarkers.length <= i) {
29563
- marker = createSvgNode('circle');
30086
+ marker = createSvgNode$1('circle');
29564
30087
  marker.classList.add('ruler-endpoint');
29565
30088
  svg.insertBefore(marker, endMarker);
29566
30089
  vertexMarkers.push(marker);
@@ -29592,8 +30115,8 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
29592
30115
  }
29593
30116
  }
29594
30117
 
29595
- function createSvgNode(name) {
29596
- return document.createElementNS(SVG_NS, name);
30118
+ function createSvgNode$1(name) {
30119
+ return document.createElementNS(SVG_NS$1, name);
29597
30120
  }
29598
30121
 
29599
30122
  function round(n) {
@@ -29766,6 +30289,24 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
29766
30289
  this.getSvgRoot = function() { return _renderer ? _renderer.getSvgRoot() : null; };
29767
30290
  this.getActiveLayer = function() { return _activeLyr; };
29768
30291
  this.getHitControl = function() { return _hit; };
30292
+ this.getCompositionLayers = function() { return getContentLayers().slice(); };
30293
+ this.getPreviewFrameData = getFrameLayerData;
30294
+ this.isPreviewView = isPreviewView;
30295
+ this.setPreviewMode = function(on, fitPage) {
30296
+ var hasFrame = !!internal.getActiveFrame(model);
30297
+ var next = !!on && hasFrame;
30298
+ var changed = gui.state.preview_mode != next;
30299
+ gui.state.preview_mode = next;
30300
+ _ext.setFrameData(isPreviewView() ? getFrameLayerData() : null);
30301
+ updateFullBounds();
30302
+ if (fitPage !== false) {
30303
+ _ext.home();
30304
+ }
30305
+ drawLayers();
30306
+ if (changed) {
30307
+ gui.dispatchEvent('preview_mode_change', {enabled: next});
30308
+ }
30309
+ };
29769
30310
  // this.getViewData = function() {
29770
30311
  // return {
29771
30312
  // isPreview: isPreviewView(),
@@ -29784,7 +30325,6 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
29784
30325
  // Set or clear a CRS to use for display, without reprojecting the underlying dataset(s).
29785
30326
  // crs: a CRS object or string, or null to clear the current setting
29786
30327
  this.setDisplayCRS = function(crs) {
29787
- // TODO: update bounds of frame layer, if there is a frame layer
29788
30328
  var oldCRS = this.getDisplayCRS();
29789
30329
  var newCRS = utils$1.isString(crs) ? internal.parseCrsString(crs) : crs;
29790
30330
  // TODO: handle case that old and new CRS are the same
@@ -29798,6 +30338,13 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
29798
30338
  getContentLayers().concat(_intersectionLyr || []).concat(_compareLyr || []).forEach(function(lyr) {
29799
30339
  projectLayerForDisplay(lyr, newCRS);
29800
30340
  });
30341
+ var frameTarget = internal.getActiveFrame(model);
30342
+ if (frameTarget) {
30343
+ if (!frameTarget.layer.gui) {
30344
+ enhanceLayerForDisplay(frameTarget.layer, frameTarget.dataset, getDisplayOptions());
30345
+ }
30346
+ projectLayerForDisplay(frameTarget.layer, newCRS);
30347
+ }
29801
30348
 
29802
30349
  // Update map extent (also triggers redraw)
29803
30350
  projectMapExtent(_ext, oldCRS, this.getDisplayCRS(), calcFullBounds());
@@ -29890,7 +30437,8 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
29890
30437
  return;
29891
30438
  }
29892
30439
 
29893
- if (arcsMayHaveChanged(e.flags)) {
30440
+ var arcsChanged = arcsMayHaveChanged(e.flags);
30441
+ if (arcsChanged) {
29894
30442
  // regenerate filtered arcs the next time they are needed for rendering
29895
30443
  // delete e.dataset.gui.displayArcs
29896
30444
  clearAllDisplayArcs();
@@ -29901,6 +30449,19 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
29901
30449
  updated.dataset.arcs.setRetainedPct(1);
29902
30450
  }
29903
30451
  }
30452
+ // The frame's display copy is built from its coordinates, so it has to go
30453
+ // whenever those can have moved. -update-frame is the obvious case; -proj
30454
+ // is the one that was missed, because it rewrites the frame through the
30455
+ // same sweep that projects the data, and the display copy left behind
30456
+ // still held the pre-projection rectangle. findFrameLayer() rebuilds it on
30457
+ // demand and it is one rectangle, so discarding it freely costs nothing.
30458
+ if (e.flags['update-frame'] || arcsChanged) {
30459
+ var frameTarget = internal.getActiveFrame(model);
30460
+ if (frameTarget) {
30461
+ delete frameTarget.layer.gui;
30462
+ delete frameTarget.dataset.gui;
30463
+ }
30464
+ }
29904
30465
 
29905
30466
  if (e.flags.simplify_method) { // no redraw needed
29906
30467
  return false;
@@ -29926,7 +30487,8 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
29926
30487
  } else if (_hit) {
29927
30488
  _hit.clearSelection();
29928
30489
  }
29929
- _hit.setLayer(_activeLyr); // need this every time, to support dynamic reprojection
30490
+ _hit.setLayer(isFrameMapLayer(_activeLyr) ? null : _activeLyr);
30491
+ // need this every time, to support dynamic reprojection
29930
30492
 
29931
30493
  updateVisibleMapLayers();
29932
30494
  fullBounds = calcFullBounds();
@@ -30046,19 +30608,28 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
30046
30608
  }
30047
30609
 
30048
30610
  function findFrameLayer() {
30049
- return getVisibleMapLayers().find(function(lyr) {
30050
- return internal.isFrameLayer(lyr.gui.displayLayer, lyr.gui.displayArcs);
30051
- });
30611
+ var target = internal.getActiveFrame(model);
30612
+ if (!target) return null;
30613
+ if (!target.layer.gui) {
30614
+ enhanceLayerForDisplay(target.layer, target.dataset, getDisplayOptions());
30615
+ }
30616
+ return target.layer;
30052
30617
  }
30053
30618
 
30054
30619
  // Preview view: symbols are scaled based on display size of frame layer
30055
30620
  function isPreviewView() {
30056
- return !isTableView() && !!getFrameLayerData();
30621
+ return !isTableView() && !!gui.state.preview_mode && !!getFrameLayerData();
30057
30622
  }
30058
30623
 
30059
30624
  function getFrameLayerData() {
30060
30625
  var lyr = findFrameLayer();
30061
- return lyr && internal.getFrameLayerData(lyr, lyr.gui.displayArcs) || null;
30626
+ var crs = lyr && (lyr.gui.dynamic_crs ||
30627
+ internal.getDatasetCRS(lyr.gui.source.dataset));
30628
+ return lyr && internal.getFrameLayerData(
30629
+ lyr.gui.displayLayer,
30630
+ lyr.gui.displayArcs,
30631
+ crs
30632
+ ) || null;
30062
30633
  }
30063
30634
 
30064
30635
  function clearAllDisplayArcs() {
@@ -30100,10 +30671,15 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
30100
30671
  return findActiveLayer(layers);
30101
30672
  }
30102
30673
  return layers.filter(function(o) {
30103
- return !!o.gui.geographic;
30674
+ return !!o.gui.geographic && !isFrameMapLayer(o);
30104
30675
  });
30105
30676
  }
30106
30677
 
30678
+ function isFrameMapLayer(lyr) {
30679
+ var dataset = lyr && lyr.gui && lyr.gui.source && lyr.gui.source.dataset;
30680
+ return !!dataset && internal.isFrameLayer(lyr, dataset.arcs);
30681
+ }
30682
+
30107
30683
  function getDrawableContentLayers() {
30108
30684
  return getContentLayers().filter(function(lyr) {
30109
30685
  if (isActiveLayer(lyr) && lyr.hidden) return false;
@@ -30173,9 +30749,9 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
30173
30749
  console.error("Collapsed map container, unable to draw.");
30174
30750
  return;
30175
30751
  }
30752
+ _ext.setFrameData(isPreviewView() ? getFrameLayerData() : null);
30176
30753
  if (layersMayHaveChanged) {
30177
30754
  // kludge to handle layer visibility toggling
30178
- _ext.setFrameData(isPreviewView() ? getFrameLayerData() : null);
30179
30755
  updateFullBounds();
30180
30756
  updateLayerStyles(contentLayers);
30181
30757
  updateLayerStackOrder(model.getLayers());// update menu_order property of all layers
@@ -30217,32 +30793,1107 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
30217
30793
  }
30218
30794
  }
30219
30795
 
30220
- // This is a new way to handle compatibility problems between
30221
- // interactive editing modes and other interface modes
30222
- // (by default, interactive modes stay on when, e.g., the user clicks
30223
- // "Export" or "Console").
30224
- //
30225
- function initModeRules(gui) {
30796
+ var SVG_NS = 'http://www.w3.org/2000/svg';
30226
30797
 
30227
- gui.on('interaction_mode_change', function(e) {
30228
- var imode = e.mode;
30229
- var mode = gui.getMode();
30798
+ function PreviewMode(gui) {
30799
+ var self = this;
30800
+ var ext = gui.map.getExtent();
30801
+ var mapLayers = gui.container.findChild('.map-layers').node();
30802
+ var map = gui.container.findChild('.mshp-main-map');
30803
+ var toggle = gui.buttons.addButton('#frame-tool-icon')
30804
+ .addClass('menu-btn preview-toggle')
30805
+ .attr('title', 'Toggle map preview');
30806
+ var readout = El('div')
30807
+ .addClass('preview-readout')
30808
+ .appendTo(map)
30809
+ .hide();
30810
+ var readoutLabel = El('span').addClass('preview-readout-label').appendTo(readout);
30811
+ El('span').addClass('preview-readout-arrow').text('⌄').appendTo(readout);
30812
+ var menu = El('div').addClass('preview-scale-menu').appendTo(readout);
30813
+ var temporaryReadout = null;
30814
+ var backgroundSvg = createSvgNode('svg');
30815
+ var background = createSvgNode('rect');
30816
+ var svg = createSvgNode('svg');
30817
+ var mask = createSvgNode('path');
30818
+ var neatline = createSvgNode('rect');
30819
+ var border = createSvgNode('rect');
30820
+
30821
+ gui.state.preview_mode = false;
30822
+ gui.previewMode = this;
30823
+
30824
+ backgroundSvg.classList.add('preview-background-overlay');
30825
+ background.classList.add('preview-page-background');
30826
+ backgroundSvg.appendChild(background);
30827
+ mapLayers.insertBefore(backgroundSvg, mapLayers.firstChild);
30828
+ svg.classList.add('preview-overlay');
30829
+ mask.classList.add('preview-outside-mask');
30830
+ mask.setAttribute('fill-rule', 'evenodd');
30831
+ neatline.classList.add('preview-page-neatline');
30832
+ border.classList.add('preview-page-border');
30833
+ svg.appendChild(mask);
30834
+ svg.appendChild(neatline);
30835
+ svg.appendChild(border);
30836
+ mapLayers.appendChild(svg);
30837
+ hideOverlay();
30838
+
30839
+ [
30840
+ {label: '50%', scale: 0.5},
30841
+ {label: '67%', scale: 0.67},
30842
+ {label: '100%', scale: 1},
30843
+ {label: '150%', scale: 1.5},
30844
+ {label: '200%', scale: 2},
30845
+ {label: 'Fit page', fit: true}
30846
+ ].forEach(function(item) {
30847
+ El('div')
30848
+ .addClass('preview-scale-menu-item')
30849
+ .attr('data-preview-scale', item.fit ? 'fit' : String(item.scale))
30850
+ .text(item.label)
30851
+ .appendTo(menu)
30852
+ .on('click', function(e) {
30853
+ e.stopPropagation();
30854
+ closeMenu();
30855
+ if (item.fit) {
30856
+ ext.home();
30857
+ } else {
30858
+ ext.zoomToFrameMagnification(item.scale);
30859
+ }
30860
+ gui.dispatchEvent('map_interaction_end');
30861
+ });
30862
+ });
30230
30863
 
30231
- // simplify and vertex editing are not compatible
30232
- if (imode == 'vertices') {
30233
- flattenArcs(gui.map.getActiveLayer());
30864
+ toggle.on('click', function(e) {
30865
+ e.stopPropagation();
30866
+ if (!hasFrame()) {
30867
+ if (gui.frameTool) gui.frameTool.openCreateDialog();
30868
+ return;
30869
+ }
30870
+ self.setOn(!self.isOn());
30871
+ });
30234
30872
 
30235
- if (mode == 'simplify') {
30236
- gui.clearMode(); // exit simplification
30237
- }
30873
+ readout.on('click', function(e) {
30874
+ e.stopPropagation();
30875
+ readout.classed('open', !readout.hasClass('open'));
30876
+ });
30238
30877
 
30878
+ gui.on('map_click', closeMenu);
30879
+ gui.on('map_rendered', function() {
30880
+ refreshControls();
30881
+ renderOverlay();
30882
+ });
30883
+ gui.on('preview_mode_change', refreshControls);
30884
+ gui.model.on('update', function() {
30885
+ if (!hasFrame() && self.isOn()) {
30886
+ gui.map.setPreviewMode(false, false);
30239
30887
  }
30240
-
30888
+ refreshControls();
30241
30889
  });
30890
+ ext.on('change', updateReadout);
30242
30891
 
30243
- gui.on('mode', function(e) {
30244
- var mode = e.name;
30245
- var imode = gui.interaction.getMode();
30892
+ this.isOn = function() {
30893
+ return !!gui.state.preview_mode;
30894
+ };
30895
+
30896
+ this.setOn = function(on) {
30897
+ gui.map.setPreviewMode(!!on, true);
30898
+ refreshControls();
30899
+ };
30900
+
30901
+ this.getReadoutText = function() {
30902
+ return readoutLabel.text();
30903
+ };
30904
+
30905
+ this.setTemporaryReadout = function(frame, scale) {
30906
+ temporaryReadout = {frame: frame, scale: scale};
30907
+ updateReadout();
30908
+ };
30909
+
30910
+ this.clearTemporaryReadout = function() {
30911
+ temporaryReadout = null;
30912
+ updateReadout();
30913
+ };
30914
+
30915
+ refreshControls();
30916
+
30917
+ function hasFrame() {
30918
+ return !!internal.getActiveFrame(gui.model);
30919
+ }
30920
+
30921
+ function refreshControls() {
30922
+ var available = hasFrame();
30923
+ toggle.removeClass('disabled');
30924
+ toggle.classed('selected', available && self.isOn());
30925
+ toggle.attr('title', available ? 'Toggle map preview' : 'Add map frame');
30926
+ toggle.attr('aria-disabled', 'false');
30927
+ toggle.attr('aria-pressed', available && self.isOn() ? 'true' : 'false');
30928
+ if (gui.map.isPreviewView()) {
30929
+ readout.show();
30930
+ updateReadout();
30931
+ } else {
30932
+ readout.hide();
30933
+ closeMenu();
30934
+ }
30935
+ }
30936
+
30937
+ function updateReadout() {
30938
+ if (!gui.map.isPreviewView()) return;
30939
+ var frame = temporaryReadout ?
30940
+ temporaryReadout.frame : gui.map.getPreviewFrameData();
30941
+ if (!frame) return;
30942
+ var size = internal.formatFrameSizeForDisplay(frame);
30943
+ var scale = temporaryReadout ?
30944
+ temporaryReadout.scale : ext.getSymbolScale();
30945
+ var pct = Math.round(scale * 100);
30946
+ readoutLabel.text(size + ' · ' + pct + '%');
30947
+ }
30948
+
30949
+ function renderOverlay() {
30950
+ if (!gui.map.isPreviewView()) {
30951
+ hideOverlay();
30952
+ return;
30953
+ }
30954
+ var frame = gui.map.getPreviewFrameData();
30955
+ if (!frame) {
30956
+ hideOverlay();
30957
+ return;
30958
+ }
30959
+ var p1 = ext.translateCoords(frame.bbox[0], frame.bbox[3]);
30960
+ var p2 = ext.translateCoords(frame.bbox[2], frame.bbox[1]);
30961
+ var x = Math.min(p1[0], p2[0]);
30962
+ var y = Math.min(p1[1], p2[1]);
30963
+ var w = Math.abs(p2[0] - p1[0]);
30964
+ var h = Math.abs(p2[1] - p1[1]);
30965
+ var viewW = ext.width();
30966
+ var viewH = ext.height();
30967
+ var style = getFrameStyle();
30968
+ backgroundSvg.setAttribute('width', viewW);
30969
+ backgroundSvg.setAttribute('height', viewH);
30970
+ backgroundSvg.setAttribute('viewBox', '0 0 ' + viewW + ' ' + viewH);
30971
+ svg.setAttribute('width', viewW);
30972
+ svg.setAttribute('height', viewH);
30973
+ svg.setAttribute('viewBox', '0 0 ' + viewW + ' ' + viewH);
30974
+ mask.setAttribute(
30975
+ 'd',
30976
+ 'M0 0H' + viewW + 'V' + viewH + 'H0Z ' +
30977
+ 'M' + x + ' ' + y + 'V' + (y + h) + 'H' + (x + w) +
30978
+ 'V' + y + 'H' + x + 'Z'
30979
+ );
30980
+ border.setAttribute('x', x);
30981
+ border.setAttribute('y', y);
30982
+ border.setAttribute('width', w);
30983
+ border.setAttribute('height', h);
30984
+ setRectGeometry(background, x, y, w, h);
30985
+ setRectGeometry(neatline, x, y, w, h);
30986
+ applyFrameStyle(style);
30987
+ svg.style.display = '';
30988
+ }
30989
+
30990
+ function hideOverlay() {
30991
+ backgroundSvg.style.display = 'none';
30992
+ svg.style.display = 'none';
30993
+ }
30994
+
30995
+ function getFrameStyle() {
30996
+ var target = internal.getActiveFrame(gui.model);
30997
+ return target && target.layer.data ?
30998
+ target.layer.data.getReadOnlyRecordAt(0) || {} : {};
30999
+ }
31000
+
31001
+ function applyFrameStyle(style) {
31002
+ var strokeWidth = Number(style['stroke-width']);
31003
+ var hasFill = style.fill && style.fill != 'none';
31004
+ var hasStroke = style.stroke && style.stroke != 'none' && strokeWidth > 0;
31005
+ if (hasFill) {
31006
+ background.setAttribute('fill', style.fill);
31007
+ background.setAttribute('fill-opacity',
31008
+ style['fill-opacity'] === undefined ? 1 : style['fill-opacity']);
31009
+ backgroundSvg.style.display = '';
31010
+ } else {
31011
+ backgroundSvg.style.display = 'none';
31012
+ }
31013
+ if (hasStroke) {
31014
+ neatline.setAttribute('fill', 'none');
31015
+ neatline.setAttribute('stroke', style.stroke);
31016
+ // Stroke width is in output pixels, so it has to be scaled to the size
31017
+ // the page is drawn at or the neatline reads thinner than it will print.
31018
+ neatline.setAttribute('stroke-width', strokeWidth * ext.getSymbolScale());
31019
+ neatline.setAttribute('stroke-opacity',
31020
+ style['stroke-opacity'] === undefined ? 1 : style['stroke-opacity']);
31021
+ neatline.style.display = '';
31022
+ } else {
31023
+ neatline.style.display = 'none';
31024
+ }
31025
+ // The border is chrome, there to show where the page is when nothing else
31026
+ // marks it. A neatline is the page edge, and since the border is painted
31027
+ // last it was covering every neatline with the same dark grey line.
31028
+ border.style.display = hasStroke ? 'none' : '';
31029
+ }
31030
+
31031
+ function closeMenu() {
31032
+ readout.removeClass('open');
31033
+ }
31034
+ }
31035
+
31036
+ function createSvgNode(name) {
31037
+ return document.createElementNS(SVG_NS, name);
31038
+ }
31039
+
31040
+ function setRectGeometry(rect, x, y, width, height) {
31041
+ rect.setAttribute('x', x);
31042
+ rect.setAttribute('y', y);
31043
+ rect.setAttribute('width', width);
31044
+ rect.setAttribute('height', height);
31045
+ }
31046
+
31047
+ function FrameResizeTool(gui) {
31048
+ var ext = gui.map.getExtent();
31049
+ var lockSize = false;
31050
+ var on = false;
31051
+ var drawing = false;
31052
+ var drawingRequested = false;
31053
+ var drawWidth = '800px';
31054
+ var drawAspect = '';
31055
+ var drawSource = null;
31056
+ var drawInstructions = null;
31057
+ var frameBox = new HighlightBox(gui, {
31058
+ name: 'frame-editor',
31059
+ classname: 'frame-edit-box',
31060
+ persistent: true,
31061
+ handles: true,
31062
+ draggable: false
31063
+ });
31064
+ var drawBox = new HighlightBox(gui, {
31065
+ name: 'frame-draw',
31066
+ classname: 'frame-draw-box',
31067
+ clickToStart: true,
31068
+ persistent: true,
31069
+ handles: true,
31070
+ draggable: true
31071
+ });
31072
+ var toolbar = new FloatingToolbar(gui, {name: 'frame-toolbar'});
31073
+ // Named by what stays put, not by the action: changing the extent -- by
31074
+ // dragging a handle or by fitting -- is the same gesture either way, and all
31075
+ // that differs is which quantity absorbs it. Both labels stay visible so the
31076
+ // lit one reads as the current mode rather than as the result of a click.
31077
+ var sizeModeControl = toolbar.addSegmentedControl(null, [
31078
+ {value: 'scale', label: 'Fix scale',
31079
+ tooltip: 'Keep the map scale; the output gets bigger or smaller'},
31080
+ {value: 'output', label: 'Fix output',
31081
+ tooltip: 'Keep the output size; the map scale changes'}
31082
+ ], {classname: 'frame-size-mode'}).on('change', function(value) {
31083
+ setLockSize(value == 'output');
31084
+ });
31085
+ var aspectField = toolbar.addTextField('Ratio', {
31086
+ classname: 'frame-toolbar-aspect-input',
31087
+ placeholder: 'free',
31088
+ tooltip: 'Hold the frame to a shape: 16:9, 1.5, or blank for none'
31089
+ }).on('change', commitAspectRatio);
31090
+ toolbar.addSeparator();
31091
+ toolbar.addTextButton('Fit', {
31092
+ tooltip: 'Fit the frame to the visible layers'
31093
+ }).on('click', fitVisibleLayers);
31094
+ // Padding is spent on the fit rather than stored on the frame, so it sits
31095
+ // beside the button it modifies, as it does in the creation dialog.
31096
+ var marginField = toolbar.addTextField('Margin', {
31097
+ classname: 'frame-toolbar-margin-input',
31098
+ placeholder: '0',
31099
+ tooltip: 'Padding added when fitting: 20px, 1cm, ' +
31100
+ "or a percentage of the frame's width and height"
31101
+ });
31102
+ toolbar.addSeparator();
31103
+ toolbar.addTextButton('Done', {
31104
+ tooltip: 'Finish resizing'
31105
+ }).on('click', function() {
31106
+ gui.interaction.turnOff();
31107
+ });
31108
+ var drawToolbar = new FloatingToolbar(gui, {name: 'frame-draw-toolbar'});
31109
+ var drawDoneButton = drawToolbar.addTextButton('Done')
31110
+ .setEnabled(false)
31111
+ .on('click', finishFrameDrawing);
31112
+ drawToolbar.addTextButton('Cancel').on('click', function() {
31113
+ gui.interaction.turnOff();
31114
+ });
31115
+
31116
+ gui.frameTool = this;
31117
+ setLockSize(lockSize); // light the default segment
31118
+
31119
+ this.open = function() {
31120
+ if (getFrameTarget()) {
31121
+ gui.interaction.setMode('frame');
31122
+ } else {
31123
+ openCreateDialog();
31124
+ }
31125
+ };
31126
+
31127
+ this.openCreateDialog = openCreateDialog;
31128
+ this.isLockSize = function() { return lockSize; };
31129
+ this.setLockSize = setLockSize;
31130
+
31131
+ frameBox.on('handle_drag', previewFrameDrag);
31132
+ frameBox.on('handle_up', commitFrameDrag);
31133
+ drawBox.on('dragend', function() {
31134
+ drawDoneButton.enable();
31135
+ hideFrameDrawingInstructions('fade');
31136
+ });
31137
+
31138
+ gui.on('interaction_mode_change', function(e) {
31139
+ if (e.mode == 'frame' || e.mode == 'frame_draw') turnOn();
31140
+ else turnOff();
31141
+ });
31142
+
31143
+ // Resizing is a preview-mode gesture: the handles sit on the page boundary
31144
+ // that preview draws, so turning preview off leaves nothing to resize and
31145
+ // the tool has to close with it rather than stranding its toolbar and
31146
+ // handles over an ordinary map view.
31147
+ gui.on('preview_mode_change', function(e) {
31148
+ if (on && !e.enabled) gui.interaction.turnOff();
31149
+ });
31150
+
31151
+ gui.model.on('update', function() {
31152
+ if (on) refreshOrClose();
31153
+ });
31154
+
31155
+ gui.on('undo_redo_post', function() {
31156
+ if (on) refreshOrClose();
31157
+ });
31158
+
31159
+ // Deleting the frame takes the tool's subject away. Preview mode drops
31160
+ // itself on the same event, but the order of model listeners is not a
31161
+ // contract worth relying on, so the tool checks for itself.
31162
+ function refreshOrClose() {
31163
+ if (!getFrameTarget()) {
31164
+ gui.interaction.turnOff();
31165
+ } else {
31166
+ syncFrameOverlay();
31167
+ }
31168
+ }
31169
+
31170
+ gui.keyboard.on('keydown', function(e) {
31171
+ if ((on || drawing) && e.keyName == 'esc') {
31172
+ gui.interaction.turnOff();
31173
+ e.stopPropagation();
31174
+ }
31175
+ }, 10);
31176
+
31177
+ function turnOn() {
31178
+ if (drawingRequested) {
31179
+ drawingRequested = false;
31180
+ drawing = true;
31181
+ drawBox.turnOn();
31182
+ drawDoneButton.disable();
31183
+ drawToolbar.show();
31184
+ showFrameDrawingInstructions();
31185
+ return;
31186
+ }
31187
+ if (!getFrameTarget()) {
31188
+ gui.interaction.turnOff();
31189
+ openCreateDialog();
31190
+ return;
31191
+ }
31192
+ on = true;
31193
+ frameBox.turnOn();
31194
+ toolbar.show();
31195
+ if (gui.previewMode && !gui.previewMode.isOn()) {
31196
+ gui.previewMode.setOn(true);
31197
+ }
31198
+ syncFrameOverlay();
31199
+ }
31200
+
31201
+ function turnOff() {
31202
+ if (on) {
31203
+ on = false;
31204
+ frameBox.turnOff();
31205
+ toolbar.hide();
31206
+ if (gui.previewMode) gui.previewMode.clearTemporaryReadout();
31207
+ }
31208
+ if (drawing) {
31209
+ drawing = false;
31210
+ drawBox.turnOff();
31211
+ drawBox.setAspectRatio(null);
31212
+ drawToolbar.hide();
31213
+ hideFrameDrawingInstructions();
31214
+ drawSource = null;
31215
+ }
31216
+ }
31217
+
31218
+ function setLockSize(value) {
31219
+ lockSize = !!value;
31220
+ sizeModeControl.setValue(lockSize ? 'output' : 'scale');
31221
+ }
31222
+
31223
+ function syncFrameOverlay() {
31224
+ var frame = gui.map.isPreviewView() && gui.map.getPreviewFrameData();
31225
+ if (frame) {
31226
+ frameBox.setDataCoords(frame.bbox.slice());
31227
+ // Only a frame with a fixed ratio constrains; otherwise the shape is
31228
+ // whatever the extent gives and the handles are free.
31229
+ frameBox.setAspectRatio(frame.aspect_ratio || null);
31230
+ } else {
31231
+ frameBox.hide();
31232
+ }
31233
+ syncAspectField();
31234
+ }
31235
+
31236
+ function previewFrameDrag(e) {
31237
+ var frame = gui.map.getPreviewFrameData();
31238
+ if (!frame || !e.map_bbox) return;
31239
+ var width = getDragWidth(frame, e.map_bbox, e.handle);
31240
+ var aspect = frame.aspect_ratio ||
31241
+ getBboxWidth(e.map_bbox) / getBboxHeight(e.map_bbox);
31242
+ var temporaryFrame = Object.assign({}, frame, {
31243
+ bbox: e.map_bbox.slice(),
31244
+ width: width,
31245
+ height: Math.round(width / aspect)
31246
+ });
31247
+ gui.previewMode.setTemporaryReadout(
31248
+ temporaryFrame,
31249
+ getPixelWidth(e.map_bbox) / width
31250
+ );
31251
+ }
31252
+
31253
+ function commitFrameDrag(e) {
31254
+ var target = getFrameTarget();
31255
+ var displayFrame = gui.map.getPreviewFrameData();
31256
+ var width = null;
31257
+ var targetScale = null;
31258
+ if (!target || !displayFrame || !e.map_bbox) return;
31259
+ if (e.handle?.type != 'center' && !lockSize) {
31260
+ var frame = internal.getFrameLayerData(target.layer, target.dataset.arcs);
31261
+ var widthPx = getDragWidth(displayFrame, e.map_bbox, e.handle);
31262
+ width = formatNumber$1(widthPx / getUnitFactor$1(frame.units)) + frame.units;
31263
+ targetScale = getPixelWidth(e.map_bbox) / widthPx;
31264
+ }
31265
+ gui.previewMode.clearTemporaryReadout();
31266
+ updateFrameBounds(
31267
+ target,
31268
+ getDisplayBoundsInLayerCRS(target.layer, e.map_bbox),
31269
+ width,
31270
+ targetScale
31271
+ );
31272
+ }
31273
+
31274
+ function getDragWidth(frame, bbox, handle) {
31275
+ if (handle?.type == 'center' || lockSize) return frame.width;
31276
+ return frame.width * getBboxWidth(bbox) / getBboxWidth(frame.bbox);
31277
+ }
31278
+
31279
+ function updateFrameBounds(target, bbox, width, targetScale) {
31280
+ var parts = [
31281
+ '-update-frame',
31282
+ 'bbox=' + quoteCommandValue(bbox.join(','))
31283
+ ];
31284
+ if (width) parts.push('width=' + quoteCommandValue(width));
31285
+ parts.push(getTargetOption(target));
31286
+ runGuiEditCommand(gui, parts.join(' '), {
31287
+ title: 'Resize map frame',
31288
+ onSuccess: function() {
31289
+ if (targetScale) {
31290
+ ext.zoomToFrameMagnification(targetScale);
31291
+ gui.dispatchEvent('map_interaction_end');
31292
+ }
31293
+ },
31294
+ onError: syncFrameOverlay
31295
+ });
31296
+ }
31297
+
31298
+ // Blank clears the fixed ratio and returns the frame to its extent's shape.
31299
+ // Anything that is not a ratio is refused and the field snaps back, rather
31300
+ // than quietly reshaping the frame to NaN.
31301
+ function commitAspectRatio(text) {
31302
+ var target = getFrameTarget();
31303
+ var value;
31304
+ if (!target) return;
31305
+ if (!text) {
31306
+ runAspectUpdate(target, 'auto-aspect');
31307
+ return;
31308
+ }
31309
+ value = parseFrameAspectRatio(text);
31310
+ if (!(value > 0)) {
31311
+ syncAspectField();
31312
+ return;
31313
+ }
31314
+ runAspectUpdate(target, 'aspect-ratio=' + value);
31315
+ }
31316
+
31317
+ function runAspectUpdate(target, option) {
31318
+ runGuiEditCommand(gui, ['-update-frame', option, getTargetOption(target)]
31319
+ .join(' '), {
31320
+ title: 'Set frame aspect ratio',
31321
+ onError: syncAspectField
31322
+ });
31323
+ }
31324
+
31325
+ // Shows the ratio the frame actually has, so a ratio typed as 3:2 reads back
31326
+ // as 3:2 and one the frame never took does not linger in the field.
31327
+ function syncAspectField() {
31328
+ var frame = gui.map.getPreviewFrameData();
31329
+ var aspect = frame && frame.aspect_ratio;
31330
+ aspectField.setValue(aspect > 0 ? formatFrameAspectRatio(aspect) : '');
31331
+ }
31332
+
31333
+ // Fitting obeys the same mode as a handle drag: holding the scale grows the
31334
+ // output to cover the new extent, holding the output rescales onto it. The
31335
+ // command works out both the padding and the held-scale width, so the
31336
+ // margin's unit handling lives in one place.
31337
+ function fitVisibleLayers() {
31338
+ var target = getFrameTarget();
31339
+ var entries = getCompositionEntries();
31340
+ var margin = getMargin();
31341
+ var parts, bounds;
31342
+ if (!target || !entries.length) return;
31343
+ bounds = entries.reduce(function(memo, o) {
31344
+ return memo.mergeBounds(
31345
+ internal.getLayerBounds(o.layer, o.dataset.arcs)
31346
+ );
31347
+ }, new internal.Bounds());
31348
+ parts = [
31349
+ '-update-frame',
31350
+ 'bbox=' + quoteCommandValue(bounds.toArray().join(','))
31351
+ ];
31352
+ if (margin) parts.push('offset=' + quoteCommandValue(margin));
31353
+ if (!lockSize) parts.push('fix-scale');
31354
+ parts.push(getTargetOption(target));
31355
+ runGuiEditCommand(gui, parts.join(' '), {
31356
+ title: 'Fit map frame',
31357
+ onError: syncFrameOverlay
31358
+ });
31359
+ }
31360
+
31361
+ // Anything that is not a positive length is dropped, and the field is
31362
+ // cleared so the fit that just ran matches what the toolbar shows.
31363
+ function getMargin() {
31364
+ var value = marginField.getValue();
31365
+ if (parseFloat(value) > 0) return value;
31366
+ if (value) marginField.setValue('');
31367
+ return '';
31368
+ }
31369
+
31370
+ function openCreateDialog() {
31371
+ if (getFrameTarget()) {
31372
+ gui.interaction.setMode('frame');
31373
+ return;
31374
+ }
31375
+ if (!getSourceLayer()) {
31376
+ showPopupAlert(
31377
+ 'Add one or more layers before creating a map frame.',
31378
+ 'Map frame'
31379
+ );
31380
+ return;
31381
+ }
31382
+ var popup = showPopupAlert('', 'Add map frame', {classname: 'frame-create-box'});
31383
+ // label-style-panel is where the flat field look lives -- no bezel, and a
31384
+ // focus ring that is a border rather than a glow. frame-create-form turns
31385
+ // off the chrome the class also carries, as the properties panel does.
31386
+ var content = El('div').addClass('label-style-panel frame-create-form')
31387
+ .appendTo(popup.container().addClass('frame-create-popup'));
31388
+ El('p').appendTo(content)
31389
+ .text('Sets the size and crop of exported maps.');
31390
+
31391
+ var widthInput = makeCreateField(content, 'Output width',
31392
+ 'Width of the exported map.\nExamples: 800px, 5in, 10cm.')
31393
+ .addClass('frame-create-width-input');
31394
+ widthInput.node().value = '800px';
31395
+
31396
+ var aspectInput = makeCreateField(content, 'Aspect ratio',
31397
+ 'Width divided by height, as a number or\na ratio: 1.5 or 3:2. ' +
31398
+ 'Leave blank to vary freely.')
31399
+ .addClass('frame-create-aspect-input');
31400
+
31401
+ // Two ways to say where the frame goes. The heading is what makes them read
31402
+ // as a choice rather than as two loose actions; the margin sits on the row
31403
+ // it modifies, because it pads layer bounds and a drawn box is already the
31404
+ // extent the user meant.
31405
+ var section = makePanelSection(content, 'Frame area', {minor: true});
31406
+ var fitRow = El('div').addClass('frame-create-option-row').appendTo(section);
31407
+ addButton(fitRow, 'Fit visible layers', function() {
31408
+ var aspect = getAspect();
31409
+ if (aspect === null) return;
31410
+ popup.close();
31411
+ createFromVisibleLayers(getWidth(), aspect, getMargin());
31412
+ });
31413
+ var marginCell = El('label').addClass('frame-create-margin').appendTo(fitRow);
31414
+ El('span').appendTo(marginCell).text('Margin');
31415
+ var marginInput = El('input').attr('type', 'text').appendTo(marginCell);
31416
+ marginInput.node().value = '2%';
31417
+ makeFieldTip(fitRow,
31418
+ 'Space around the layers: 2%, 20px, 1cm.\n' +
31419
+ "A percentage is of the frame's width on the\n" +
31420
+ 'side and of its height on top and bottom.');
31421
+
31422
+ var drawRow = El('div').addClass('frame-create-option-row').appendTo(section);
31423
+ addButton(drawRow, 'Draw on the map', function() {
31424
+ var aspect = getAspect();
31425
+ if (aspect === null) return;
31426
+ popup.close();
31427
+ beginFrameDrawing(getWidth(), aspect);
31428
+ });
31429
+
31430
+ function getWidth() {
31431
+ var value = widthInput.node().value.trim();
31432
+ return parseFloat(value) > 0 ? value : '800px';
31433
+ }
31434
+
31435
+ // Returns a ratio, '' when the field is blank -- the frame area then gives
31436
+ // the shape -- or null if the field holds something that is not a ratio, in
31437
+ // which case the dialog stays open on the bad value.
31438
+ function getAspect() {
31439
+ var text = aspectInput.node().value.trim();
31440
+ var value;
31441
+ if (!text) return '';
31442
+ value = parseFrameAspectRatio(text);
31443
+ if (!(value > 0)) {
31444
+ aspectInput.node().focus();
31445
+ return null;
31446
+ }
31447
+ return String(value);
31448
+ }
31449
+
31450
+ function getMargin() {
31451
+ var value = marginInput.node().value.trim();
31452
+ return parseFloat(value) > 0 ? value : '';
31453
+ }
31454
+ }
31455
+
31456
+ // A captioned field with a tip beside it. The caption and field are a label
31457
+ // so that clicking the caption focuses the field; the tip is outside it, or
31458
+ // opening the tip would focus the field too.
31459
+ function makeCreateField(parent, label, tip) {
31460
+ var row = El('div').addClass('frame-create-row').appendTo(parent);
31461
+ var cell = El('label').addClass('frame-field-row').appendTo(row);
31462
+ El('span').appendTo(cell).text(label);
31463
+ var input = El('input').attr('type', 'text').appendTo(cell);
31464
+ makeFieldTip(row, tip);
31465
+ return input;
31466
+ }
31467
+
31468
+ function beginFrameDrawing(width, aspect) {
31469
+ drawSource = getSourceLayer();
31470
+ if (!drawSource) {
31471
+ showPopupAlert(
31472
+ 'Add or select a geographic layer before creating a frame.',
31473
+ 'Map frame'
31474
+ );
31475
+ return;
31476
+ }
31477
+ drawWidth = width;
31478
+ drawAspect = aspect || '';
31479
+ // Without this the box is drawn freehand and -frame silently pads it out to
31480
+ // the ratio, so the frame that appears is bigger than the one drawn.
31481
+ drawBox.setAspectRatio(drawAspect ? Number(drawAspect) : null);
31482
+ drawingRequested = true;
31483
+ gui.interaction.setMode('frame_draw');
31484
+ }
31485
+
31486
+ function showFrameDrawingInstructions() {
31487
+ var text = 'Click to place the first corner, then click the opposite ' +
31488
+ 'corner. Drag the handles to resize the frame.';
31489
+ if (drawAspect) {
31490
+ text += ' The box is held to ' +
31491
+ formatFrameAspectRatio(Number(drawAspect)) + '.';
31492
+ }
31493
+ drawInstructions = showPopupAlert(text, null,
31494
+ {non_blocking: true, max_width: '380px'});
31495
+ }
31496
+
31497
+ function hideFrameDrawingInstructions(action) {
31498
+ if (!drawInstructions) return;
31499
+ drawInstructions.close(action);
31500
+ drawInstructions = null;
31501
+ }
31502
+
31503
+ function finishFrameDrawing() {
31504
+ var displayBounds = drawBox.getDisplayCoords();
31505
+ var source = drawSource;
31506
+ if (!displayBounds || !source) return;
31507
+ var bbox = getDisplayBoundsInLayerCRS(source, displayBounds);
31508
+ gui.interaction.turnOff();
31509
+ var parts = [
31510
+ '-frame',
31511
+ 'bbox=' + quoteCommandValue(bbox.join(',')),
31512
+ 'width=' + quoteCommandValue(drawWidth)
31513
+ ];
31514
+ if (drawAspect) parts.push('aspect-ratio=' + drawAspect);
31515
+ parts.push('name=frame');
31516
+ parts.push('target=' +
31517
+ internal.formatOptionValue(internal.getLayerTargetId(gui.model, source)));
31518
+ runCreateCommand(parts.join(' '));
31519
+ }
31520
+
31521
+ function createFromVisibleLayers(width, aspect, margin) {
31522
+ var entries = getCompositionEntries();
31523
+ if (!entries.length) {
31524
+ showPopupAlert('No visible geographic layers are available.', 'Map frame');
31525
+ return;
31526
+ }
31527
+ var ids = entries.map(function(o) {
31528
+ return internal.getLayerTargetId(gui.model, o.layer);
31529
+ });
31530
+ var parts = ['-frame', 'width=' + quoteCommandValue(width)];
31531
+ if (aspect) parts.push('aspect-ratio=' + aspect);
31532
+ if (margin) parts.push('offset=' + quoteCommandValue(margin));
31533
+ parts.push('name=frame');
31534
+ parts.push('target=' + internal.formatOptionValue(ids.join(',')));
31535
+ runCreateCommand(parts.join(' '));
31536
+ }
31537
+
31538
+ function runCreateCommand(cmd) {
31539
+ runGuiEditCommand(gui, cmd, {
31540
+ title: 'Create map frame',
31541
+ onSuccess: function() {
31542
+ gui.previewMode.setOn(true);
31543
+ }
31544
+ });
31545
+ }
31546
+
31547
+ function getFrameTarget() {
31548
+ return internal.getActiveFrame(gui.model);
31549
+ }
31550
+
31551
+ function getTargetOption(target) {
31552
+ return 'target=' +
31553
+ internal.formatOptionValue(internal.getLayerTargetId(gui.model, target.layer));
31554
+ }
31555
+
31556
+ function getSourceLayer() {
31557
+ var active = gui.model.getActiveLayer();
31558
+ if (active && active.layer && active.layer.geometry_type &&
31559
+ !internal.isFrameLayer(active.layer, active.dataset.arcs)) {
31560
+ return active.layer;
31561
+ }
31562
+ var entries = getCompositionEntries();
31563
+ return entries.length ? entries[0].layer : null;
31564
+ }
31565
+
31566
+ function getCompositionEntries() {
31567
+ var layers = gui.map.getCompositionLayers();
31568
+ return gui.model.getLayers().filter(function(o) {
31569
+ return layers.includes(o.layer);
31570
+ });
31571
+ }
31572
+
31573
+ function getDisplayBoundsInLayerCRS(layer, view) {
31574
+ if (!layer.gui?.invertPoint) return view.slice();
31575
+ var bounds = new internal.Bounds();
31576
+ var steps = 16;
31577
+ for (var i = 0; i <= steps; i++) {
31578
+ var t = i / steps;
31579
+ mergeDisplayPoint(bounds, layer, view[0] + getBboxWidth(view) * t, view[1]);
31580
+ mergeDisplayPoint(bounds, layer, view[0] + getBboxWidth(view) * t, view[3]);
31581
+ mergeDisplayPoint(bounds, layer, view[0], view[1] + getBboxHeight(view) * t);
31582
+ mergeDisplayPoint(bounds, layer, view[2], view[1] + getBboxHeight(view) * t);
31583
+ }
31584
+ return bounds.toArray();
31585
+ }
31586
+
31587
+ function mergeDisplayPoint(bounds, layer, x, y) {
31588
+ var p = translateDisplayPoint(layer, [x, y]);
31589
+ if (p && Number.isFinite(p[0]) && Number.isFinite(p[1])) {
31590
+ bounds.mergePoint(p[0], p[1]);
31591
+ }
31592
+ }
31593
+
31594
+ function getPixelWidth(bbox) {
31595
+ var a = ext.translateCoords(bbox[0], bbox[1]);
31596
+ var b = ext.translateCoords(bbox[2], bbox[1]);
31597
+ return Math.abs(b[0] - a[0]);
31598
+ }
31599
+ }
31600
+
31601
+ function addButton(parent, label, action) {
31602
+ El('div').addClass('btn dialog-btn').appendTo(parent)
31603
+ .text(label).on('click', action);
31604
+ }
31605
+
31606
+ // The "?" the rest of the app uses for field help (see .tip-button in
31607
+ // elements.css and the static ones in index.html). The bubble is white-space:
31608
+ // pre, so the line breaks in the text are the ones it gets.
31609
+ function makeFieldTip(parent, text) {
31610
+ var btn = El('div').addClass('tip-button').appendTo(parent).text('?');
31611
+ var anchor = El('div').addClass('tip-anchor').appendTo(btn);
31612
+ El('div').addClass('tip').appendTo(anchor).text(text);
31613
+ return btn;
31614
+ }
31615
+
31616
+ function getUnitFactor$1(units) {
31617
+ return units == 'in' ? 72 : units == 'cm' ? 28.3465 : 1;
31618
+ }
31619
+
31620
+ function formatNumber$1(value) {
31621
+ return String(Math.round(value * 100) / 100);
31622
+ }
31623
+
31624
+ function getBboxWidth(bbox) {
31625
+ return bbox[2] - bbox[0];
31626
+ }
31627
+
31628
+ function getBboxHeight(bbox) {
31629
+ return bbox[3] - bbox[1];
31630
+ }
31631
+
31632
+ function FrameProperties(gui) {
31633
+ var target, form, widthInput, heightInput, unitsSelect, aspectValue;
31634
+ var backgroundControl, neatlineControl, neatlineWidthInput;
31635
+ var boundsValue, crsValue;
31636
+
31637
+ gui.frameProperties = this;
31638
+
31639
+ this.open = function(targetArg) {
31640
+ target = targetArg || internal.getActiveFrame(gui.model);
31641
+ if (!target) return;
31642
+ var popup = showPopupAlert('', 'Frame properties');
31643
+ popup.container().addClass('frame-properties-popup');
31644
+ form = El('div')
31645
+ .addClass('label-style-panel frame-properties-form')
31646
+ .appendTo(popup.container());
31647
+ initForm();
31648
+ updateControls();
31649
+ };
31650
+
31651
+ function initForm() {
31652
+ // Width and height are two ways of writing one thing: either one rescales
31653
+ // the frame and the other follows, because the frame's extent is not what
31654
+ // this panel changes. Shown side by side so that they read as a pair.
31655
+ var sizeRow = El('div').addClass('label-style-row frame-size-row').appendTo(form);
31656
+ widthInput = makeLabeledInput(sizeRow, 'Width')
31657
+ .addClass('frame-width-input').on('change', updateWidth);
31658
+ heightInput = makeLabeledInput(sizeRow, 'Height')
31659
+ .addClass('frame-height-input').on('change', updateHeight);
31660
+ unitsSelect = El('select').addClass('frame-units-select').appendTo(sizeRow)
31661
+ .on('change', updateUnits);
31662
+ ['px', 'pt', 'in', 'cm'].forEach(function(unit) {
31663
+ El('option').attr('value', unit).appendTo(unitsSelect).text(unit);
31664
+ });
31665
+
31666
+ // Read-only: setting a ratio reshapes the frame's extent, which is what the
31667
+ // resize tool is for. What is worth saying here is whether the ratio is
31668
+ // fixed or follows the extent, since that is what decides how the pair
31669
+ // above behaves.
31670
+ aspectValue = makeReadOnlyRow(form, 'Aspect ratio');
31671
+
31672
+ var appearance = makePanelSection(form, 'Appearance');
31673
+ backgroundControl = makeColorRow(appearance, {
31674
+ label: 'Background',
31675
+ onColor: function(color) {
31676
+ if (color) applyFrameStyle([['fill', color]]);
31677
+ },
31678
+ onOpacity: function(value) {
31679
+ applyFrameStyle([['fill-opacity', value]]);
31680
+ },
31681
+ revert: updateControls
31682
+ });
31683
+ neatlineControl = makeColorRow(appearance, {
31684
+ label: 'Neatline',
31685
+ onColor: function(color) {
31686
+ if (!color) return;
31687
+ var styles = [['stroke', color]];
31688
+ if (!getStyleValue('stroke-width')) styles.push(['stroke-width', 1]);
31689
+ applyFrameStyle(styles);
31690
+ },
31691
+ onOpacity: function(value) {
31692
+ applyFrameStyle([['stroke-opacity', value]]);
31693
+ },
31694
+ revert: updateControls
31695
+ });
31696
+ // The width belongs to the same property as the colour and opacity beside
31697
+ // it, and all three fit on the row. The background row leaves the column
31698
+ // empty rather than putting the width on a line of its own.
31699
+ neatlineWidthInput = makeRowField(neatlineControl.row, 'Width')
31700
+ .addClass('frame-neatline-width')
31701
+ .on('change', updateNeatlineWidth);
31702
+ var clearRow = El('div')
31703
+ .addClass('label-style-row label-panel-button-row')
31704
+ .appendTo(appearance);
31705
+ makeActionButton(clearRow, 'Clear appearance', clearFrameStyle);
31706
+
31707
+ var details = makePanelSection(form, 'Details');
31708
+ boundsValue = makeReadOnlyRow(details, 'Bounds');
31709
+ crsValue = makeReadOnlyRow(details, 'CRS');
31710
+ }
31711
+
31712
+ function updateControls() {
31713
+ if (!target || !internal.isFrameLayer(target.layer, target.dataset.arcs)) return;
31714
+ var frame = internal.getFrameLayerData(target.layer, target.dataset.arcs);
31715
+ var rec = target.layer.data.getReadOnlyRecordAt(0);
31716
+ var units = frame.units || 'px';
31717
+ var factor = getUnitFactor(units);
31718
+ var info = internal.getLayerInfo(target.layer, target.dataset);
31719
+ widthInput.node().value = formatNumber(frame.width / factor);
31720
+ heightInput.node().value = formatNumber(frame.height / factor);
31721
+ unitsSelect.node().value = units;
31722
+ aspectValue.text(getAspectText(frame));
31723
+ backgroundControl.showColor(rec.fill || '');
31724
+ backgroundControl.opacity.node().value = formatOpacity(rec['fill-opacity']);
31725
+ neatlineControl.showColor(rec.stroke || '');
31726
+ neatlineControl.opacity.node().value = formatOpacity(rec['stroke-opacity']);
31727
+ neatlineWidthInput.node().value =
31728
+ rec['stroke-width'] === undefined ? '' : rec['stroke-width'];
31729
+ boundsValue.text(frame.bbox.map(formatCoordinate).join(', '));
31730
+ crsValue.text(info.proj4 || '[unknown]');
31731
+ }
31732
+
31733
+ function updateWidth() {
31734
+ updateDimension('width', widthInput);
31735
+ }
31736
+
31737
+ function updateHeight() {
31738
+ updateDimension('height', heightInput);
31739
+ }
31740
+
31741
+ // One dimension per command, never both: -update-frame takes a lone width= or
31742
+ // height= as a rescale and derives the other from the extent, but takes the
31743
+ // two together as a new page shape and stretches the extent to fit it.
31744
+ function updateDimension(name, input) {
31745
+ var value = Number(input.node().value);
31746
+ if (!(value > 0)) {
31747
+ updateControls();
31748
+ return;
31749
+ }
31750
+ runCommand(
31751
+ '-update-frame ' + name + '=' +
31752
+ quoteCommandValue(value + unitsSelect.node().value) + ' ' +
31753
+ getTargetOption(),
31754
+ 'Update frame size'
31755
+ );
31756
+ }
31757
+
31758
+ function updateUnits() {
31759
+ var frame = internal.getFrameLayerData(target.layer, target.dataset.arcs);
31760
+ var value = frame.width / getUnitFactor(unitsSelect.node().value);
31761
+ runCommand(
31762
+ '-update-frame width=' +
31763
+ quoteCommandValue(formatNumber(value) + unitsSelect.node().value) + ' ' +
31764
+ getTargetOption(),
31765
+ 'Update frame units'
31766
+ );
31767
+ }
31768
+
31769
+ function updateNeatlineWidth() {
31770
+ var value = Number(neatlineWidthInput.node().value);
31771
+ if (!(value >= 0)) {
31772
+ updateControls();
31773
+ return;
31774
+ }
31775
+ var styles = [['stroke-width', value]];
31776
+ if (value > 0 && !getStyleValue('stroke')) {
31777
+ styles.push(['stroke', '#000000']);
31778
+ }
31779
+ applyFrameStyle(styles);
31780
+ }
31781
+
31782
+ function getStyleValue(field) {
31783
+ var rec = target.layer.data.getReadOnlyRecordAt(0);
31784
+ return rec && rec[field];
31785
+ }
31786
+
31787
+ function applyFrameStyle(styles) {
31788
+ var parts = ['-style'];
31789
+ styles.forEach(function(item) {
31790
+ parts.push(item[0] + '=' + quoteCommandValue(item[1]));
31791
+ });
31792
+ parts.push(getTargetOption());
31793
+ runCommand(parts.join(' '), 'Style map frame');
31794
+ }
31795
+
31796
+ function clearFrameStyle() {
31797
+ runCommand('-style clear ' + getTargetOption(), 'Clear frame appearance');
31798
+ }
31799
+
31800
+ function getTargetOption() {
31801
+ return 'target=' +
31802
+ internal.formatOptionValue(internal.getLayerTargetId(gui.model, target.layer));
31803
+ }
31804
+
31805
+ function runCommand(cmd, title) {
31806
+ runGuiEditCommand(gui, cmd, {
31807
+ title: title,
31808
+ onDone: updateControls
31809
+ });
31810
+ }
31811
+ }
31812
+
31813
+ // An extra captioned cell on an existing colour row.
31814
+ function makeRowField(row, label) {
31815
+ var cell = El('div').addClass('label-split-cell').appendTo(row);
31816
+ El('span').appendTo(cell).text(label);
31817
+ return makeTextInput(cell);
31818
+ }
31819
+
31820
+ function makeTextInput(parent) {
31821
+ return El('input').attr('type', 'text').appendTo(parent);
31822
+ }
31823
+
31824
+ function makeLabeledInput(parent, label) {
31825
+ var cell = El('label').addClass('frame-input-cell').appendTo(parent);
31826
+ El('span').appendTo(cell).text(label);
31827
+ return makeTextInput(cell);
31828
+ }
31829
+
31830
+ function makeActionButton(parent, label, action) {
31831
+ return El('div').addClass('label-panel-action-btn')
31832
+ .attr('role', 'button').appendTo(parent).text(label).on('click', action);
31833
+ }
31834
+
31835
+ function makeReadOnlyRow(parent, label) {
31836
+ var row = El('div').addClass('frame-property-detail-row').appendTo(parent);
31837
+ El('span').appendTo(row).text(label);
31838
+ return El('span').addClass('frame-property-detail-value').appendTo(row);
31839
+ }
31840
+
31841
+ function getUnitFactor(units) {
31842
+ return units == 'in' ? 72 : units == 'cm' ? 28.3465 : 1;
31843
+ }
31844
+
31845
+ function formatNumber(value) {
31846
+ return String(Math.round(value * 100) / 100);
31847
+ }
31848
+
31849
+ function formatCoordinate(value) {
31850
+ return String(Number(value.toPrecision(12)));
31851
+ }
31852
+
31853
+ function formatOpacity(value) {
31854
+ return value === undefined ? '' :
31855
+ String(Math.round(Number(value) * 100)) + '%';
31856
+ }
31857
+
31858
+ // A fixed ratio holds when the frame is rescaled; one taken from the extent
31859
+ // changes whenever the extent does. That difference is the reason to show the
31860
+ // ratio at all, so it is said rather than implied.
31861
+ function getAspectText(frame) {
31862
+ if (frame.aspect_ratio > 0) {
31863
+ return formatFrameAspectRatio(frame.aspect_ratio) + ' (fixed)';
31864
+ }
31865
+ var bbox = frame.bbox;
31866
+ var ratio = (bbox[2] - bbox[0]) / (bbox[3] - bbox[1]);
31867
+ if (!(ratio > 0) || !Number.isFinite(ratio)) return 'unavailable';
31868
+ return formatFrameAspectRatio(ratio) + ' (from extent)';
31869
+ }
31870
+
31871
+ // This is a new way to handle compatibility problems between
31872
+ // interactive editing modes and other interface modes
31873
+ // (by default, interactive modes stay on when, e.g., the user clicks
31874
+ // "Export" or "Console").
31875
+ //
31876
+ function initModeRules(gui) {
31877
+
31878
+ gui.on('interaction_mode_change', function(e) {
31879
+ var imode = e.mode;
31880
+ var mode = gui.getMode();
31881
+
31882
+ // simplify and vertex editing are not compatible
31883
+ if (imode == 'vertices') {
31884
+ flattenArcs(gui.map.getActiveLayer());
31885
+
31886
+ if (mode == 'simplify') {
31887
+ gui.clearMode(); // exit simplification
31888
+ }
31889
+
31890
+ }
31891
+
31892
+ });
31893
+
31894
+ gui.on('mode', function(e) {
31895
+ var mode = e.name;
31896
+ var imode = gui.interaction.getMode();
30246
31897
 
30247
31898
  // simplify and vertex editing are not compatible
30248
31899
  if (mode == 'simplify' && imode == 'vertices') {
@@ -31016,6 +32667,9 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
31016
32667
  new SessionSnapshots(gui);
31017
32668
  }
31018
32669
  gui.interaction = new InteractionMode(gui);
32670
+ new PreviewMode(gui);
32671
+ new FrameResizeTool(gui);
32672
+ new FrameProperties(gui);
31019
32673
  gui.editToolbar = new EditToolbar(gui);
31020
32674
  gui.labelTool = new LabelTool(gui);
31021
32675
  gui.layerStyleTool = new LayerStyleTool(gui);
@@ -31301,6 +32955,13 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
31301
32955
  })
31302
32956
  };
31303
32957
  },
32958
+ selectLayer: function(name) {
32959
+ var target = gui.model.getLayers().filter(function(o) {
32960
+ return getLayerName(o.layer) === name;
32961
+ })[0];
32962
+ if (!target) throw new Error('Missing layer: ' + name);
32963
+ gui.model.selectLayer(target.layer, target.dataset);
32964
+ },
31304
32965
  // What the label path guide drew, as last rendered.
31305
32966
  getLabelPathGuideInfo: function() {
31306
32967
  return getLabelPathGuideLayers(gui).map(function(lyr) {
@@ -31334,6 +32995,34 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
31334
32995
  zoomByPct: function(pct) {
31335
32996
  gui.map.getExtent().zoomByPct(pct);
31336
32997
  },
32998
+ setPreviewMode: function(on) {
32999
+ if (gui.previewMode) gui.previewMode.setOn(on);
33000
+ },
33001
+ getPreviewMode: function() {
33002
+ return gui.previewMode ? gui.previewMode.isOn() : false;
33003
+ },
33004
+ getPreviewReadout: function() {
33005
+ return gui.previewMode ? gui.previewMode.getReadoutText() : '';
33006
+ },
33007
+ getSymbolScale: function() {
33008
+ return gui.map.getExtent().getSymbolScale();
33009
+ },
33010
+ zoomToFrameMagnification: function(scale) {
33011
+ gui.map.getExtent().zoomToFrameMagnification(scale);
33012
+ },
33013
+ openFrameTool: function() {
33014
+ if (gui.frameTool) gui.frameTool.open();
33015
+ },
33016
+ getFrameInfo: function() {
33017
+ var target = internal.getActiveFrame(gui.model);
33018
+ if (!target) return null;
33019
+ return Object.assign({
33020
+ name: target.layer.name || ''
33021
+ }, internal.getFrameLayerData(target.layer, target.dataset.arcs));
33022
+ },
33023
+ setFrameLockSize: function(on) {
33024
+ if (gui.frameTool) gui.frameTool.setLockSize(on);
33025
+ },
31337
33026
  // The map's current view, in display CRS coordinates, for a test that an
31338
33027
  // edit leaves the view where the user put it.
31339
33028
  getViewBounds: function() {