mapshaper 0.7.50 → 0.7.52

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.
@@ -6876,31 +6876,37 @@
6876
6876
  // instead.
6877
6877
 
6878
6878
 
6879
+ // Undo is on unless it has been turned off. Undoing is what a user expects by
6880
+ // default; the History menu's checkbox exists so that sessions working with
6881
+ // data too large to store restore payloads for can opt out.
6882
+ var APP_UNDO_DEFAULT = true;
6883
+
6879
6884
  // Returns true if the manifest, URL query, gui-installed checker, or
6880
6885
  // localStorage indicates that app-level undo should be active.
6881
6886
  function appUndoIsEnabled(gui) {
6882
6887
  var opt = gui && gui.options && (gui.options.undoCommands || gui.options.appUndo);
6883
6888
  var query = getUndoQueryValue();
6889
+ if (query == 'off') return false;
6884
6890
  if (opt === true || query == 'on' || query == 'commands') return true;
6885
6891
  if (gui && gui.appUndoIsEnabled) return gui.appUndoIsEnabled();
6886
6892
  return appUndoSettingIsOn();
6887
6893
  }
6888
6894
 
6889
- // True when the undo URL flag forces app undo on regardless of UI settings.
6890
- // Mirrors the toggle-disable behavior in HistoryMenu.
6895
+ // True when the undo URL flag pins app undo on or off regardless of UI
6896
+ // settings. Mirrors the toggle-disable behavior in HistoryMenu.
6891
6897
  function appUndoForcedByUrl() {
6892
6898
  var query = getUndoQueryValue();
6893
- return query == 'on' || query == 'commands';
6899
+ return query == 'on' || query == 'commands' || query == 'off';
6894
6900
  }
6895
6901
 
6896
- // Read the persisted localStorage opt-in. Returns false in non-browser
6897
- // environments or when storage is blocked.
6902
+ // Read the persisted setting. Only an explicit opt-out turns undo off, so a
6903
+ // first-time session (and one where storage is unreadable) gets the default.
6898
6904
  function appUndoSettingIsOn() {
6899
6905
  try {
6900
- return !!(typeof window != 'undefined' && window.localStorage &&
6901
- window.localStorage.getItem('mapshaper.undo') == 'on');
6906
+ if (typeof window == 'undefined' || !window.localStorage) return APP_UNDO_DEFAULT;
6907
+ return window.localStorage.getItem('mapshaper.undo') != 'off';
6902
6908
  } catch(e) {
6903
- return false;
6909
+ return APP_UNDO_DEFAULT;
6904
6910
  }
6905
6911
  }
6906
6912
 
@@ -8067,6 +8073,15 @@
8067
8073
  });
8068
8074
  }
8069
8075
 
8076
+ // A .aux.xml sidecar is named after the whole raster file
8077
+ // (world.tif.aux.xml), so it joins that file's group rather than one of its
8078
+ // own. Returns null if the raster it names was not loaded with it.
8079
+ function auxSourceGroup(d) {
8080
+ var sourceName = internal.getAuxSourceFilename(d.name).toLowerCase();
8081
+ var source = data.find(f => f.name.toLowerCase() == sourceName);
8082
+ return source ? key(fileBase(source), fileType(source)) : null;
8083
+ }
8084
+
8070
8085
  data.forEach(d => {
8071
8086
  var basename = fileBase(d);
8072
8087
  var type = fileType(d);
@@ -8078,6 +8093,8 @@
8078
8093
  } else if (type == 'dbf') {
8079
8094
  d.filename = d.name;
8080
8095
  d.group = key(basename, 'dbf');
8096
+ } else if (type == 'aux' && auxSourceGroup(d)) {
8097
+ d.group = auxSourceGroup(d);
8081
8098
  } else if ((type == 'png' || type == 'jpeg') || isRasterImagePart(d.name) && hasRasterImage(basename)) {
8082
8099
  d.group = key(basename, type == 'png' || type == 'jpeg' ? type : getRasterImageGroupType(data, basename));
8083
8100
  if (type == 'png' || type == 'jpeg') d.filename = d.name;
@@ -8916,6 +8933,44 @@
8916
8933
  }
8917
8934
 
8918
8935
 
8936
+ // Divide an arc at one or two cut locations, appending the pieces to the end of
8937
+ // the arc collection (the original arc is left in place).
8938
+ // cuts: cut descriptors ordered from the start of the arc, each containing an
8939
+ // arc-relative vertex offset plus source and display coordinates of the cut
8940
+ // location (both null when cutting at an existing vertex).
8941
+ // Returns the ids of the new arcs.
8942
+ function splitArc(lyr, arcId, cuts) {
8943
+ var ids = internal.splitArcAtCuts(lyr.gui.source.dataset.arcs, arcId,
8944
+ cuts.map(function(cut) {
8945
+ return {offset: cut.offset, point: cut.point || null};
8946
+ }));
8947
+ if (isProjectedLayer(lyr)) {
8948
+ internal.splitArcAtCuts(lyr.gui.displayArcs, arcId,
8949
+ cuts.map(function(cut) {
8950
+ return {offset: cut.offset, point: cut.displayPoint || null};
8951
+ }));
8952
+ }
8953
+ return ids;
8954
+ }
8955
+
8956
+ function deleteLastArcs(lyr, count) {
8957
+ for (var i=0; i<count; i++) {
8958
+ internal.deleteLastArc(lyr.gui.source.dataset.arcs);
8959
+ if (isProjectedLayer(lyr)) {
8960
+ internal.deleteLastArc(lyr.gui.displayArcs);
8961
+ }
8962
+ }
8963
+ }
8964
+
8965
+ // Add a path feature to the end of a layer.
8966
+ // shp: an array of parts; rec: an attribute record, or null for an empty one
8967
+ function appendFeature(lyr, shp, rec) {
8968
+ lyr.shapes.push(shp);
8969
+ if (lyr.data) {
8970
+ lyr.data.getRecords().push(rec || getEmptyDataRecord(lyr.data));
8971
+ }
8972
+ }
8973
+
8919
8974
  function deleteLastPath(lyr) {
8920
8975
  var arcId = lyr.gui.displayArcs.size() - 1;
8921
8976
  if (lyr.data) {
@@ -10641,6 +10696,8 @@
10641
10696
  var unsupportedMsg = "Exporting is not supported in this browser";
10642
10697
  var menu = gui.container.findChild('.export-options').on('click', GUI.handleDirectEvent(gui.clearMode));
10643
10698
  var layersArr = [];
10699
+ var menuFormats = null; // formats the format menu was built with
10700
+ var formatPickedByUser = false;
10644
10701
  var toggleBtn = null; // checkbox <input> for toggling layer selection
10645
10702
  var exportBtn = gui.container.findChild('.export-btn').addClass('disabled');
10646
10703
  var ofileName = gui.container.findChild('#ofile-name');
@@ -10709,6 +10766,7 @@
10709
10766
 
10710
10767
  function turnOn() {
10711
10768
  layersArr = initLayerMenu();
10769
+ formatPickedByUser = false;
10712
10770
  // initZipOption();
10713
10771
  initFormatMenu();
10714
10772
  updateExportCheckboxes();
@@ -10890,6 +10948,7 @@
10890
10948
  }
10891
10949
 
10892
10950
  function updateToggleBtn() {
10951
+ refreshFormatMenu(); // the layer selection decides which formats apply
10893
10952
  updateExportCheckboxes(); // checkbox visibility is affected by number of export layers
10894
10953
  if (!toggleBtn) return;
10895
10954
  var state = getSelectionState();
@@ -10912,13 +10971,17 @@
10912
10971
  }
10913
10972
 
10914
10973
  function getDefaultExportFormat() {
10974
+ var layers = getExportFormatLayers();
10915
10975
  var active = model.getActiveLayer();
10916
10976
  var dataset = active.dataset;
10917
10977
  var inputFmt = dataset.info && dataset.info.input_formats &&
10918
10978
  dataset.info.input_formats[0];
10919
- // SVG stays the default for rasters: it is the only format that also
10920
- // accepts the vector layers a session usually has alongside them.
10921
- if (activeLayerHasRaster()) return 'svg';
10979
+ // Rasters and nothing else can only go to GeoTIFF, the one format that
10980
+ // takes their pixels.
10981
+ if (layers.length > 0 && layers.every(hasRaster)) return 'geotiff';
10982
+ // SVG is the default for a selection that mixes rasters with vector layers,
10983
+ // being the only format that accepts both.
10984
+ if (layers.some(hasRaster)) return 'svg';
10922
10985
  return getExportFormats().includes(inputFmt) ? inputFmt : 'geojson';
10923
10986
  }
10924
10987
 
@@ -10926,16 +10989,26 @@
10926
10989
  var formats = ['shapefile', 'json', 'geojson', 'dsv', 'topojson', 'flatgeobuf', 'geopackage', 'geoparquet', 'kml', 'svg', internal.PACKAGE_EXT];
10927
10990
  // GeoTIFF is the one format here that only accepts raster layers, so it is
10928
10991
  // offered only when there is a raster to export.
10929
- if (activeLayerHasRaster()) formats.push('geotiff');
10992
+ if (getExportFormatLayers().some(hasRaster)) formats.push('geotiff');
10930
10993
  return formats;
10931
10994
  }
10932
10995
 
10933
- function activeLayerHasRaster() {
10996
+ // The layers the format menu describes: the ones checked for export, falling
10997
+ // back to the active layer when the menu is not up yet or nothing is checked.
10998
+ function getExportFormatLayers() {
10999
+ var selected = layersArr.length ? getSelectedLayerEntries() : [];
10934
11000
  var active = model.getActiveLayer();
10935
- return !!active.layer && internal.layerHasRaster(active.layer);
11001
+ if (selected.length > 0) {
11002
+ return selected.map(function(o) { return o.layer; });
11003
+ }
11004
+ return active && active.layer ? [active.layer] : [];
11005
+ }
11006
+
11007
+ function hasRaster(lyr) {
11008
+ return internal.layerHasRaster(lyr);
10936
11009
  }
10937
11010
 
10938
- function initFormatMenu() {
11011
+ function initFormatMenu(preferredFmt) {
10939
11012
  var formats = getExportFormats();
10940
11013
  var items = formats.map(function(fmt) {
10941
11014
  return utils$1.format('<td><label><input type="radio" name="format" value="%s"' +
@@ -10947,13 +11020,39 @@
10947
11020
  }
10948
11021
  table += '</table>';
10949
11022
  menu.findChild('.export-formats').html(table);
10950
- menu.findChild('.export-formats input[value="' + getDefaultExportFormat() + '"]').node().checked = true;
11023
+ menuFormats = formats;
11024
+ setSelectedFormat(formats.includes(preferredFmt) ? preferredFmt :
11025
+ getDefaultExportFormat());
10951
11026
  // update save-as settings when value changes
10952
11027
  menu.findChildren('input[type="radio"]').forEach(el => {
10953
- el.on('change', updateExportCheckboxes);
11028
+ el.on('change', onFormatChange);
10954
11029
  });
10955
11030
  }
10956
11031
 
11032
+ function onFormatChange() {
11033
+ // A format the user picked is theirs to keep, however the layer selection
11034
+ // changes afterwards.
11035
+ formatPickedByUser = true;
11036
+ updateExportCheckboxes();
11037
+ }
11038
+
11039
+ function setSelectedFormat(fmt) {
11040
+ var el = menu.findChild('.export-formats input[value="' + fmt + '"]');
11041
+ if (el) el.node().checked = true;
11042
+ }
11043
+
11044
+ // Which formats apply depends on what is checked for export, so unchecking
11045
+ // the vector layers of a mixed session both offers GeoTIFF and makes it the
11046
+ // default -- unless the user has already chosen a format.
11047
+ function refreshFormatMenu() {
11048
+ var formats = getExportFormats();
11049
+ if (formats.join(',') != (menuFormats || []).join(',')) {
11050
+ initFormatMenu(formatPickedByUser ? getSelectedFormat() : null);
11051
+ } else if (!formatPickedByUser) {
11052
+ setSelectedFormat(getDefaultExportFormat());
11053
+ }
11054
+ }
11055
+
10957
11056
 
10958
11057
  // function getInputFormats() {
10959
11058
  // return model.getDatasets().reduce(function(memo, d) {
@@ -12095,6 +12194,7 @@
12095
12194
  }
12096
12195
 
12097
12196
  function isAppUndoEnabled() {
12197
+ if (getUndoQueryValue() == 'off') return false;
12098
12198
  return appUndoForcedByUrl() || appUndoSettingIsOn();
12099
12199
  }
12100
12200
 
@@ -12461,6 +12561,60 @@
12461
12561
  }
12462
12562
  }
12463
12563
 
12564
+ // Divide one path of one feature at one location (open path) or two locations
12565
+ // (ring). See internal.planPathDivision() for the cut format.
12566
+ //
12567
+ // The shorter of the two resulting paths is split off into a new feature at the
12568
+ // end of the layer, with a copy of the original feature's attributes; the
12569
+ // longer one stays in place. For a multipart feature this leaves the longer
12570
+ // path connected to the rest of the feature.
12571
+ //
12572
+ // Returns a descriptor for undoing the edit, or null if the cuts are degenerate.
12573
+ //
12574
+ function snipPath(lyr, fid, partId, cuts) {
12575
+ var arcs = lyr.gui.source.dataset.arcs;
12576
+ var shp = lyr.shapes[fid];
12577
+ var path = shp[partId];
12578
+ var closed = geom.pathIsClosed(path, arcs);
12579
+ var prevShape = shp.map(function(part) { return part.concat(); });
12580
+ var arcCount = arcs.size();
12581
+ var plan = internal.planPathDivision(path, cuts, function(arcId) {
12582
+ return arcs.getVertexData().nn[arcId];
12583
+ }, arcCount, closed);
12584
+ var newShape, keep, remove, rec;
12585
+ if (!plan) return null;
12586
+ plan.splits.forEach(function(split) {
12587
+ splitArc(lyr, split.arcId, split.cuts);
12588
+ });
12589
+ if (internal.getPlanarPathLength(plan.a, arcs) >=
12590
+ internal.getPlanarPathLength(plan.b, arcs)) {
12591
+ keep = plan.a;
12592
+ remove = plan.b;
12593
+ } else {
12594
+ keep = plan.b;
12595
+ remove = plan.a;
12596
+ }
12597
+ newShape = shp.concat();
12598
+ newShape[partId] = keep;
12599
+ lyr.shapes[fid] = newShape;
12600
+ rec = lyr.data ? internal.copyRecord(lyr.data.getRecordAt(fid)) : null;
12601
+ appendFeature(lyr, [remove], rec);
12602
+ return {
12603
+ fid: fid,
12604
+ partId: partId,
12605
+ cuts: cuts,
12606
+ prevShape: prevShape,
12607
+ newFeatureId: lyr.shapes.length - 1,
12608
+ appendedArcCount: arcs.size() - arcCount
12609
+ };
12610
+ }
12611
+
12612
+ function undoSnip(lyr, result) {
12613
+ deleteFeature(lyr, result.newFeatureId);
12614
+ lyr.shapes[result.fid] = result.prevShape;
12615
+ deleteLastArcs(lyr, result.appendedArcCount);
12616
+ }
12617
+
12464
12618
  var copyRecord = internal.copyRecord;
12465
12619
 
12466
12620
  function isUndoEvt(e) {
@@ -12631,6 +12785,22 @@
12631
12785
  addHistoryState(undo, redo);
12632
12786
  });
12633
12787
 
12788
+ gui.on('snip', function(e) {
12789
+ var target = e.target;
12790
+ var result = e.result;
12791
+ var undo = function() {
12792
+ undoSnip(target, result);
12793
+ gui.model.updated({arc_count: true});
12794
+ };
12795
+ var redo = function() {
12796
+ // re-snipping restores the same arc ids, because the arcs added by the
12797
+ // previous snip were removed from the end of the collection
12798
+ result = snipPath(target, result.fid, result.partId, result.cuts);
12799
+ gui.model.updated({arc_count: true});
12800
+ };
12801
+ addHistoryState(undo, redo);
12802
+ });
12803
+
12634
12804
  gui.on('point_add', function(e) {
12635
12805
  var redo = function() {
12636
12806
  appendNewPoint(e.data.target, e.p);
@@ -13112,7 +13282,7 @@
13112
13282
  empty: ['edit_polygons', 'edit_lines', 'edit_points', 'box', 'ruler'],
13113
13283
  polygons: ['info', 'selection', 'box', 'polygon_style', 'edit_polygons', 'ruler'],
13114
13284
  rectangles: ['info', 'selection', 'box', 'polygon_style', 'rectangles', 'edit_polygons', 'ruler'],
13115
- lines: ['info', 'selection', 'box', 'line_style', 'edit_lines', 'ruler'], // 'snip_lines'
13285
+ lines: ['info', 'selection', 'box', 'line_style', 'edit_lines', 'snip_lines', 'ruler'],
13116
13286
  table: ['info', 'selection'],
13117
13287
  raster: ['ruler', 'box'],
13118
13288
  labels: ['info', 'selection', 'box', 'point_style', 'labels', 'edit_points', 'ruler'],
@@ -13216,7 +13386,7 @@
13216
13386
  };
13217
13387
 
13218
13388
  this.modeSupportsUndo = function(mode) {
13219
- return ['data', 'label_style', 'point_style', 'line_style', 'polygon_style', 'labels', 'edit_points', 'edit_lines', 'edit_polygons', 'vertices', 'rectangles'].includes(mode);
13389
+ return ['data', 'label_style', 'point_style', 'line_style', 'polygon_style', 'labels', 'edit_points', 'edit_lines', 'edit_polygons', 'snip_lines', 'vertices', 'rectangles'].includes(mode);
13220
13390
  };
13221
13391
 
13222
13392
  this.getMode = getInteractionMode;
@@ -16745,7 +16915,8 @@
16745
16915
  test = polygonVertexTest;
16746
16916
  } else if (
16747
16917
  interactionMode == 'vertices' ||
16748
- interactionMode == 'edit_lines') {
16918
+ interactionMode == 'edit_lines' ||
16919
+ interactionMode == 'snip_lines') {
16749
16920
  test = vertexTest;
16750
16921
  } else if (geoType == 'polyline') {
16751
16922
  test = polylineTest;
@@ -17270,6 +17441,22 @@
17270
17441
  triggerChangeEvent();
17271
17442
  };
17272
17443
 
17444
+ // Mark the location of a snip that has been chosen but not yet applied
17445
+ // (the first of the two cuts needed to divide a ring).
17446
+ self.setPendingSnip = function(p) {
17447
+ var p2 = storedData.snip_coordinates;
17448
+ if (!active || !p) return;
17449
+ if (p2 && p2[0] == p[0] && p2[1] == p[1]) return;
17450
+ storedData.snip_coordinates = p;
17451
+ triggerChangeEvent();
17452
+ };
17453
+
17454
+ self.clearPendingSnip = function() {
17455
+ if (!storedData.snip_coordinates) return;
17456
+ delete storedData.snip_coordinates;
17457
+ triggerChangeEvent();
17458
+ };
17459
+
17273
17460
  self.clearSelection = function() {
17274
17461
  updateSelectionState(null);
17275
17462
  };
@@ -17522,6 +17709,11 @@
17522
17709
  (type == 'hover' || type == 'dblclick')) {
17523
17710
  return true; // special case -- using hover for line drawing animation
17524
17711
  }
17712
+ if (mode == 'snip_lines' && (type == 'hover' || type == 'click')) {
17713
+ // the snip tool tracks vertices under the pointer, so it needs hover
17714
+ // events as soon as a feature is hit, not one event later
17715
+ return true;
17716
+ }
17525
17717
 
17526
17718
  // ignore pointer events when no features are being hit
17527
17719
  // (don't block pan and other navigation when events aren't being used for editing)
@@ -17539,7 +17731,8 @@
17539
17731
  }
17540
17732
 
17541
17733
  function possiblyStopPropagation(e) {
17542
- if (interactionMode() == 'edit_lines' || interactionMode() == 'edit_polygons') {
17734
+ var mode = interactionMode();
17735
+ if (mode == 'edit_lines' || mode == 'edit_polygons' || mode == 'snip_lines') {
17543
17736
  // handled conditionally in the control
17544
17737
  return;
17545
17738
  }
@@ -19127,6 +19320,7 @@
19127
19320
  // hoverFill = "rgba(255, 120, 255, 0.12)",
19128
19321
  hoverFill = "rgba(0, 0, 0, 0.08)",
19129
19322
  grey = "#888",
19323
+ orange = "#f28100",
19130
19324
  violet = "#cc6acc",
19131
19325
  black = 'black',
19132
19326
  violetFill = "rgba(249, 120, 249, 0.25)",
@@ -19358,20 +19552,30 @@
19358
19552
 
19359
19553
  // style for vertex edit mode
19360
19554
  function getLineEditingStyle(o) {
19555
+ var isVertex = o.hit_type == 'vertex' || o.hit_type == 'disabled';
19361
19556
  return {
19362
19557
  ids: o.ids,
19363
19558
  overlay: true,
19364
19559
  strokeColor: black,
19365
19560
  strokeWidth: 1.2,
19366
19561
  vertices: true,
19367
- vertex_overlay_color: o.hit_type == 'vertex' ? violet : black,
19368
- vertex_overlay_scale: o.hit_type == 'vertex' ? 2.5 : 2,
19562
+ vertex_overlay_color: getVertexOverlayColor(o.hit_type),
19563
+ vertex_overlay_scale: isVertex ? 2.5 : 2,
19369
19564
  vertex_overlay: o.hit_coordinates || null,
19565
+ pending_snip: o.snip_coordinates || null,
19566
+ pending_snip_color: orange,
19370
19567
  selected_points: o.selected_points || null,
19371
19568
  fillColor: null
19372
19569
  };
19373
19570
  }
19374
19571
 
19572
+ function getVertexOverlayColor(hitType) {
19573
+ if (hitType == 'vertex') return violet;
19574
+ // a muted dot marks a vertex that the current tool can not act on
19575
+ if (hitType == 'disabled') return grey;
19576
+ return black;
19577
+ }
19578
+
19375
19579
  function getSelectedFeatureStyle(lyr, o, opts) {
19376
19580
  var isPinned = o.pinned;
19377
19581
  var inSelection = o.ids.indexOf(o.id) > -1;
@@ -20418,147 +20622,275 @@
20418
20622
  }
20419
20623
  }
20420
20624
 
20421
- function snipLineAtVertex(lyr, fid, vid) {
20422
- // find the feature part and arc containing the vertex
20423
- // divide the one feature into two features
20424
- // divide the arc into two new arcs
20425
- var arc1, arc2;
20426
- // divide the feature into two parts
20427
- }
20428
-
20429
- function mergeLinesAtVertex(lyr) {
20430
-
20431
- }
20432
-
20433
- // TODO: support snipping rings (by snipping in two places)
20434
-
20435
-
20436
- // pixel distance threshold for hovering near a vertex or segment midpoint
20625
+ // pixel distance threshold for hovering near a vertex or a segment
20437
20626
  var HOVER_THRESHOLD = 10;
20438
20627
 
20439
20628
  function initSnipTool(gui, ext, hit) {
20440
- var _active = true;
20441
- var hoverVertexInfo;
20442
- var prevHoverEvent;
20629
+ var hoverInfo = null;
20630
+ var pendingCut = null; // first of the two cuts needed to divide a ring
20631
+ var sessionCount = 0;
20632
+ var alert;
20633
+
20634
+ function active() {
20635
+ return gui.interaction.getMode() == 'snip_lines' && !!hit.getHitTarget();
20636
+ }
20443
20637
 
20444
20638
  gui.on('interaction_mode_change', function(e) {
20445
- if (active()) {
20639
+ if (e.mode == 'snip_lines') {
20640
+ turnOn();
20641
+ } else {
20446
20642
  turnOff();
20447
20643
  }
20448
- // updateCursor();
20644
+ updateCursor();
20449
20645
  }, null, 10); // higher priority than hit control, so turnOff() has correct hit target
20450
20646
 
20647
+ gui.on('undo_redo_pre', function() {
20648
+ clearPendingCut();
20649
+ hoverInfo = null;
20650
+ });
20651
+
20652
+ // A pending cut describes a location in the target layer's current geometry,
20653
+ // and choosing one does not change the model -- so any model update means
20654
+ // something else has happened and the cut is stale.
20655
+ gui.model.on('update', function() {
20656
+ clearPendingCut();
20657
+ hoverInfo = null;
20658
+ });
20659
+
20660
+ function turnOn() {
20661
+ hoverInfo = null;
20662
+ if (sessionCount === 0) {
20663
+ showInstructions();
20664
+ }
20665
+ sessionCount++;
20666
+ }
20667
+
20668
+ function turnOff() {
20669
+ clearPendingCut();
20670
+ clearHoverVertex();
20671
+ hideInstructions();
20672
+ }
20673
+
20674
+ function showInstructions() {
20675
+ var msg = 'Instructions: click a line to snip it apart. Snipping a ring ' +
20676
+ 'takes two clicks -- the ring divides at the second one.';
20677
+ alert = showPopupAlert(msg, null, {non_blocking: true, max_width: '350px'});
20678
+ }
20679
+
20680
+ function hideInstructions() {
20681
+ if (!alert) return;
20682
+ alert.close('fade');
20683
+ alert = null;
20684
+ }
20685
+
20686
+ function clearHoverVertex() {
20687
+ hit.clearHoverVertex();
20688
+ hoverInfo = null;
20689
+ }
20690
+
20691
+ function clearPendingCut() {
20692
+ if (!pendingCut) return;
20693
+ pendingCut = null;
20694
+ hit.clearPendingSnip();
20695
+ }
20696
+
20697
+ function setPendingCut(info) {
20698
+ pendingCut = info;
20699
+ hit.setPendingSnip(info.markerPoint);
20700
+ }
20701
+
20702
+ // The pending cut is described in terms of the target layer's current arcs
20703
+ // and feature ids, so it has to be discarded if either could have changed.
20704
+ function pendingCutIsValid() {
20705
+ return !!pendingCut && pendingCut.target === hit.getHitTarget();
20706
+ }
20451
20707
 
20452
- // hover event highlights the nearest point in close proximity to the pointer
20453
- // ... or the closest point along the segment (for adding a new vertex)
20454
20708
  hit.on('hover', function(e) {
20455
20709
  if (!active()) return;
20456
-
20457
- // highlight nearby snappable vertex (the closest vertex on a nearby line,
20458
- // or the first vertex of the current drawing path if not near a line)
20459
- hoverVertexInfo = e.id >= 0 && findDraggableVertices(e) ||
20460
- e.id >= 0 && findInterpolatedPoint(e);
20461
- if (hoverVertexInfo) {
20462
- // hovering near a vertex: highlight the vertex
20463
- hit.setHoverVertex(hoverVertexInfo.displayPoint, hoverVertexInfo.type);
20710
+ if (pendingCut && !pendingCutIsValid()) {
20711
+ clearPendingCut();
20712
+ }
20713
+ hoverInfo = e.id >= 0 ? findSnipTarget(e) : null;
20714
+ if (hoverInfo) {
20715
+ hit.setHoverVertex(hoverInfo.markerPoint,
20716
+ hoverInfo.disabled ? 'disabled' : hoverInfo.type);
20464
20717
  } else {
20465
- clearHoverVertex();
20718
+ hit.clearHoverVertex();
20466
20719
  }
20467
- // updateCursor();
20468
- prevHoverEvent = e;
20720
+ if (pendingCut) {
20721
+ hit.setPendingSnip(pendingCut.markerPoint);
20722
+ }
20723
+ updateCursor();
20469
20724
  }, null, 100);
20470
20725
 
20471
20726
  hit.on('click', function(e) {
20472
- if (!active() || !hoverVertexInfo) return;
20473
- var target = hit.getHitTarget();
20474
- if (vertexIsEndpoint(hoverVertexInfo, target)) {
20475
- // TODO: don't allow hovering on endpoints
20727
+ var info = hoverInfo;
20728
+ var cuts;
20729
+ if (!active() || !info || info.disabled) return;
20730
+ // a snip invalidates the hover data (feature ids and arcs both change), so
20731
+ // clear it -- this also makes the second click of a double-click a no-op
20732
+ hoverInfo = null;
20733
+ if (!info.ring) {
20734
+ clearPendingCut();
20735
+ snip(info, [info.cut]);
20476
20736
  return;
20477
20737
  }
20478
-
20479
- if (hoverVertexInfo.type == 'interpolated') {
20480
- insertVertex$1(target, hoverVertexInfo.i, hoverVertexInfo.point);
20481
- hoverVertexInfo.ids = [hoverVertexInfo.i];
20738
+ if (!pendingCutIsValid() || pendingCut.fid !== info.fid ||
20739
+ pendingCut.partId !== info.partId) {
20740
+ // first cut on this ring: remember it, don't change any geometry
20741
+ setPendingCut(info);
20742
+ return;
20743
+ }
20744
+ if (cutsAreAdjacent(pendingCut, info)) {
20745
+ clearPendingCut(); // clicking the pending cut again cancels it
20746
+ return;
20482
20747
  }
20748
+ cuts = [pendingCut.cut, info.cut];
20749
+ clearPendingCut();
20750
+ snip(info, cuts);
20751
+ });
20483
20752
 
20484
- snipLineAtVertex(target, e.id, hoverVertexInfo.ids[0]);
20753
+ gui.keyboard.on('keydown', function(e) {
20754
+ if (!active() || !pendingCut || e.keyName != 'esc') return;
20755
+ e.stopPropagation();
20756
+ e.originalEvent.preventDefault();
20757
+ clearPendingCut();
20758
+ }, null, 10);
20485
20759
 
20486
- hit.setHoverVertex(hoverVertexInfo.displayPoint, hoverVertexInfo.type);
20760
+ function snip(info, cuts) {
20761
+ var target = hit.getHitTarget();
20762
+ var result = snipPath(target, info.fid, info.partId, cuts);
20763
+ if (!result) return;
20764
+ gui.dispatchEvent('snip', {target: target, result: result});
20765
+ hit.clearHoverVertex();
20766
+ hideInstructions();
20767
+ gui.model.updated({arc_count: true}); // update display arcs and redraw
20768
+ }
20487
20769
 
20488
- });
20770
+ // Test if two cuts are close enough on screen to be treated as the same place
20771
+ function cutsAreAdjacent(a, b) {
20772
+ var dist = geom.distance2D(a.markerPoint[0], a.markerPoint[1],
20773
+ b.markerPoint[0], b.markerPoint[1]);
20774
+ return dist / ext.getPixelSize() < HOVER_THRESHOLD;
20775
+ }
20489
20776
 
20490
- // return data on the nearest vertex (or identical vertices) to the pointer
20491
- // (if within a distance threshold)
20492
- //
20493
- function findDraggableVertices(e) {
20494
- var target = hit.getHitTarget();
20495
- var shp = target.shapes[e.id];
20496
- var p = ext.pixCoordsToMapCoords(e.x, e.y);
20497
- var ids = internal.findNearestVertices(p, shp, target.gui.displayArcs);
20498
- var p2 = target.gui.displayArcs.getVertex2(ids[0]);
20499
- var dist = geom.distance2D(p[0], p[1], p2[0], p2[1]);
20500
- var pixelDist = dist / ext.getPixelSize();
20501
- if (pixelDist > HOVER_THRESHOLD) {
20502
- return null;
20503
- }
20504
- var point = getVertexCoords(target, ids[0]); // data coordinates
20505
- var displayPoint = target.gui.displayArcs.getVertex2(ids[0]);
20506
- return {target, ids, point, displayPoint, type: 'vertex'};
20777
+ function updateCursor() {
20778
+ var el = gui.container.findChild('.map-layers');
20779
+ el.classed('snip-tool', active());
20780
+ el.classed('no-snip', !!(hoverInfo && hoverInfo.disabled));
20507
20781
  }
20508
20782
 
20509
- function findInterpolatedPoint(e) {
20783
+ // Find the snip location under the pointer: the nearest vertex within the
20784
+ // hover threshold, or failing that the nearest point on a segment.
20785
+ // Returns null if the pointer is not close enough to the hit feature.
20786
+ function findSnipTarget(e) {
20510
20787
  var target = hit.getHitTarget();
20511
- //// vertex insertion not supported with simplification
20512
- // if (!target.arcs.isFlat()) return null;
20513
- var p = ext.pixCoordsToMapCoords(e.x, e.y);
20514
- var minDist = Infinity;
20515
- var shp = target.shapes[e.id];
20516
- var closest;
20517
- internal.forEachSegmentInShape(shp, target.gui.displayArcs, function(i, j, xx, yy) {
20518
- var x1 = xx[i],
20519
- y1 = yy[i],
20520
- x2 = xx[j],
20521
- y2 = yy[j],
20522
- p2 = internal.findClosestPointOnSeg(p[0], p[1], x1, y1, x2, y2, 0),
20523
- dist = geom.distance2D(p2[0], p2[1], p[0], p[1]);
20524
- if (dist < minDist) {
20525
- minDist = dist;
20526
- closest = {
20527
- i: (i < j ? i : j) + 1, // insertion vertex id
20528
- displayPoint: p2,
20529
- distance: dist
20530
- };
20531
- }
20788
+ var shp = target && target.shapes[e.id];
20789
+ var arcs = target && target.gui.displayArcs;
20790
+ var bestVertex = null;
20791
+ var bestSegment = null;
20792
+ var p, threshold, data;
20793
+ if (!shp || !arcs) return null;
20794
+ p = ext.pixCoordsToMapCoords(e.x, e.y);
20795
+ threshold = HOVER_THRESHOLD * ext.getPixelSize();
20796
+ data = arcs.getVertexData();
20797
+
20798
+ shp.forEach(function(path, partId) {
20799
+ path.forEach(function(arcId, seq) {
20800
+ var absId = internal.absArcId(arcId);
20801
+ var fwd = arcId >= 0;
20802
+ var start = data.ii[absId];
20803
+ var n = data.nn[absId];
20804
+ var i, v1, v2, dist, p2;
20805
+ if (arcIsOutOfRange(data.bb, absId, p, threshold)) return;
20806
+ for (i=0; i<n; i++) {
20807
+ v1 = fwd ? start + i : start + n - 1 - i;
20808
+ dist = geom.distance2D(p[0], p[1], data.xx[v1], data.yy[v1]);
20809
+ if (dist < threshold && (!bestVertex || dist < bestVertex.dist)) {
20810
+ bestVertex = {partId: partId, seq: seq, offset: i, dist: dist, vid: v1};
20811
+ }
20812
+ if (i === n - 1) break;
20813
+ v2 = fwd ? v1 + 1 : v1 - 1;
20814
+ p2 = internal.findClosestPointOnSeg(p[0], p[1], data.xx[v1],
20815
+ data.yy[v1], data.xx[v2], data.yy[v2], 0);
20816
+ dist = geom.distance2D(p[0], p[1], p2[0], p2[1]);
20817
+ if (dist < threshold && (!bestSegment || dist < bestSegment.dist)) {
20818
+ bestSegment = {
20819
+ partId: partId, seq: seq, offset: i, dist: dist, displayPoint: p2,
20820
+ t: getSegmentPosition(data.xx[v1], data.yy[v1], data.xx[v2],
20821
+ data.yy[v2], p2)
20822
+ };
20823
+ }
20824
+ }
20825
+ });
20532
20826
  });
20533
20827
 
20534
- if (closest.distance / ext.getPixelSize() > HOVER_THRESHOLD) {
20535
- return null;
20536
- }
20537
- closest.point = translateDisplayPoint(target, closest.displayPoint);
20538
- closest.type = 'interpolated';
20539
- closest.target = target;
20540
- return closest;
20828
+ if (bestVertex) return getVertexTarget(target, e.id, bestVertex);
20829
+ if (bestSegment) return getSegmentTarget(target, e.id, bestSegment);
20830
+ return null;
20541
20831
  }
20542
20832
 
20543
- function vertexIsEndpoint(info, target) {
20544
- var vId = info.ids[0];
20545
- return internal.vertexIsArcStart(vId, target.gui.displayArcs) ||
20546
- internal.vertexIsArcEnd(vId, target.gui.displayArcs);
20833
+ function getVertexTarget(target, fid, info) {
20834
+ var displayPoint = target.gui.displayArcs.getVertex2(info.vid);
20835
+ return {
20836
+ target: target,
20837
+ fid: fid,
20838
+ partId: info.partId,
20839
+ type: 'vertex',
20840
+ ring: pathIsRing(target, fid, info.partId),
20841
+ disabled: !pathIsRing(target, fid, info.partId) &&
20842
+ vertexIsPathEndpoint(target, fid, info),
20843
+ markerPoint: displayPoint,
20844
+ cut: {seq: info.seq, offset: info.offset, point: null, displayPoint: null, t: 0}
20845
+ };
20547
20846
  }
20548
20847
 
20549
- function clearHoverVertex() {
20550
- hit.clearHoverVertex();
20551
- hoverVertexInfo = null;
20848
+ function getSegmentTarget(target, fid, info) {
20849
+ return {
20850
+ target: target,
20851
+ fid: fid,
20852
+ partId: info.partId,
20853
+ type: 'interpolated',
20854
+ ring: pathIsRing(target, fid, info.partId),
20855
+ disabled: false,
20856
+ markerPoint: info.displayPoint,
20857
+ cut: {
20858
+ seq: info.seq,
20859
+ offset: info.offset,
20860
+ point: translateDisplayPoint(target, info.displayPoint),
20861
+ displayPoint: info.displayPoint,
20862
+ t: info.t
20863
+ }
20864
+ };
20552
20865
  }
20553
20866
 
20554
- function active() {
20555
- return _active && gui.interaction.getMode() == 'snip_lines';
20867
+ function pathIsRing(target, fid, partId) {
20868
+ return geom.pathIsClosed(target.shapes[fid][partId], target.gui.displayArcs);
20556
20869
  }
20557
20870
 
20558
- function turnOff() {
20559
-
20871
+ // Test if a vertex is one of the two endpoints of an open path (as opposed to
20872
+ // an interior node between two of the path's arcs, which is snippable).
20873
+ function vertexIsPathEndpoint(target, fid, info) {
20874
+ var path = target.shapes[fid][info.partId];
20875
+ var data = target.gui.displayArcs.getVertexData();
20876
+ var lastId = internal.absArcId(path[path.length - 1]);
20877
+ if (info.seq === 0 && info.offset === 0) return true;
20878
+ return info.seq === path.length - 1 && info.offset === data.nn[lastId] - 1;
20560
20879
  }
20880
+ }
20561
20881
 
20882
+ // Test if an arc's bounding box is too far from a point to contain a hit
20883
+ function arcIsOutOfRange(bb, arcId, p, dist) {
20884
+ var i = arcId * 4;
20885
+ return p[0] + dist < bb[i] || p[0] - dist > bb[i + 2] ||
20886
+ p[1] + dist < bb[i + 1] || p[1] - dist > bb[i + 3];
20887
+ }
20888
+
20889
+ // Position of point p along the segment from (x1, y1) to (x2, y2), as a
20890
+ // fraction of the segment's length
20891
+ function getSegmentPosition(x1, y1, x2, y2, p) {
20892
+ var len = geom.distance2D(x1, y1, x2, y2);
20893
+ return len > 0 ? geom.distance2D(x1, y1, p[0], p[1]) / len : 0;
20562
20894
  }
20563
20895
 
20564
20896
  function initInteractiveEditing(gui, ext, hit) {
@@ -21760,6 +22092,15 @@
21760
22092
  _ctx.fill();
21761
22093
  _ctx.closePath();
21762
22094
  }
22095
+
22096
+ if (style.pending_snip) {
22097
+ _ctx.beginPath();
22098
+ _ctx.fillStyle = style.pending_snip_color || 'black';
22099
+ p = style.pending_snip;
22100
+ drawCircle(p[0] * t.mx + t.bx, p[1] * t.my + t.by, radius * 2.5, _ctx);
22101
+ _ctx.fill();
22102
+ _ctx.closePath();
22103
+ }
21763
22104
  };
21764
22105
 
21765
22106
  // Optimized to draw paths in same-style batches (faster Canvas drawing)
@@ -25430,6 +25771,7 @@ GUI and setting the size and crop of SVG output.</p><div><input type="text" clas
25430
25771
  return {
25431
25772
  layerCount: dataset.layers ? dataset.layers.length : 0,
25432
25773
  arcCount: dataset.arcs ? dataset.arcs.size() : 0,
25774
+ crs_string: dataset.info && dataset.info.crs_string || null,
25433
25775
  layers: (dataset.layers || []).map(function(lyr) {
25434
25776
  return {
25435
25777
  name: getLayerName(lyr),