mapshaper 0.7.68 → 0.7.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3324,7 +3324,7 @@
3324
3324
  return str;
3325
3325
  }
3326
3326
 
3327
- function formatValue(val, matches) {
3327
+ function formatValue$2(val, matches) {
3328
3328
  var flags = matches[1];
3329
3329
  var padding = matches[2];
3330
3330
  var decimals = matches[3] ? parseInt(matches[3].substr(1)) : void 0;
@@ -3420,7 +3420,7 @@
3420
3420
  error$1("[format()] Data does not match format string; format:", fmt, "data:", arguments);
3421
3421
  }
3422
3422
  for (var i=0; i<n; i++) {
3423
- str += formatValue(arguments[i], formatCodes[i]) + literals[i+1];
3423
+ str += formatValue$2(arguments[i], formatCodes[i]) + literals[i+1];
3424
3424
  }
3425
3425
  return str;
3426
3426
  };
@@ -8623,10 +8623,10 @@
8623
8623
  }
8624
8624
 
8625
8625
  // Whether a layer can be shown on the map alongside the active layer, and so
8626
- // has an eye icon in the layers panel. A frame is drawn by preview mode, and a
8627
- // data-only layer has nothing to draw.
8626
+ // has an eye icon in the layers panel. A frame and its furniture are drawn by
8627
+ // preview mode, and a data-only layer has nothing to draw.
8628
8628
  function layerIsPinnable(lyr, dataset) {
8629
- if (dataset && internal.isFrameLayer(lyr, dataset.arcs)) return false;
8629
+ if (internal.isFrameComponentLayer(lyr, dataset)) return false;
8630
8630
  return internal.layerIsGeometric(lyr) || internal.layerHasRaster(lyr) ||
8631
8631
  internal.layerHasFurniture(lyr);
8632
8632
  }
@@ -10888,6 +10888,9 @@
10888
10888
  if (opts.format == 'svg' || opts.format == 'topojson') {
10889
10889
  opts.gui_frame = getGuiFrameContext();
10890
10890
  }
10891
+ if (opts.format == 'svg') {
10892
+ targets = addFrameFurnitureTargets(targets, opts.gui_frame);
10893
+ }
10891
10894
  try {
10892
10895
  var files = await internal.exportTargetLayers(model, targets, opts);
10893
10896
  } catch(e) {
@@ -10953,7 +10956,7 @@
10953
10956
  function initLayerMenu() {
10954
10957
  var list = menu.findChild('.export-layer-list').empty();
10955
10958
  var layers = model.getLayers().filter(function(o) {
10956
- return !internal.isFrameLayer(o.layer, o.dataset.arcs);
10959
+ return !internal.isFrameComponentLayer(o.layer, o.dataset);
10957
10960
  });
10958
10961
  sortLayersForMenuDisplay(layers);
10959
10962
 
@@ -11147,21 +11150,42 @@
11147
11150
  };
11148
11151
  }
11149
11152
 
11153
+ // Snapshots keep the frame and its furniture
11150
11154
  function addFrameTarget(targets) {
11151
11155
  var frame = internal.getActiveFrame(model);
11152
- var group;
11153
11156
  if (!frame) return targets;
11157
+ return addDatasetTargets(targets, frame.dataset,
11158
+ [frame.layer].concat(internal.getFrameFurnitureLayers(frame.dataset)));
11159
+ }
11160
+
11161
+ // The frame's furniture is drawn in SVG output, the way it is in preview.
11162
+ // An unprojected frame can't show a scalebar, so it doesn't block the export.
11163
+ function addFrameFurnitureTargets(targets, frameContext) {
11164
+ var frame = internal.getActiveFrame(model);
11165
+ var furniture = frame ? internal.getFrameFurnitureLayers(frame.dataset) : [];
11166
+ if (furniture.length === 0) return targets;
11167
+ if (internal.getFurnitureFrameProblem(frameContext && frameContext.data)) {
11168
+ console.warn('Map furniture was not exported: the map frame is unprojected');
11169
+ return targets;
11170
+ }
11171
+ return addDatasetTargets(targets, frame.dataset, furniture);
11172
+ }
11173
+
11174
+ function addDatasetTargets(targets, dataset, layers) {
11175
+ var group;
11154
11176
  targets = targets.map(function(target) {
11155
11177
  return Object.assign({}, target, {layers: target.layers.slice()});
11156
11178
  });
11157
11179
  group = targets.find(function(target) {
11158
- return target.dataset == frame.dataset;
11180
+ return target.dataset == dataset;
11159
11181
  });
11160
- if (group) {
11161
- if (!group.layers.includes(frame.layer)) group.layers.push(frame.layer);
11162
- } else {
11163
- targets.push({dataset: frame.dataset, layers: [frame.layer]});
11182
+ if (!group) {
11183
+ group = {dataset: dataset, layers: []};
11184
+ targets.push(group);
11164
11185
  }
11186
+ layers.forEach(function(lyr) {
11187
+ if (!group.layers.includes(lyr)) group.layers.push(lyr);
11188
+ });
11165
11189
  return targets;
11166
11190
  }
11167
11191
 
@@ -11456,6 +11480,9 @@
11456
11480
  if (e.frameProperties) {
11457
11481
  addMenuItem('frame properties', e.frameProperties, '');
11458
11482
  }
11483
+ if (e.scalebarProperties) {
11484
+ addMenuItem(e.scalebarLabel || 'scale bar', e.scalebarProperties, '');
11485
+ }
11459
11486
  if (e.resizeFrame) {
11460
11487
  addMenuItem('resize frame', e.resizeFrame, '');
11461
11488
  }
@@ -11883,6 +11910,8 @@
11883
11910
  sortLayersForMenuDisplay(model.getLayers()).forEach(function(o) {
11884
11911
  var lyr = o.layer;
11885
11912
  var isFrame = internal.isFrameLayer(lyr, o.dataset.arcs);
11913
+ // furniture is shown as part of its frame's entry
11914
+ if (!isFrame && internal.isFrameComponentLayer(lyr, o.dataset)) return;
11886
11915
  var opts = {
11887
11916
  show_source: layerCount < 5,
11888
11917
  pinnable: pinnableCount > 0 && isPinnable(lyr, o.dataset)
@@ -11982,25 +12011,39 @@
11982
12011
  function deleteLayer() {
11983
12012
  var target = findLayerById(id);
11984
12013
  var undoTransaction;
12014
+ var furniture;
11985
12015
  if (!target) return;
12016
+ // a frame's furniture goes with it, rather than becoming a stray layer
12017
+ furniture = internal.isFrameLayer(target.layer, target.dataset.arcs) ?
12018
+ internal.getFrameFurnitureLayers(target.dataset) : [];
11986
12019
  undoTransaction = createUndoTransaction(gui, 'delete layer');
11987
12020
  if (map.isVisibleLayer(target.layer)) {
11988
12021
  // TODO: check for double map refresh after model.deleteLayer() below
11989
12022
  setLayerPinning(target.layer, false);
11990
12023
  }
11991
12024
  if (undoTransaction) {
11992
- undoTransaction.run(function() {
11993
- model.deleteLayer(target.layer, target.dataset);
11994
- });
12025
+ undoTransaction.run(deleteLayers);
11995
12026
  addUndoTransactionToHistory(gui, undoTransaction, {
11996
12027
  flags: {select: true, arc_count: true},
11997
12028
  entryPrefix: 'delete-layer'
11998
12029
  });
11999
12030
  } else {
12031
+ deleteLayers();
12032
+ }
12033
+
12034
+ function deleteLayers() {
12035
+ furniture.forEach(function(lyr) {
12036
+ model.deleteLayer(lyr, target.dataset);
12037
+ });
12000
12038
  model.deleteLayer(target.layer, target.dataset);
12001
12039
  }
12002
12040
  }
12003
12041
 
12042
+ function openScalebarProperties() {
12043
+ var target = findLayerById(id);
12044
+ if (target && gui.scalebarProperties) gui.scalebarProperties.open(target);
12045
+ }
12046
+
12004
12047
  // Duplicate a layer by running an equivalent command, so the copy is
12005
12048
  // recorded in session history and can be undone. Uses '-filter true +'
12006
12049
  // to append a copy without replacing the source, keeps the same name
@@ -12088,6 +12131,10 @@
12088
12131
  internal.isFrameLayer(target.layer, target.dataset.arcs);
12089
12132
  if (isFrame) {
12090
12133
  menuEvent.frameProperties = openFrameProperties;
12134
+ menuEvent.scalebarProperties = openScalebarProperties;
12135
+ menuEvent.scalebarLabel =
12136
+ internal.findFrameFurnitureLayer(target.dataset, 'scalebar') ?
12137
+ 'scale bar' : 'add scale bar';
12091
12138
  menuEvent.resizeFrame = resizeFrame;
12092
12139
  menuEvent.deleteFrame = deleteLayer;
12093
12140
  } else {
@@ -16612,20 +16659,43 @@
16612
16659
 
16613
16660
  // The fraction an opacity control's contents mean ("50%", " 50 " -> 0.5), or
16614
16661
  // null if it is not holding a number. Out-of-range values are clamped rather
16615
- // than refused: a pasted 150% is an intent, not a mistake.
16662
+ // than refused: a pasted 150% is an intent, not a mistake. A blank field is
16663
+ // null, not zero: it is what a colour with no value shows beside it.
16616
16664
  function parseOpacityValue(str) {
16617
- var pct = Number(String(str).replace('%', '').trim());
16618
- if (!isFinite(pct)) return null;
16665
+ var txt = String(str).replace('%', '').trim();
16666
+ var pct = Number(txt);
16667
+ if (txt === '' || !isFinite(pct)) return null;
16619
16668
  return Math.max(0, Math.min(100, pct)) / 100;
16620
16669
  }
16621
16670
 
16671
+ // A typed dash pattern in the form -style stroke-dasharray= takes: lengths
16672
+ // separated by single spaces. Commas and runs of whitespace are accepted
16673
+ // because SVG accepts them, and a pasted "4, 2" means the same as "4 2".
16674
+ // Returns '' for a blank field. Does not check that the lengths are numbers.
16675
+ function normalizeDashArrayInput(str) {
16676
+ return String(str).trim().split(/[\s,]+/).filter(Boolean).join(' ');
16677
+ }
16678
+
16622
16679
  // A stored fraction as the percentage a control shows, or '' for no value --
16623
16680
  // which is how a control over a selection that does not agree shows.
16624
16681
  function formatOpacityPct(val) {
16682
+ if (isUnsetValue(val)) return '';
16625
16683
  val = Number(val);
16626
16684
  return isFinite(val) ? Math.round(Math.max(0, Math.min(1, val)) * 100) + '%' : '';
16627
16685
  }
16628
16686
 
16687
+ // The opacity shown beside a colour. A colour with no opacity of its own is
16688
+ // drawn opaque, and says so; with no colour either, there is nothing for an
16689
+ // opacity to apply to, and the field is blank like the colour.
16690
+ function formatColorOpacityPct(opacity, hasColor) {
16691
+ if (isUnsetValue(opacity)) return hasColor ? '100%' : '';
16692
+ return formatOpacityPct(opacity);
16693
+ }
16694
+
16695
+ function isUnsetValue(val) {
16696
+ return val === undefined || val === null || val === '';
16697
+ }
16698
+
16629
16699
  // The parts the style panels are built from. They were the label panel's, and
16630
16700
  // three panels drawing the same control three ways is how they came to look
16631
16701
  // like three programs: a colour was a swatch inside a field in one and a
@@ -16685,6 +16755,16 @@
16685
16755
  return btn;
16686
16756
  }
16687
16757
 
16758
+ // The "?" the rest of the app uses for field help (see .tip-button in
16759
+ // elements.css and the static ones in index.html). The bubble is white-space:
16760
+ // pre, so the line breaks in the text are the ones it gets.
16761
+ function makeFieldTip(parent, text) {
16762
+ var btn = El('div').addClass('tip-button').appendTo(parent).text('?');
16763
+ var anchor = El('div').addClass('tip-anchor').appendTo(btn);
16764
+ El('div').addClass('tip').appendTo(anchor).text(text);
16765
+ return btn;
16766
+ }
16767
+
16688
16768
  function setPanelButtonDisabled(el, disabled) {
16689
16769
  el.classed('disabled', !!disabled)
16690
16770
  .attr('aria-disabled', disabled ? 'true' : 'false');
@@ -19555,12 +19635,12 @@
19555
19635
  }
19556
19636
 
19557
19637
  var savedStylesKey = 'layer_style_presets';
19558
- var styleFields = ['stroke', 'stroke-width', 'stroke-opacity', 'fill', 'fill-opacity'];
19638
+ var styleFields = ['stroke', 'stroke-width', 'stroke-opacity', 'stroke-dasharray', 'fill', 'fill-opacity'];
19559
19639
 
19560
19640
  function LayerStyleTool(gui) {
19561
19641
  var parent = gui.container.findChild('.mshp-main-map');
19562
19642
  var panel = El('div').addClass('label-style-panel layer-style-panel rollover').appendTo(parent).hide();
19563
- var title, editingStatus, clearLink, strokeControl, fillControl, strokeWidthField, randomFillBtn, presetControl, hit;
19643
+ var title, editingStatus, clearLink, strokeControl, fillControl, strokeWidthField, dashControl, randomFillBtn, presetControl, hit;
19564
19644
  var targetLayer = null;
19565
19645
 
19566
19646
  initPanel();
@@ -19646,6 +19726,7 @@
19646
19726
  fillControl = addColorControl(panel, 'Fill', 'fill', '');
19647
19727
  strokeControl = addColorControl(panel, 'Stroke', 'stroke', '#000000');
19648
19728
  strokeWidthField = addStrokeWidthControl(strokeControl.aside);
19729
+ dashControl = addDashArrayControl(panel);
19649
19730
 
19650
19731
  var buttonRow = El('div').addClass('label-style-row label-panel-button-row').appendTo(panel);
19651
19732
  randomFillBtn = makePanelActionButton(buttonRow, 'Random fills', applyRandomFillColors);
@@ -19706,6 +19787,29 @@
19706
19787
  });
19707
19788
  }
19708
19789
 
19790
+ // A literal stroke-dasharray, in the wide column so that it lines up with
19791
+ // the stroke's colour above it. A blank field makes the line solid again.
19792
+ function addDashArrayControl(parent) {
19793
+ var row = El('div').addClass('label-style-row label-split-row layer-dash-row').appendTo(parent);
19794
+ var cell = El('div').addClass('label-split-cell').appendTo(row);
19795
+ El('div').addClass('label-split-cell').appendTo(row);
19796
+ var caption = El('div').addClass('label-style-row-label').appendTo(cell).text('Dashes');
19797
+ makeFieldTip(caption,
19798
+ 'Dash and gap lengths in pixels, separated\n' +
19799
+ 'by spaces. "4" gives 4px dashes and 4px gaps.\n' +
19800
+ '"6 3" gives 6px dashes and 3px gaps.');
19801
+ var input = El('input').attr('type', 'text').appendTo(cell)
19802
+ .on('change', function() {
19803
+ var value = normalizeDashArrayInput(input.node().value);
19804
+ if (value && internal.parseStyleLiteral('stroke-dasharray', value) === undefined) {
19805
+ updateControls();
19806
+ return;
19807
+ }
19808
+ applyDashArrayStyle(value);
19809
+ });
19810
+ return {row: row, input: input};
19811
+ }
19812
+
19709
19813
  function releaseFocus() {
19710
19814
  releasePanelFocus(panel.node());
19711
19815
  }
@@ -19719,9 +19823,11 @@
19719
19823
  updateEditingStatus(manualIds.length);
19720
19824
  strokeControl.row.show();
19721
19825
  fillControl.row.classed('hidden', geom != 'polygon');
19826
+ dashControl.row.classed('hidden', geom != 'polyline');
19722
19827
  updateColorControl(strokeControl);
19723
19828
  updateColorControl(fillControl);
19724
19829
  updateStrokeWidthControl();
19830
+ updateDashArrayControl();
19725
19831
  randomFillBtn.classed('hidden', geom != 'polygon');
19726
19832
  presetControl.render();
19727
19833
  updateSavedStyleControls();
@@ -19735,10 +19841,13 @@
19735
19841
  if (!isHexColor(value)) control.picker.hide();
19736
19842
  }
19737
19843
 
19844
+ // A selection whose colours disagree still has colours, so an opacity unset
19845
+ // on all of it shows as full rather than blank.
19738
19846
  function updateOpacityControl(control) {
19739
- var value = getCommonStyleValue(control.field + '-opacity');
19740
- control.opacity.node().value =
19741
- formatOpacityPct(value === '' || value === undefined || value === null ? 1 : value);
19847
+ var field = control.field + '-opacity';
19848
+ control.opacity.node().value = styleFieldIsUnsetForTargets(field) ?
19849
+ formatColorOpacityPct(null, !styleFieldIsUnsetForTargets(control.field)) :
19850
+ formatOpacityPct(getCommonStyleValue(field));
19742
19851
  }
19743
19852
 
19744
19853
  function updateStrokeWidthControl() {
@@ -19748,6 +19857,11 @@
19748
19857
  getDefaultStrokeWidth() : value));
19749
19858
  }
19750
19859
 
19860
+ function updateDashArrayControl() {
19861
+ var value = getCommonStyleValue('stroke-dasharray');
19862
+ dashControl.input.node().value = value === undefined || value === null ? '' : String(value);
19863
+ }
19864
+
19751
19865
  function nudgeStrokeWidth(direction) {
19752
19866
  var value = Number(getCommonStyleValue('stroke-width'));
19753
19867
  if (!isFinite(value)) value = getDefaultStrokeWidth();
@@ -19789,6 +19903,14 @@
19789
19903
  runStyleCommand(styles);
19790
19904
  }
19791
19905
 
19906
+ function applyDashArrayStyle(value) {
19907
+ var styles = [['stroke-dasharray', value]];
19908
+ if (value && styleFieldIsUnsetForTargets('stroke')) {
19909
+ styles.push(['stroke', strokeControl.defaultColor]);
19910
+ }
19911
+ runStyleCommand(styles);
19912
+ }
19913
+
19792
19914
  function runStyleCommand(styles) {
19793
19915
  var parts = ['-style'];
19794
19916
  releaseFocus();
@@ -19850,6 +19972,9 @@
19850
19972
  addStyleValue(style, 'stroke', getControlValue(strokeControl.input));
19851
19973
  addStyleValue(style, 'stroke-width', strokeWidthField.getValue());
19852
19974
  addStyleValue(style, 'stroke-opacity', parseOpacityValue(strokeControl.opacity.node().value));
19975
+ if (targetLayer && targetLayer.geometry_type == 'polyline') {
19976
+ addStyleValue(style, 'stroke-dasharray', getControlValue(dashControl.input));
19977
+ }
19853
19978
  if (targetLayer && targetLayer.geometry_type == 'polygon') {
19854
19979
  addStyleValue(style, 'fill', getControlValue(fillControl.input));
19855
19980
  addStyleValue(style, 'fill-opacity', parseOpacityValue(fillControl.opacity.node().value));
@@ -20260,27 +20385,42 @@
20260
20385
  var radius = getCommonValue('r');
20261
20386
  var fill = getCommonValue('fill');
20262
20387
  var stroke = getCommonValue('stroke');
20263
- var fillOpacity = getCommonValue('fill-opacity');
20264
- var strokeOpacity = getCommonValue('stroke-opacity');
20265
20388
  var strokeWidth = getCommonValue('stroke-width');
20266
20389
  setCircleRadius(radius);
20267
20390
  setCircleColor(circleFillControl, fill);
20268
20391
  setCircleColor(circleStrokeControl, stroke);
20269
- setCircleOpacity(circleFillControl, fillOpacity);
20270
- setCircleOpacity(circleStrokeControl, strokeOpacity);
20392
+ updateCircleOpacity(circleFillControl);
20393
+ updateCircleOpacity(circleStrokeControl);
20271
20394
  setCircleStrokeWidth(strokeWidth === '' ? 0 : strokeWidth);
20272
20395
  if (representation != 'circle' && representation != 'unstyled') {
20273
20396
  setCircleRadius(defaultCircleRadius);
20274
20397
  setCircleColor(circleFillControl, '');
20275
20398
  setCircleColor(circleStrokeControl, '');
20276
- setCircleOpacity(circleFillControl, '');
20277
- setCircleOpacity(circleStrokeControl, '');
20399
+ circleFillControl.opacity.node().value = '';
20400
+ circleStrokeControl.opacity.node().value = '';
20278
20401
  setCircleStrokeWidth(0);
20279
20402
  }
20280
20403
  }
20281
20404
 
20282
- function setCircleOpacity(control, value) {
20283
- control.opacity.node().value = formatOpacityPct(value === '' ? 1 : value);
20405
+ // The opacity to put in the style command: undefined to leave the property
20406
+ // as it is, null for a field holding something that is not an opacity.
20407
+ // A blank field has nothing to say, and 100% over an unset opacity is the
20408
+ // default being shown rather than a value to store.
20409
+ function getCircleOpacityToWrite(control) {
20410
+ var str = control.opacity.node().value.trim();
20411
+ var val = parseOpacityValue(str);
20412
+ if (str === '') return undefined;
20413
+ if (val === 1 && styleFieldIsUnset(control.field + '-opacity')) return undefined;
20414
+ return val;
20415
+ }
20416
+
20417
+ // A selection whose colours disagree still has colours, so an opacity unset
20418
+ // on all of it shows as full rather than blank.
20419
+ function updateCircleOpacity(control) {
20420
+ var field = control.field + '-opacity';
20421
+ control.opacity.node().value = styleFieldIsUnset(field) ?
20422
+ formatColorOpacityPct(null, !styleFieldIsUnset(control.field)) :
20423
+ formatOpacityPct(getCommonValue(field));
20284
20424
  }
20285
20425
 
20286
20426
  function renderCreateFields() {
@@ -20331,16 +20471,16 @@
20331
20471
  function createSimpleCircles() {
20332
20472
  runCommand('-style r=' + defaultCreatedCircleRadius +
20333
20473
  ' fill=' + quoteCommandValue(defaultCircleFill) +
20334
- ' fill-opacity=1 stroke-opacity=1 stroke-width=0', 'Create circles');
20474
+ ' stroke-width=0', 'Create circles');
20335
20475
  }
20336
20476
 
20337
20477
  function applyCircleStyles() {
20338
20478
  var representation = getPointRepresentation();
20339
20479
  var radius = getCircleRadius();
20340
20480
  var fill = circleFillControl.input.node().value.trim();
20341
- var fillOpacity = parseOpacityValue(circleFillControl.opacity.node().value);
20481
+ var fillOpacity = getCircleOpacityToWrite(circleFillControl);
20342
20482
  var stroke = circleStrokeControl.input.node().value.trim();
20343
- var strokeOpacity = parseOpacityValue(circleStrokeControl.opacity.node().value);
20483
+ var strokeOpacity = getCircleOpacityToWrite(circleStrokeControl);
20344
20484
  var strokeWidth = getCircleStrokeWidth();
20345
20485
  var args;
20346
20486
  if (!gui.console || !(representation == 'unstyled' || representation == 'circle') ||
@@ -20349,11 +20489,9 @@
20349
20489
  fill = defaultCircleFill;
20350
20490
  setCircleColor(circleFillControl, fill);
20351
20491
  }
20352
- args = [
20353
- 'fill-opacity=' + fillOpacity,
20354
- 'stroke-opacity=' + strokeOpacity,
20355
- 'stroke-width=' + strokeWidth
20356
- ];
20492
+ args = ['stroke-width=' + strokeWidth];
20493
+ if (fillOpacity !== undefined) args.push('fill-opacity=' + fillOpacity);
20494
+ if (strokeOpacity !== undefined) args.push('stroke-opacity=' + strokeOpacity);
20357
20495
  if (radius !== null) args.unshift('r=' + radius);
20358
20496
  if (fill) args.push('fill=' + quoteCommandValue(fill));
20359
20497
  if (stroke) args.push('stroke=' + quoteCommandValue(stroke));
@@ -20580,7 +20718,7 @@
20580
20718
  var setDefaultTargets = self.setDefaultTargets;
20581
20719
  utils$1.extend(self, EventDispatcher.prototype);
20582
20720
 
20583
- // A map frame is composition state, not editable content. Commands may
20721
+ // A map frame (and its furniture) is composition state, not editable content. Commands may
20584
20722
  // explicitly target it, but it must not replace the GUI's active content
20585
20723
  // layer or become the implicit target of the next console command.
20586
20724
  self.setDefaultTargets = function(targets, opts) {
@@ -20647,15 +20785,25 @@
20647
20785
  };
20648
20786
 
20649
20787
  self.selectNextLayer = function() {
20650
- var next = self.findNextLayer(self.getActiveLayer().layer);
20788
+ var next = findContentLayer(self.findNextLayer);
20651
20789
  if (next) self.selectLayer(next.layer, next.dataset);
20652
20790
  };
20653
20791
 
20654
20792
  self.selectPrevLayer = function() {
20655
- var prev = self.findPrevLayer(self.getActiveLayer().layer);
20793
+ var prev = findContentLayer(self.findPrevLayer);
20656
20794
  if (prev) self.selectLayer(prev.layer, prev.dataset);
20657
20795
  };
20658
20796
 
20797
+ // step past the frame and its furniture, which can't be selected
20798
+ function findContentLayer(step) {
20799
+ var start = self.getActiveLayer().layer;
20800
+ var o = step.call(self, start);
20801
+ while (o && o.layer != start && internal.isFrameComponentLayer(o.layer, o.dataset)) {
20802
+ o = step.call(self, o.layer);
20803
+ }
20804
+ return o;
20805
+ }
20806
+
20659
20807
  return self;
20660
20808
 
20661
20809
  function getContentTargets(targets) {
@@ -20663,7 +20811,7 @@
20663
20811
  return {
20664
20812
  dataset: target.dataset,
20665
20813
  layers: target.layers.filter(function(lyr) {
20666
- return !internal.isFrameLayer(lyr, target.dataset.arcs);
20814
+ return !internal.isFrameComponentLayer(lyr, target.dataset);
20667
20815
  })
20668
20816
  };
20669
20817
  }).filter(function(target) {
@@ -20673,7 +20821,7 @@
20673
20821
 
20674
20822
  function getFirstContentTarget() {
20675
20823
  var target = self.getLayers().find(function(o) {
20676
- return !internal.isFrameLayer(o.layer, o.dataset.arcs);
20824
+ return !internal.isFrameComponentLayer(o.layer, o.dataset);
20677
20825
  });
20678
20826
  return target ? [{dataset: target.dataset, layers: [target.layer]}] : [];
20679
20827
  }
@@ -23056,15 +23204,22 @@
23056
23204
  this.zoomToBbox = zoomToBbox;
23057
23205
 
23058
23206
  if (gui.options.homeControl) {
23059
- gui.buttons.addButton("#home-icon").on('click', function() {
23060
- if (disabled()) return;
23061
- gui.dispatchEvent('map_reset');
23062
- });
23207
+ gui.buttons.addButton("#home-icon")
23208
+ .attr('data-tooltip', 'Zoom to fit')
23209
+ .attr('aria-label', 'Zoom to fit')
23210
+ .on('click', function() {
23211
+ if (disabled()) return;
23212
+ gui.dispatchEvent('map_reset');
23213
+ });
23063
23214
  }
23064
23215
 
23065
23216
  if (gui.options.zoomControl) {
23066
- inBtn = gui.buttons.addButton("#zoom-in-icon");
23067
- outBtn = gui.buttons.addButton("#zoom-out-icon");
23217
+ inBtn = gui.buttons.addButton("#zoom-in-icon")
23218
+ .attr('data-tooltip', 'Zoom in')
23219
+ .attr('aria-label', 'Zoom in');
23220
+ outBtn = gui.buttons.addButton("#zoom-out-icon")
23221
+ .attr('data-tooltip', 'Zoom out')
23222
+ .attr('aria-label', 'Zoom out');
23068
23223
  initVariableClick(inBtn.node(), zoomIn);
23069
23224
  initVariableClick(outBtn.node(), zoomOut);
23070
23225
  ext.on('change', function() {
@@ -29341,6 +29496,7 @@
29341
29496
  referenceStyle = { // outline style for reference layers
29342
29497
  type: 'outline',
29343
29498
  strokeColors: [null, '#87b73b'], // was 78c110
29499
+ // strokeColors: [null, 'rgba(79,140,0,0.67)'],
29344
29500
  strokeWidth: 0.85,
29345
29501
  dotColor: "#73ba20",
29346
29502
  dotSize: 1
@@ -30220,37 +30376,103 @@
30220
30376
  }
30221
30377
 
30222
30378
 
30223
- function getCanvasFillPattern(style) {
30224
- var fill = hatches[style.fillPattern];
30225
- if (fill === undefined) {
30226
- fill = makePatternFill(style);
30227
- hatches[style.fillPattern] = fill;
30379
+ // symbolScale: the preview symbol scale (1 outside preview). Pattern sizes are
30380
+ // in output pixels, like stroke widths, so the tile grows with the page.
30381
+ // anchor: canvas-pixel position the pattern origin is pinned to, so the tile
30382
+ // travels with the map instead of sticking to the viewport. Panning only
30383
+ // updates this matrix. The tile image is rebuilt when the scale changes,
30384
+ // which is at most once per pattern per render.
30385
+ function getCanvasFillPattern(style, symbolScale, anchor) {
30386
+ var scale = symbolScale > 0 ? symbolScale : 1;
30387
+ var entry = hatches[style.fillPattern];
30388
+ if (!entry || entry.scale != scale) {
30389
+ entry = makePatternEntry(style, scale);
30390
+ hatches[style.fillPattern] = entry;
30228
30391
  }
30229
- return fill || style.fill || '#000'; // use fill if hatches are invalid
30392
+ if (!entry || !entry.pattern) return style.fill || '#000';
30393
+ if (anchor) applyPatternAnchor(entry, anchor);
30394
+ return entry.pattern;
30230
30395
  }
30231
30396
 
30232
- function makePatternFill(style) {
30397
+ function makePatternEntry(style, scale) {
30233
30398
  var o = internal.parsePattern(style.fillPattern);
30234
30399
  if (!o) return null;
30235
30400
  var canv = document.createElement('canvas');
30236
30401
  var ctx = canv.getContext('2d');
30237
- var res = GUI.getPixelRatio();
30238
- var w = o.tileSize[0] * res;
30239
- var h = o.tileSize[1] * res;
30402
+ var k = GUI.getPixelRatio() * scale;
30403
+ var tw = o.tileSize[0], th = o.tileSize[1];
30404
+ // A canvas has whole-pixel dimensions, but the scaled tile generally does
30405
+ // not. The tile is drawn into the nearest whole-pixel canvas, and the
30406
+ // pattern transform stretches it back to its exact size so that the
30407
+ // repeat period doesn't drift across a large polygon.
30408
+ var w = Math.max(1, Math.round(tw * k));
30409
+ var h = Math.max(1, Math.round(th * k));
30240
30410
  canv.setAttribute('width', w);
30241
30411
  canv.setAttribute('height', h);
30412
+ ctx.scale(w / tw, h / th);
30242
30413
  if (o.background) {
30243
30414
  ctx.fillStyle = o.background;
30244
- ctx.fillRect(0, 0, w, h);
30415
+ ctx.fillRect(0, 0, tw, th);
30245
30416
  }
30246
- if (o.type == 'dots' || o.type == 'squares') makeDotFill(o, ctx, res);
30247
- if (o.type == 'dashes') makeDashFill(o, ctx, res);
30248
- if (o.type == 'hatches') makeHatchFill(o, ctx, res);
30249
- var pattern = ctx.createPattern(canv, 'repeat');
30250
- if (o.rotation) {
30251
- pattern.setTransform(new DOMMatrix('rotate(' + o.rotation + 'deg)'));
30252
- }
30253
- return pattern;
30417
+ if (o.type == 'dots' || o.type == 'squares') makeDotFill(o, ctx, 1);
30418
+ if (o.type == 'dashes') makeDashFill(o, ctx, 1);
30419
+ if (o.type == 'hatches') makeHatchFill(o, ctx, 1);
30420
+ return {
30421
+ scale: scale,
30422
+ pattern: ctx.createPattern(canv, 'repeat'),
30423
+ rotation: o.rotation || 0,
30424
+ sx: tw * k / w,
30425
+ sy: th * k / h,
30426
+ w: w,
30427
+ h: h
30428
+ };
30429
+ }
30430
+
30431
+ // Pins the cached pattern to @anchor. Skipped when the view has not moved,
30432
+ // so a hover redraw does not touch the pattern.
30433
+ function applyPatternAnchor(entry, anchor) {
30434
+ if (entry.ax == anchor.x && entry.ay == anchor.y) return;
30435
+ entry.ax = anchor.x;
30436
+ entry.ay = anchor.y;
30437
+ var t = getPatternTransform(entry, anchor);
30438
+ entry.pattern.setTransform(new DOMMatrix([t.a, t.b, t.c, t.d, t.e, t.f]));
30439
+ }
30440
+
30441
+ // Matrix taking pattern-image pixels to canvas pixels: translate to the
30442
+ // anchor, then rotate, then scale. The anchor is folded into one tile so a
30443
+ // map origin far outside the viewport does not blow the matrix precision.
30444
+ function getPatternTransform(entry, anchor) {
30445
+ var p = wrapPatternAnchor(anchor.x, anchor.y, entry);
30446
+ var rad = (entry.rotation || 0) * Math.PI / 180;
30447
+ var c = Math.cos(rad);
30448
+ var s = Math.sin(rad);
30449
+ return {
30450
+ a: c * entry.sx,
30451
+ b: s * entry.sx,
30452
+ c: -s * entry.sy,
30453
+ d: c * entry.sy,
30454
+ e: p.x,
30455
+ f: p.y
30456
+ };
30457
+ }
30458
+
30459
+ // @entry.sx/sy scale image pixels to canvas pixels; the image repeats every
30460
+ // @entry.w by @entry.h pixels. Returns a point in the same place on the
30461
+ // pattern, within one tile of the origin.
30462
+ function wrapPatternAnchor(x, y, entry) {
30463
+ var rad = (entry.rotation || 0) * Math.PI / 180;
30464
+ var c = Math.cos(rad);
30465
+ var s = Math.sin(rad);
30466
+ var rx = c * x + s * y;
30467
+ var ry = -s * x + c * y;
30468
+ var qx = rx / entry.sx - Math.floor(rx / entry.sx / entry.w) * entry.w;
30469
+ var qy = ry / entry.sy - Math.floor(ry / entry.sy / entry.h) * entry.h;
30470
+ var lx = qx * entry.sx;
30471
+ var ly = qy * entry.sy;
30472
+ return {
30473
+ x: c * lx - s * ly,
30474
+ y: s * lx + c * ly
30475
+ };
30254
30476
  }
30255
30477
 
30256
30478
  function makeDashFill(o, ctx, res) {
@@ -30941,27 +31163,66 @@
30941
31163
  return iter;
30942
31164
  }
30943
31165
 
31166
+ // Same multiplier as ctx.lineWidth. Retina canvases are pixRatio times the
31167
+ // CSS pixel; preview mode multiplies styled strokes by the symbol scale.
31168
+ function getCanvasStrokeScale(pixRatio, lineScale) {
31169
+ return (pixRatio > 1 ? pixRatio : 1) * (lineScale || 1);
31170
+ }
31171
+
31172
+ // Scale a stroke-dasharray into canvas pixels. Scale 1 returns the authored
31173
+ // tokens, so a 100% preview and a non-preview view keep the pattern as stored.
31174
+ function scaleCanvasLineDash(lineDash, scale) {
31175
+ var parts = String(lineDash).split(' ');
31176
+ var out, i;
31177
+ if (!(scale > 0) || scale === 1) return parts;
31178
+ out = new Array(parts.length);
31179
+ for (i = 0; i < parts.length; i++) {
31180
+ out[i] = +parts[i] * scale;
31181
+ }
31182
+ return out;
31183
+ }
31184
+
31185
+ // Where a fill pattern's origin sits, in canvas pixels. In preview that is
31186
+ // the top-left of the page; otherwise the geographic origin, so a pan (which
31187
+ // only changes the translation) carries the pattern with the shapes.
31188
+ function getPatternAnchor(ext) {
31189
+ var t = ext.getTransform(GUI.getPixelRatio());
31190
+ var frame = ext.getFrameData();
31191
+ if (frame && frame.bbox) {
31192
+ return {
31193
+ x: frame.bbox[0] * t.mx + t.bx,
31194
+ y: frame.bbox[3] * t.my + t.by
31195
+ };
31196
+ }
31197
+ return {x: t.bx, y: t.by};
31198
+ }
31199
+
30944
31200
  function getPathStart(ext, lineScale) {
30945
31201
  var pixRatio = GUI.getPixelRatio();
30946
31202
  if (!lineScale) lineScale = 1;
31203
+ // Same factor as lineWidth, so dash length and gap stay in proportion to
31204
+ // the stroke. In preview that factor includes the symbol scale; without it
31205
+ // the pattern stays a fixed screen size while the line grows.
31206
+ var strokeScale = getCanvasStrokeScale(pixRatio, lineScale);
31207
+ // The symbol scale itself, not lineScale: lineScale adds a bump to widen
31208
+ // hover outlines, which a fill has no reason to follow.
31209
+ var patternScale = ext.getSymbolScale() || 1;
31210
+ // Page corner in preview, so the pattern is fixed to the page the way the
31211
+ // exported SVG is. With no frame, the geographic origin, which is enough
31212
+ // for panning: the pattern is not scaled by the geographic zoom then.
31213
+ var patternAnchor = getPatternAnchor(ext);
30947
31214
  return function(ctx, style) {
30948
31215
  var strokeWidth;
30949
31216
  ctx.beginPath();
30950
31217
  if (style.strokeWidth > 0) {
30951
31218
  strokeWidth = style.strokeWidth;
30952
- if (pixRatio > 1) {
30953
- // bump up thin lines on retina, but not to more than 1px
30954
- // (tests on Chrome showed much faster rendering of 1px lines)
30955
- // strokeWidth = strokeWidth < 1 ? 1 : strokeWidth * pixRatio;
30956
- strokeWidth = strokeWidth * pixRatio;
30957
- }
30958
31219
  ctx.lineCap = style.lineCap || 'round';
30959
31220
  ctx.lineJoin = style.lineJoin || 'round';
30960
- ctx.lineWidth = strokeWidth * lineScale;
31221
+ ctx.lineWidth = strokeWidth * strokeScale;
30961
31222
  ctx.strokeStyle = style.strokeColor;
30962
31223
  if (style.lineDash){
30963
31224
  ctx.lineCap = 'butt';
30964
- ctx.setLineDash(style.lineDash.split(' '));
31225
+ ctx.setLineDash(scaleCanvasLineDash(style.lineDash, strokeScale));
30965
31226
  }
30966
31227
  if (style.miterLimit) {
30967
31228
  ctx.miterLimit = style.miterLimit;
@@ -30969,7 +31230,7 @@
30969
31230
  }
30970
31231
 
30971
31232
  if (style.fillPattern) {
30972
- ctx.fillStyle = getCanvasFillPattern(style);
31233
+ ctx.fillStyle = getCanvasFillPattern(style, patternScale, patternAnchor);
30973
31234
  } else if (style.fillColor) {
30974
31235
  ctx.fillStyle = style.fillColor;
30975
31236
  }
@@ -31008,20 +31269,28 @@
31008
31269
  return internal.svg.getTransform(p, scale);
31009
31270
  }
31010
31271
 
31011
- function repositionFurniture(container, layer, ext) {
31012
- var g = El.findAll('.mapshaper-svg-furniture', container)[0];
31013
- g.setAttribute('transform', getSvgFurnitureTransform(ext));
31272
+ function repositionFurniture(container, ext) {
31273
+ if (!ext.getFrameData()) return;
31274
+ El.findAll('.mapshaper-svg-furniture', container).forEach(function(g) {
31275
+ g.setAttribute('transform', getSvgFurnitureTransform(ext));
31276
+ });
31014
31277
  }
31015
31278
 
31279
+ // Returns an SVG string, or '' if the furniture can't be drawn in the current
31280
+ // frame (e.g. the map is unprojected). The scalebar panel explains why.
31016
31281
  function renderFurniture(lyr, ext) {
31017
- var frame = ext.getFrameData(); // frame should be set if we're rendering a furniture layer
31018
- var obj = internal.getEmptyLayerForSVG(lyr, {});
31019
- if (!frame) {
31020
- stop$1('Missing map frame data');
31021
- }
31282
+ var frame = ext.getFrameData();
31283
+ var obj;
31284
+ if (internal.getFurnitureFrameProblem(frame)) return '';
31285
+ obj = internal.getEmptyLayerForSVG(lyr, {});
31022
31286
  obj.properties.transform = getSvgFurnitureTransform(ext);
31023
31287
  obj.properties.class = 'mapshaper-svg-furniture';
31024
- obj.children = internal.renderFurnitureLayer(lyr, frame);
31288
+ try {
31289
+ obj.children = internal.renderFurnitureLayer(lyr, frame);
31290
+ } catch(e) {
31291
+ console.error(e);
31292
+ return '';
31293
+ }
31025
31294
  return internal.svg.stringify(obj);
31026
31295
  }
31027
31296
 
@@ -31063,8 +31332,6 @@
31063
31332
  resize(ext);
31064
31333
  if (type == 'label' || type == 'symbol') {
31065
31334
  html = renderSymbols(lyr.gui.displayLayer, ext, id);
31066
- } else if (type == 'furniture') {
31067
- html = renderFurniture(lyr.gui.displayLayer, ext);
31068
31335
  }
31069
31336
  g.innerHTML = html;
31070
31337
  if (type == 'label' || type == 'symbol') {
@@ -31078,6 +31345,26 @@
31078
31345
  }
31079
31346
  };
31080
31347
 
31348
+ // Furniture layers are dataset layers (they have no display layer)
31349
+ el.drawFurniture = function(layers) {
31350
+ el.clear();
31351
+ resize(ext);
31352
+ layers.forEach(function(lyr) {
31353
+ var html = renderFurniture(lyr, ext);
31354
+ var g;
31355
+ if (!html) return;
31356
+ g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
31357
+ g.setAttribute('class', 'mapshaper-svg-layer mapshaper-furniture-layer');
31358
+ g.innerHTML = html;
31359
+ svg.append(g);
31360
+ });
31361
+ };
31362
+
31363
+ el.repositionFurniture = function() {
31364
+ resize(ext);
31365
+ repositionFurniture(svg, ext);
31366
+ };
31367
+
31081
31368
  function reposition(lyr, type, ext) {
31082
31369
  var container = el.findChild('.' + lyr.gui.svg_id);
31083
31370
  if (!container || !container.node()) {
@@ -31091,8 +31378,6 @@
31091
31378
  // a symbol group's transform absorbs panning, and zooming too when a
31092
31379
  // frame is defined; anything it can't absorb needs the baselines rebuilt
31093
31380
  updateLabelPaths(container.node(), lyr.gui.displayLayer, ext);
31094
- } else if (type == 'furniture') {
31095
- repositionFurniture(container.node(), lyr.gui.displayLayer, ext);
31096
31381
  } else {
31097
31382
  // container.getElementsByTagName('text')
31098
31383
  error('Unsupported symbol type:', type);
@@ -31248,19 +31533,14 @@
31248
31533
  });
31249
31534
  };
31250
31535
 
31536
+ // Furniture is sized in frame pixels, so panning and zooming only need to
31537
+ // update its transform; its content changes only with a full redraw.
31251
31538
  this.drawFurnitureLayers = function(layers, action) {
31252
- // re-render if action == 'nav', because scalebars get resized
31253
- var noRedraw = action == 'hover';
31254
- if (!noRedraw) {
31255
- _furniture.clear();
31539
+ if (action == 'nav' || action == 'hover') {
31540
+ _furniture.repositionFurniture();
31541
+ } else {
31542
+ _furniture.drawFurniture(layers);
31256
31543
  }
31257
- layers.forEach(function(lyr) {
31258
- if (noRedraw) {
31259
- _furniture.reposition(lyr, 'furniture');
31260
- } else {
31261
- _furniture.drawLayer(lyr, 'furniture');
31262
- }
31263
- });
31264
31544
  };
31265
31545
 
31266
31546
  // kludge: skip rendering base layers if hovering, except on first hover
@@ -33111,7 +33391,7 @@
33111
33391
 
33112
33392
  function isFrameMapLayer(lyr) {
33113
33393
  var dataset = lyr && lyr.gui && lyr.gui.source && lyr.gui.source.dataset;
33114
- return !!dataset && internal.isFrameLayer(lyr, dataset.arcs);
33394
+ return !!dataset && internal.isFrameComponentLayer(lyr, dataset);
33115
33395
  }
33116
33396
 
33117
33397
  function getDrawableContentLayers() {
@@ -33121,11 +33401,18 @@
33121
33401
  });
33122
33402
  }
33123
33403
 
33124
- function getDrawableFurnitureLayers(layers) {
33125
- if (!isPreviewView()) return [];
33126
- return getVisibleMapLayers().filter(function(o) {
33127
- return internal.isFurnitureLayer(o);
33404
+ // The active frame's furniture, plus any visible standalone furniture layers
33405
+ // (e.g. from -scalebar run before a frame was created)
33406
+ function getDrawableFurnitureLayers() {
33407
+ var frame = isPreviewView() ? internal.getActiveFrame(model) : null;
33408
+ if (!frame) return [];
33409
+ var layers = internal.getFrameFurnitureLayers(frame.dataset);
33410
+ getVisibleMapLayers().forEach(function(lyr) {
33411
+ if (internal.layerIsFurniture(lyr) && !layers.includes(lyr) && !lyr.hidden) {
33412
+ layers.push(lyr);
33413
+ }
33128
33414
  });
33415
+ return layers;
33129
33416
  }
33130
33417
 
33131
33418
  function updateLayerStyles(layers) {
@@ -33177,7 +33464,6 @@
33177
33464
  var layersMayHaveChanged = action != 'nav'; // !action;
33178
33465
  var fullBounds;
33179
33466
  var contentLayers = getDrawableContentLayers();
33180
- // var furnitureLayers = getDrawableFurnitureLayers();
33181
33467
  if (!(_ext.width() > 0 && _ext.height() > 0)) {
33182
33468
  // TODO: track down source of these errors
33183
33469
  console.error("Collapsed map container, unable to draw.");
@@ -33218,8 +33504,7 @@
33218
33504
  }
33219
33505
  _renderer.drawOverlayLayers(_overlayLayers, action);
33220
33506
 
33221
- // TODO: draw furniture
33222
- // _renderer.drawFurnitureLayers(furnitureLayers, action);
33507
+ _renderer.drawFurnitureLayers(getDrawableFurnitureLayers(), action);
33223
33508
  // The action says how much was redrawn, which a listener rebuilding its own
33224
33509
  // DOM overlays needs: a 'hover' draw leaves the SVG markup and its
33225
33510
  // transforms alone, so anything anchored to them is still good.
@@ -33808,10 +34093,7 @@
33808
34093
  return;
33809
34094
  }
33810
34095
  if (!getSourceLayer()) {
33811
- showPopupAlert(
33812
- 'Add one or more layers before creating a map frame.',
33813
- 'Map frame'
33814
- );
34096
+ showPopupAlert(getNoSourceMessage(), 'Map frame');
33815
34097
  return;
33816
34098
  }
33817
34099
  var popup = showPopupAlert('', 'Add map frame', {classname: 'frame-create-box'});
@@ -33956,7 +34238,7 @@
33956
34238
  function createFromVisibleLayers(width, aspect, margin) {
33957
34239
  var entries = getCompositionEntries();
33958
34240
  if (!entries.length) {
33959
- showPopupAlert('No visible geographic layers are available.', 'Map frame');
34241
+ showPopupAlert(getNoSourceMessage(), 'Map frame');
33960
34242
  return;
33961
34243
  }
33962
34244
  var ids = entries.map(function(o) {
@@ -33979,6 +34261,15 @@
33979
34261
  });
33980
34262
  }
33981
34263
 
34264
+ function getNoSourceMessage() {
34265
+ var active = gui.model.getActiveLayer();
34266
+ if (active && active.layer && !internal.layerHasGeometry(active.layer)) {
34267
+ return 'A map frame is fitted to layers with shapes, and this layer ' +
34268
+ 'contains only attribute data. Select or show a map layer to create a frame.';
34269
+ }
34270
+ return 'Add one or more layers before creating a map frame.';
34271
+ }
34272
+
33982
34273
  function getFrameTarget() {
33983
34274
  return internal.getActiveFrame(gui.model);
33984
34275
  }
@@ -33998,10 +34289,11 @@
33998
34289
  return entries.length ? entries[0].layer : null;
33999
34290
  }
34000
34291
 
34292
+ // A frame is fitted to shapes, so a data-only layer on show doesn't count
34001
34293
  function getCompositionEntries() {
34002
34294
  var layers = gui.map.getCompositionLayers();
34003
34295
  return gui.model.getLayers().filter(function(o) {
34004
- return layers.includes(o.layer);
34296
+ return layers.includes(o.layer) && internal.layerHasGeometry(o.layer);
34005
34297
  });
34006
34298
  }
34007
34299
 
@@ -34038,16 +34330,6 @@
34038
34330
  .text(label).on('click', action);
34039
34331
  }
34040
34332
 
34041
- // The "?" the rest of the app uses for field help (see .tip-button in
34042
- // elements.css and the static ones in index.html). The bubble is white-space:
34043
- // pre, so the line breaks in the text are the ones it gets.
34044
- function makeFieldTip(parent, text) {
34045
- var btn = El('div').addClass('tip-button').appendTo(parent).text('?');
34046
- var anchor = El('div').addClass('tip-anchor').appendTo(btn);
34047
- El('div').addClass('tip').appendTo(anchor).text(text);
34048
- return btn;
34049
- }
34050
-
34051
34333
  function getUnitFactor$1(units) {
34052
34334
  return units == 'in' ? 72 : units == 'cm' ? 28.3465 : 1;
34053
34335
  }
@@ -34067,7 +34349,7 @@
34067
34349
  function FrameProperties(gui) {
34068
34350
  var target, form, widthInput, heightInput, unitsSelect, aspectValue;
34069
34351
  var backgroundControl, neatlineControl, neatlineWidthInput;
34070
- var boundsValue, crsValue;
34352
+ var boundsValue, crsValue, scalebarButton;
34071
34353
 
34072
34354
  gui.frameProperties = this;
34073
34355
 
@@ -34139,6 +34421,14 @@
34139
34421
  .appendTo(appearance);
34140
34422
  makeActionButton(clearRow, 'Clear appearance', clearFrameStyle);
34141
34423
 
34424
+ var elements = makePanelSection(form, 'Map elements');
34425
+ var elementsRow = El('div')
34426
+ .addClass('label-style-row label-panel-button-row')
34427
+ .appendTo(elements);
34428
+ scalebarButton = makeActionButton(elementsRow, '', function() {
34429
+ if (gui.scalebarProperties) gui.scalebarProperties.open(target);
34430
+ }).addClass('frame-scalebar-btn');
34431
+
34142
34432
  var details = makePanelSection(form, 'Details');
34143
34433
  boundsValue = makeReadOnlyRow(details, 'Bounds');
34144
34434
  crsValue = makeReadOnlyRow(details, 'CRS');
@@ -34156,11 +34446,13 @@
34156
34446
  unitsSelect.node().value = units;
34157
34447
  aspectValue.text(getAspectText(frame));
34158
34448
  backgroundControl.showColor(rec.fill || '');
34159
- backgroundControl.opacity.node().value = formatOpacity(rec['fill-opacity'], rec.fill);
34449
+ backgroundControl.opacity.node().value = formatColorOpacityPct(rec['fill-opacity'], !!rec.fill);
34160
34450
  neatlineControl.showColor(rec.stroke || '');
34161
- neatlineControl.opacity.node().value = formatOpacity(rec['stroke-opacity'], rec.stroke);
34451
+ neatlineControl.opacity.node().value = formatColorOpacityPct(rec['stroke-opacity'], !!rec.stroke);
34162
34452
  neatlineWidthInput.node().value =
34163
34453
  rec['stroke-width'] === undefined ? '' : rec['stroke-width'];
34454
+ scalebarButton.text(internal.findFrameFurnitureLayer(target.dataset, 'scalebar') ?
34455
+ 'Edit scale bar' : 'Add scale bar');
34164
34456
  boundsValue.text(frame.bbox.map(formatCoordinate).join(', '));
34165
34457
  crsValue.text(info.proj4 || '[unknown]');
34166
34458
  }
@@ -34284,15 +34576,6 @@
34284
34576
  return String(Number(value.toPrecision(12)));
34285
34577
  }
34286
34578
 
34287
- // A colour with no opacity of its own is drawn opaque, and says so; with no
34288
- // colour either, there is nothing for an opacity to apply to.
34289
- function formatOpacity(value, color) {
34290
- if (value === undefined || value === null || value === '') {
34291
- return color ? '100%' : '';
34292
- }
34293
- return String(Math.round(Number(value) * 100)) + '%';
34294
- }
34295
-
34296
34579
  // A fixed ratio holds when the frame is rescaled; one taken from the extent
34297
34580
  // changes whenever the extent does. That difference is the reason to show the
34298
34581
  // ratio at all, so it is said rather than implied.
@@ -34306,6 +34589,434 @@
34306
34589
  return formatFrameAspectRatio(ratio) + ' (from extent)';
34307
34590
  }
34308
34591
 
34592
+ // Record fields of a scalebar, in the order they are written as options
34593
+ var scalebarOptions = ['units', 'style', 'position', 'label_position',
34594
+ 'font_size', 'font_family', 'font_style', 'font_weight', 'color', 'bar_width',
34595
+ 'tic_length', 'label_offset', 'margin'];
34596
+
34597
+ // Returns a -scalebar command that recreates a scalebar with the given
34598
+ // settings (a scalebar data record, with any edits applied). -scalebar
34599
+ // replaces the frame's scalebar, so every setting has to be written.
34600
+ function getScalebarCommand(settings) {
34601
+ var parts = ['-scalebar'];
34602
+ var d = settings || {};
34603
+ if (isSet(d.label)) {
34604
+ parts.push(quoteCommandValue(d.label));
34605
+ }
34606
+ scalebarOptions.forEach(function(k) {
34607
+ if (isSet(d[k])) {
34608
+ parts.push(k.replace(/_/g, '-') + '=' + formatValue$1(d[k]));
34609
+ }
34610
+ });
34611
+ if (d.dual_units) {
34612
+ parts.push('dual-units');
34613
+ }
34614
+ return parts.join(' ');
34615
+ }
34616
+
34617
+ function isSet(val) {
34618
+ return val !== undefined && val !== null && val !== '';
34619
+ }
34620
+
34621
+ function formatValue$1(val) {
34622
+ var str = String(val);
34623
+ return /^[\w.#-]+$/.test(str) ? str : quoteCommandValue(str);
34624
+ }
34625
+
34626
+ var positionOptions = [
34627
+ ['top-left', 'Top left'],
34628
+ ['top-right', 'Top right'],
34629
+ ['bottom-left', 'Bottom left'],
34630
+ ['bottom-right', 'Bottom right']
34631
+ ];
34632
+
34633
+ // What the scalebar is drawn in when it has no color of its own
34634
+ var defaultColor = '#000000';
34635
+
34636
+ // Settings of the frame's scalebar. Every edit sends a complete -scalebar
34637
+ // command, which replaces the scalebar, so edits are undoable and replayable
34638
+ // like any other command.
34639
+ function ScalebarProperties(gui) {
34640
+ var popup, form, note;
34641
+ var labelInput, unitsSelect, styleSelect, dualUnitsBox, positionSelect,
34642
+ labelPositionSelect, fontSizeInput, barWidthInput, marginInput,
34643
+ fontSelect, fontStyleSelect, colorChit, colorInput, colorPicker;
34644
+
34645
+ gui.scalebarProperties = this;
34646
+
34647
+ // The panel doesn't block the map, so anything can change the scalebar or
34648
+ // the frame while it is open: an undo, a frame edit, a console command.
34649
+ gui.model.on('update', function(e) {
34650
+ var flags = e.flags || {};
34651
+ if (!popup || flags.simplify_amount || flags.redraw_only) return;
34652
+ if (getScalebarRecord()) {
34653
+ updateControls();
34654
+ } else {
34655
+ closePanel();
34656
+ }
34657
+ });
34658
+
34659
+ // Adds a scalebar to the frame if it doesn't have one
34660
+ this.open = function(frameTarget) {
34661
+ var frame = frameTarget || internal.getActiveFrame(gui.model);
34662
+ if (!frame) return;
34663
+ if (getScalebarRecord()) {
34664
+ showPanel();
34665
+ } else {
34666
+ runCommand('-scalebar', 'Add scale bar', showPanel);
34667
+ }
34668
+ };
34669
+
34670
+ this.isOpen = function() {
34671
+ return !!popup;
34672
+ };
34673
+
34674
+ // The map stays live, so the panel is placed where it is least likely to
34675
+ // hide the scalebar: at the top of the map, on the side away from it
34676
+ function showPanel() {
34677
+ var d = getScalebarRecord();
34678
+ if (!d) return;
34679
+ closePanel();
34680
+ popup = showPopupAlert('', 'Scale bar', {
34681
+ non_blocking: true,
34682
+ classname: 'scalebar-properties-box'
34683
+ });
34684
+ popup.onClose(function() {
34685
+ popup = form = null;
34686
+ });
34687
+ popup.container().addClass('scalebar-properties-popup');
34688
+ form = El('div')
34689
+ .addClass('label-style-panel scalebar-properties-form')
34690
+ .appendTo(popup.container());
34691
+ initForm();
34692
+ updateControls();
34693
+ placePanel(/right/.test(d.position || '') ? 'left' : 'right');
34694
+ }
34695
+
34696
+ function closePanel() {
34697
+ if (popup) popup.close();
34698
+ }
34699
+
34700
+ function placePanel(side) {
34701
+ var box = popup.container().node().parentNode;
34702
+ var map = gui.container.findChild('.map-layers').node().getBoundingClientRect();
34703
+ // clears the zoom buttons on the right edge of the map
34704
+ var rightInset = 58;
34705
+ var leftInset = 16;
34706
+ box.style.position = 'absolute';
34707
+ box.style.marginTop = '0';
34708
+ box.style.top = Math.round(map.top + 12) + 'px';
34709
+ if (side == 'left') {
34710
+ box.style.left = Math.round(map.left + leftInset) + 'px';
34711
+ } else {
34712
+ box.style.right = Math.round(window.innerWidth - map.right + rightInset) + 'px';
34713
+ }
34714
+ }
34715
+
34716
+ function initForm() {
34717
+ var row;
34718
+ note = El('div').addClass('scalebar-properties-note').appendTo(form).hide();
34719
+
34720
+ row = makeRow(form);
34721
+ labelInput = makeInput(makeCell(row, 'Distance'), 'scalebar-label-input')
34722
+ .attr('placeholder', 'Automatic')
34723
+ .on('change', function() {
34724
+ update({label: labelInput.node().value.trim()});
34725
+ });
34726
+ unitsSelect = makeSelect(makeCell(row, 'Units'), 'scalebar-units-select',
34727
+ [['miles', 'Miles'], ['km', 'Kilometers']])
34728
+ .on('change', function() {
34729
+ var changes = {units: unitsSelect.node().value};
34730
+ var bare = getBareDistance(getScalebarRecord().label);
34731
+ if (bare) changes.label = bare;
34732
+ update(changes);
34733
+ });
34734
+
34735
+ row = makeRow(form);
34736
+ styleSelect = makeSelect(makeCell(row, 'Style'), 'scalebar-style-select',
34737
+ [['a', 'Bar'], ['b', 'Ticks']])
34738
+ .on('change', function() {
34739
+ update({style: styleSelect.node().value});
34740
+ });
34741
+ dualUnitsBox = makeCheckbox(row, 'Metric + imperial', 'scalebar-dual-units')
34742
+ .on('change', function() {
34743
+ update({dual_units: dualUnitsBox.node().checked});
34744
+ });
34745
+
34746
+ row = makeRow(form);
34747
+ positionSelect = makeSelect(makeCell(row, 'Position'), 'scalebar-position-select',
34748
+ positionOptions)
34749
+ .on('change', function() {
34750
+ update({position: positionSelect.node().value});
34751
+ });
34752
+ labelPositionSelect = makeSelect(makeCell(row, 'Labels'),
34753
+ 'scalebar-label-position-select', [['top', 'Above'], ['bottom', 'Below']])
34754
+ .on('change', function() {
34755
+ update({label_position: labelPositionSelect.node().value});
34756
+ });
34757
+
34758
+ var appearance = makePanelSection(form, 'Appearance');
34759
+
34760
+ // Font and face menus, as in the label panel
34761
+ row = makeRow(appearance);
34762
+ fontSelect = El('select').attr('title', 'Font').addClass('scalebar-font-select')
34763
+ .appendTo(makeCell(row, 'Font'))
34764
+ .on('change', function() {
34765
+ if (fontSelect.node().value) applyFont(fontSelect.node().value);
34766
+ });
34767
+ renderFontOptions();
34768
+ fontStyleSelect = El('select').attr('title', 'Font style')
34769
+ .addClass('scalebar-font-style-select')
34770
+ .appendTo(makeCell(row, 'Font style'))
34771
+ .on('change', function() {
34772
+ if (fontStyleSelect.node().value) applyFontStyle(fontStyleSelect.node().value);
34773
+ });
34774
+
34775
+ // The picker lines up with the right edge of its parent, so the row is its
34776
+ // parent rather than the narrow color cell
34777
+ row = makeRow(appearance);
34778
+ var colorCell = El('div').addClass('frame-input-cell scalebar-color-cell').appendTo(row);
34779
+ El('span').appendTo(colorCell).text('Color');
34780
+ colorChit = El('div').addClass('label-color-chit').attr('role', 'button')
34781
+ .on('click', function() { colorPicker.toggle(); });
34782
+ colorInput = El('input').attr('type', 'text').attr('title', 'Scale bar color')
34783
+ .on('change', function() {
34784
+ var color = colorInput.node().value.trim();
34785
+ if (!color) {
34786
+ updateControls();
34787
+ return;
34788
+ }
34789
+ if (isHexColor(color)) colorPicker.setColor(color);
34790
+ update({color: color});
34791
+ });
34792
+ makeColorField(colorCell, colorChit, colorInput);
34793
+ colorPicker = new ColorPicker(row, {
34794
+ onPreview: showColor,
34795
+ onChange: function(hex) {
34796
+ showColor(hex);
34797
+ update({color: hex});
34798
+ }
34799
+ });
34800
+ fontSizeInput = makeNumberInput(makeCell(row, 'Font size'), 'font_size',
34801
+ 'scalebar-font-size');
34802
+
34803
+ row = makeRow(appearance);
34804
+ barWidthInput = makeNumberInput(makeCell(row, 'Bar width'), 'bar_width',
34805
+ 'scalebar-bar-width');
34806
+ marginInput = makeNumberInput(makeCell(row, 'Inset'), 'margin',
34807
+ 'scalebar-margin')
34808
+ .attr('title', 'Distance from the edges of the frame, in pixels');
34809
+
34810
+ row = El('div').addClass('label-style-row label-panel-button-row').appendTo(form);
34811
+ makePanelActionButton(row, 'Remove scale bar', function() {
34812
+ runCommand('-scalebar remove', 'Remove scale bar', function() {
34813
+ if (popup) popup.close();
34814
+ });
34815
+ });
34816
+ }
34817
+
34818
+ function updateControls() {
34819
+ var d = getScalebarRecord();
34820
+ var problem;
34821
+ if (!d || !form) return;
34822
+ labelInput.node().value = d.label || '';
34823
+ setSelectValue(unitsSelect, getUnitsMenuValue(d.units ||
34824
+ internal.parseScalebarUnits(d.label || '')));
34825
+ setSelectValue(styleSelect, d.style == 'b' || d.style == 'B' ||
34826
+ !d.style && d.dual_units ? 'b' : 'a');
34827
+ dualUnitsBox.node().checked = !!d.dual_units;
34828
+ setSelectValue(positionSelect, d.position || 'top-left');
34829
+ setSelectValue(labelPositionSelect, d.label_position || 'top');
34830
+ fontSizeInput.node().value = formatValue(d.font_size);
34831
+ barWidthInput.node().value = formatValue(d.bar_width);
34832
+ marginInput.node().value = formatValue(d.margin);
34833
+ updateFontControls(d);
34834
+ showColor(d.color || defaultColor);
34835
+ if (isHexColor(d.color || defaultColor)) colorPicker.setColor(d.color || defaultColor);
34836
+ problem = getFrameProblem();
34837
+ note.text(problem ? 'The scale bar is hidden: ' + problem + '.' : '');
34838
+ if (problem) note.show(); else note.hide();
34839
+ }
34840
+
34841
+ function showColor(color) {
34842
+ colorInput.node().value = color || '';
34843
+ colorChit.css('background-color', color || 'transparent');
34844
+ }
34845
+
34846
+ function renderFontOptions() {
34847
+ getInstalledFonts().forEach(function(group) {
34848
+ var optgroup = El('optgroup').attr('label', group.name).appendTo(fontSelect);
34849
+ group.fonts.forEach(function(fontName) {
34850
+ El('option').attr('value', fontName).appendTo(optgroup).text(fontName);
34851
+ });
34852
+ });
34853
+ }
34854
+
34855
+ // A scalebar with no font of its own is shown in the font it is drawn in
34856
+ function getShownFont(d) {
34857
+ return d.font_family || getDefaultFontName();
34858
+ }
34859
+
34860
+ function updateFontControls(d) {
34861
+ var font = getShownFont(d);
34862
+ var shown = font ? getNearestFontStyleVariant(font, d.font_style, d.font_weight) : null;
34863
+ if (font) {
34864
+ setSelectValue(fontSelect, font);
34865
+ } else {
34866
+ fontSelect.node().selectedIndex = -1;
34867
+ }
34868
+ fontStyleSelect.empty();
34869
+ getFontStyleVariants(font).forEach(function(variant) {
34870
+ El('option').attr('value', variant.value).appendTo(fontStyleSelect).text(variant.label);
34871
+ });
34872
+ fontStyleSelect.node().disabled = !font;
34873
+ if (shown) {
34874
+ fontStyleSelect.node().value = shown.value;
34875
+ } else {
34876
+ fontStyleSelect.node().selectedIndex = -1;
34877
+ }
34878
+ }
34879
+
34880
+ // Keeps the current face, or the nearest one the new font has
34881
+ function applyFont(fontName) {
34882
+ var d = getScalebarRecord();
34883
+ var changes = {font_family: fontName};
34884
+ var variant = getNearestFontStyleVariant(fontName, d.font_style, d.font_weight);
34885
+ if (variant) Object.assign(changes, getFontStyleValues(variant));
34886
+ update(changes);
34887
+ }
34888
+
34889
+ // A face belongs to a font, so a scalebar given one names the font it is
34890
+ // drawn in
34891
+ function applyFontStyle(value) {
34892
+ var parts = value.split('|');
34893
+ var changes = getFontStyleValues({style: parts[0], weight: parts[1]});
34894
+ var d = getScalebarRecord();
34895
+ if (!d.font_family && getDefaultFontName()) {
34896
+ changes.font_family = getDefaultFontName();
34897
+ }
34898
+ update(changes);
34899
+ }
34900
+
34901
+ // Regular is stored as no face at all
34902
+ function getFontStyleValues(variant) {
34903
+ if (variantIsRegular(variant)) {
34904
+ return {font_style: '', font_weight: ''};
34905
+ }
34906
+ return {font_style: variant.style, font_weight: variant.weight};
34907
+ }
34908
+
34909
+ // Applies edits to the current settings and replaces the scalebar. The
34910
+ // units menu always applies, so it is written even when it shows the default.
34911
+ function update(changes) {
34912
+ var d = Object.assign({}, getScalebarRecord(), changes);
34913
+ if (changes.style == 'a') d.dual_units = false;
34914
+ if (changes.dual_units) d.style = 'b';
34915
+ if (!d.units) d.units = unitsSelect.node().value;
34916
+ runCommand(getScalebarCommand(d), 'Update scale bar', updateControls);
34917
+ }
34918
+
34919
+ function makeNumberInput(cell, field, className) {
34920
+ var input = makeInput(cell, className).attr('placeholder', 'auto');
34921
+ input.on('change', function() {
34922
+ var str = input.node().value.trim();
34923
+ var o = {};
34924
+ if (str !== '' && !(Number(str) >= 0)) {
34925
+ updateControls();
34926
+ return;
34927
+ }
34928
+ o[field] = str === '' ? '' : Number(str);
34929
+ update(o);
34930
+ });
34931
+ return input;
34932
+ }
34933
+
34934
+ function getFrameProblem() {
34935
+ var frame = internal.getActiveFrame(gui.model);
34936
+ var crs, data;
34937
+ if (!frame) return 'there is no map frame';
34938
+ crs = frame.layer.gui && frame.layer.gui.dynamic_crs ||
34939
+ internal.getDatasetCRS(frame.dataset);
34940
+ data = internal.getFrameLayerData(frame.layer, frame.dataset.arcs, crs);
34941
+ return internal.getFurnitureFrameProblem(data);
34942
+ }
34943
+
34944
+ function getScalebarRecord() {
34945
+ var frame = internal.getActiveFrame(gui.model);
34946
+ var lyr = frame && internal.findFrameFurnitureLayer(frame.dataset, 'scalebar');
34947
+ return lyr ? lyr.data.getReadOnlyRecordAt(0) : null;
34948
+ }
34949
+
34950
+ function runCommand(cmd, title, done) {
34951
+ runGuiEditCommand(gui, cmd, {
34952
+ title: title,
34953
+ onSuccess: done,
34954
+ onError: updateControls
34955
+ });
34956
+ }
34957
+ }
34958
+
34959
+ function makeRow(parent, className) {
34960
+ return El('div').addClass('label-style-row scalebar-row')
34961
+ .addClass(className || 'scalebar-two-col').appendTo(parent);
34962
+ }
34963
+
34964
+ function makeCell(row, label) {
34965
+ var cell = El('label').addClass('frame-input-cell').appendTo(row);
34966
+ El('span').appendTo(cell).text(label);
34967
+ return cell;
34968
+ }
34969
+
34970
+ function makeInput(parent, className) {
34971
+ return El('input').attr('type', 'text').addClass(className).appendTo(parent);
34972
+ }
34973
+
34974
+ function makeSelect(parent, className, options) {
34975
+ var select = El('select').addClass(className).appendTo(parent);
34976
+ options.forEach(function(o) {
34977
+ El('option').attr('value', o[0]).appendTo(select).text(o[1]);
34978
+ });
34979
+ return select;
34980
+ }
34981
+
34982
+ function makeCheckbox(parent, label, className) {
34983
+ var box = El('input').attr('type', 'checkbox').addClass(className);
34984
+ var wrapper = El('label').addClass('scalebar-checkbox').appendTo(parent);
34985
+ box.appendTo(wrapper);
34986
+ El('span').appendTo(wrapper).text(label);
34987
+ return box;
34988
+ }
34989
+
34990
+ // Shows a value that came from the command line even if the menu lacks it
34991
+ function setSelectValue(select, value) {
34992
+ var node = select.node();
34993
+ var found = Array.prototype.some.call(node.options, function(o) {
34994
+ return o.value == value;
34995
+ });
34996
+ if (!found) {
34997
+ El('option').attr('value', value).appendTo(select).text(value);
34998
+ }
34999
+ node.value = value;
35000
+ }
35001
+
35002
+ // The number of a single-distance label, e.g. "100" from "100 km", so the
35003
+ // units menu can apply to it
35004
+ function getBareDistance(label) {
35005
+ var match = /^([\d\s.,/]*\d)\s*[^\d\s.,/]+$/.exec(String(label || '').trim());
35006
+ return match ? match[1].trim() : '';
35007
+ }
35008
+
35009
+ function getUnitsMenuValue(units) {
35010
+ var parsed = internal.parseLabelUnits(units);
35011
+ return parsed == 'mile' || !parsed ? 'miles' :
35012
+ parsed == 'm' ? 'meters' :
35013
+ parsed == 'ft' ? 'feet' : 'km';
35014
+ }
35015
+
35016
+ function formatValue(val) {
35017
+ return val === undefined || val === null ? '' : String(val);
35018
+ }
35019
+
34309
35020
  // This is a new way to handle compatibility problems between
34310
35021
  // interactive editing modes and other interface modes
34311
35022
  // (by default, interactive modes stay on when, e.g., the user clicks
@@ -35108,6 +35819,7 @@
35108
35819
  new PreviewMode(gui);
35109
35820
  new FrameResizeTool(gui);
35110
35821
  new FrameProperties(gui);
35822
+ new ScalebarProperties(gui);
35111
35823
  gui.editToolbar = new EditToolbar(gui);
35112
35824
  gui.labelTool = new LabelTool(gui);
35113
35825
  gui.layerStyleTool = new LayerStyleTool(gui);