mapshaper 0.7.52 → 0.7.53

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.
package/mapshaper.js CHANGED
@@ -2061,6 +2061,10 @@
2061
2061
  return deg * D2R$8 * R$3;
2062
2062
  }
2063
2063
 
2064
+ function metersToDegrees(m) {
2065
+ return m / (D2R$8 * R$3);
2066
+ }
2067
+
2064
2068
  function distance3D(ax, ay, az, bx, by, bz) {
2065
2069
  var dx = ax - bx,
2066
2070
  dy = ay - by,
@@ -2240,6 +2244,15 @@
2240
2244
  return dist * R$3;
2241
2245
  }
2242
2246
 
2247
+ // Fast local meters distance for geographic decimal-degree coordinates.
2248
+ // Equirectangular; one cosine. Prefer greatCircleDistance only when
2249
+ // global-scale accuracy matters.
2250
+ function fastLonLatDistance(lng1, lat1, lng2, lat2) {
2251
+ var dx = (lng2 - lng1) * Math.cos(lat1 * D2R$8);
2252
+ var dy = lat2 - lat1;
2253
+ return degreesToMeters(Math.sqrt(dx * dx + dy * dy));
2254
+ }
2255
+
2243
2256
 
2244
2257
  function triangleArea(ax, ay, bx, by, cx, cy) {
2245
2258
  var area = Math.abs(((ay - cy) * (bx - cx) + (by - cy) * (cx - ax)) / 2);
@@ -2348,9 +2361,11 @@
2348
2361
  distance3D: distance3D,
2349
2362
  distanceSq: distanceSq,
2350
2363
  distanceSq3D: distanceSq3D,
2364
+ fastLonLatDistance: fastLonLatDistance,
2351
2365
  greatCircleDistance: greatCircleDistance,
2352
2366
  innerAngle2: innerAngle2,
2353
2367
  lngLatToXYZ: lngLatToXYZ,
2368
+ metersToDegrees: metersToDegrees,
2354
2369
  pointSegDistSq: pointSegDistSq,
2355
2370
  pointSegDistSq2: pointSegDistSq2,
2356
2371
  pointSegDistSq3D: pointSegDistSq3D,
@@ -17991,7 +18006,10 @@
17991
18006
  k = 1;
17992
18007
  } else if (fromParam && !fromCRS) {
17993
18008
  // known param units, unknown CRS -- error condition, not convertible
17994
- stop$1('Unable to convert', paramUnits, 'to unknown coordinates');
18009
+ stop$1('Unable to convert', paramUnits,
18010
+ 'to unknown coordinates: the data has no coordinate reference system. ' +
18011
+ 'Give one with -proj init=<crs> (e.g. -proj init=EPSG:3857), ' +
18012
+ 'or use a distance in the data\'s own units.');
17995
18013
  } else if (!fromParam && fromCRS) {
17996
18014
  // unknown param units, known CRS -- assume param in meters (bw compatibility)
17997
18015
  k = 1 / fromCRS;
@@ -18117,10 +18135,47 @@
18117
18135
  parseSizeParam: parseSizeParam
18118
18136
  });
18119
18137
 
18120
- // Used by -clean -dissolve2 -filter-slivers -filter-islands to generate area filters
18121
- // for removing small polygon rings.
18122
- // Assumes lyr is a polygon layer.
18138
+ // Default gap-width as a fraction of the layer's median polygon-ring segment
18139
+ // length. Shared by interior fill and exterior close-outer-gaps.
18140
+ var GAP_WIDTH_SEGMENT_FRACTION = 0.01;
18141
+
18142
+ // Used by -clean -dissolve -filter-slivers -filter-islands to generate filters
18143
+ // for removing small polygon rings / filling mosaic gaps.
18144
+ // Prefers gap_width= (including 'auto'). Legacy gap_fill_area / sliver_control
18145
+ // remain for compatibility when gap_width is not set.
18123
18146
  function getSliverFilter(lyr, dataset, opts) {
18147
+ opts = opts || {};
18148
+ if (opts.gap_width != null) {
18149
+ return getGapWidthFilter(lyr, dataset, opts);
18150
+ }
18151
+ return getLegacyAreaFilter(lyr, dataset, opts);
18152
+ }
18153
+
18154
+ function getGapWidthFilter(lyr, dataset, opts) {
18155
+ var crs = getDatasetCRS(dataset);
18156
+ var widthArg = opts.gap_width;
18157
+ if (+widthArg === 0) {
18158
+ return {
18159
+ filter: function() { return false; },
18160
+ threshold: 0,
18161
+ label: '0 width threshold'
18162
+ };
18163
+ }
18164
+ var threshold = (widthArg === 'auto') ?
18165
+ getDefaultGapWidth(lyr, dataset.arcs) :
18166
+ convertDistanceParam(widthArg, crs);
18167
+ var filter = getGapWidthTest(dataset.arcs, threshold);
18168
+ if (opts.keep_shapes) {
18169
+ filter = keepShapes(filter);
18170
+ }
18171
+ return {
18172
+ threshold: threshold,
18173
+ filter: filter,
18174
+ label: getGapWidthLabel(threshold, crs)
18175
+ };
18176
+ }
18177
+
18178
+ function getLegacyAreaFilter(lyr, dataset, opts) {
18124
18179
  var areaArg = opts.min_gap_area || opts.min_area || opts.gap_fill_area;
18125
18180
  if (+areaArg == 0) {
18126
18181
  return {
@@ -18128,7 +18183,7 @@
18128
18183
  threshold: 0
18129
18184
  };
18130
18185
  }
18131
- var sliverControl = opts.sliver_control >= 0 ? opts.sliver_control : 0; // 0 is default
18186
+ var sliverControl = opts.sliver_control >= 0 ? opts.sliver_control : 0;
18132
18187
  var crs = getDatasetCRS(dataset);
18133
18188
  var threshold = areaArg && areaArg != 'auto' ?
18134
18189
  convertAreaParam(areaArg, crs) :
@@ -18152,7 +18207,6 @@
18152
18207
  function keepShapes(filter) {
18153
18208
  var flags;
18154
18209
  return function(path, pathId, paths) {
18155
- // console.log("keepShapes()", path, pathId, paths)
18156
18210
  if (pathId === 0) {
18157
18211
  flags = paths.map(path => filter(path));
18158
18212
  }
@@ -18171,6 +18225,19 @@
18171
18225
  return areaStr + ' threshold';
18172
18226
  }
18173
18227
 
18228
+ function getGapWidthLabel(width, crs) {
18229
+ var meters = width;
18230
+ if (crs && crs.to_meter > 0 && !crs.is_latlong) {
18231
+ meters = width * crs.to_meter;
18232
+ }
18233
+ if (crs && (crs.is_latlong || crs.to_meter > 0)) {
18234
+ if (meters < 1) return roundToSignificantDigits(meters * 1000, 2) + 'mm width threshold';
18235
+ if (meters < 1000) return roundToSignificantDigits(meters, 2) + 'm width threshold';
18236
+ return roundToSignificantDigits(meters / 1000, 2) + 'km width threshold';
18237
+ }
18238
+ return roundToSignificantDigits(width, 2) + ' width threshold';
18239
+ }
18240
+
18174
18241
  function getMinAreaTest(minArea, dataset) {
18175
18242
  var pathArea = dataset.arcs.isPlanar() ? geom.getPlanarPathArea : geom.getSphericalPathArea;
18176
18243
  return function(path) {
@@ -18179,6 +18246,19 @@
18179
18246
  };
18180
18247
  }
18181
18248
 
18249
+ // Fill when characteristic width 2A/P is below the threshold. Equivalent to
18250
+ // the old sliver-control=1 effective-area test when threshold = sqrt(A0/π).
18251
+ function getGapWidthTest(arcs, maxWidth) {
18252
+ var pathArea = arcs.isPlanar() ? geom.getPlanarPathArea : geom.getSphericalPathArea;
18253
+ var pathPerim = arcs.isPlanar() ? geom.getPlanarPathPerimeter : geom.getSphericalPathPerimeter;
18254
+ return function(ring) {
18255
+ var area = Math.abs(pathArea(ring, arcs));
18256
+ var perim = pathPerim(ring, arcs);
18257
+ if (!(perim > 0)) return false;
18258
+ return 2 * area / perim < maxWidth;
18259
+ };
18260
+ }
18261
+
18182
18262
  function getSliverTest(arcs, threshold, strength) {
18183
18263
  if (strength >= 0 === false) {
18184
18264
  strength = 1; // default is 1 (full-strength)
@@ -18204,10 +18284,46 @@
18204
18284
  };
18205
18285
  }
18206
18286
 
18287
+ // Median polygon-ring segment length * GAP_WIDTH_SEGMENT_FRACTION.
18288
+ function getDefaultGapWidth(lyr, arcs) {
18289
+ return getMedianPolygonSegmentLength(lyr, arcs) * GAP_WIDTH_SEGMENT_FRACTION;
18290
+ }
18291
+
18292
+ function getMedianPolygonSegmentLength(lyr, arcs) {
18293
+ var lengths = collectPolygonSegmentLengths(lyr, arcs);
18294
+ if (lengths.length === 0) return 0;
18295
+ lengths.sort(); // numeric sort: lengths is a Float64Array
18296
+ return lengths[Math.floor(lengths.length / 2)];
18297
+ }
18298
+
18299
+ // Returns a Float64Array, so that the median can use a comparator-free numeric
18300
+ // sort. This measures every segment of every polygon ring, so on a detailed
18301
+ // mosaic the sort dominates.
18302
+ function collectPolygonSegmentLengths(lyr, arcs) {
18303
+ var lengths = new Float64Array(1024);
18304
+ var n = 0;
18305
+ var calcLen = arcs.isPlanar() ? distance2D$1 : fastLonLatDistance;
18306
+ var onSeg = function(i, j, xx, yy) {
18307
+ var len = calcLen(xx[i], yy[i], xx[j], yy[j]);
18308
+ if (len > 0 && isFinite(len)) {
18309
+ if (n === lengths.length) {
18310
+ var grown = new Float64Array(n * 2);
18311
+ grown.set(lengths);
18312
+ lengths = grown;
18313
+ }
18314
+ lengths[n++] = len;
18315
+ }
18316
+ };
18317
+ editShapes(lyr.shapes, function(path) {
18318
+ forEachSegmentInPath(path, arcs, onSeg);
18319
+ });
18320
+ return lengths.subarray(0, n);
18321
+ }
18322
+
18207
18323
  // Calculate a default area threshold using average segment length,
18208
18324
  // but increase the threshold for high-detail datasets and decrease it for
18209
18325
  // low-detail datasets (using segments per ring as a measure of detail).
18210
- //
18326
+ // Kept for legacy gap_fill_area=auto.
18211
18327
  function getDefaultSliverThreshold(lyr, arcs) {
18212
18328
  var ringCount = 0;
18213
18329
  var calcLen = arcs.isPlanar() ? geom.distance2D : geom.greatCircleDistance;
@@ -18250,8 +18366,13 @@
18250
18366
 
18251
18367
  var Slivers = /*#__PURE__*/Object.freeze({
18252
18368
  __proto__: null,
18369
+ GAP_WIDTH_SEGMENT_FRACTION: GAP_WIDTH_SEGMENT_FRACTION,
18253
18370
  calcMaxSliverArea: calcMaxSliverArea,
18371
+ getDefaultGapWidth: getDefaultGapWidth,
18254
18372
  getDefaultSliverThreshold: getDefaultSliverThreshold,
18373
+ getGapWidthLabel: getGapWidthLabel,
18374
+ getGapWidthTest: getGapWidthTest,
18375
+ getMedianPolygonSegmentLength: getMedianPolygonSegmentLength,
18255
18376
  getSliverAreaFunction: getSliverAreaFunction,
18256
18377
  getSliverFilter: getSliverFilter,
18257
18378
  getSliverTest: getSliverTest
@@ -18843,7 +18964,13 @@
18843
18964
  // Look for even better fit close to best-fit point
18844
18965
  p2 = probeForBestAnchorPoint(shp, arcs, p.x - hstep / 2,
18845
18966
  p.x + hstep / 2, 2, weight);
18846
- if (p2.distance > p.distance) {
18967
+ // The refinement can come back empty even though the coarse sweep did not,
18968
+ // because it samples only two x-values inside a narrow band. On a ring that
18969
+ // encloses no real area -- a collapsed sliver that runs out to a point and
18970
+ // back along itself, which a polygon buffer readily produces -- a ray at
18971
+ // either of those two can enclose no interval at all. The coarse result
18972
+ // already in hand stands in that case.
18973
+ if (p2 && p2.distance > p.distance) {
18847
18974
  p = p2;
18848
18975
  }
18849
18976
  }
@@ -18912,16 +19039,42 @@
18912
19039
  return p;
18913
19040
  }
18914
19041
 
19042
+ // Ceiling on the vertical scan below, which is otherwise unbounded: the scan
19043
+ // walks its chord in steps of vstep, so it runs for roughly the shape's height
19044
+ // over vstep, and nothing constrains that ratio. A tall hairline sliver -- the
19045
+ // kind a buffer mosaic produces between two nearly-coincident arcs -- makes it
19046
+ // astronomical.
19047
+ //
19048
+ // Truncating is safe rather than free: the scan only replaces p when it finds a
19049
+ // strictly better point, so stopping early keeps the best point so far, which
19050
+ // is still inside the polygon -- but on a shape thin enough to need more steps
19051
+ // than this, that point can differ from the one an unbounded scan would reach
19052
+ // (measured on a 1.1m x 111km rectangle: same polygon, ~12% less clearance).
19053
+ // The ceiling does bind on real data -- two of the -clean fixtures reach
19054
+ // ~180,000 steps here -- yet cutting every scan off at 10,000 left all 744
19055
+ // buffer fixture cases byte-identical, because the long tail is usually a
19056
+ // plateau the scan walks without improving on.
19057
+ var MAX_SCAN_STEPS = 10000;
19058
+
18915
19059
  // Try to find a better-fit point than @p by scanning vertically
18916
19060
  // Modify p in-place
18917
19061
  function scanForBetterPoint(p, shp, arcs, vstep, weight) {
18918
19062
  var x = p.x,
18919
19063
  y = p.y,
18920
19064
  dmax = p.distance,
18921
- d;
18922
-
18923
- while (true) {
18924
- y += vstep;
19065
+ d, y2;
19066
+
19067
+ for (var i = 0; i < MAX_SCAN_STEPS; i++) {
19068
+ y2 = y + vstep;
19069
+ // Stop when the step cannot move the point at all. vstep is the width of
19070
+ // the probe band, and on a shape narrower than the floating-point spacing
19071
+ // at its own coordinates that band collapses to nothing (see the
19072
+ // refinement probe in findAnchorPoint2), leaving y + vstep === y. Every
19073
+ // iteration would then retest the same point forever: a collapsed mosaic
19074
+ // tile 20nm wide, at longitude -119, hung here indefinitely rather than
19075
+ // merely running slowly.
19076
+ if (y2 === y) break;
19077
+ y = y2;
18925
19078
  d = geom.getPointToShapeDistance(x, y, shp, arcs) * weight(x, y);
18926
19079
  // overcome vary small local minima
18927
19080
  if (d > dmax * 0.90 && geom.testPointInPolygon(x, y, shp, arcs)) {
@@ -21677,7 +21830,22 @@
21677
21830
  var tile = mosaic[tileId];
21678
21831
  return filter(tile[0]); // test tile ring, ignoring any holes (does this matter?)
21679
21832
  });
21680
- filledIds.forEach(assignTileToAdjacentShape);
21833
+ // Choose every tile's owner before filling any of them. A tile that has just
21834
+ // been filled would otherwise present itself to the next one as a feature
21835
+ // boundary, so a gap divided into sections could award every section to
21836
+ // whichever feature received the first -- along a cut as long as the
21837
+ // boundaries it was meant to divide between.
21838
+ var chosen = filledIds.map(findAdjacentShapeId);
21839
+ var deferredIds = [];
21840
+ filledIds.forEach(function(tileId, i) {
21841
+ if (chosen[i] > -1) {
21842
+ tileShapeIndex.addTileToShape(chosen[i], tileId);
21843
+ } else {
21844
+ deferredIds.push(tileId);
21845
+ }
21846
+ });
21847
+ // A tile bordered only by other gaps can be filled once they have been.
21848
+ deferredIds.forEach(assignTileToAdjacentShape);
21681
21849
  return {
21682
21850
  removed: filledIds.length,
21683
21851
  remaining: remainingIds.length - filledIds.length
@@ -21688,6 +21856,26 @@
21688
21856
  return tileShapeIndex.getUnusedTileIds().map(tileIdToTile);
21689
21857
  };
21690
21858
 
21859
+ // Return unused tiles together with the source-feature owner of each boundary
21860
+ // arc. Used by -clean to partition multi-owner gaps before assigning them.
21861
+ this.getUnusedTileData = function(filter) {
21862
+ return tileShapeIndex.getUnusedTileIds().reduce(function(out, tileId) {
21863
+ var tile = mosaic[tileId];
21864
+ if (filter && !filter(tile[0])) return out;
21865
+ var boundary = [];
21866
+ tile[0].forEach(function(arcId) {
21867
+ var neighborTileId = arcTileIndex.getShapeIdByArcId(~arcId);
21868
+ var shapeId = neighborTileId < 0 ? -1 :
21869
+ tileShapeIndex.getShapeIdByTileId(neighborTileId);
21870
+ if (shapeId >= 0) {
21871
+ boundary.push({arcId: arcId, shapeId: shapeId});
21872
+ }
21873
+ });
21874
+ out.push({tileId: tileId, tile: tile, boundary: boundary});
21875
+ return out;
21876
+ }, []);
21877
+ };
21878
+
21691
21879
  this.getTilesByShapeIds = function(shapeIds) {
21692
21880
  return getTileIdsByShapeIds(shapeIds).map(tileIdToTile);
21693
21881
  };
@@ -22137,7 +22325,9 @@
22137
22325
  return mosaic[id];
22138
22326
  }
22139
22327
 
22140
- function assignTileToAdjacentShape(tileId) {
22328
+ // The shape sharing the longest boundary with a tile, or -1 if the tile is
22329
+ // bordered only by unassigned tiles.
22330
+ function findAdjacentShapeId(tileId) {
22141
22331
  var ring = mosaic[tileId][0];
22142
22332
  var arcs = nodes.arcs;
22143
22333
  var arcId, neighborShapeId, neighborTileId, arcLen;
@@ -22154,6 +22344,11 @@
22154
22344
  maxArcLen = arcLen;
22155
22345
  }
22156
22346
  }
22347
+ return shapeId;
22348
+ }
22349
+
22350
+ function assignTileToAdjacentShape(tileId) {
22351
+ var shapeId = findAdjacentShapeId(tileId);
22157
22352
  if (shapeId > -1) {
22158
22353
  tileShapeIndex.addTileToShape(shapeId, tileId);
22159
22354
  }
@@ -22179,6 +22374,26 @@
22179
22374
  }
22180
22375
  }
22181
22376
 
22377
+ // Flags, by absolute arc id, for arcs with no mosaic tile on one of their sides:
22378
+ // the border between the mosaic and the space outside it. Space that polygons
22379
+ // enclose becomes a tile, however narrow, so an arc facing untiled space faces
22380
+ // the outside, and a gap there is one that gap filling cannot reach.
22381
+ //
22382
+ // Only the mosaic is needed, not the tile assignment a MosaicIndex goes on to
22383
+ // make, so this builds the cheaper half on its own.
22384
+ function getOutsideFacingArcFlags(nodes) {
22385
+ var mosaic = buildPolygonMosaic(nodes).mosaic;
22386
+ var arcTileIndex = new ShapeArcIndex(mosaic, nodes.arcs);
22387
+ var flags = new Uint8Array(nodes.arcs.size());
22388
+ for (var i = 0; i < flags.length; i++) {
22389
+ if (arcTileIndex.getShapeIdByArcId(i) < 0 ||
22390
+ arcTileIndex.getShapeIdByArcId(~i) < 0) {
22391
+ flags[i] = 1;
22392
+ }
22393
+ }
22394
+ return flags;
22395
+ }
22396
+
22182
22397
  // Map arc ids to shape ids, assuming perfect topology
22183
22398
  // (an arcId maps to at most one shape)
22184
22399
  // Supports looking up a shape id using an arc id.
@@ -22209,7 +22424,8 @@
22209
22424
  var MosaicIndex$1 = /*#__PURE__*/Object.freeze({
22210
22425
  __proto__: null,
22211
22426
  MosaicIndex: MosaicIndex,
22212
- ShapeArcIndex: ShapeArcIndex
22427
+ ShapeArcIndex: ShapeArcIndex,
22428
+ getOutsideFacingArcFlags: getOutsideFacingArcFlags
22213
22429
  });
22214
22430
 
22215
22431
  // Assumes that arcs do not intersect except at endpoints
@@ -22256,6 +22472,13 @@
22256
22472
  profileStart('dissolvePolygonGroups2');
22257
22473
  profileStart('dpg2.NodeCollection');
22258
22474
  var arcFilter = getArcPresenceTest(lyr.shapes, dataset.arcs);
22475
+ if (opts._mosaic_cut_arcs) {
22476
+ var layerArcFilter = arcFilter;
22477
+ arcFilter = function(arcId) {
22478
+ var id = arcId < 0 ? ~arcId : arcId;
22479
+ return layerArcFilter(arcId) || opts._mosaic_cut_arcs[id];
22480
+ };
22481
+ }
22259
22482
  var nodes = new NodeCollection(dataset.arcs, arcFilter);
22260
22483
  profileEnd('dpg2.NodeCollection');
22261
22484
  var mosaicOpts = {
@@ -22267,12 +22490,17 @@
22267
22490
  var mosaicIndex = new MosaicIndex(lyr, nodes, mosaicOpts);
22268
22491
  profileEnd('dpg2.MosaicIndex');
22269
22492
  // gap fill doesn't work yet with overlapping shapes
22270
- var fillGaps = !opts.allow_overlaps && (opts.sliver_control || opts.gap_fill_area);
22493
+ var fillGaps = !opts.allow_overlaps &&
22494
+ (opts.gap_width || opts.sliver_control || opts.gap_fill_area);
22271
22495
  var cleanupData, filterData;
22272
22496
  if (fillGaps) {
22273
22497
  profileStart('dpg2.removeGaps');
22274
- var sliverOpts = utils.extend({sliver_control: 1}, opts);
22275
- filterData = getSliverFilter(lyr, dataset, sliverOpts);
22498
+ var filterOpts = utils.extend({}, opts);
22499
+ // Legacy area path: keep the historical default of full sliver control.
22500
+ if (filterOpts.gap_width == null && filterOpts.sliver_control == null) {
22501
+ filterOpts.sliver_control = 1;
22502
+ }
22503
+ filterData = getSliverFilter(lyr, dataset, filterOpts);
22276
22504
  cleanupData = mosaicIndex.removeGaps(filterData.filter);
22277
22505
  profileEnd('dpg2.removeGaps');
22278
22506
  }
@@ -22513,6 +22741,1245 @@
22513
22741
  target.push.apply(target, source);
22514
22742
  }
22515
22743
 
22744
+ // Two boundaries that should be one, but were digitized or computed twice, drift
22745
+ // apart by more than the single interval that ordinary vertex snapping repairs,
22746
+ // and their vertices are staggered, so no pairwise snapping ever sees them. In
22747
+ // the test corpus the worst of them sit ~20 intervals apart. This multiple keeps
22748
+ // the default repair at sub-micron scale, far below any gap a map could show,
22749
+ // while still catching them.
22750
+ var PRECISION_SEAM_INTERVALS = 100;
22751
+
22752
+ // Minimum accepted seam length, as a multiple of the median segment length. Two
22753
+ // boundaries that merely pass close to each other for a vertex or two are not a
22754
+ // seam.
22755
+ var MIN_SEAM_SEGMENT_FACTOR = 3;
22756
+
22757
+ // Collapse duplicate boundaries: two arcs carrying what should be one line,
22758
+ // digitized or computed twice and drifting apart by no more than floating-point
22759
+ // rounding (see PRECISION_SEAM_INTERVALS). Vertices on both banks are snapped to
22760
+ // the line midway between them, leaving a single shared boundary.
22761
+ //
22762
+ // Runs unconditionally. Left in place, such a seam becomes an enclosed sliver
22763
+ // that gap filling awards to one neighbor, giving that feature a zero-width spike
22764
+ // along the shared border.
22765
+ //
22766
+ // Returns the number of duplicate boundaries collapsed (see countConnectedGaps).
22767
+ function collapseDuplicateBoundaries(layers, dataset) {
22768
+ var polygonLayers = layers.filter(function(lyr) {
22769
+ return lyr.geometry_type == 'polygon' && lyr.shapes && lyr.shapes.length > 1;
22770
+ });
22771
+ if (polygonLayers.length === 0 || !dataset.arcs) return 0;
22772
+
22773
+ var spherical = isLatLngCRS(getDatasetCRS(dataset));
22774
+ var changed = 0;
22775
+ polygonLayers.forEach(function(lyr) {
22776
+ changed += collapseLayerDuplicates(lyr, dataset.arcs, spherical);
22777
+ });
22778
+ if (changed > 0) {
22779
+ rebuildTopology(dataset);
22780
+ verbose(utils.format('Closed %s duplicate boundar%s', changed,
22781
+ changed == 1 ? 'y' : 'ies'));
22782
+ }
22783
+ return changed;
22784
+ }
22785
+
22786
+ function collapseLayerDuplicates(lyr, arcs, spherical) {
22787
+ var medianSeg = measureMedianSegment(lyr, arcs);
22788
+ var distance = getPrecisionSeamDistance(arcs.getBounds().toArray(), spherical);
22789
+ if (!(distance > 0)) return 0;
22790
+
22791
+ // Keep the automatic search radius when the closing distance is smaller. This
22792
+ // lets us see the complete seam and reject it as a unit instead of partially
22793
+ // snapping the already-near portion while leaving its wider mouth open. A
22794
+ // duplicate boundary often has a mouth several orders of magnitude wider than
22795
+ // the seam behind it.
22796
+ var searchDistance = Math.max(distance, medianSeg * GAP_WIDTH_SEGMENT_FRACTION);
22797
+ var found = findLayerSeams(lyr, arcs, distance, searchDistance, spherical,
22798
+ medianSeg * MIN_SEAM_SEGMENT_FACTOR, null);
22799
+ if (!found) return 0;
22800
+ var snapped = profileWrap('cg.snapSeams', function() {
22801
+ return snapSeams(found.seams, found.seeds, found.arcsById, arcs,
22802
+ found.footpoints, distance);
22803
+ });
22804
+ return countConnectedGaps(snapped);
22805
+ }
22806
+
22807
+ // A crack that opens onto the space outside the mosaic is not enclosed by
22808
+ // polygons, so no tile covers it and gap filling cannot reach it. Snapping the
22809
+ // facing pair of vertices at each open end together encloses the crack, handing
22810
+ // it to the mosaic as an ordinary gap to be filled -- or divided between its
22811
+ // neighbors, where it borders three or more of them -- like any other.
22812
+ //
22813
+ // Only those pairs move: two coordinates per mouth, each by half the width of the
22814
+ // mouth. Interior gaps are left exactly as they were found, so they are filled the
22815
+ // same way whether or not this pass runs.
22816
+ //
22817
+ // Returns the number of mouths pinched shut.
22818
+ function pinchOuterCrackMouths(lyr, dataset, nodes, opts) {
22819
+ var arcs = dataset.arcs;
22820
+ if (!lyr.shapes || lyr.shapes.length < 2 || !arcs) return 0;
22821
+ var crs = getDatasetCRS(dataset);
22822
+ var spherical = isLatLngCRS(crs);
22823
+ var medianSeg = measureMedianSegment(lyr, arcs);
22824
+ // Same width as interior gap filling, from the same automatic default.
22825
+ var distance = resolveCloseDistance(opts, crs,
22826
+ medianSeg * GAP_WIDTH_SEGMENT_FRACTION);
22827
+ if (!(distance > 0)) return 0;
22828
+
22829
+ profileStart('cg.outsideFacingArcs');
22830
+ var outsideFacing = getOutsideFacingArcFlags(nodes);
22831
+ profileEnd('cg.outsideFacingArcs');
22832
+ var found = findLayerSeams(lyr, arcs, distance, distance, spherical,
22833
+ medianSeg * MIN_SEAM_SEGMENT_FACTOR, function(arcId) {
22834
+ return outsideFacing[arcId] === 1;
22835
+ });
22836
+ if (!found) return 0;
22837
+
22838
+ var pinched = profileWrap('cg.pinchMouths', function() {
22839
+ return pinchSeamMouths(found.seams, found.arcsById, arcs, nodes,
22840
+ found.footpoints, distance, spherical);
22841
+ });
22842
+ if (pinched > 0) {
22843
+ rebuildTopology(dataset);
22844
+ message(utils.format('Closed %s external gap%s', pinched,
22845
+ utils.pluralSuffix(pinched)));
22846
+ }
22847
+ return pinched;
22848
+ }
22849
+
22850
+ // Seams between facing arcs owned by different features: stretches where two
22851
+ // boundaries run within @distance of each other for at least @minSeamLength.
22852
+ // @keepArc, when given, limits which arcs are considered.
22853
+ function findLayerSeams(lyr, arcs, distance, searchDistance, spherical,
22854
+ minSeamLength, keepArc) {
22855
+ profileStart('cg.collectVertices');
22856
+ var owners = getArcOwners(lyr.shapes, arcs.size());
22857
+ var records = collectExternalVertices(arcs, owners, keepArc);
22858
+ profileEnd('cg.collectVertices');
22859
+ if (records.length < 4) return null;
22860
+
22861
+ profileStart('cg.findMouthSeeds');
22862
+ var seeds = findMouthSeeds(records, searchDistance, spherical);
22863
+ profileEnd('cg.findMouthSeeds');
22864
+ if (seeds.length === 0) return null;
22865
+
22866
+ var arcsById = indexArcRecords(records);
22867
+ var footpoints = new ArcFootpoints(arcs, spherical);
22868
+ profileStart('cg.growSeams');
22869
+ var seams = growSeamsFromSeeds(seeds, arcsById, footpoints, distance, spherical,
22870
+ minSeamLength);
22871
+ profileEnd('cg.growSeams');
22872
+ if (seams.length === 0) return null;
22873
+ return {seams: seams, seeds: seeds, arcsById: arcsById, footpoints: footpoints};
22874
+ }
22875
+
22876
+ // Same segment median as interior gap-width=auto (all polygon-ring segments).
22877
+ function measureMedianSegment(lyr, arcs) {
22878
+ return profileWrap('cg.medianSegment', function() {
22879
+ return getMedianPolygonSegmentLength(lyr, arcs);
22880
+ });
22881
+ }
22882
+
22883
+ function rebuildTopology(dataset) {
22884
+ profileStart('cg.rebuildTopology');
22885
+ dataset.arcs.dedupCoords();
22886
+ buildTopology(dataset);
22887
+ profileEnd('cg.rebuildTopology');
22888
+ }
22889
+
22890
+ // One crack presents many seams: a seam is a stretch of two facing arcs, and
22891
+ // every node along either side ends one arc and starts another -- where a third
22892
+ // feature meets the boundary, or where an intersection cut fell. Reporting seams
22893
+ // would give a number several times the number of cracks there are to see, so
22894
+ // seams meeting along a shared arc count once.
22895
+ //
22896
+ // Two cracks that face the same arc at different stretches of it are counted as
22897
+ // one. Erring towards the smaller number suits a count of gaps closed better than
22898
+ // splitting one crack into the arcs it happens to be divided into.
22899
+ function countConnectedGaps(seams) {
22900
+ var parents = new Map();
22901
+
22902
+ function find(id) {
22903
+ var parent = parents.get(id);
22904
+ if (parent === undefined) {
22905
+ parents.set(id, id);
22906
+ return id;
22907
+ }
22908
+ while (parent !== id) {
22909
+ id = parent;
22910
+ parent = parents.get(id);
22911
+ }
22912
+ return id;
22913
+ }
22914
+
22915
+ seams.forEach(function(seam) {
22916
+ var a = find(seam.arcA);
22917
+ var b = find(seam.arcB);
22918
+ if (a !== b) parents.set(a, b);
22919
+ });
22920
+ var roots = new Set();
22921
+ seams.forEach(function(seam) { roots.add(find(seam.arcA)); });
22922
+ return roots.size;
22923
+ }
22924
+
22925
+ // Closing distance for the default pass, which collapses duplicate boundaries
22926
+ // and nothing else. @bounds is the arc bounding box in coordinate units; the seam
22927
+ // walk measures distances in meters when those units are degrees.
22928
+ function getPrecisionSeamDistance(bounds, spherical) {
22929
+ var interval = getHighPrecisionSnapInterval(bounds) * PRECISION_SEAM_INTERVALS;
22930
+ return spherical ? degreesToMeters(interval) : interval;
22931
+ }
22932
+
22933
+ function resolveCloseDistance(opts, crs, autoDistance) {
22934
+ var arg = opts.gap_width;
22935
+ if (arg == null || arg === 'auto') return autoDistance;
22936
+ if (+arg === 0) return 0;
22937
+ return convertDistanceParam(arg, crs);
22938
+ }
22939
+
22940
+ // An external arc belongs to exactly one feature. Shared arcs are excluded: they
22941
+ // already represent clean topology and must not be moved by this repair.
22942
+ function getArcOwners(shapes, arcCount) {
22943
+ var first = new Int32Array(arcCount);
22944
+ var second = new Int32Array(arcCount);
22945
+ for (var i = 0; i < arcCount; i++) {
22946
+ first[i] = -1;
22947
+ second[i] = -1;
22948
+ }
22949
+ traversePaths(shapes, function(o) {
22950
+ var id = o.arcId < 0 ? ~o.arcId : o.arcId;
22951
+ var owner = o.shapeId;
22952
+ if (first[id] == -1) first[id] = owner;
22953
+ else if (first[id] != owner) second[id] = owner;
22954
+ });
22955
+ return {first: first, second: second};
22956
+ }
22957
+
22958
+ function collectExternalVertices(arcs, owners, keepArc) {
22959
+ var data = arcs.getVertexData();
22960
+ var records = [];
22961
+ var offs = 0;
22962
+ for (var arcId = 0; arcId < data.nn.length; arcId++) {
22963
+ var n = data.nn[arcId];
22964
+ if (owners.first[arcId] >= 0 && owners.second[arcId] == -1 &&
22965
+ (!keepArc || keepArc(arcId))) {
22966
+ for (var k = 0; k < n; k++) {
22967
+ var i = offs + k;
22968
+ records.push({
22969
+ x: data.xx[i],
22970
+ y: data.yy[i],
22971
+ i: i,
22972
+ arc: arcId,
22973
+ k: k,
22974
+ owner: owners.first[arcId]
22975
+ });
22976
+ }
22977
+ }
22978
+ offs += n;
22979
+ }
22980
+ return records;
22981
+ }
22982
+
22983
+ function indexArcRecords(records) {
22984
+ var byArc = {};
22985
+ records.forEach(function(o) {
22986
+ if (!byArc[o.arc]) byArc[o.arc] = [];
22987
+ byArc[o.arc].push(o);
22988
+ });
22989
+ Object.keys(byArc).forEach(function(id) {
22990
+ byArc[id].sort(function(a, b) { return a.k - b.k; });
22991
+ });
22992
+ return byArc;
22993
+ }
22994
+
22995
+ // Mouth seeds: mutual nearest external vertices on different owners/arcs.
22996
+ // A single positive-distance pair is enough to start an edge walk.
22997
+ function findMouthSeeds(records, distance, spherical) {
22998
+ var index = new Flatbush(records.length);
22999
+ records.forEach(function(o) { index.add(o.x, o.y, o.x, o.y); });
23000
+ index.finish();
23001
+
23002
+ var nearest = new Int32Array(records.length);
23003
+ var distances = new Float64Array(records.length);
23004
+ nearest.fill(-1);
23005
+ records.forEach(function(a, ai) {
23006
+ var padY = getCoordinatePad(distance, a.y, spherical, false);
23007
+ var padX = getCoordinatePad(distance, a.y, spherical, true);
23008
+ var ids = index.search(a.x - padX, a.y - padY, a.x + padX, a.y + padY);
23009
+ var best = -1, bestDist = Infinity;
23010
+ ids.forEach(function(bi) {
23011
+ var b = records[bi];
23012
+ if (bi == ai || b.owner == a.owner || b.arc == a.arc) return;
23013
+ var d = spherical ?
23014
+ fastLonLatDistance(a.x, a.y, b.x, b.y) :
23015
+ distance2D$1(a.x, a.y, b.x, b.y);
23016
+ if (d <= distance && d < bestDist) {
23017
+ best = bi;
23018
+ bestDist = d;
23019
+ }
23020
+ });
23021
+ nearest[ai] = best;
23022
+ distances[ai] = bestDist;
23023
+ });
23024
+
23025
+ var seen = {};
23026
+ var seeds = [];
23027
+ records.forEach(function(a, ai) {
23028
+ var bi = nearest[ai];
23029
+ if (bi < 0 || !(distances[ai] > 0)) return;
23030
+ var best = records[bi];
23031
+ // Positive-distance matches must be mutual nearest neighbors.
23032
+ if (nearest[bi] != ai) return;
23033
+ var lo = Math.min(a.i, best.i), hi = Math.max(a.i, best.i);
23034
+ var key = lo + ':' + hi;
23035
+ if (seen[key]) return;
23036
+ seen[key] = true;
23037
+ seeds.push({a: a, b: best, distance: distances[ai]});
23038
+ });
23039
+ return seeds;
23040
+ }
23041
+
23042
+ function getCoordinatePad(distance, lat, spherical, longitude) {
23043
+ if (spherical) {
23044
+ var deg = distance / R$3 * R2D$8;
23045
+ return longitude ? deg / Math.max(Math.cos(lat / R2D$8), 0.01) : deg;
23046
+ }
23047
+ // convertDistanceParam() has already converted physical units into projected
23048
+ // coordinate units (or left unitless values unchanged).
23049
+ return distance;
23050
+ }
23051
+
23052
+ // Grow a seam from each mouth seed by walking both arcs while each vertex stays
23053
+ // within closeDistance of the opposite arc's edges. Accept only seams whose
23054
+ // walked length on both sides meets minSeamLength. Overlapping seeds that grow
23055
+ // into the same arc-pair range are merged.
23056
+ function growSeamsFromSeeds(seeds, arcsById, footpoints, closeDistance,
23057
+ spherical, minSeamLength) {
23058
+ var seams = [];
23059
+ // Seeds crowd along a crack: every facing pair of vertices along it is one, and
23060
+ // each grows into the same stretch of the same two arcs, since what the walk
23061
+ // finds is the whole run of in-tolerance vertices around the seed rather than
23062
+ // anything particular to where it started. Walking that run once per seed
23063
+ // instead of once is what made this repair take minutes on a detailed mosaic,
23064
+ // so a seed already inside a run grown for its own two arcs is passed over.
23065
+ var grownByPair = new Map();
23066
+ seeds.forEach(function(seed) {
23067
+ var key = arcPairKey(seed.a.arc, seed.b.arc);
23068
+ var grown = grownByPair.get(key);
23069
+ if (!grown) {
23070
+ grown = [];
23071
+ grownByPair.set(key, grown);
23072
+ }
23073
+ if (seamContainsSeed(grown, seed)) return;
23074
+ var seam = growSeamFromSeed(seed, arcsById, footpoints, closeDistance,
23075
+ spherical);
23076
+ if (!seam) return;
23077
+ // Runs rejected as too short are recorded too, so that the seeds along them
23078
+ // are not walked again either.
23079
+ grown.push(seam);
23080
+ if (seam.lengthA < minSeamLength || seam.lengthB < minSeamLength) return;
23081
+ seams.push(seam);
23082
+ });
23083
+ return mergeOverlappingSeams(seams);
23084
+ }
23085
+
23086
+ function arcPairKey(arcA, arcB) {
23087
+ return arcA < arcB ? arcA + '|' + arcB : arcB + '|' + arcA;
23088
+ }
23089
+
23090
+ function indexSeamsByArcPair(seams) {
23091
+ var byPair = new Map();
23092
+ seams.forEach(function(seam) {
23093
+ var key = arcPairKey(seam.arcA, seam.arcB);
23094
+ var group = byPair.get(key);
23095
+ if (!group) {
23096
+ group = [];
23097
+ byPair.set(key, group);
23098
+ }
23099
+ group.push(seam);
23100
+ });
23101
+ return byPair;
23102
+ }
23103
+
23104
+ function growSeamFromSeed(seed, arcsById, footpoints, closeDistance, spherical) {
23105
+ var sideA = arcsById[seed.a.arc];
23106
+ var sideB = arcsById[seed.b.arc];
23107
+ if (!sideA || !sideB) return null;
23108
+
23109
+ var rangeA = expandRangeOnArc(sideA, seed.a.k, sideB, footpoints, closeDistance,
23110
+ spherical);
23111
+ var rangeB = expandRangeOnArc(sideB, seed.b.k, sideA, footpoints, closeDistance,
23112
+ spherical);
23113
+ if (!rangeA || !rangeB) return null;
23114
+
23115
+ return {
23116
+ arcA: seed.a.arc,
23117
+ arcB: seed.b.arc,
23118
+ loA: rangeA.lo,
23119
+ hiA: rangeA.hi,
23120
+ loB: rangeB.lo,
23121
+ hiB: rangeB.hi,
23122
+ lengthA: rangeA.length,
23123
+ lengthB: rangeB.length
23124
+ };
23125
+ }
23126
+
23127
+ // Expand along @side from seed index @seedK while each vertex remains within
23128
+ // closeDistance of @other's edges. Returns the inclusive index range and the
23129
+ // path length covered by the accepted stretch.
23130
+ function expandRangeOnArc(side, seedK, other, footpoints, closeDistance, spherical) {
23131
+ if (seedK < 0 || seedK >= side.length) return null;
23132
+ if (!vertexWithinTolerance(side[seedK], other, footpoints, closeDistance)) {
23133
+ return null;
23134
+ }
23135
+ var lo = seedK;
23136
+ var hi = seedK;
23137
+ while (lo > 0 &&
23138
+ vertexWithinTolerance(side[lo - 1], other, footpoints, closeDistance)) {
23139
+ lo--;
23140
+ }
23141
+ while (hi + 1 < side.length &&
23142
+ vertexWithinTolerance(side[hi + 1], other, footpoints, closeDistance)) {
23143
+ hi++;
23144
+ }
23145
+ return {
23146
+ lo: lo,
23147
+ hi: hi,
23148
+ length: pathLengthBetween(side, lo, hi, spherical)
23149
+ };
23150
+ }
23151
+
23152
+ function vertexWithinTolerance(vertex, otherSide, footpoints, closeDistance) {
23153
+ return !!footpoints.closestPoint(vertex.x, vertex.y, otherSide, closeDistance);
23154
+ }
23155
+
23156
+ function pathLengthBetween(side, lo, hi, spherical) {
23157
+ var len = 0;
23158
+ for (var k = lo; k < hi; k++) {
23159
+ var a = side[k], b = side[k + 1];
23160
+ len += spherical ?
23161
+ fastLonLatDistance(a.x, a.y, b.x, b.y) :
23162
+ distance2D$1(a.x, a.y, b.x, b.y);
23163
+ }
23164
+ return len;
23165
+ }
23166
+
23167
+ function mergeOverlappingSeams(seams) {
23168
+ if (seams.length <= 1) return seams;
23169
+ var out = [];
23170
+ // Only seams on the same two arcs can overlap, so each one is compared against
23171
+ // those alone rather than against every seam found so far.
23172
+ var byPair = new Map();
23173
+ seams.forEach(function(seam) {
23174
+ var a = Math.min(seam.arcA, seam.arcB);
23175
+ var b = Math.max(seam.arcA, seam.arcB);
23176
+ var swapped = seam.arcA > seam.arcB;
23177
+ var norm = {
23178
+ arcA: a,
23179
+ arcB: b,
23180
+ loA: swapped ? seam.loB : seam.loA,
23181
+ hiA: swapped ? seam.hiB : seam.hiA,
23182
+ loB: swapped ? seam.loA : seam.loB,
23183
+ hiB: swapped ? seam.hiA : seam.hiB,
23184
+ lengthA: swapped ? seam.lengthB : seam.lengthA,
23185
+ lengthB: swapped ? seam.lengthA : seam.lengthB
23186
+ };
23187
+ var key = arcPairKey(norm.arcA, norm.arcB);
23188
+ var group = byPair.get(key);
23189
+ if (!group) {
23190
+ group = [];
23191
+ byPair.set(key, group);
23192
+ }
23193
+ var existing = group.find(function(o) {
23194
+ return rangesOverlap(o.loA, o.hiA, norm.loA, norm.hiA) &&
23195
+ rangesOverlap(o.loB, o.hiB, norm.loB, norm.hiB);
23196
+ });
23197
+ if (existing) {
23198
+ existing.loA = Math.min(existing.loA, norm.loA);
23199
+ existing.hiA = Math.max(existing.hiA, norm.hiA);
23200
+ existing.loB = Math.min(existing.loB, norm.loB);
23201
+ existing.hiB = Math.max(existing.hiB, norm.hiB);
23202
+ existing.lengthA = Math.max(existing.lengthA, norm.lengthA);
23203
+ existing.lengthB = Math.max(existing.lengthB, norm.lengthB);
23204
+ } else {
23205
+ group.push(norm);
23206
+ out.push(norm);
23207
+ }
23208
+ });
23209
+ return out;
23210
+ }
23211
+
23212
+ function rangesOverlap(lo1, hi1, lo2, hi2) {
23213
+ return lo1 <= hi2 && lo2 <= hi1;
23214
+ }
23215
+
23216
+ // Snap every vertex in an accepted seam range to the midpoint between itself
23217
+ // and its nearest footpoint on the opposite arc. Works with staggered vertices:
23218
+ // the opposite side need not have a coinciding vertex. Mouth seed pairs are
23219
+ // forced to their shared vertex midpoint so the crack tip closes exactly.
23220
+ // Returns the seams that moved coordinates.
23221
+ function snapSeams(seams, seeds, arcsById, arcs, footpoints, closeDistance) {
23222
+ arcs.getVertexData();
23223
+ var targets = new CoordinateTargets();
23224
+ var snapped = [];
23225
+
23226
+ seams.forEach(function(seam) {
23227
+ var sideA = arcsById[seam.arcA];
23228
+ var sideB = arcsById[seam.arcB];
23229
+ if (!sideA || !sideB) return;
23230
+ var before = targets.size();
23231
+ snapSideToOpposite(sideA, seam.loA, seam.hiA, sideB, arcs, footpoints,
23232
+ closeDistance, targets);
23233
+ snapSideToOpposite(sideB, seam.loB, seam.hiB, sideA, arcs, footpoints,
23234
+ closeDistance, targets);
23235
+ if (targets.size() > before) snapped.push(seam);
23236
+ });
23237
+
23238
+ // Mouth pairs win over footpoint midpoints so both tips land on one point.
23239
+ var seamsByPair = indexSeamsByArcPair(seams);
23240
+ seeds.forEach(function(seed) {
23241
+ if (!(seed.distance > 0)) return;
23242
+ var group = seamsByPair.get(arcPairKey(seed.a.arc, seed.b.arc));
23243
+ if (!group || !seamContainsSeed(group, seed)) return;
23244
+ var mid = [(seed.a.x + seed.b.x) / 2, (seed.a.y + seed.b.y) / 2];
23245
+ targets.set(seed.a.x, seed.a.y, mid);
23246
+ targets.set(seed.b.x, seed.b.y, mid);
23247
+ });
23248
+
23249
+ return applyTargets(arcs, targets) ? snapped : [];
23250
+ }
23251
+
23252
+ // Snap the facing pair of vertices at each open end of a seam together, which
23253
+ // closes the crack off there without disturbing the boundary between.
23254
+ //
23255
+ // An end counts as open when the two banks turn away from each other there. Where
23256
+ // the crack carries on instead, into the next pair of arcs, pinching would divide
23257
+ // one gap into a row of them -- each awarded separately, so the boundary would
23258
+ // zigzag from bank to bank along a crack that should have been resolved whole.
23259
+ //
23260
+ // Returns the number of mouths pinched shut.
23261
+ function pinchSeamMouths(seams, arcsById, arcs, nodes, footpoints, distance,
23262
+ spherical) {
23263
+ var data = arcs.getVertexData();
23264
+ var targets = new CoordinateTargets();
23265
+ var pinched = 0;
23266
+
23267
+ seams.forEach(function(seam) {
23268
+ var sideA = arcsById[seam.arcA];
23269
+ var sideB = arcsById[seam.arcB];
23270
+ if (!sideA || !sideB) return;
23271
+ [seam.loA, seam.hiA].forEach(function(kA, i) {
23272
+ var outwardA = i === 0 ? -1 : 1;
23273
+ var a = sideA[kA];
23274
+ // The two runs were grown from the seed independently, so they need not end
23275
+ // opposite each other; the nearest vertex within the facing run is the one
23276
+ // to close against.
23277
+ var kB = nearestVertexInRange(a, sideB, seam.loB, seam.hiB, distance,
23278
+ spherical);
23279
+ if (kB < 0) return;
23280
+ var outwardB = kB - seam.loB < seam.hiB - kB ? -1 : 1;
23281
+ if (!endIsOpen(sideA, seam.arcA, kA, outwardA, sideB, arcs, nodes,
23282
+ footpoints, distance) &&
23283
+ !endIsOpen(sideB, seam.arcB, kB, outwardB, sideA, arcs, nodes,
23284
+ footpoints, distance)) {
23285
+ return;
23286
+ }
23287
+ var b = sideB[kB];
23288
+ var mid = [(a.x + b.x) / 2, (a.y + b.y) / 2];
23289
+ var before = targets.size();
23290
+ targets.set(data.xx[a.i], data.yy[a.i], mid);
23291
+ targets.set(data.xx[b.i], data.yy[b.i], mid);
23292
+ if (targets.size() > before) pinched++;
23293
+ });
23294
+ });
23295
+
23296
+ return applyTargets(arcs, targets) ? pinched : 0;
23297
+ }
23298
+
23299
+ // True if the boundary has left the facing arc behind by the vertex just past
23300
+ // @k, so that the crack ends there.
23301
+ //
23302
+ // Where the arc itself ends at @k, the question is what carries on past the node:
23303
+ // an arc continuing along the crack keeps its next vertex within tolerance of the
23304
+ // facing bank, whereas one turning away from the crack -- the coverage boundary at
23305
+ // the crack's mouth, say -- does not. A crack whose banks are split into several
23306
+ // arcs is walked as several seams, and this is what keeps their inner ends from
23307
+ // being taken for mouths.
23308
+ function endIsOpen(side, arcId, k, outward, other, arcs, nodes, footpoints,
23309
+ distance) {
23310
+ var next = k + outward;
23311
+ if (next >= 0 && next < side.length) {
23312
+ return !footpoints.closestPoint(side[next].x, side[next].y, other, distance);
23313
+ }
23314
+ var entering = outward > 0 ? arcId : ~arcId;
23315
+ var connected = nodes.getConnectedArcs(entering);
23316
+ for (var i = 0; i < connected.length; i++) {
23317
+ // Connected ids lead into the node, so the vertex adjacent to it is the last
23318
+ // but one along each.
23319
+ var v = arcs.getVertex(connected[i], -2);
23320
+ if (footpoints.closestPoint(v.x, v.y, other, distance)) return false;
23321
+ }
23322
+ return true;
23323
+ }
23324
+
23325
+ // Index of the vertex in @side[lo..hi] nearest @vertex, or -1 if the nearest is
23326
+ // further off than @maxDist or already coincides with it. A coinciding pair marks
23327
+ // a tip where the two banks already meet, which needs no pinching.
23328
+ function nearestVertexInRange(vertex, side, lo, hi, maxDist, spherical) {
23329
+ var best = -1;
23330
+ var bestDist = Infinity;
23331
+ for (var k = lo; k <= hi; k++) {
23332
+ var d = spherical ?
23333
+ fastLonLatDistance(vertex.x, vertex.y, side[k].x, side[k].y) :
23334
+ distance2D$1(vertex.x, vertex.y, side[k].x, side[k].y);
23335
+ if (d < bestDist) {
23336
+ bestDist = d;
23337
+ best = k;
23338
+ }
23339
+ }
23340
+ if (best < 0 || !(bestDist > 0) || bestDist > maxDist) return -1;
23341
+ return best;
23342
+ }
23343
+
23344
+ // Move every occurrence of a selected coordinate, including coincident arc
23345
+ // endpoints, so existing nodes remain connected. Coordinates that were not
23346
+ // selected are untouched. Returns false if there was nothing to move.
23347
+ function applyTargets(arcs, targets) {
23348
+ if (targets.size() === 0) return false;
23349
+ var data = arcs.getVertexData();
23350
+ for (var i = 0; i < data.xx.length; i++) {
23351
+ var target = targets.get(data.xx[i], data.yy[i]);
23352
+ if (target) {
23353
+ data.xx[i] = target[0];
23354
+ data.yy[i] = target[1];
23355
+ }
23356
+ }
23357
+ arcs.updateVertexData(data.nn, data.xx, data.yy, data.zz);
23358
+ return true;
23359
+ }
23360
+
23361
+ // Replacement coordinates, looked up by exact x/y. Keyed by x in a Map, with a
23362
+ // short bucket per x, because the lookup runs once per vertex in the whole arc
23363
+ // table: building a string key for each of those is slower than the seam
23364
+ // detection that precedes it.
23365
+ function CoordinateTargets() {
23366
+ var byX = new Map();
23367
+ var count = 0;
23368
+
23369
+ this.set = function(x, y, target) {
23370
+ var bucket = byX.get(x);
23371
+ if (!bucket) {
23372
+ bucket = [];
23373
+ byX.set(x, bucket);
23374
+ }
23375
+ for (var i = 0; i < bucket.length; i++) {
23376
+ if (bucket[i].y === y) {
23377
+ bucket[i].target = target;
23378
+ return;
23379
+ }
23380
+ }
23381
+ bucket.push({y: y, target: target});
23382
+ count++;
23383
+ };
23384
+
23385
+ this.get = function(x, y) {
23386
+ var bucket = byX.get(x);
23387
+ if (!bucket) return null;
23388
+ for (var i = 0; i < bucket.length; i++) {
23389
+ if (bucket[i].y === y) return bucket[i].target;
23390
+ }
23391
+ return null;
23392
+ };
23393
+
23394
+ this.size = function() { return count; };
23395
+ }
23396
+
23397
+ function seamContainsSeed(seams, seed) {
23398
+ return seams.some(function(seam) {
23399
+ var aOnA = seed.a.arc === seam.arcA &&
23400
+ seed.a.k >= seam.loA && seed.a.k <= seam.hiA;
23401
+ var aOnB = seed.a.arc === seam.arcB &&
23402
+ seed.a.k >= seam.loB && seed.a.k <= seam.hiB;
23403
+ var bOnA = seed.b.arc === seam.arcA &&
23404
+ seed.b.k >= seam.loA && seed.b.k <= seam.hiA;
23405
+ var bOnB = seed.b.arc === seam.arcB &&
23406
+ seed.b.k >= seam.loB && seed.b.k <= seam.hiB;
23407
+ return (aOnA && bOnB) || (aOnB && bOnA);
23408
+ });
23409
+ }
23410
+
23411
+ function snapSideToOpposite(side, lo, hi, other, arcs, footpoints, closeDistance,
23412
+ targets) {
23413
+ var data = arcs.getVertexData();
23414
+ for (var k = lo; k <= hi; k++) {
23415
+ var v = side[k];
23416
+ var foot = footpoints.closestPoint(v.x, v.y, other, closeDistance);
23417
+ if (!foot || !(foot.distance > 0)) continue;
23418
+ var mid = [(v.x + foot.x) / 2, (v.y + foot.y) / 2];
23419
+ targets.set(data.xx[v.i], data.yy[v.i], mid);
23420
+ }
23421
+ }
23422
+
23423
+ // Nearest-point queries against an arc, indexing each arc once rather than
23424
+ // scanning it per query.
23425
+ //
23426
+ // Growing a seam tests every vertex along one arc against the whole of the
23427
+ // facing arc, and snapping repeats that for the vertices it accepted, so a scan
23428
+ // per query costs the product of the two arcs' lengths. That is unnoticeable
23429
+ // where a mosaic's arcs run between neighboring junctions a few vertices apart,
23430
+ // but a detailed boundary is one arc of thousands of vertices, and a mosaic of
23431
+ // those presents thousands of seams to walk: enough for the same repair to run
23432
+ // for many minutes.
23433
+ //
23434
+ // The index is built from live coordinates, and seam snapping moves coordinates
23435
+ // only once every seam has been measured, so one instance serves a whole pass
23436
+ // over a layer and must not outlive it.
23437
+ function ArcFootpoints(arcs, spherical) {
23438
+ var data = arcs.getVertexData();
23439
+ var indexes = new Map();
23440
+
23441
+ function getIndex(side) {
23442
+ if (!indexes.has(side)) {
23443
+ indexes.set(side, side.length > 1 ? buildIndex(side) : null);
23444
+ }
23445
+ return indexes.get(side);
23446
+ }
23447
+
23448
+ function buildIndex(side) {
23449
+ var index = new Flatbush(side.length - 1);
23450
+ for (var i = 0; i + 1 < side.length; i++) {
23451
+ var a = side[i].i, b = side[i + 1].i;
23452
+ index.add(
23453
+ Math.min(data.xx[a], data.xx[b]), Math.min(data.yy[a], data.yy[b]),
23454
+ Math.max(data.xx[a], data.xx[b]), Math.max(data.yy[a], data.yy[b]));
23455
+ }
23456
+ index.finish();
23457
+ return index;
23458
+ }
23459
+
23460
+ // The nearest point on @side within @maxDist of (x, y), with that distance, or
23461
+ // null if the arc stays clear of it. A segment within @maxDist has its nearest
23462
+ // point inside the query box, so its own bounding box meets that box: no
23463
+ // segment near enough is missed. Nothing beyond @maxDist is reported, which the
23464
+ // callers discarded anyway.
23465
+ this.closestPoint = function(x, y, side, maxDist) {
23466
+ var index = getIndex(side);
23467
+ if (!index) return null;
23468
+ var padX = getCoordinatePad(maxDist, y, spherical, true);
23469
+ var padY = getCoordinatePad(maxDist, y, spherical, false);
23470
+ var ids = index.search(x - padX, y - padY, x + padX, y + padY);
23471
+ var bestD2 = Infinity, bestX = 0, bestY = 0;
23472
+ for (var k = 0; k < ids.length; k++) {
23473
+ var i = ids[k];
23474
+ var ax = data.xx[side[i].i], ay = data.yy[side[i].i];
23475
+ var dx = data.xx[side[i + 1].i] - ax, dy = data.yy[side[i + 1].i] - ay;
23476
+ var len2 = dx * dx + dy * dy;
23477
+ var t = len2 > 0 ? ((x - ax) * dx + (y - ay) * dy) / len2 : 0;
23478
+ if (t < 0) t = 0;
23479
+ if (t > 1) t = 1;
23480
+ var fx = ax + t * dx, fy = ay + t * dy;
23481
+ var d2 = (x - fx) * (x - fx) + (y - fy) * (y - fy);
23482
+ if (d2 < bestD2) {
23483
+ bestD2 = d2;
23484
+ bestX = fx;
23485
+ bestY = fy;
23486
+ }
23487
+ }
23488
+ if (bestD2 === Infinity) return null;
23489
+ var distance = spherical ?
23490
+ fastLonLatDistance(x, y, bestX, bestY) :
23491
+ distance2D$1(x, y, bestX, bestY);
23492
+ if (!isFinite(distance) || distance > maxDist) return null;
23493
+ return {x: bestX, y: bestY, distance: distance};
23494
+ };
23495
+ }
23496
+
23497
+ var MIN_GAP_OWNERS = 3;
23498
+ var WIDTH_FACTOR = 4;
23499
+
23500
+
23501
+ // Split enclosed sliver gaps that border three or more features along the lines
23502
+ // midway between their neighbors' boundaries. The existing mosaic assignment can
23503
+ // then distribute the resulting pieces among those features instead of handing
23504
+ // the entire long gap to one owner.
23505
+ //
23506
+ // Only the boundaries of the gaps being repaired are examined, so the cost
23507
+ // scales with those gaps rather than with the full mosaic.
23508
+ // Returns a layer of cut lines added to the dataset.
23509
+ function partitionPolygonMosaicGaps(lyr, dataset, nodes, opts) {
23510
+ if (!lyr.shapes || lyr.shapes.length < MIN_GAP_OWNERS) return false;
23511
+ var mosaicIndex = new MosaicIndex(lyr, nodes, {flat: true});
23512
+ var sliverOpts = utils.extend({}, opts);
23513
+ if (sliverOpts.gap_width == null) {
23514
+ if (sliverOpts.gap_fill_area != null || sliverOpts.min_gap_area != null ||
23515
+ sliverOpts.min_area != null || sliverOpts.sliver_control != null) {
23516
+ if (sliverOpts.gap_fill_area == null && sliverOpts.min_gap_area == null &&
23517
+ sliverOpts.min_area == null) {
23518
+ sliverOpts.gap_fill_area = 'auto';
23519
+ }
23520
+ } else {
23521
+ sliverOpts.gap_width = 'auto';
23522
+ }
23523
+ }
23524
+ var filter = getSliverFilter(lyr, dataset, sliverOpts).filter;
23525
+ var gaps = mosaicIndex.getUnusedTileData(filter).filter(function(gap) {
23526
+ return countOwners(gap.boundary) >= MIN_GAP_OWNERS;
23527
+ });
23528
+ if (gaps.length === 0) return false;
23529
+
23530
+ var minWidth = getMinDivisibleGapWidth(lyr, nodes.arcs);
23531
+ var cuts = [];
23532
+ var partitioned = 0;
23533
+ gaps.forEach(function(gap) {
23534
+ var result = buildGapPartitionCuts(gap, nodes.arcs, minWidth);
23535
+ if (!result) return;
23536
+ var before = cuts.length;
23537
+ result.lines.forEach(function(line) {
23538
+ if (line.length < 2) return;
23539
+ cuts.push(line);
23540
+ });
23541
+ if (cuts.length > before) partitioned++;
23542
+ });
23543
+ if (cuts.length === 0) return false;
23544
+
23545
+ // Both mutations below have to be announced before either happens: undo
23546
+ // captures the dataset's arc collection and layer list together, and it needs
23547
+ // the originals -- the collection this replaces, and a layer list without the
23548
+ // temporary layer added here. Restoring the arc coordinates alone would leave
23549
+ // the dataset pointing at the merged collection, with layer shapes referring to
23550
+ // arc ids from the original one.
23551
+ noteDatasetWillChange(dataset, {operation: 'partitionGaps', unit: 'arcs'});
23552
+ var firstCutArc = dataset.arcs.size();
23553
+ dataset.arcs = mergeArcs([dataset.arcs, new ArcCollection(cuts)]);
23554
+ // Keep cut arcs in a temporary line layer while addIntersectionCuts() nodes
23555
+ // them against the gap boundary. The caller removes this layer before polygon
23556
+ // cleaning and passes its updated arc ids to the mosaic node filter.
23557
+ var cutLayer = {
23558
+ geometry_type: 'polyline',
23559
+ shapes: cuts.map(function(line, i) {
23560
+ return [[firstCutArc + i]];
23561
+ })
23562
+ };
23563
+ dataset.layers.push(cutLayer);
23564
+ markDatasetChanged(dataset, {operation: 'partitionGaps', unit: 'arcs'});
23565
+ verbose(utils.format('Partitioned %s multi-feature interior gap%s',
23566
+ partitioned, utils.pluralSuffix(partitioned)));
23567
+ return cutLayer;
23568
+ }
23569
+
23570
+ function countOwners(boundary) {
23571
+ var owners = {};
23572
+ boundary.forEach(function(o) { owners[o.shapeId] = true; });
23573
+ return Object.keys(owners).length;
23574
+ }
23575
+
23576
+ // Divide a gap along the centerlines between the boundary runs that face each
23577
+ // other across it.
23578
+ //
23579
+ // The gap's boundary is a cyclic sequence of arcs, each owned by one adjacent
23580
+ // feature, so it decomposes into one run per owner. The dividing line between
23581
+ // two runs is the locus of points equidistant from both; taking the midpoint
23582
+ // between each run's vertices and its footpoint on the other run gives a line
23583
+ // that bends wherever either bank bends, and between two consecutive bends both
23584
+ // banks are straight, where the exact bisector is straight as well.
23585
+ //
23586
+ // Cost and output size track the boundary's own vertex count, so a gap that is
23587
+ // long and hairline costs no more than a compact one. (A sampled Voronoi medial
23588
+ // axis cannot make that promise: its circumcenters are only meaningful when the
23589
+ // sample spacing is finer than the channel is wide, so the work it takes grows
23590
+ // with the gap's length-to-width ratio.)
23591
+ function buildGapPartitionCuts(gap, arcs, minWidth) {
23592
+ var runs = getOwnerRuns(gap.boundary);
23593
+ if (runs.length < MIN_GAP_OWNERS) return null;
23594
+ var ring = gap.tile[0];
23595
+ // Both measures must be planar even when the source CRS is geographic: the
23596
+ // cuts are built in source-coordinate space.
23597
+ var area = Math.abs(geom.getPlanarPathArea(ring, arcs));
23598
+ var perimeter = geom.getPlanarPathPerimeter(ring, arcs);
23599
+ if (!(area > 0) || !(perimeter > 0)) return null;
23600
+ // For a long narrow polygon, 2A/P approximates its width.
23601
+ var gapWidth = area / perimeter * 2;
23602
+ if (gapWidth < minWidth) return null;
23603
+ // A modest multiplier gives facing banks enough reach where the gap is locally
23604
+ // wider, e.g. at a junction.
23605
+ var width = gapWidth * WIDTH_FACTOR;
23606
+ var ringPoints = getPathPoints(ring, arcs);
23607
+ var ringIndex = new SegmentIndex(ringPoints);
23608
+ var banks = runs.map(function(run) {
23609
+ return makeBank(getRunPoints(run.arcIds, arcs));
23610
+ });
23611
+ var lines = [];
23612
+ for (var i = 0; i < banks.length; i++) {
23613
+ for (var j = i + 1; j < banks.length; j++) {
23614
+ var line = buildCenterlineBetweenBanks(banks[i], banks[j], width, ringIndex,
23615
+ collectEndAnchors(banks, i, j, width));
23616
+ if (line.length >= 2) lines.push(line);
23617
+ }
23618
+ }
23619
+ if (lines.length === 0) return null;
23620
+ return {lines: anchorCutLines(lines, width, ringPoints)};
23621
+ }
23622
+
23623
+ function makeBank(points) {
23624
+ return {
23625
+ points: points,
23626
+ index: points.length > 1 ? new SegmentIndex(points) : null
23627
+ };
23628
+ }
23629
+
23630
+ // Make a set of centerlines divide the gap: every line has to reach the gap's
23631
+ // boundary at each end, either directly or through a shared junction with other
23632
+ // lines, or the mosaic treats it as a dangling arc and ignores it.
23633
+ //
23634
+ // A line ending on a vertex of the boundary is already joined to it, that vertex
23635
+ // being a node of the mosaic. Anywhere else, including a point part way along a
23636
+ // boundary segment, the line has to be projected past the boundary to cross it:
23637
+ // starting an extension on the boundary rather than crossing it creates no node
23638
+ // at all, and the cut is ignored.
23639
+ function anchorCutLines(lines, width, ringPoints) {
23640
+ var ringVertices = new Set(ringPoints.map(function(p) {
23641
+ return p[0] + '|' + p[1];
23642
+ }));
23643
+ var ends = [];
23644
+ lines.forEach(function(line, lineId) {
23645
+ [line[0], line[line.length - 1]].forEach(function(point, side) {
23646
+ ends.push({
23647
+ lineId: lineId,
23648
+ side: side,
23649
+ point: point,
23650
+ onBoundary: ringVertices.has(point[0] + '|' + point[1])
23651
+ });
23652
+ });
23653
+ });
23654
+ var connectors = [];
23655
+ var joined = new Uint8Array(ends.length);
23656
+ clusterCutEnds(ends, width * 3).forEach(function(cluster) {
23657
+ var hub = [0, 0];
23658
+ cluster.forEach(function(i) {
23659
+ hub[0] += ends[i].point[0];
23660
+ hub[1] += ends[i].point[1];
23661
+ });
23662
+ hub[0] /= cluster.length;
23663
+ hub[1] /= cluster.length;
23664
+ cluster.forEach(function(i) {
23665
+ joined[i] = 1;
23666
+ connectors.push([ends[i].point, hub]);
23667
+ });
23668
+ });
23669
+ var anchored = lines.map(function(line, lineId) {
23670
+ var out = line.concat();
23671
+ ends.forEach(function(end, i) {
23672
+ if (end.lineId !== lineId || joined[i] || end.onBoundary) return;
23673
+ var inward = end.side ? out[out.length - 2] : out[1];
23674
+ var beyond = projectPast$1(end.point, inward, width);
23675
+ if (!beyond) return;
23676
+ if (end.side) out.push(beyond);
23677
+ else out.unshift(beyond);
23678
+ });
23679
+ return out;
23680
+ });
23681
+ return anchored.concat(connectors);
23682
+ }
23683
+
23684
+ // Group line ends that meet in the interior of the gap, where three or more
23685
+ // centerlines converge on the point equidistant from three boundary runs. Ends
23686
+ // already on the boundary are not candidates, and neither is a pair of ends
23687
+ // from the same line.
23688
+ function clusterCutEnds(ends, maxDist) {
23689
+ var used = new Uint8Array(ends.length);
23690
+ var clusters = [];
23691
+ ends.forEach(function(end, i) {
23692
+ if (used[i] || end.onBoundary) return;
23693
+ var cluster = [];
23694
+ var stack = [i];
23695
+ used[i] = 1;
23696
+ while (stack.length) {
23697
+ var id = stack.pop();
23698
+ cluster.push(id);
23699
+ ends.forEach(function(other, j) {
23700
+ if (used[j] || other.onBoundary || other.lineId === ends[id].lineId) return;
23701
+ if (geom.distance2D(ends[id].point[0], ends[id].point[1],
23702
+ other.point[0], other.point[1]) <= maxDist) {
23703
+ used[j] = 1;
23704
+ stack.push(j);
23705
+ }
23706
+ });
23707
+ }
23708
+ var lineIds = {};
23709
+ cluster.forEach(function(id) { lineIds[ends[id].lineId] = true; });
23710
+ if (Object.keys(lineIds).length >= MIN_GAP_OWNERS) clusters.push(cluster);
23711
+ });
23712
+ return clusters;
23713
+ }
23714
+
23715
+ // One run per owner, in ring order. The ring is cyclic, so a run split across
23716
+ // the array ends is rejoined.
23717
+ function getOwnerRuns(boundary) {
23718
+ var runs = [];
23719
+ boundary.forEach(function(o) {
23720
+ var last = runs[runs.length - 1];
23721
+ if (last && last.shapeId === o.shapeId) {
23722
+ last.arcIds.push(o.arcId);
23723
+ } else {
23724
+ runs.push({shapeId: o.shapeId, arcIds: [o.arcId]});
23725
+ }
23726
+ });
23727
+ if (runs.length > 2 && runs[0].shapeId === runs[runs.length - 1].shapeId) {
23728
+ runs[0].arcIds = runs.pop().arcIds.concat(runs[0].arcIds);
23729
+ }
23730
+ return runs;
23731
+ }
23732
+
23733
+ function getPathPoints(ids, arcs) {
23734
+ var points = [];
23735
+ var iter = arcs.getShapeIter(ids);
23736
+ while (iter.hasNext()) {
23737
+ points.push([iter.x, iter.y]);
23738
+ }
23739
+ return points;
23740
+ }
23741
+
23742
+ function getRunPoints(arcIds, arcs) {
23743
+ var points = [];
23744
+ arcIds.forEach(function(arcId) {
23745
+ var iter = arcs.getArcIter(arcId);
23746
+ while (iter.hasNext()) {
23747
+ var last = points[points.length - 1];
23748
+ // consecutive arcs share their joining vertex
23749
+ if (!last || last[0] !== iter.x || last[1] !== iter.y) {
23750
+ points.push([iter.x, iter.y]);
23751
+ }
23752
+ }
23753
+ });
23754
+ return points;
23755
+ }
23756
+
23757
+ function buildCenterlineBetweenBanks(bankA, bankB, maxDist, ringIndex, anchors) {
23758
+ var events = [];
23759
+ // Each vertex contributes a midpoint, positioned along bank A so that
23760
+ // contributions from both banks interleave in one order.
23761
+ bankA.points.forEach(function(p, i) {
23762
+ var mid = crossGapMidpoint(p, bankB, maxDist, ringIndex);
23763
+ if (mid) events.push({pos: i, point: mid.point});
23764
+ });
23765
+ bankB.points.forEach(function(p, i) {
23766
+ var mid = crossGapMidpoint(p, bankA, maxDist, ringIndex);
23767
+ if (mid) events.push({pos: mid.pos, point: mid.point});
23768
+ });
23769
+ anchors.forEach(function(event) { events.push(event); });
23770
+ events.sort(function(a, b) { return a.pos - b.pos; });
23771
+ var points = [];
23772
+ events.forEach(function(o) {
23773
+ var last = points[points.length - 1];
23774
+ if (!last || last[0] !== o.point[0] || last[1] !== o.point[1]) {
23775
+ points.push(o.point);
23776
+ }
23777
+ });
23778
+ return points;
23779
+ }
23780
+
23781
+ // Where the centerline between two boundary runs meets the ends of the gap.
23782
+ // Ordered outside bank A's own vertex positions, so they bracket the midpoints
23783
+ // between the two banks.
23784
+ //
23785
+ // Midpoints alone do not reach the ends: approaching one, a vertex's nearest point
23786
+ // on the facing bank swings round towards the end itself, and the midpoints stop
23787
+ // describing the middle of the gap. Taking the ends from the boundary is both
23788
+ // exact and what makes the cut divide the gap, since a cut has to reach the
23789
+ // boundary to have any effect.
23790
+ function collectEndAnchors(banks, i, j, maxDist) {
23791
+ var points = banks[i].points;
23792
+ var out = [];
23793
+ // walking the boundary each way round from bank A: A's last vertex leads
23794
+ // forwards, its first vertex backwards
23795
+ [[j, points.length], [i, -1]].forEach(function(o) {
23796
+ var between = runsBetween(banks, o[0] === j ? i : j, o[0]);
23797
+ var point = null;
23798
+ if (between.length === 0) {
23799
+ // The two runs meet, pinching the gap shut, and their shared node is
23800
+ // equidistant from both.
23801
+ point = o[1] < 0 ? points[0] : points[points.length - 1];
23802
+ } else if (between.length === 1 && pathLength(between[0].points) <= maxDist) {
23803
+ // A single run short enough to be a cap rather than a third bank: the gap
23804
+ // ends across it, midway along.
23805
+ point = midpointOfPath(between[0].points);
23806
+ }
23807
+ if (point) out.push({pos: o[1], point: point});
23808
+ });
23809
+ return out;
23810
+ }
23811
+
23812
+ // The runs lying between two others, walking the boundary forwards.
23813
+ function runsBetween(banks, from, to) {
23814
+ var out = [];
23815
+ for (var k = (from + 1) % banks.length; k !== to; k = (k + 1) % banks.length) {
23816
+ out.push(banks[k]);
23817
+ }
23818
+ return out;
23819
+ }
23820
+
23821
+ function pathLength(points) {
23822
+ var len = 0;
23823
+ for (var i = 1; i < points.length; i++) {
23824
+ len += geom.distance2D(points[i - 1][0], points[i - 1][1],
23825
+ points[i][0], points[i][1]);
23826
+ }
23827
+ return len;
23828
+ }
23829
+
23830
+ function midpointOfPath(points) {
23831
+ var half = pathLength(points) / 2;
23832
+ var walked = 0;
23833
+ for (var i = 1; i < points.length; i++) {
23834
+ var seg = geom.distance2D(points[i - 1][0], points[i - 1][1],
23835
+ points[i][0], points[i][1]);
23836
+ if (walked + seg >= half) {
23837
+ var t = seg > 0 ? (half - walked) / seg : 0;
23838
+ return [
23839
+ points[i - 1][0] + (points[i][0] - points[i - 1][0]) * t,
23840
+ points[i - 1][1] + (points[i][1] - points[i - 1][1]) * t
23841
+ ];
23842
+ }
23843
+ walked += seg;
23844
+ }
23845
+ return points[points.length - 1];
23846
+ }
23847
+
23848
+ // The midpoint between a vertex of one boundary run and its footpoint on
23849
+ // another, where the two runs face each other across the gap.
23850
+ //
23851
+ // Two runs can instead meet at a node and carry on along the same side of the
23852
+ // gap. Those produce midpoints too, but ones lying on the boundary rather than
23853
+ // between two banks, and a cut along them shaves a hairline strip off one feature
23854
+ // to award it to another -- the very artifact partitioning exists to avoid. So a
23855
+ // midpoint counts only if it lies inside the gap, clear of the boundary by an
23856
+ // amount comparable to the separation it came from.
23857
+ function crossGapMidpoint(point, otherBank, maxDist, ringIndex) {
23858
+ if (!otherBank.index) return null;
23859
+ var foot = otherBank.index.closestPoint(point[0], point[1], maxDist);
23860
+ if (!foot || !(foot.distSq > 0)) return null;
23861
+ var mid = [(point[0] + foot.x) / 2, (point[1] + foot.y) / 2];
23862
+ // Half the separation is what a midpoint between facing banks has. A quarter
23863
+ // allows for a gap that narrows, bringing a third stretch of its boundary
23864
+ // closer to the midpoint than the banks it came from.
23865
+ if (ringIndex.hasSegmentWithin(mid[0], mid[1], Math.sqrt(foot.distSq) / 4)) {
23866
+ return null;
23867
+ }
23868
+ // Two runs can also face each other across a feature, in a gap that bends back
23869
+ // on itself.
23870
+ if (!ringIndex.containsPoint(mid[0], mid[1])) return null;
23871
+ return {point: mid, pos: foot.pos};
23872
+ }
23873
+
23874
+ // The vertex tests above run once per boundary vertex, and the boundary of a long
23875
+ // gap in detailed data carries thousands of them, so each test has to examine a
23876
+ // bounded number of segments rather than the whole path. @points is an open
23877
+ // polyline, or a closed ring for containsPoint().
23878
+ function SegmentIndex(points) {
23879
+ var n = points.length - 1;
23880
+ var index = new Flatbush(n);
23881
+ var xmax = -Infinity;
23882
+ for (var i = 0; i < n; i++) {
23883
+ var ax = points[i][0], ay = points[i][1];
23884
+ var bx = points[i + 1][0], by = points[i + 1][1];
23885
+ index.add(Math.min(ax, bx), Math.min(ay, by), Math.max(ax, bx), Math.max(ay, by));
23886
+ if (ax > xmax) xmax = ax;
23887
+ if (bx > xmax) xmax = bx;
23888
+ }
23889
+ index.finish();
23890
+
23891
+ // Nearest point on the path within @maxDist, with its position along the path
23892
+ // (segment index plus the fraction into that segment) so that midpoints from
23893
+ // either bank can be ordered on a common parameter. A segment within @maxDist
23894
+ // of the point has its nearest point inside the query box, so its own bounding
23895
+ // box meets that box: nothing near enough is missed.
23896
+ this.closestPoint = function(x, y, maxDist) {
23897
+ var ids = index.search(x - maxDist, y - maxDist, x + maxDist, y + maxDist);
23898
+ var maxSq = maxDist * maxDist;
23899
+ var best = null;
23900
+ for (var k = 0; k < ids.length; k++) {
23901
+ var i = ids[k];
23902
+ var ax = points[i][0], ay = points[i][1];
23903
+ var dx = points[i + 1][0] - ax, dy = points[i + 1][1] - ay;
23904
+ var len2 = dx * dx + dy * dy;
23905
+ var t = len2 > 0 ? ((x - ax) * dx + (y - ay) * dy) / len2 : 0;
23906
+ if (t < 0) t = 0;
23907
+ if (t > 1) t = 1;
23908
+ var fx = ax + t * dx, fy = ay + t * dy;
23909
+ var distSq = (x - fx) * (x - fx) + (y - fy) * (y - fy);
23910
+ if (distSq <= maxSq && (!best || distSq < best.distSq)) {
23911
+ best = {x: fx, y: fy, distSq: distSq, pos: i + t};
23912
+ }
23913
+ }
23914
+ return best;
23915
+ };
23916
+
23917
+ this.hasSegmentWithin = function(x, y, dist) {
23918
+ var ids = index.search(x - dist, y - dist, x + dist, y + dist);
23919
+ var distSq = dist * dist;
23920
+ for (var k = 0; k < ids.length; k++) {
23921
+ var i = ids[k];
23922
+ if (geom.pointSegDistSq2(x, y, points[i][0], points[i][1],
23923
+ points[i + 1][0], points[i + 1][1]) < distSq) {
23924
+ return true;
23925
+ }
23926
+ }
23927
+ return false;
23928
+ };
23929
+
23930
+ // Crossings of the ray running right from the point: odd means inside. A point
23931
+ // on the ring counts as inside, as the linear test this replaces had it.
23932
+ this.containsPoint = function(x, y) {
23933
+ var ids = index.search(x, y, xmax, y);
23934
+ var inside = false;
23935
+ for (var k = 0; k < ids.length; k++) {
23936
+ var i = ids[k];
23937
+ var ay = points[i][1], by = points[i + 1][1];
23938
+ if (ay > y === by > y) continue;
23939
+ var ax = points[i][0], bx = points[i + 1][0];
23940
+ if (ax + (y - ay) / (by - ay) * (bx - ax) > x) inside = !inside;
23941
+ }
23942
+ return inside;
23943
+ };
23944
+ }
23945
+
23946
+ // The narrowest gap worth dividing, in coordinate units: the automatic
23947
+ // gap-width, one hundredth of the median distance between vertices.
23948
+ //
23949
+ // Dividing a gap is only an improvement while the gap has a width worth
23950
+ // dividing. Awarding a whole gap to one feature moves that feature's boundary
23951
+ // across to the far bank, by the width of the gap; dividing it instead gives
23952
+ // every neighbor a strip of its own, and the boundary between two strips runs
23953
+ // half the gap's width from the bank it was cut from, out and back. Wide gap:
23954
+ // the first is a visible displacement and the second is a fair division. Gap a
23955
+ // few millimeters wide: the first is imperceptible, and the second decorates
23956
+ // several features with hairline slivers, which is worse for having more of them.
23957
+ //
23958
+ // A layer's median segment length is the scale at which its boundaries were
23959
+ // drawn, so the same fraction of it that serves as the default gap-width -- what
23960
+ // mapshaper already treats as too narrow to be intended -- marks where dividing
23961
+ // stops being worth it. This is deliberately independent of the gap-width in
23962
+ // effect: raising that to fill wider gaps should not coarsen the data's own scale.
23963
+ function getMinDivisibleGapWidth(lyr, arcs) {
23964
+ var width = getMedianPolygonSegmentLength(lyr, arcs) *
23965
+ GAP_WIDTH_SEGMENT_FRACTION;
23966
+ // That median comes back in meters for geographic coordinates, whereas cuts
23967
+ // are built in the source coordinate space. Converting it back with the same
23968
+ // test it was measured under keeps the two in step.
23969
+ return arcs.isPlanar() ? width : metersToDegrees(width);
23970
+ }
23971
+
23972
+ function projectPast$1(from, toward, distance) {
23973
+ var dx = from[0] - toward[0];
23974
+ var dy = from[1] - toward[1];
23975
+ var len = Math.sqrt(dx * dx + dy * dy);
23976
+ if (!(len > 0)) return null;
23977
+ return [
23978
+ from[0] + dx / len * distance,
23979
+ from[1] + dy / len * distance
23980
+ ];
23981
+ }
23982
+
22516
23983
  cmd.cleanLayers = cleanLayers;
22517
23984
 
22518
23985
  function cleanLayers(layers, dataset, optsArg) {
@@ -22521,6 +23988,27 @@
22521
23988
  var deepClean = !opts.only_arcs;
22522
23989
  var pathClean = utils.some(layers, layerHasPaths);
22523
23990
  var nodes;
23991
+ // Collapsing duplicate boundaries runs unconditionally, and only ever merges
23992
+ // banks that differ by floating-point rounding. Left in place, such a seam
23993
+ // becomes an enclosed sliver that gap filling awards to one neighbor, giving
23994
+ // that feature a zero-width spike along the shared border.
23995
+ if (pathClean && dataset.arcs) {
23996
+ profileStart('collapseDuplicateBoundaries');
23997
+ noteArcsWillChange(dataset.arcs, {operation: 'clean-closeGaps'});
23998
+ var polygonLayers = layers.filter(function(lyr) {
23999
+ return lyr.geometry_type == 'polygon';
24000
+ });
24001
+ polygonLayers.forEach(function(lyr) {
24002
+ noteLayerWillChange(lyr, {operation: 'clean-closeGaps', unit: 'arc-ids'});
24003
+ });
24004
+ if (collapseDuplicateBoundaries(layers, dataset)) {
24005
+ markArcsChanged(dataset.arcs, {operation: 'clean-closeGaps'});
24006
+ polygonLayers.forEach(function(lyr) {
24007
+ markLayerChanged(lyr, {operation: 'clean-closeGaps', unit: 'arc-ids'});
24008
+ });
24009
+ }
24010
+ profileEnd('collapseDuplicateBoundaries');
24011
+ }
22524
24012
  if (opts.debug) {
22525
24013
  addIntersectionCuts(dataset, opts);
22526
24014
  profileEnd('cleanLayers');
@@ -22538,9 +24026,49 @@
22538
24026
  nodes = addIntersectionCuts(dataset, opts);
22539
24027
  }
22540
24028
  if (lyr.geometry_type == 'polygon') {
24029
+ // Pinching the mouths of cracks that open to the outside encloses them,
24030
+ // so that the passes below fill or divide them like any other gap. It
24031
+ // comes first for that reason, and only the pinched vertices move.
24032
+ if (opts.close_outer_gaps) {
24033
+ profileStart('pinchOuterCrackMouths');
24034
+ noteArcsWillChange(dataset.arcs, {operation: 'clean-pinchGapMouths'});
24035
+ noteLayerWillChange(lyr,
24036
+ {operation: 'clean-pinchGapMouths', unit: 'arc-ids'});
24037
+ if (pinchOuterCrackMouths(lyr, dataset, nodes, opts)) {
24038
+ markArcsChanged(dataset.arcs, {operation: 'clean-pinchGapMouths'});
24039
+ markLayerChanged(lyr,
24040
+ {operation: 'clean-pinchGapMouths', unit: 'arc-ids'});
24041
+ // A pinched pair only touches: the cutter turns each touch into a
24042
+ // node, which is what makes the crack a tile of the mosaic. Undo
24043
+ // already holds everything this re-cut changes, captured above.
24044
+ withActiveUndoTransaction(null, function() {
24045
+ nodes = addIntersectionCuts(dataset, opts);
24046
+ });
24047
+ }
24048
+ profileEnd('pinchOuterCrackMouths');
24049
+ }
24050
+ delete opts._mosaic_cut_arcs;
24051
+ profileStart('partitionPolygonMosaicGaps');
24052
+ noteArcsWillChange(dataset.arcs, {operation: 'clean-partitionGaps'});
24053
+ var cutLayer = partitionPolygonMosaicGaps(lyr, dataset, nodes, opts);
24054
+ if (cutLayer) {
24055
+ markArcsChanged(dataset.arcs, {operation: 'clean-partitionGaps'});
24056
+ // The arc collection the partition just installed is a throwaway: undo
24057
+ // puts back the collection this command started with, which the
24058
+ // partition captured. Capturing this one too would store a second copy
24059
+ // of the arc table to no purpose. Every layer baseline the re-cut needs
24060
+ // was captured by the addIntersectionCuts() call above.
24061
+ withActiveUndoTransaction(null, function() {
24062
+ nodes = addIntersectionCuts(dataset, opts);
24063
+ });
24064
+ opts._mosaic_cut_arcs = collectArcIds(cutLayer.shapes);
24065
+ dataset.layers.splice(dataset.layers.indexOf(cutLayer), 1);
24066
+ }
24067
+ profileEnd('partitionPolygonMosaicGaps');
22541
24068
  profileStart('cleanPolygonLayerGeometry');
22542
24069
  cleanPolygonLayerGeometry(lyr, dataset, opts);
22543
24070
  profileEnd('cleanPolygonLayerGeometry');
24071
+ delete opts._mosaic_cut_arcs;
22544
24072
  } else if (lyr.geometry_type == 'polyline') {
22545
24073
  profileStart('cleanPolylineLayerGeometry');
22546
24074
  cleanPolylineLayerGeometry(lyr, dataset);
@@ -22568,7 +24096,7 @@
22568
24096
 
22569
24097
  function cleanPolygonLayerGeometry(lyr, dataset, opts) {
22570
24098
  // clean polygons by apply the 'dissolve2' function to each feature
22571
- opts = Object.assign({gap_fill_area: 'auto'}, opts);
24099
+ opts = withDefaultGapWidth(opts);
22572
24100
  var groups = lyr.shapes.map(function(shp, i) {
22573
24101
  return [i];
22574
24102
  });
@@ -22577,6 +24105,30 @@
22577
24105
  markLayerChanged(lyr, {operation: 'cleanPolygonLayerGeometry', unit: 'shapes'});
22578
24106
  }
22579
24107
 
24108
+ function withDefaultGapWidth(opts) {
24109
+ opts = Object.assign({}, opts);
24110
+ if (opts.gap_width != null) return opts;
24111
+ // Legacy area/sliver options: keep the historical gap_fill_area=auto default.
24112
+ if (opts.gap_fill_area != null || opts.min_gap_area != null ||
24113
+ opts.min_area != null || opts.sliver_control != null) {
24114
+ if (opts.gap_fill_area == null && opts.min_gap_area == null &&
24115
+ opts.min_area == null) {
24116
+ opts.gap_fill_area = 'auto';
24117
+ }
24118
+ return opts;
24119
+ }
24120
+ opts.gap_width = 'auto';
24121
+ return opts;
24122
+ }
24123
+
24124
+ function collectArcIds(shapes) {
24125
+ var ids = {};
24126
+ traversePaths(shapes, function(o) {
24127
+ ids[o.arcId < 0 ? ~o.arcId : o.arcId] = true;
24128
+ });
24129
+ return ids;
24130
+ }
24131
+
22580
24132
  // Remove duplicate points from multipoint geometries
22581
24133
  // TODO: consider checking for invalid coordinates
22582
24134
  function cleanPointLayerGeometry(lyr, dataset, opts) {
@@ -31872,13 +33424,27 @@ ${svg}
31872
33424
  var featureLength = bb.readUint32(offset);
31873
33425
  bb.setPosition(offset);
31874
33426
  var feature = Feature.getSizePrefixedRootAsFeature(bb);
31875
- geojsonFeature = fromFeature(id++, feature, headerMeta);
33427
+ geojsonFeature = readGeoJSONFeature(feature, headerMeta, id++);
31876
33428
  delete geojsonFeature.id;
31877
33429
  offset += SIZE_PREFIX_LEN + featureLength;
31878
33430
  return geojsonFeature;
31879
33431
  };
31880
33432
  }
31881
33433
 
33434
+ // The geometry field of a FlatGeobuf Feature is optional; fromFeature() assumes
33435
+ // it is present, so features without one are converted here.
33436
+ function readGeoJSONFeature(feature, headerMeta, id) {
33437
+ if (feature.geometry() === null) {
33438
+ return {
33439
+ type: 'Feature',
33440
+ id: id,
33441
+ geometry: null,
33442
+ properties: parseProperties$1(feature, headerMeta.columns)
33443
+ };
33444
+ }
33445
+ return fromFeature(id, feature, headerMeta);
33446
+ }
33447
+
31882
33448
  function buildHeaderWithCRS(headerMeta, crsMeta) {
31883
33449
  var builder = new Builder();
31884
33450
  var columnsOffset = createColumnsVector(builder, headerMeta.columns || []);
@@ -31923,24 +33489,31 @@ ${svg}
31923
33489
  function encodeCollection(source, columns, forcedType) {
31924
33490
  var headerMeta = null;
31925
33491
  var sink = null;
33492
+ // Null-geometry features encountered before the collection's geometry type is
33493
+ // known, and therefore before the header can be written.
33494
+ var deferred = [];
31926
33495
  for (var i = 0; i < source.length; i++) {
31927
33496
  var feature = source.getFeature(i);
33497
+ var properties = normalizeProperties(feature.properties, columns);
33498
+ if (!feature.geometry) {
33499
+ // A feature's geometry can go missing during export -- e.g. a path that
33500
+ // collapses to a single point at the output coordinate precision -- and a
33501
+ // layer can also contain null shapes to begin with. Write a record with
33502
+ // no geometry, matching what the GeoJSON and Shapefile exporters do.
33503
+ // Such a feature says nothing about the collection's geometry type, so it
33504
+ // must not make a homogeneous layer look mixed.
33505
+ var nullFeature = buildNullGeometryFeature(properties, columns);
33506
+ if (sink) {
33507
+ sink.append(nullFeature);
33508
+ } else {
33509
+ deferred.push(nullFeature);
33510
+ }
33511
+ continue;
33512
+ }
31928
33513
  var type = GeometryType[feature.geometry.type] || GeometryType.Unknown;
31929
33514
  if (!headerMeta) {
31930
- headerMeta = {
31931
- geometryType: forcedType === null ? type : forcedType,
31932
- columns: columns,
31933
- envelope: null,
31934
- featuresCount: source.length,
31935
- indexNodeSize: 0,
31936
- crs: null,
31937
- title: null,
31938
- description: null,
31939
- metadata: null
31940
- };
31941
- sink = new ByteSink(magicbytes.length + estimateEncodedBytes(source));
31942
- sink.append(magicbytes);
31943
- sink.append(buildHeader(headerMeta));
33515
+ headerMeta = getEncodedHeaderMeta(forcedType === null ? type : forcedType, columns, source.length);
33516
+ sink = initSink(source, headerMeta, deferred);
31944
33517
  } else if (forcedType === null && type != headerMeta.geometryType) {
31945
33518
  return null;
31946
33519
  }
@@ -31948,11 +33521,66 @@ ${svg}
31948
33521
  parseGC(feature.geometry) :
31949
33522
  parseGeometry(feature.geometry);
31950
33523
  omitRedundantGeometryType(geometry, headerMeta.geometryType);
31951
- sink.append(buildFeature(geometry, normalizeProperties(feature.properties, columns), headerMeta));
33524
+ sink.append(buildFeature(geometry, properties, headerMeta));
33525
+ }
33526
+ if (!sink) {
33527
+ // No feature had a geometry, so the collection has no geometry type.
33528
+ headerMeta = getEncodedHeaderMeta(forcedType === null ? GeometryType.Unknown : forcedType,
33529
+ columns, source.length);
33530
+ sink = initSink(source, headerMeta, deferred);
31952
33531
  }
31953
33532
  return sink.toBytes();
31954
33533
  }
31955
33534
 
33535
+ function getEncodedHeaderMeta(geometryType, columns, featuresCount) {
33536
+ return {
33537
+ geometryType: geometryType,
33538
+ columns: columns,
33539
+ envelope: null,
33540
+ featuresCount: featuresCount,
33541
+ indexNodeSize: 0,
33542
+ crs: null,
33543
+ title: null,
33544
+ description: null,
33545
+ metadata: null
33546
+ };
33547
+ }
33548
+
33549
+ function initSink(source, headerMeta, deferredFeatures) {
33550
+ var sink = new ByteSink(magicbytes.length + estimateEncodedBytes(source));
33551
+ sink.append(magicbytes);
33552
+ sink.append(buildHeader(headerMeta));
33553
+ for (var i = 0; i < deferredFeatures.length; i++) {
33554
+ sink.append(deferredFeatures[i]);
33555
+ }
33556
+ return sink;
33557
+ }
33558
+
33559
+ // Encodes a feature with attributes but no geometry, which FlatGeobuf supports
33560
+ // by omitting the Feature table's optional geometry field. buildFeature() always
33561
+ // writes a geometry, so the properties are encoded with it and then re-emitted:
33562
+ // property values are written in a typed binary layout that is better read from
33563
+ // one implementation than copied.
33564
+ function buildNullGeometryFeature(properties, columns) {
33565
+ var meta = {columns: columns};
33566
+ var withGeometry = buildFeature({xy: [], type: GeometryType.Unknown}, properties, meta);
33567
+ var bb = new ByteBuffer(withGeometry);
33568
+ var propertyBytes = Feature.getSizePrefixedRootAsFeature(bb).propertiesArray();
33569
+ var builder = new Builder();
33570
+ // Features are concatenated in the file and read in place, so every feature
33571
+ // buffer has to leave the next one 8-byte aligned or reading a Float64 array
33572
+ // of coordinates from it throws. Buffers holding coordinates are padded to a
33573
+ // multiple of 8 because of those arrays; a feature with only property bytes
33574
+ // needs the alignment requested explicitly.
33575
+ builder.prep(8, 0);
33576
+ var propertiesOffset = propertyBytes && propertyBytes.length > 0 ?
33577
+ Feature.createPropertiesVector(builder, propertyBytes) : 0;
33578
+ Feature.startFeature(builder);
33579
+ if (propertiesOffset) Feature.addProperties(builder, propertiesOffset);
33580
+ builder.finishSizePrefixed(Feature.endFeature(builder));
33581
+ return builder.asUint8Array();
33582
+ }
33583
+
31956
33584
  // Encoding the first feature is the only sample available before the buffer
31957
33585
  // has to be allocated; overshooting costs a resize, which is why it is loose.
31958
33586
  function estimateEncodedBytes(source) {
@@ -36724,11 +38352,15 @@ ${svg}
36724
38352
  },
36725
38353
  minGapAreaOpt = {
36726
38354
  old_alias: 'min-gap-area',
36727
- describe: 'threshold for filling gaps, e.g. 1.5km2 (default is small)',
38355
+ describe: '(deprecated) use gap-width= instead',
36728
38356
  type: 'area'
36729
38357
  },
38358
+ gapWidthOpt = {
38359
+ describe: 'fill gaps narrower than this width, e.g. 2m (default is automatic)',
38360
+ type: 'distance'
38361
+ },
36730
38362
  sliverControlOpt = {
36731
- describe: 'boost gap-fill-area of slivers (0-1, default is 1)',
38363
+ describe: '(deprecated) use gap-width= instead',
36732
38364
  type: 'number'
36733
38365
  },
36734
38366
  calcOpt = {
@@ -37456,6 +39088,11 @@ ${svg}
37456
39088
 
37457
39089
  parser.command('clean')
37458
39090
  .describe('fixes geometry issues, such as polygon overlaps and gaps')
39091
+ .option('close-outer-gaps', {
39092
+ describe: 'close cracks that open to the outside of the mosaic',
39093
+ type: 'flag'
39094
+ })
39095
+ .option('gap-width', gapWidthOpt)
37459
39096
  .option('gap-fill-area', minGapAreaOpt)
37460
39097
  .option('sliver-control', sliverControlOpt)
37461
39098
  .option('snap-interval', snapIntervalOpt)
@@ -37655,8 +39292,9 @@ ${svg}
37655
39292
  type: 'flag',
37656
39293
  describe: '[points] use 2D math to find centroids of latlong points'
37657
39294
  })
39295
+ .option('gap-width', gapWidthOpt)
37658
39296
  .option('gap-fill-area', {
37659
- describe: '[polygons] threshold for filling gaps, e.g. 1.5km2',
39297
+ describe: '[polygons] (deprecated) use gap-width= instead',
37660
39298
  type: 'area'
37661
39299
  })
37662
39300
  .option('sliver-control', sliverControlOpt)
@@ -37685,8 +39323,9 @@ ${svg}
37685
39323
  .option('calc', calcOpt)
37686
39324
  .option('sum-fields', sumFieldsOpt)
37687
39325
  .option('copy-fields', copyFieldsOpt)
39326
+ .option('gap-width', gapWidthOpt)
37688
39327
  .option('gap-fill-area', {
37689
- describe: 'threshold for filling gaps, e.g. 1.5km2',
39328
+ describe: '(deprecated) use gap-width= instead',
37690
39329
  type: 'area'
37691
39330
  })
37692
39331
  .option('sliver-control', sliverControlOpt)
@@ -45853,10 +47492,10 @@ ${svg}
45853
47492
  }
45854
47493
 
45855
47494
  function isClosedPath$1(arr) {
45856
- return isArrayOfPoints(arr) && arr.length > 3 && samePoint$1(arr[0], arr[arr.length - 1]);
47495
+ return isArrayOfPoints(arr) && arr.length > 3 && samePoint$2(arr[0], arr[arr.length - 1]);
45857
47496
  }
45858
47497
 
45859
- function samePoint$1(a, b) {
47498
+ function samePoint$2(a, b) {
45860
47499
  return a[0] == b[0] && a[1] == b[1];
45861
47500
  }
45862
47501
 
@@ -48205,12 +49844,12 @@ ${svg}
48205
49844
  return arr[arr.length - 1];
48206
49845
  }
48207
49846
 
48208
- function samePoint(a, b) {
49847
+ function samePoint$1(a, b) {
48209
49848
  return a && b && a[0] === b[0] && a[1] === b[1];
48210
49849
  }
48211
49850
 
48212
49851
  function isClosedPath(arr) {
48213
- return samePoint(arr[0], lastEl(arr));
49852
+ return samePoint$1(arr[0], lastEl(arr));
48214
49853
  }
48215
49854
 
48216
49855
  // remove likely rounding errors
@@ -48371,7 +50010,7 @@ ${svg}
48371
50010
  if (!nextPart) {
48372
50011
  return null;
48373
50012
  }
48374
- if (samePoint(ring[0], nextPart[0])) {
50013
+ if (samePoint$1(ring[0], nextPart[0])) {
48375
50014
  // done!
48376
50015
  ring.push(ring[0]); // close the ring
48377
50016
  return ring;
@@ -48420,7 +50059,7 @@ ${svg}
48420
50059
 
48421
50060
  function findPartStartingAt(parts, firstPoint) {
48422
50061
  for (var i=0; i<parts.length; i++) {
48423
- if (samePoint(parts[i][0], firstPoint)) {
50062
+ if (samePoint$1(parts[i][0], firstPoint)) {
48424
50063
  return parts[i];
48425
50064
  }
48426
50065
  }
@@ -48444,7 +50083,7 @@ ${svg}
48444
50083
  var part = [];
48445
50084
  var firstPoint = path[0];
48446
50085
  var lastPoint = lastEl(path);
48447
- var closed = samePoint(firstPoint, lastPoint);
50086
+ var closed = samePoint$1(firstPoint, lastPoint);
48448
50087
  var p, pp, y;
48449
50088
  for (var i=0, n=path.length; i<n; i++) {
48450
50089
  p = path[i];
@@ -48812,6 +50451,16 @@ ${svg}
48812
50451
 
48813
50452
  var POLAR_BUFFER_MARGIN_DEGREES$1 = 1e-4;
48814
50453
 
50454
+ // Distance at which to place the vertices of a round join whose chords span
50455
+ // stepAngle degrees, so that the chord MIDPOINTS -- not the vertices -- lie on
50456
+ // the offset circle of radius dist. The join then brackets the true arc instead
50457
+ // of being inscribed in it (see makeInscribedRoundJoin). The correction grows
50458
+ // with the step angle, so a gentle bend is barely moved and a sharp corner gets
50459
+ // the full ~0.5% of dist (at the default 8 segments/quadrant).
50460
+ function circumscribedJoinDist(dist, stepAngle) {
50461
+ return dist / Math.cos(stepAngle / 2 / R2D$8);
50462
+ }
50463
+
48815
50464
 
48816
50465
  // Returns a function for generating GeoJSON MultiPolygon geometries
48817
50466
  function getPolylineBufferMaker(dataset, opts) {
@@ -48821,6 +50470,7 @@ ${svg}
48821
50470
  var getOffsetPoint = getOffsetFunction(crs, opts);
48822
50471
  var roundJoinSegsPerQuadrant = opts.quad_segs >= 2 ? opts.quad_segs : 8;
48823
50472
  var roundJoinSegAngle = 90 / roundJoinSegsPerQuadrant;
50473
+ var circumscribeJoins = !!opts.circumscribe_joins; // see makeInscribedRoundJoin
48824
50474
  // Max arc step (degrees) for the coarse concave bridge (makeCoarseConcaveJoin),
48825
50475
  // the optional low-resolution alternative to makeConcaveJoin in
48826
50476
  // traceCleanOffsetSide. Larger = fewer points = faster dissolve. NOTE: a
@@ -49994,14 +51644,36 @@ ${svg}
49994
51644
  [getOffsetPoint(x, y, startDir + 180, dist)];
49995
51645
  }
49996
51646
 
49997
- // Inscribed round join: vertices on the offset arc at equal angle steps,
49998
- // each at the true offset distance (geodesic or planar).
51647
+ // Round join: an equal-angle-step polygonal approximation of the arc between
51648
+ // the incoming and outgoing offset edges, replacing the incoming edge's end
51649
+ // (p2Prev) and the outgoing edge's start (p1).
51650
+ //
51651
+ // Inscribed (default): vertices at the true offset distance, stepping from the
51652
+ // incoming perpendicular round to the outgoing one. Because its chords cut the
51653
+ // corner, the join lies INSIDE the true circular offset, falling short of it by
51654
+ // up to one sagitta (~0.5% of dist at the default 8 segments/quadrant).
51655
+ //
51656
+ // Circumscribed (circumscribe_joins): the same vertices, moved out to
51657
+ // circumscribedJoinDist so the chords span the offset circle instead of cutting
51658
+ // inside it. The LAST vertex stays at the true distance because the outgoing
51659
+ // straight edge attaches there: moving it radially outward would carry that edge
51660
+ // outward too, and since it also seeds the elbow joins of the segments that
51661
+ // follow, the bias propagates into a collar of offset-vs-source overshoot along
51662
+ // the whole outline -- which the fill-gaps mask cannot tell from a real gap fill
51663
+ // (its coast test tolerance is itself calibrated to the join sagitta). Leaving
51664
+ // that one vertex on the circle costs some accuracy: the final chord still dips
51665
+ // inside by about half a sagitta, so a gap tip whose closure happens to fall on
51666
+ // that chord is still short by ~0.25% of dist. Raise quad-segs to shrink it.
49999
51667
  function makeInscribedRoundJoin(cx, cy, startBearing, arcAngle, dist) {
50000
51668
  var pointCount = Math.max(1, Math.round(arcAngle / roundJoinSegAngle));
50001
51669
  var stepAngle = arcAngle / pointCount;
50002
51670
  var points = [];
50003
- for (var i = 1; i <= pointCount; i++) {
50004
- points.push(getOffsetPoint(cx, cy, startBearing + stepAngle * i, dist));
51671
+ var i;
51672
+ var outerDist = circumscribeJoins ?
51673
+ circumscribedJoinDist(dist, stepAngle) : dist;
51674
+ for (i = 1; i <= pointCount; i++) {
51675
+ points.push(getOffsetPoint(cx, cy, startBearing + stepAngle * i,
51676
+ i == pointCount ? dist : outerDist));
50005
51677
  }
50006
51678
  return points;
50007
51679
  }
@@ -50610,10 +52282,24 @@ ${svg}
50610
52282
  if (filterOpts.skipShape && filterOpts.skipShape(srcShape, srcArcs)) return bufShape;
50611
52283
  var intervalPct = simplifyFn ? simplifyFn(distance) / distance : 0;
50612
52284
  return filterOutlineArtifactHolesFromShape(bufShape, bufArcs, srcShape,
50613
- srcArcs, distance, intervalPct, sagPct, oneSided, sideOpts, spherical);
52285
+ srcArcs, distance, intervalPct, sagPct, oneSided, sideOpts, spherical,
52286
+ filterOpts.coverageIndex || null);
50614
52287
  });
50615
52288
  }
50616
52289
 
52290
+ // True if some other output feature covers the inside of this hole, making it
52291
+ // that feature's territory rather than an artifact of this one's
52292
+ // construction. @index is a PathIndex over every output shape: a hole is
52293
+ // outside its own feature by definition, so an enclosed probe can only be
52294
+ // another feature's.
52295
+ function holeIsNeighborTerritory(path, arcs, index) {
52296
+ var probes = getHoleInteriorProbes(getProjectedRingPoints(path, arcs, false));
52297
+ for (var i = 0; i < probes.length; i++) {
52298
+ if (index.pointIsEnclosed(probes[i])) return true;
52299
+ }
52300
+ return false;
52301
+ }
52302
+
50617
52303
  // Remove artifact rings left by dissolving the self-intersecting outline rings
50618
52304
  // made by the two-sided buffer fast path. The outline's concave-join loops can
50619
52305
  // survive the dissolve as spurious holes (and, degenerately, as zero-area rings
@@ -50623,8 +52309,12 @@ ${svg}
50623
52309
  // that its entire region lies inside the true buffer, and kept otherwise.
50624
52310
  // Filters one buffer shape (one source feature's dissolved outline); returns
50625
52311
  // the filtered shape, or null if nothing survives.
52312
+ // @coverageIndex: optional PathIndex over every output shape; a hole another
52313
+ // feature covers is kept whatever the distance test says. See the topological
52314
+ // polygon buffer, whose holes can be a neighbour's territory.
50626
52315
  function filterOutlineArtifactHolesFromShape(bufShape, bufArcs, srcShape,
50627
- srcArcs, distance, intervalPct, sagPct, oneSided, sideOpts, spherical) {
52316
+ srcArcs, distance, intervalPct, sagPct, oneSided, sideOpts, spherical,
52317
+ coverageIndex) {
50628
52318
  var sourceSegments = null;
50629
52319
  var sourceParts = null;
50630
52320
  var shape2 = (bufShape || []).filter(function(path) {
@@ -50637,6 +52327,9 @@ ${svg}
50637
52327
  return false;
50638
52328
  }
50639
52329
  if (!isHole) return true;
52330
+ if (coverageIndex && holeIsNeighborTerritory(path, bufArcs, coverageIndex)) {
52331
+ return true;
52332
+ }
50640
52333
  if (oneSided) {
50641
52334
  if (!sourceParts) {
50642
52335
  sourceParts = getSourcePathParts(srcShape, srcArcs, spherical);
@@ -52767,6 +54460,43 @@ ${svg}
52767
54460
  // floor/maxSpacing ratio at which small mosaics already sample cleanly.
52768
54461
  var FLOOR_DISTANCE_FRACTION = 0.1;
52769
54462
 
54463
+ // Soft cap on the number of entries in the boundary-segment grid, used to floor
54464
+ // the cell size (see buildSegmentGrid). The cell otherwise tracks the buffer
54465
+ // distance alone, so a small distance on a large input gives cells far shorter
54466
+ // than a single boundary segment and the index grows in inverse proportion to
54467
+ // the distance: a nationwide mosaic buffered at 1m spans ~1e7 cells, which costs
54468
+ // hundreds of megabytes and seconds of build time before any distance is
54469
+ // measured. A wider cell is always correct -- the 3x3 query window only requires
54470
+ // cell >= reach -- it just puts more segments in each bucket, so the budget
54471
+ // trades index size against bucket scan length. This value keeps the average
54472
+ // bucket near one segment on a nationwide mosaic, so the queries stay as cheap as
54473
+ // they are at large buffer distances.
54474
+ var GRID_INSERTION_BUDGET = 5e5;
54475
+
54476
+ // Rungs of the widening box search in treeGapAtPoint, and the factor between
54477
+ // them: four rungs a factor of 16 apart, so the search starts at 1/4096 of the
54478
+ // reach and climbs to it in three more steps. The step is coarse deliberately.
54479
+ // The rungs cost a geometric series dominated by the last, so a query that has
54480
+ // to climb the whole ladder pays only a few percent more than one that jumps
54481
+ // straight to the top, while a short ladder keeps the fixed per-search cost
54482
+ // down. Both numbers are fixed, and the last rung is always the full reach,
54483
+ // which is what bounds the query: it makes at most LADDER_RUNGS searches
54484
+ // whatever the input, and cannot widen past the reach.
54485
+ var LADDER_RUNGS = 4;
54486
+ var LADDER_STEP = 16;
54487
+ var LADDER_BASE = Math.pow(LADDER_STEP, LADDER_RUNGS - 1);
54488
+
54489
+ // Window size (segments in the 3x3 cell neighborhood) above which gapAtPoint
54490
+ // asks the tree instead of scanning. A scan rejects a segment belonging to the
54491
+ // query point's own feature in a couple of nanoseconds, so a window of a few
54492
+ // thousand still costs about what one tree query does -- that equivalence is
54493
+ // what the threshold marks, and below it the scan wins on fixed costs alone.
54494
+ // Purely a speed tradeoff: the two paths return the same distance, so this can
54495
+ // be retuned freely. Measured on two county mosaics, scanning up to this size
54496
+ // left the cheapest cases at their original speed while still handing over
54497
+ // early enough to keep the widest buffers off the scan's quadratic path.
54498
+ var WINDOW_SCAN_LIMIT = 3000;
54499
+
52770
54500
  // Soft target total site count. coarsen scales the gap-proportional spacing up
52771
54501
  // until the predicted total falls under this, keeping the Delaunay bounded on
52772
54502
  // dense shared-border mosaics while leaving sparse inputs at coarsen=1 (fully
@@ -52793,10 +54523,18 @@ ${svg}
52793
54523
  profileStart('medial:assembleChains');
52794
54524
  var chains = assembleChains(medial.segments, medial.coords);
52795
54525
  profileEnd('medial:assembleChains');
54526
+ profileStart('medial:recenter');
54527
+ chains = chains.map(function(chain) {
54528
+ return recenterMedialChain(chain, sites.grid);
54529
+ });
54530
+ profileEnd('medial:recenter');
52796
54531
  if (opts.smooth) {
52797
54532
  profileStart('medial:smooth');
52798
54533
  chains = chains.map(function(chain) {
52799
- return smoothMedialChain(chain, sites.grid);
54534
+ // Smoothing uses one kernel width for the whole chain (see
54535
+ // smoothMedialChain), so on a channel whose width varies it can bow the
54536
+ // line out of the narrow stretches; re-center again to pull it back.
54537
+ return recenterMedialChain(smoothMedialChain(chain, sites.grid), sites.grid);
52800
54538
  });
52801
54539
  profileEnd('medial:smooth');
52802
54540
  }
@@ -52814,7 +54552,9 @@ ${svg}
52814
54552
  for (var di = 0; di < coordDistances.length; di++) {
52815
54553
  if (coordDistances[di] > extendDist) extendDist = coordDistances[di];
52816
54554
  }
52817
- if (extendDist > 0) {
54555
+ // Local gap partitioning joins multi-owner junctions before extending only
54556
+ // the remaining outer endpoints; it opts out of this blanket extension.
54557
+ if (extendDist > 0 && !opts.no_extend) {
52818
54558
  chains = chains.map(function(chain) {
52819
54559
  return extendChainEndpoints(chain, extendDist);
52820
54560
  });
@@ -53036,6 +54776,150 @@ ${svg}
53036
54776
  return Math.sqrt(best);
53037
54777
  }
53038
54778
 
54779
+ // A medial vertex counts as off-center when its distance to the nearer bank
54780
+ // falls below this fraction of its distance to the opposite bank (1 = exactly
54781
+ // centered). Where a channel narrows past the site spacing the sampled Voronoi
54782
+ // can no longer resolve it: the triangles spanning the neck are slivers whose
54783
+ // circumcenters land on, or beyond, one bank, so the assembled chain zigzags
54784
+ // from side to side instead of running down the middle. Finer sampling cannot
54785
+ // cure this -- a channel that pinches to a point has zero width while the
54786
+ // spacing has a floor (see spacingFromGap) -- so the geometry is corrected
54787
+ // afterwards instead. 0.5 leaves the well-sampled interior (measured balance
54788
+ // ~0.9) untouched and catches the zigzag (~0.1 and below).
54789
+ var MEDIAL_BALANCE_TOLERANCE = 0.5;
54790
+
54791
+ // Pull medial vertices that are off-center, or have slipped inside a source
54792
+ // polygon, back onto the local centerline: each is replaced by the midpoint of
54793
+ // its nearest footpoint on either bank. That midpoint is equidistant from the two
54794
+ // banks by construction and lies between them, so it stays inside the channel
54795
+ // however narrow it gets; where the channel pinches shut both footpoints converge
54796
+ // on the neck and the line passes straight through it rather than bouncing off
54797
+ // the sides.
54798
+ //
54799
+ // Valid vertices are left exactly as sampled, so a well-resolved channel keeps
54800
+ // its true Voronoi geometry. Chain endpoints are also left alone: they sit
54801
+ // outside the overlap on purpose (hull rays, junction ends) so the cut-line can
54802
+ // reach the enclosing boundary, and re-centering them would pull the cut back
54803
+ // inside the tile it has to span.
54804
+ function recenterMedialChain(points, ctx) {
54805
+ if (!ctx || points.length < 3) return points;
54806
+ points = straightenChainTails(points, ctx);
54807
+ if (points.length < 3) return points;
54808
+ var out = [points[0]];
54809
+ for (var i = 1; i < points.length - 1; i++) {
54810
+ var p = recenteredVertex(ctx, points[i]);
54811
+ // Successive off-center vertices can re-center onto the same neck point;
54812
+ // keep one copy so the chain has no zero-length segments.
54813
+ if (!samePoint(p, out[out.length - 1])) out.push(p);
54814
+ }
54815
+ var last = points[points.length - 1];
54816
+ if (!samePoint(last, out[out.length - 1])) out.push(last);
54817
+ return out.length >= 2 ? out : points;
54818
+ }
54819
+
54820
+ // Straighten the runs of vertices at each end of a chain that lie inside a
54821
+ // source polygon. Such a vertex cannot be a medial point (its distance to that
54822
+ // polygon's boundary would be 0), and it cannot be re-centered either: these
54823
+ // runs trail past the mouth of a gap, where the banks converge into a border the
54824
+ // medial construction deliberately does not see (a shared arc partitions any
54825
+ // overlap by itself, so collectCandidateArcPaths prunes it), leaving no channel
54826
+ // to center them in. They are not dropped, because a chain has to poke out past
54827
+ // the gap it divides to node against the enclosing boundary (see
54828
+ // extendChainEndpoints): the run is collapsed to one straight segment, which
54829
+ // keeps the chain's reach and loses only its wandering.
54830
+ function straightenChainTails(points, ctx) {
54831
+ var last = points.length - 1;
54832
+ var lo = 0, hi = last;
54833
+ while (lo < hi && insideAnyPolygon(ctx, points[lo])) lo++;
54834
+ while (hi > lo && insideAnyPolygon(ctx, points[hi])) hi--;
54835
+ if (hi - lo < 1) return points; // no interior vertex survives: leave it alone
54836
+ if (lo === 0 && hi === last) return points;
54837
+ var out = points.slice(lo, hi + 1);
54838
+ if (lo > 0) out.unshift(points[0]);
54839
+ if (hi < last) out.push(points[last]);
54840
+ return out;
54841
+ }
54842
+
54843
+ function insideAnyPolygon(ctx, p) {
54844
+ var near = nearestSegment(ctx, p[0], p[1], -1);
54845
+ return near.id !== -1 && insideOwnerPolygon(ctx.seg, near.id, p[0], p[1]);
54846
+ }
54847
+
54848
+ // @p re-centered between its two nearest banks, or @p itself when it is already
54849
+ // a valid centerline point or only one feature is in range.
54850
+ var footA = [0, 0], footB = [0, 0]; // scratch, not retained
54851
+ function recenteredVertex(ctx, p) {
54852
+ var near = nearestSegment(ctx, p[0], p[1], -1);
54853
+ if (near.id === -1) return p;
54854
+ var far = nearestSegment(ctx, p[0], p[1], ctx.seg.feat[near.id]);
54855
+ if (far.id === -1) return p;
54856
+ // Only a pair of banks within their combined buffer reach forms a channel this
54857
+ // vertex could be the centerline of. Past the mouth of a gap the opposite bank
54858
+ // can be arbitrarily far away, and pulling the vertex to the midpoint of that
54859
+ // pair would move it hundreds of metres off any boundary.
54860
+ if (far.dist > ctx.seg.reach[near.id] + ctx.seg.reach[far.id]) return p;
54861
+ var offCenter = near.dist < far.dist * MEDIAL_BALANCE_TOLERANCE;
54862
+ if (!offCenter && !insideOwnerPolygon(ctx.seg, near.id, p[0], p[1])) return p;
54863
+ segmentFoot(ctx.seg, near.id, p[0], p[1], footA);
54864
+ segmentFoot(ctx.seg, far.id, p[0], p[1], footB);
54865
+ return [(footA[0] + footB[0]) / 2, (footA[1] + footB[1]) / 2];
54866
+ }
54867
+
54868
+ // True when (x, y) lies on the interior side of indexed segment @s, i.e. inside
54869
+ // the feature that owns it. Source rings are traversed with the polygon interior
54870
+ // on their right, and seg.side records whether the stored segment direction
54871
+ // matches its owner's traversal, so the sign of the cross product places the
54872
+ // point. Judged against the point's nearest segment, this is the test that
54873
+ // catches a medial vertex thrown out of a pinching gap into a neighbouring
54874
+ // polygon -- distance cannot: such a vertex is a channel width behind one bank
54875
+ // and two channel widths from the other, the same ratio as a merely off-center
54876
+ // point still inside the channel. A true medial vertex is never inside a source
54877
+ // polygon (its distance to that polygon's boundary would be 0).
54878
+ function insideOwnerPolygon(seg, s, x, y) {
54879
+ var dx = seg.x1[s] - seg.x0[s], dy = seg.y1[s] - seg.y0[s];
54880
+ var cross = dx * (y - seg.y0[s]) - dy * (x - seg.x0[s]);
54881
+ return cross * seg.side[s] < 0;
54882
+ }
54883
+
54884
+ // The nearest indexed source segment to (x, y), skipping segments owned by
54885
+ // @excludeFeat (-1 excludes nothing). Probes the 3x3 grid-cell neighborhood
54886
+ // (cell == max reach). id is -1 when the window holds no eligible segment.
54887
+ function nearestSegment(ctx, x, y, excludeFeat) {
54888
+ var seg = ctx.seg;
54889
+ var cx = ctx.colOf(x), cy = ctx.rowOf(y);
54890
+ var bestId = -1, best = Infinity;
54891
+ for (var gx = cx - 1; gx <= cx + 1; gx++) {
54892
+ for (var gy = cy - 1; gy <= cy + 1; gy++) {
54893
+ var bucket = ctx.grid.get(ctx.cellKey(gx, gy));
54894
+ if (!bucket) continue;
54895
+ for (var b = 0; b < bucket.length; b++) {
54896
+ var s = bucket[b];
54897
+ if (seg.feat[s] === excludeFeat) continue;
54898
+ var d2 = pointSegDistSq2(x, y, seg.x0[s], seg.y0[s], seg.x1[s], seg.y1[s]);
54899
+ if (d2 < best) { best = d2; bestId = s; }
54900
+ }
54901
+ }
54902
+ }
54903
+ return {id: bestId, dist: Math.sqrt(best)};
54904
+ }
54905
+
54906
+ // Closest point to (x, y) on indexed segment @s, clamped to its endpoints,
54907
+ // written into @out.
54908
+ function segmentFoot(seg, s, x, y, out) {
54909
+ var ax = seg.x0[s], ay = seg.y0[s];
54910
+ var dx = seg.x1[s] - ax, dy = seg.y1[s] - ay;
54911
+ var len2 = dx * dx + dy * dy;
54912
+ var t = len2 > 0 ? ((x - ax) * dx + (y - ay) * dy) / len2 : 0;
54913
+ if (t < 0) t = 0;
54914
+ if (t > 1) t = 1;
54915
+ out[0] = ax + t * dx;
54916
+ out[1] = ay + t * dy;
54917
+ }
54918
+
54919
+ function samePoint(a, b) {
54920
+ return a[0] === b[0] && a[1] === b[1];
54921
+ }
54922
+
53039
54923
  // Boundary sample spacing as a fraction of the local gap width: smaller gives a
53040
54924
  // smoother medial axis (more sites) in narrow channels. 0.5 keeps the spacing
53041
54925
  // at most half the gap, so a channel of width w has at least two samples per
@@ -53083,7 +54967,12 @@ ${svg}
53083
54967
  if (f === -1 && r === -1) continue; // arc not used by a buffered feature
53084
54968
  if (f !== -1 && r !== -1) continue; // shared interior border -- not a gap
53085
54969
  var pts = arcCoords(arcs, i);
53086
- if (pts.length >= 2) paths.push({owner: f !== -1 ? f : r, points: pts});
54970
+ // forward records whether the owner traverses the arc in the stored
54971
+ // direction, which is what fixes the interior side of its segments (see
54972
+ // insideOwnerPolygon).
54973
+ if (pts.length >= 2) {
54974
+ paths.push({owner: f !== -1 ? f : r, points: pts, forward: f !== -1});
54975
+ }
53087
54976
  }
53088
54977
  return paths;
53089
54978
  }
@@ -53136,8 +55025,15 @@ ${svg}
53136
55025
  // in a single pass, then we densify once.
53137
55026
  var grid = buildSegmentGrid$1(verts, coordDistances);
53138
55027
  var gaps = computeVertexGaps(grid, verts, coordDistances);
53139
- var coarsen = fitCoarsen(verts, gaps, coordDistances, spacingFloor);
53140
- var sites = densifyVertices(verts, gaps, coordDistances, spacingFloor, coarsen);
55028
+ // No contested channel anywhere: every vertex is either out of reach of every
55029
+ // other feature (open coast) or within the touching threshold of one (where the
55030
+ // source boundary already partitions). densify + Delaunay would only allocate
55031
+ // sites that keptSites then discards, so stop here. Single-feature inputs and
55032
+ // mosaics whose features are all farther than a buffer-diameter apart take this
55033
+ // path; a mosaic with even one real gap continues.
55034
+ if (!hasContestedGap(gaps, maxDistance)) return null;
55035
+ var coarsen = fitCoarsen(verts, gaps, coordDistances, spacingFloor, grid);
55036
+ var sites = densifyVertices(verts, gaps, coordDistances, spacingFloor, coarsen, grid);
53141
55037
  // Triangulate only the sites bordering a real gap. Its medial segments come
53142
55038
  // exclusively from cross-feature edges, and the well-shaped triangles that
53143
55039
  // bridge a gap have their apex within reach too (a far apex makes a thin
@@ -53154,65 +55050,232 @@ ${svg}
53154
55050
 
53155
55051
  // Bucket every boundary segment into a uniform grid so the nearest cross-feature
53156
55052
  // segment to an arbitrary point can be found by probing its 3x3 cell
53157
- // neighborhood. The cell equals the maximum reach (sum of the two largest buffer
53158
- // distances), so any in-reach segment is guaranteed to fall in that 3x3 window.
53159
- // Returns null when there is no positive reach. Reused for both the per-vertex
53160
- // gap (drives adaptive sampling) and the per-site keep test (gapAtPoint).
55053
+ // neighborhood. The cell is at least the maximum reach (sum of the two largest
55054
+ // buffer distances), so any in-reach segment is guaranteed to fall in that 3x3
55055
+ // window. Returns null when there is no positive reach. Reused for both the
55056
+ // per-vertex gap (drives adaptive sampling) and the per-site keep test
55057
+ // (gapAtPoint).
53161
55058
  function buildSegmentGrid$1(verts, coordDistances) {
53162
55059
  var paths = verts.paths;
53163
55060
  var maxDist = 0;
53164
55061
  for (var d = 0; d < coordDistances.length; d++) {
53165
55062
  if (coordDistances[d] > maxDist) maxDist = coordDistances[d];
53166
55063
  }
53167
- var cell = 2 * maxDist; // upper bound on any pair's reach
53168
- if (!(cell > 0)) return null;
55064
+ var reach = 2 * maxDist; // upper bound on any pair's reach
55065
+ if (!(reach > 0)) return null;
53169
55066
  var xmin = Infinity, ymin = Infinity, ymax = -Infinity;
55067
+ var totalLen = 0;
53170
55068
  paths.forEach(function(path) {
53171
55069
  var pts = path.points;
53172
55070
  for (var i = 0; i < pts.length; i++) {
53173
55071
  if (pts[i][0] < xmin) xmin = pts[i][0];
53174
55072
  if (pts[i][1] < ymin) ymin = pts[i][1];
53175
55073
  if (pts[i][1] > ymax) ymax = pts[i][1];
55074
+ if (i > 0) {
55075
+ var sdx = pts[i][0] - pts[i - 1][0];
55076
+ var sdy = pts[i][1] - pts[i - 1][1];
55077
+ totalLen += Math.sqrt(sdx * sdx + sdy * sdy);
55078
+ }
53176
55079
  }
53177
55080
  });
55081
+ // A segment occupies about len/cell cells, so the whole index is about
55082
+ // totalLen/cell entries; widening the cell to fit the budget bounds it (see
55083
+ // GRID_INSERTION_BUDGET). The reach is the floor, not the target: a cell
55084
+ // narrower than the reach would break the 3x3 query window.
55085
+ var cell = Math.max(reach, totalLen / GRID_INSERTION_BUDGET);
53178
55086
  // +1 cell index shift keeps probed -1 neighbors non-negative; rowSpan packs
53179
55087
  // (col, row) into a collision-free integer key.
53180
55088
  var rowSpan = Math.floor((ymax - ymin) / cell) + 3;
53181
55089
  function cellKey(cx, cy) { return (cx + 1) * rowSpan + (cy + 1); }
53182
55090
  function colOf(x) { return Math.floor((x - xmin) / cell); }
53183
55091
  function rowOf(y) { return Math.floor((y - ymin) / cell); }
53184
- var seg = {x0: [], y0: [], x1: [], y1: [], feat: [], reach: []};
55092
+ var seg = {x0: [], y0: [], x1: [], y1: [], feat: [], reach: [], side: []};
53185
55093
  var grid = new Map();
55094
+
55095
+ // Index a segment into every cell it crosses, column by column: within one
55096
+ // column the segment covers a single y-range (it is a straight line clipped to
55097
+ // that column's x-range), so the exact row span is two floor()s. This costs
55098
+ // O(len/cell) entries, where stamping the segment's bounding box instead costs
55099
+ // O((len/cell)^2) for a diagonal segment -- on a nationwide input buffered by a
55100
+ // few meters that quadratic term ran to ~1e9 entries and exceeded the runtime's
55101
+ // maximum Map size.
55102
+ function addSegment(ax, ay, bx, by, idx) {
55103
+ if (ax > bx) {
55104
+ var tx = ax; ax = bx; bx = tx;
55105
+ var ty = ay; ay = by; by = ty;
55106
+ }
55107
+ var dx = bx - ax, dy = by - ay;
55108
+ var cxa = colOf(ax), cxb = colOf(bx);
55109
+ for (var gx = cxa; gx <= cxb; gx++) {
55110
+ var yLo = ay, yHi = by;
55111
+ if (dx > 0) {
55112
+ // clip the segment to this column and take the y-range of the piece
55113
+ var xLo = Math.max(ax, xmin + gx * cell);
55114
+ var xHi = Math.min(bx, xmin + (gx + 1) * cell);
55115
+ yLo = ay + dy * (xLo - ax) / dx;
55116
+ yHi = ay + dy * (xHi - ax) / dx;
55117
+ }
55118
+ var gya = rowOf(Math.min(yLo, yHi));
55119
+ var gyb = rowOf(Math.max(yLo, yHi));
55120
+ for (var gy = gya; gy <= gyb; gy++) {
55121
+ var key = cellKey(gx, gy);
55122
+ var bucket = grid.get(key);
55123
+ if (bucket) bucket.push(idx); else grid.set(key, [idx]);
55124
+ }
55125
+ }
55126
+ }
55127
+
53186
55128
  paths.forEach(function(path) {
53187
55129
  var pts = path.points;
53188
55130
  var feat = path.owner;
53189
- var reach = coordDistances[feat];
55131
+ var featReach = coordDistances[feat];
55132
+ var side = path.forward === false ? -1 : 1;
53190
55133
  for (var k = 0; k + 1 < pts.length; k++) {
53191
55134
  var ax = pts[k][0], ay = pts[k][1], bx = pts[k + 1][0], by = pts[k + 1][1];
53192
55135
  var idx = seg.feat.length;
53193
55136
  seg.x0.push(ax); seg.y0.push(ay); seg.x1.push(bx); seg.y1.push(by);
53194
- seg.feat.push(feat); seg.reach.push(reach);
53195
- var cxa = colOf(Math.min(ax, bx)), cxb = colOf(Math.max(ax, bx));
53196
- var cya = rowOf(Math.min(ay, by)), cyb = rowOf(Math.max(ay, by));
53197
- for (var gx = cxa; gx <= cxb; gx++) {
53198
- for (var gy = cya; gy <= cyb; gy++) {
53199
- var key = cellKey(gx, gy);
53200
- var bucket = grid.get(key);
53201
- if (bucket) bucket.push(idx); else grid.set(key, [idx]);
53202
- }
53203
- }
55137
+ seg.feat.push(feat); seg.reach.push(featReach); seg.side.push(side);
55138
+ addSegment(ax, ay, bx, by, idx);
53204
55139
  }
53205
55140
  });
53206
- return {seg: seg, grid: grid, cellKey: cellKey, colOf: colOf, rowOf: rowOf};
55141
+ return {seg: seg, grid: grid, cellKey: cellKey, colOf: colOf, rowOf: rowOf,
55142
+ index: buildSegmentTree(seg), maxReach: maxDist};
55143
+ }
55144
+
55145
+ // R-tree over the same segments, used only by gapAtPoint and only where the
55146
+ // grid's query window has grown expensive (see there). The grid remains the
55147
+ // index of record: it answers every other probe in this file.
55148
+ function buildSegmentTree(seg) {
55149
+ var n = seg.feat.length;
55150
+ if (n === 0) return null;
55151
+ var index = new Flatbush(n);
55152
+ for (var i = 0; i < n; i++) {
55153
+ var x0 = seg.x0[i], x1 = seg.x1[i], y0 = seg.y0[i], y1 = seg.y1[i];
55154
+ index.add(Math.min(x0, x1), Math.min(y0, y1), Math.max(x0, x1), Math.max(y0, y1));
55155
+ }
55156
+ index.finish();
55157
+ return index;
53207
55158
  }
53208
55159
 
53209
55160
  // The channel width at (x, y): distance to the nearest different-feature segment
53210
55161
  // within their combined reach, or Infinity if none. Works for any point, not
53211
55162
  // just original vertices, so a long edge whose endpoints are out of reach but
53212
55163
  // whose middle crosses a gap is still measured correctly at the interior sites.
55164
+ // Answered from whichever index is cheaper here. Both return the same number,
55165
+ // so the choice is a speed decision only: the window scan costs one pass over
55166
+ // the segments in the point's grid cells, which is nothing on a sparse
55167
+ // neighborhood but grows as the square of the buffer distance (the cell is
55168
+ // floored at the reach), while the tree search does not grow that way but pays
55169
+ // fixed costs on every call.
53213
55170
  function gapAtPoint(ctx, x, y, feat, reachF) {
53214
- return nearestCrossFeatureSegmentDist(x, y, feat, reachF, ctx.seg, ctx.grid,
53215
- ctx.cellKey, ctx.colOf, ctx.rowOf, Infinity);
55171
+ var n = collectWindow(ctx, x, y);
55172
+ if (ctx.index && windowSize > WINDOW_SCAN_LIMIT) {
55173
+ return treeGapAtPoint(ctx, x, y, feat, reachF);
55174
+ }
55175
+ return scanWindow(ctx.seg, n, x, y, feat, reachF, Infinity);
55176
+ }
55177
+
55178
+ // Number of segments in the 3x3 cell window last collected by collectWindow.
55179
+ var windowSize = 0;
55180
+ // Scratch list of that window's buckets, reused across calls to keep the query
55181
+ // allocation-free. Safe because a query never re-enters gapAtPoint: the buckets
55182
+ // are consumed by scanWindow before anything else runs.
55183
+ var windowBuckets = [];
55184
+
55185
+ // Gather the buckets of the 3x3 cell neighborhood around (x, y) into the
55186
+ // scratch list, returning how many there are and setting windowSize to the
55187
+ // total segment count. The cell is at least the maximum reach, so this window
55188
+ // is guaranteed to hold every segment within reach of the point.
55189
+ function collectWindow(ctx, x, y) {
55190
+ var cx = ctx.colOf(x), cy = ctx.rowOf(y);
55191
+ var n = 0;
55192
+ windowSize = 0;
55193
+ for (var gx = cx - 1; gx <= cx + 1; gx++) {
55194
+ for (var gy = cy - 1; gy <= cy + 1; gy++) {
55195
+ var bucket = ctx.grid.get(ctx.cellKey(gx, gy));
55196
+ if (!bucket) continue;
55197
+ windowBuckets[n++] = bucket;
55198
+ windowSize += bucket.length;
55199
+ }
55200
+ }
55201
+ return n;
55202
+ }
55203
+
55204
+ // Exhaustive scan of the collected window: distance to the nearest
55205
+ // different-feature segment within their combined reach, or @best if none is
55206
+ // closer.
55207
+ function scanWindow(seg, n, x, y, feat, reachF, best) {
55208
+ for (var i = 0; i < n; i++) {
55209
+ var bucket = windowBuckets[i];
55210
+ for (var b = 0; b < bucket.length; b++) {
55211
+ var s = bucket[b];
55212
+ if (seg.feat[s] === feat) continue;
55213
+ var reach = reachF + seg.reach[s];
55214
+ var dsq = pointSegDistSq2(x, y, seg.x0[s], seg.y0[s], seg.x1[s], seg.y1[s]);
55215
+ if (dsq <= reach * reach) {
55216
+ var dist = Math.sqrt(dsq);
55217
+ if (dist < best) best = dist;
55218
+ }
55219
+ }
55220
+ }
55221
+ return best;
55222
+ }
55223
+
55224
+ // Same query answered from the R-tree, by widening a box search along a fixed
55225
+ // ladder of radii until the answer it holds is provably the global one.
55226
+ //
55227
+ // The invariant that makes an intermediate rung conclusive: a box of half-width
55228
+ // r contains the whole disc of radius r, so every segment closer than r to the
55229
+ // point is among the hits. If the nearest in-reach hit is itself within r, no
55230
+ // unreturned segment can beat it and the search can stop. Otherwise the answer
55231
+ // might lie in the annulus beyond r and the next rung has to look.
55232
+ //
55233
+ // The ladder is what keeps the cost sane in both directions. Starting small
55234
+ // makes the common case -- a point on one bank of a narrow gap, its neighbor a
55235
+ // few meters away -- cost one search of a tiny box, independent of the buffer
55236
+ // distance. Ending at the full reach makes the rare case -- a point with no
55237
+ // feature anywhere near it, so nothing can be proven short of exhausting the
55238
+ // reach -- cost the geometric sum of the rungs, a small multiple of going there
55239
+ // directly. The step is coarse so that there are only a handful of rungs.
55240
+ //
55241
+ // A previous attempt used neighbors() to take the k nearest instead. It was
55242
+ // much worse here, because a query that cannot find k in-reach candidates makes
55243
+ // neighbors() heap-walk every leaf in the whole reach disc -- and points with
55244
+ // no feature within reach are exactly what a coastline is made of.
55245
+ function treeGapAtPoint(ctx, x, y, feat, reachF) {
55246
+ var seg = ctx.seg;
55247
+ var index = ctx.index;
55248
+ // Nothing beyond the largest possible pair reach can pass the test below, so
55249
+ // the ladder tops out there rather than searching the whole extent.
55250
+ var maxDist = reachF + ctx.maxReach;
55251
+ var filter = function(s) { return seg.feat[s] !== feat; };
55252
+ var r = maxDist / LADDER_BASE;
55253
+ for (var k = 0; k < LADDER_RUNGS - 1; k++) {
55254
+ var best = nearestInBox(seg, index, x, y, r, reachF, filter);
55255
+ if (best <= r) return best;
55256
+ r *= LADDER_STEP;
55257
+ }
55258
+ // The top rung covers every segment that could be in reach at all, so its
55259
+ // answer is final even when nothing was found.
55260
+ return nearestInBox(seg, index, x, y, maxDist, reachF, filter);
55261
+ }
55262
+
55263
+ // Distance from (x, y) to the nearest segment that passes @filter and lies
55264
+ // within its pair reach, considering only segments whose bounding box meets the
55265
+ // box of half-width @r. Infinity if there is none.
55266
+ function nearestInBox(seg, index, x, y, r, reachF, filter) {
55267
+ var ids = index.search(x - r, y - r, x + r, y + r, filter);
55268
+ var best = Infinity;
55269
+ for (var i = 0; i < ids.length; i++) {
55270
+ var s = ids[i];
55271
+ var reach = reachF + seg.reach[s];
55272
+ var dsq = pointSegDistSq2(x, y, seg.x0[s], seg.y0[s], seg.x1[s], seg.y1[s]);
55273
+ if (dsq <= reach * reach) {
55274
+ var dist = Math.sqrt(dsq);
55275
+ if (dist < best) best = dist;
55276
+ }
55277
+ }
55278
+ return best;
53216
55279
  }
53217
55280
 
53218
55281
  // Local gap at each original vertex (drives adaptive sampling, see
@@ -53239,31 +55302,6 @@ ${svg}
53239
55302
  return gaps;
53240
55303
  }
53241
55304
 
53242
- // Distance from (vx, vy) (owned by @feat, reach @reachF) to the nearest
53243
- // different-feature segment within their combined reach, or @best if none is
53244
- // closer. Probes the 3x3 grid-cell neighborhood (cell == max reach).
53245
- function nearestCrossFeatureSegmentDist(vx, vy, feat, reachF, seg, grid, cellKey,
53246
- colOf, rowOf, best) {
53247
- var cx = colOf(vx), cy = rowOf(vy);
53248
- for (var gx = cx - 1; gx <= cx + 1; gx++) {
53249
- for (var gy = cy - 1; gy <= cy + 1; gy++) {
53250
- var bucket = grid.get(cellKey(gx, gy));
53251
- if (!bucket) continue;
53252
- for (var b = 0; b < bucket.length; b++) {
53253
- var s = bucket[b];
53254
- if (seg.feat[s] === feat) continue;
53255
- var reach = reachF + seg.reach[s];
53256
- var dsq = pointSegDistSq2(vx, vy, seg.x0[s], seg.y0[s], seg.x1[s], seg.y1[s]);
53257
- if (dsq <= reach * reach) {
53258
- var dist = Math.sqrt(dsq);
53259
- if (dist < best) best = dist;
53260
- }
53261
- }
53262
- }
53263
- }
53264
- return best;
53265
- }
53266
-
53267
55305
  // True when medial vertex c lies in the buffer overlap of features fp and fq:
53268
55306
  // within fp's radius of an fp-owned source segment AND within fq's radius of an
53269
55307
  // fq-owned source segment. Measured against the actual source segments via the
@@ -53376,21 +55414,52 @@ ${svg}
53376
55414
  return {paths: layout, count: count};
53377
55415
  }
53378
55416
 
55417
+ // True when any vertex borders a real gap: within reach of another feature, but
55418
+ // farther than the touching threshold. Matching keptSites' keep rule, so when
55419
+ // this is false every densified site would be discarded and there is nothing for
55420
+ // the medial to cut.
55421
+ function hasContestedGap(gaps, maxDistance) {
55422
+ var touch = maxDistance * TOUCHING_GAP_FRACTION;
55423
+ for (var i = 0; i < gaps.length; i++) {
55424
+ if (isFinite(gaps[i]) && gaps[i] > touch) return true;
55425
+ }
55426
+ return false;
55427
+ }
55428
+
53379
55429
  // The spacing for a path segment: the tighter of its two endpoints' gap-derived
53380
55430
  // spacings (so a segment straddling a narrowing gap samples at the finer rate).
53381
- function segmentSpacing(path, k, gaps, maxSpacing, spacingFloor, coarsen) {
53382
- var sA = spacingFromGap(gaps[path.vids[k]], maxSpacing, spacingFloor, coarsen);
53383
- var sB = spacingFromGap(gaps[path.vids[k + 1]], maxSpacing, spacingFloor, coarsen);
53384
- return Math.min(sA, sB);
55431
+ // Infinity means "do not densify" (see spacingFromGap). When both endpoints are
55432
+ // open-coast but the segment is long enough that its middle could still pass
55433
+ // within reach of another feature, the midpoint is probed via @ctx so a
55434
+ // contested channel bounded by long unvertexed edges is not missed.
55435
+ function segmentSpacing(path, k, gaps, maxSpacing, spacingFloor, coarsen, ctx) {
55436
+ var gA = gaps[path.vids[k]], gB = gaps[path.vids[k + 1]];
55437
+ var sA = spacingFromGap(gA, maxSpacing, spacingFloor, coarsen);
55438
+ var sB = spacingFromGap(gB, maxSpacing, spacingFloor, coarsen);
55439
+ var s = Math.min(sA, sB);
55440
+ if (isFinite(s) || !ctx) return s;
55441
+ // both endpoints open: only a long segment can hide a contested middle
55442
+ // (shorter than the reach, either endpoint would have seen it)
55443
+ var a = path.points[k], b = path.points[k + 1];
55444
+ var dx = b[0] - a[0], dy = b[1] - a[1];
55445
+ if (dx * dx + dy * dy <= 4 * maxSpacing * maxSpacing) return s;
55446
+ var midGap = gapAtPoint(ctx, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2,
55447
+ path.owner, maxSpacing);
55448
+ return spacingFromGap(midGap, maxSpacing, spacingFloor, coarsen);
53385
55449
  }
53386
55450
 
53387
55451
  // Re-sample every candidate path: emit each original vertex (tagged with its
53388
55452
  // vid) plus interior points spaced by the local gap-derived spacing (see
53389
55453
  // segmentSpacing). Paths are open, so the last vertex has no following segment.
53390
- // Long edges are densified even where their endpoints are out of reach, so a
53391
- // contested middle is sampled; keptSites later prunes the points that turn out
53392
- // not to border a real gap.
53393
- function densifyVertices(verts, gaps, coordDistances, spacingFloor, coarsen) {
55454
+ // Open-coast segments (both endpoints out of reach of every other feature) are
55455
+ // not densified -- keptSites would discard those interior points, and densifying
55456
+ // them at spacing = buffer distance is what used to emit tens of millions of
55457
+ // sites along a nationwide coastline. A segment with one contested endpoint
55458
+ // still densifies at that endpoint's rate, so a channel that pinches shut is
55459
+ // sampled into its mouth. Long open-coast edges are midpoint-probed (see
55460
+ // segmentSpacing) so a contested channel whose bounding edges lack a vertex
55461
+ // within reach is still sampled.
55462
+ function densifyVertices(verts, gaps, coordDistances, spacingFloor, coarsen, ctx) {
53394
55463
  var coords = [];
53395
55464
  var owner = [];
53396
55465
  var origin = []; // vid for original vertices, -1 for interpolated points
@@ -53404,10 +55473,11 @@ ${svg}
53404
55473
  origin.push(path.vids[k]);
53405
55474
  if (k + 1 >= m) continue; // open path: no segment past the last vertex
53406
55475
  var b = path.points[k + 1];
53407
- var s = segmentSpacing(path, k, gaps, maxSpacing, spacingFloor, coarsen);
55476
+ var s = segmentSpacing(path, k, gaps, maxSpacing, spacingFloor, coarsen, ctx);
53408
55477
  var dx = b[0] - a[0], dy = b[1] - a[1];
53409
55478
  var len = Math.sqrt(dx * dx + dy * dy);
53410
- if (s > 0 && len > s) {
55479
+ // s == Infinity for open-coast segments (see spacingFromGap): skip
55480
+ if (s > 0 && isFinite(s) && len > s) {
53411
55481
  var steps = Math.floor(len / s);
53412
55482
  for (var t = 1; t <= steps; t++) {
53413
55483
  var f = t / (steps + 1);
@@ -53421,8 +55491,15 @@ ${svg}
53421
55491
  return {coords: coords, owner: owner, origin: origin};
53422
55492
  }
53423
55493
 
55494
+ // Spacing used to densify a segment endpoint. Open coast (no other feature
55495
+ // within reach) returns Infinity so densifyVertices emits no interior points --
55496
+ // those sites cannot shape the medial and used to dominate the site count on
55497
+ // large inputs. Touching/coincident borders keep the coarse buffer-distance
55498
+ // spacing (the source boundary already partitions them; fine sampling would only
55499
+ // flood the triangulation with collinear sites). Real gaps densify at a fraction
55500
+ // of the local width, floored and capped.
53424
55501
  function spacingFromGap(gap, maxSpacing, spacingFloor, coarsen) {
53425
- if (!isFinite(gap)) return maxSpacing;
55502
+ if (!isFinite(gap)) return Infinity;
53426
55503
  // A gap at or below the buffer's positional tolerance means the two features
53427
55504
  // effectively touch: there is no contested channel to run a medial down, and
53428
55505
  // the shared source boundary already partitions the overlap. Densifying it
@@ -53440,18 +55517,18 @@ ${svg}
53440
55517
  // interior points per segment). Pure counting, no Delaunay -- cheap enough to
53441
55518
  // binary-search coarsen against. Counts pre-keep sites (the densification work),
53442
55519
  // which is what coarsen actually bounds.
53443
- function predictSiteCount(verts, gaps, coordDistances, spacingFloor, coarsen) {
55520
+ function predictSiteCount(verts, gaps, coordDistances, spacingFloor, coarsen, ctx) {
53444
55521
  var total = verts.count; // every original vertex is emitted
53445
55522
  verts.paths.forEach(function(path) {
53446
55523
  var maxSpacing = coordDistances[path.owner];
53447
55524
  var m = path.vids.length;
53448
55525
  for (var k = 0; k + 1 < m; k++) {
53449
- var s = segmentSpacing(path, k, gaps, maxSpacing, spacingFloor, coarsen);
55526
+ var s = segmentSpacing(path, k, gaps, maxSpacing, spacingFloor, coarsen, ctx);
53450
55527
  var a = path.points[k];
53451
55528
  var b = path.points[k + 1];
53452
55529
  var dx = b[0] - a[0], dy = b[1] - a[1];
53453
55530
  var len = Math.sqrt(dx * dx + dy * dy);
53454
- if (s > 0 && len > s) total += Math.floor(len / s);
55531
+ if (s > 0 && isFinite(s) && len > s) total += Math.floor(len / s);
53455
55532
  }
53456
55533
  });
53457
55534
  return total;
@@ -53461,17 +55538,17 @@ ${svg}
53461
55538
  // count decreases monotonically as coarsen grows (spacing widens), so binary
53462
55539
  // search converges; capped because near-coincident gaps (gap ~ 0) can't be
53463
55540
  // thinned by coarsen and are bounded by spacingFloor instead.
53464
- function fitCoarsen(verts, gaps, coordDistances, spacingFloor) {
53465
- if (predictSiteCount(verts, gaps, coordDistances, spacingFloor, 1) <= SITE_BUDGET) {
55541
+ function fitCoarsen(verts, gaps, coordDistances, spacingFloor, ctx) {
55542
+ if (predictSiteCount(verts, gaps, coordDistances, spacingFloor, 1, ctx) <= SITE_BUDGET) {
53466
55543
  return 1;
53467
55544
  }
53468
55545
  var lo = 1, hi = 1024;
53469
- if (predictSiteCount(verts, gaps, coordDistances, spacingFloor, hi) > SITE_BUDGET) {
55546
+ if (predictSiteCount(verts, gaps, coordDistances, spacingFloor, hi, ctx) > SITE_BUDGET) {
53470
55547
  return hi; // even fully coarsened we can't fit; accept the floor-bounded count
53471
55548
  }
53472
55549
  for (var i = 0; i < 20; i++) {
53473
55550
  var mid = (lo + hi) / 2;
53474
- if (predictSiteCount(verts, gaps, coordDistances, spacingFloor, mid) > SITE_BUDGET) {
55551
+ if (predictSiteCount(verts, gaps, coordDistances, spacingFloor, mid, ctx) > SITE_BUDGET) {
53475
55552
  lo = mid;
53476
55553
  } else {
53477
55554
  hi = mid;
@@ -54692,6 +56769,15 @@ ${svg}
54692
56769
  }
54693
56770
  var output = buildPolygonBufferOutput(lyr, dataset, opts);
54694
56771
  var dataset2 = importGeoJSON(output.geojson, {type: 'polygon'});
56772
+ // A buffer is in the same CRS as its input, and the GeoJSON round trip above
56773
+ // does not carry one. Say so, because a caller that buffers this result again
56774
+ // has to parse a distance against it -- fill-gaps does exactly that when it
56775
+ // erodes the union of the dilated features. Without this, such a distance can
56776
+ // only be resolved for a lat-long input, where getDatasetCrsInfo guesses
56777
+ // wgs84 from the coordinate bounds; on projected input there is nothing to
56778
+ // guess from and the buffer failed with 'Unable to convert kilometers to
56779
+ // unknown coordinates'.
56780
+ setDatasetCrsInfo(dataset2, getDatasetCrsInfo(dataset));
54695
56781
  if (spherical) {
54696
56782
  splitAntimeridianBufferDataset(dataset2);
54697
56783
  if (output.dissolveAfterSplit) {
@@ -54703,7 +56789,21 @@ ${svg}
54703
56789
  lyr, dataset, opts, spherical, {
54704
56790
  skipShape: function(shape, arcs) {
54705
56791
  return shapeHasFillInsideHole(shape, arcs);
54706
- }
56792
+ },
56793
+ // Under the topological path a hole is often a tile withheld from this
56794
+ // feature because it is a neighbour's territory (see
56795
+ // getTopologicalBufferTileIds). The distance test cannot tell those from
56796
+ // dissolve artifacts -- both lie inside this feature's own buffer -- so
56797
+ // it deleted them, giving this feature ground another feature also
56798
+ // claims. It got worse as the radius grew, since a bigger buffer leaves
56799
+ // a shallower ring behind: across 3108 US counties it double-covered
56800
+ // 7.6km2 at R=500m and 507km2 at R=5km, mostly Virginia's independent
56801
+ // cities inside the counties enclosing them, and on 01_thin_gap_detail2
56802
+ // at 5km it consumed one of the two features outright. Holes that no
56803
+ // other feature covers are still the filter's to judge.
56804
+ coverageIndex: opts.topological ?
56805
+ new PathIndex(dataset2.layers[0].shapes.filter(Boolean), dataset2.arcs) :
56806
+ null
54707
56807
  });
54708
56808
  }
54709
56809
  cullSubTolerancePolygonArtifacts(dataset2, lyr, dataset, opts);
@@ -54878,17 +56978,30 @@ ${svg}
54878
56978
  // Mouth-gating mask: closing of the land by the mouth radius r (dilate by r,
54879
56979
  // union, erode by r). Built separately from the fill dilation because the
54880
56980
  // mouth threshold stays at the mouth size while the fill reaches further.
56981
+ // Two settings keep the dilation from falling short at the tips of a hairline
56982
+ // gap, which is where the mouth of the fill ends up: whatever the dilation fails
56983
+ // to bridge there is left as a residual indentation at the mouth, in proportion
56984
+ // to r, and no later stage can recover it.
56985
+ // - circumscribe_joins: the round joins are otherwise inscribed in the true
56986
+ // circular offset and fall a join sagitta short of it (~0.5% of r at the
56987
+ // default 8 segments/quadrant). See makeInscribedRoundJoin: the correction
56988
+ // costs no vertices, in contrast to raising quad-segs, whose cost is linear
56989
+ // in vertices through every stage below (dilate, union, erode, mask).
56990
+ // - tolerance 0: the default pre-simplification budget is 1% of the radius,
56991
+ // several times a hairline gap's width, so it smooths away the very tips
56992
+ // being bridged (and undoes the join correction with them). Skipping it
56993
+ // costs only a few percent here, since the joins dominate the vertex count.
54881
56994
  var dilatedAtR = makePolygonBuffer(lyr, dataset,
54882
- Object.assign({}, baseOpts, {radius: mouthRadius, topological: false}));
56995
+ Object.assign({}, baseOpts, {radius: mouthRadius, topological: false,
56996
+ circumscribe_joins: true, tolerance: 0}));
54883
56997
  var union = unionBufferDataset(dilatedAtR, baseOpts);
54884
56998
  if (!union || !union.arcs) return dilated;
54885
56999
  // tolerance: 0 disables the buffer's Douglas-Peucker pre-simplification for the
54886
57000
  // erosion. Pre-simplifying before an INWARD offset can push a simplified concave
54887
57001
  // vertex past its neighbours and self-intersect the eroded ring; on a large,
54888
57002
  // dense outline (e.g. a whole-country coastline) the dissolve then keeps the
54889
- // wrong side and the ring collapses, wiping the gap-fill extent. The dilation
54890
- // stays pre-simplified (outward offsets don't fold this way) so only the fragile
54891
- // erode pays the full-resolution cost.
57003
+ // wrong side and the ring collapses, wiping the gap-fill extent. (The dilation
57004
+ // skips pre-simplification for its own reason -- see above.)
54892
57005
  var closing = makePolygonBuffer(union.layers[0], union,
54893
57006
  Object.assign({}, baseOpts, {radius: '-' + mouthRadius, topological: false,
54894
57007
  tolerance: 0}));
@@ -54976,8 +57089,12 @@ ${svg}
54976
57089
  // clears the arc/mouth tortuosity clause despite little depth. Requiring the patch
54977
57090
  // depth to reach this fraction of the mouth radius drops both while keeping real
54978
57091
  // inlets (which run far deeper than their mouth radius; on the Columbia the kept
54979
- // gaps reach depth/r >= 1.3, the shallow scallops only ~0.2).
54980
- var INLET_MIN_ABS_DEPTH = 0.3; // min patch depth as a fraction of the mouth radius
57092
+ // gaps reach depth/r >= 1.3, the shallow scallops only ~0.2). The margin matters,
57093
+ // because a coast-hugging strip only a few dozen metres thick can still measure a
57094
+ // large depth: depth is taken from the mouth CHORD, and that chord cuts straight
57095
+ // across whatever bay the strip follows. Those strips land around depth/r ~0.3, so
57096
+ // the floor sits above them rather than on them.
57097
+ var INLET_MIN_ABS_DEPTH = 0.4; // min patch depth as a fraction of the mouth radius
54981
57098
 
54982
57099
  // Island-bridge classification of a fill that joins >= 2 source parts, one small
54983
57100
  // (see bridgesSmallIsland). Two independent signatures mark an island bridge:
@@ -55135,9 +57252,14 @@ ${svg}
55135
57252
  var islandDataset = importGeoJSON(
55136
57253
  {type: 'GeometryCollection', geometries: islandGeoms}, {type: 'polygon'});
55137
57254
  setDatasetCrsInfo(islandDataset, crsInfo);
57255
+ // circumscribe_joins matches the closing's dilation (see makeGapFillPolygonBuffer):
57256
+ // this buffer has to cancel the island's mouth-radius reach, so if it grew a join
57257
+ // sagitta less than that dilation did, a hairline of bridge would survive the
57258
+ // erase and reconnect the island.
55138
57259
  var islandBuf = makePolygonBuffer(islandDataset.layers[0], islandDataset,
55139
57260
  Object.assign({}, opts, {fill_gaps: false, max_widening: null,
55140
- radius: mouthRadiusStr, topological: false, no_replace: true}));
57261
+ radius: mouthRadiusStr, topological: false, no_replace: true,
57262
+ circumscribe_joins: true}));
55141
57263
  if (!islandBuf || !islandBuf.arcs) return;
55142
57264
  var bridgeDataset = importGeoJSON(
55143
57265
  {type: 'GeometryCollection', geometries: bridgeGeoms}, {type: 'polygon'});
@@ -55413,6 +57535,10 @@ ${svg}
55413
57535
  if (coords.length === 0) return null;
55414
57536
  var unionDataset = getBufferDataset(coords);
55415
57537
  if (!unionDataset.arcs) return null;
57538
+ // getBufferDataset builds from bare coordinates, so carry the CRS across:
57539
+ // fill-gaps buffers this union again to erode it, and that needs a CRS to
57540
+ // resolve a distance given in real-world units.
57541
+ setDatasetCrsInfo(unionDataset, getDatasetCrsInfo(dataset));
55416
57542
  dissolveBufferDataset2(unionDataset, Object.assign({}, opts, {winding_fill: false}));
55417
57543
  return unionDataset;
55418
57544
  }
@@ -56193,22 +58319,22 @@ ${svg}
56193
58319
  var sourceIdIndex = getIdLookup(sourceIds);
56194
58320
  var bufferIdIndex = getIdToFeatureIdLookup(bufferIds);
56195
58321
  var sourceAreas = getSourceShapeAreas(shapes, sourceArcs);
56196
- var sourceInteriorPoints = getSourceInteriorPoints(shapes, sourceArcs);
56197
58322
  var ownerCtx = createTileOwnerContext(shapes, sourceArcs, sourceAreas,
56198
58323
  mosaicIndex.nodes.arcs);
56199
58324
  profileStart('topo:assignTiles');
56200
58325
  var result = shapes.map(function(shape, i) {
56201
58326
  var distance = distances[i];
56202
- var tileIds, geom;
58327
+ var tileIds;
56203
58328
  if (!distance || !shape) return null;
56204
58329
  tileIds = getTopologicalBufferTileIds(sourceIds[i], bufferIds[i],
56205
58330
  i, mosaicIndex, sourceIdIndex, bufferIdIndex, ownerCtx);
56206
- geom = getTileIdsGeometry(tileIds, mosaicIndex, pathfind);
56207
- // Preserve holes that coincide with another feature's source territory (a
56208
- // tile excluded to prevent buffer overlap, see getTopologicalBufferTileIds);
56209
- // the single-shape artifact/sliver heuristics would otherwise fill them.
56210
- return removePositiveBufferArtifactHoles(geom, shape, sourceArcs, distance,
56211
- {points: sourceInteriorPoints, featureId: i});
58331
+ // No artifact-hole filtering here. Every hole in this geometry is a tile
58332
+ // withheld from this feature on purpose (see getTopologicalBufferTileIds),
58333
+ // so there is nothing for a single-shape heuristic to clean up and it can
58334
+ // only do harm: the interior-point territory test it used to run missed the
58335
+ // holes it could not sample, and filling those handed a neighbour's ground
58336
+ // to this feature -- 18.6 km2 of it across 3108 US counties at R=5km.
58337
+ return getTileIdsGeometry(tileIds, mosaicIndex, pathfind);
56212
58338
  });
56213
58339
  profileEnd('topo:assignTiles');
56214
58340
  return result;
@@ -56427,22 +58553,6 @@ ${svg}
56427
58553
  });
56428
58554
  }
56429
58555
 
56430
- // One interior point per positive ring-group (part) of every source shape,
56431
- // tagged with its feature id. A multipart source contributes a point for each
56432
- // detached part (e.g. a small island ring), so the topological hole filter can
56433
- // recognize a neighbor's territory even when it is one part of a multipolygon.
56434
- function getSourceInteriorPoints(shapes, arcs) {
56435
- var points = [];
56436
- shapes.forEach(function(shape, featureId) {
56437
- if (!shape) return;
56438
- getPolygonRingGroupShapes(shape, arcs).forEach(function(group) {
56439
- var p = findAnchorPoint(group, arcs);
56440
- if (p) points.push({x: p.x, y: p.y, featureId: featureId});
56441
- });
56442
- });
56443
- return points;
56444
- }
56445
-
56446
58556
  function getIdLookup(ids) {
56447
58557
  var index = [];
56448
58558
  ids.forEach(function(id) {
@@ -56654,11 +58764,7 @@ ${svg}
56654
58764
  return false;
56655
58765
  }
56656
58766
 
56657
- // @territory (optional, topological pipeline only): {points, featureId} where
56658
- // points are source-part interior points tagged by feature id; a candidate hole
56659
- // enclosing another feature's point is that neighbor's territory (excluded to
56660
- // prevent overlap) and is always kept.
56661
- function removePositiveBufferArtifactHoles(geom, shape, arcs, distance, territory) {
58767
+ function removePositiveBufferArtifactHoles(geom, shape, arcs, distance) {
56662
58768
  if (!geom) return null;
56663
58769
  // Nothing to filter unless the result actually has interior rings. Skip the
56664
58770
  // index build (filterArtifactHoles is itself a no-op on hole-free polygons,
@@ -56682,9 +58788,7 @@ ${svg}
56682
58788
  shapeIndex: buildShapeSegmentIndex(shape, arcs),
56683
58789
  pathIndex: shape && shape.length > 0 ? new PathIndex([shape], arcs) : null,
56684
58790
  holeIndex: sourceHoles.length > 0 ?
56685
- buildShapeSegmentIndex(sourceHoles.map(function(h) {return h[0];}), arcs) : null,
56686
- territoryPoints: territory ? territory.points : null,
56687
- featureId: territory ? territory.featureId : -1
58791
+ buildShapeSegmentIndex(sourceHoles.map(function(h) {return h[0];}), arcs) : null
56688
58792
  };
56689
58793
  if (geom.type == 'Polygon') {
56690
58794
  geom.coordinates = filterArtifactHoles(geom.coordinates, minHoleArea, ctx);
@@ -56794,63 +58898,11 @@ ${svg}
56794
58898
  function filterArtifactHoles(polygon, minHoleArea, ctx) {
56795
58899
  if (polygon.length < 2) return polygon;
56796
58900
  return [polygon[0]].concat(polygon.slice(1).filter(function(ring) {
56797
- // A hole over another feature's source is real territory (kept regardless of
56798
- // size or how deep it sits in this feature's buffer); see getSourceInteriorPoints.
56799
- if (ringEnclosesOtherTerritory(ring, ctx)) return true;
56800
58901
  return Math.abs(getGeoJSONRingArea(ring)) > minHoleArea &&
56801
58902
  !positiveBufferHoleIsArtifact(ring, ctx);
56802
58903
  }));
56803
58904
  }
56804
58905
 
56805
- function ringEnclosesOtherTerritory(ring, ctx) {
56806
- var points = ctx.territoryPoints;
56807
- if (!points) return false;
56808
- var bounds = getGeoJSONRingBounds(ring);
56809
- for (var i = 0; i < points.length; i++) {
56810
- if (points[i].featureId === ctx.featureId) continue;
56811
- if (!pointInGeoJSONRingBounds(points[i].x, points[i].y, bounds)) continue;
56812
- if (pointInGeoJSONRing(points[i].x, points[i].y, ring)) return true;
56813
- }
56814
- return false;
56815
- }
56816
-
56817
- function getGeoJSONRingBounds(ring) {
56818
- var n = ring.length - 1; // skip duplicate closing vertex
56819
- if (n <= 0) n = ring.length;
56820
- var xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity;
56821
- var i, x, y;
56822
- for (i = 0; i < n; i++) {
56823
- x = ring[i][0];
56824
- y = ring[i][1];
56825
- if (x < xmin) xmin = x;
56826
- if (x > xmax) xmax = x;
56827
- if (y < ymin) ymin = y;
56828
- if (y > ymax) ymax = y;
56829
- }
56830
- return {xmin: xmin, ymin: ymin, xmax: xmax, ymax: ymax};
56831
- }
56832
-
56833
- function pointInGeoJSONRingBounds(x, y, bounds) {
56834
- return x >= bounds.xmin && x <= bounds.xmax &&
56835
- y >= bounds.ymin && y <= bounds.ymax;
56836
- }
56837
-
56838
- // Ray-casting point-in-ring test for a closed GeoJSON ring (array of [x, y],
56839
- // first == last). Boundary cases are irrelevant here: territory probe points are
56840
- // well inside their source part, far from any hole-ring edge.
56841
- function pointInGeoJSONRing(x, y, ring) {
56842
- var inside = false;
56843
- for (var i = 0, j = ring.length - 1; i < ring.length; j = i++) {
56844
- var xi = ring[i][0], yi = ring[i][1];
56845
- var xj = ring[j][0], yj = ring[j][1];
56846
- if ((yi > y) !== (yj > y) &&
56847
- x < (xj - xi) * (y - yi) / (yj - yi) + xi) {
56848
- inside = !inside;
56849
- }
56850
- }
56851
- return inside;
56852
- }
56853
-
56854
58906
  function positiveBufferHoleIsArtifact(ring, ctx) {
56855
58907
  var n = ring.length - 1; // skip duplicate endpoint
56856
58908
  var step = Math.max(1, Math.floor(n / 20));
@@ -57753,7 +59805,7 @@ ${svg}
57753
59805
  debug('Short ring', coords);
57754
59806
  return;
57755
59807
  }
57756
- if (!samePoint(coords[0], lastEl(coords))) {
59808
+ if (!samePoint$1(coords[0], lastEl(coords))) {
57757
59809
  error('Open polygon ring');
57758
59810
  }
57759
59811
  rings.push(coords); // accumulate rings
@@ -66130,7 +68182,7 @@ ${svg}
66130
68182
 
66131
68183
  // Options that require the topology-repair algorithm and are not supported
66132
68184
  // by the no-repair fast path.
66133
- var REPAIR_REQUIRED_OPTS = ['gap_fill_area', 'sliver_control', 'allow_overlaps'];
68185
+ var REPAIR_REQUIRED_OPTS = ['gap_width', 'gap_fill_area', 'sliver_control', 'allow_overlaps'];
66134
68186
 
66135
68187
  // Sample size used to detect intersections in no-repair mode. Detection stops
66136
68188
  // after this many hits, so the warning message can include sample locations
@@ -75897,7 +77949,7 @@ ${svg}
75897
77949
  return name == 'rectangle' || name == 'rectangles' || name == 'filter' && opts.cleanup;
75898
77950
  }
75899
77951
 
75900
- var version = "0.7.52";
77952
+ var version = "0.7.53";
75901
77953
 
75902
77954
  // Parse command line args into commands and run them
75903
77955
  // Function takes an optional Node-style callback. A Promise is returned if no callback is given.