mapshaper 0.7.52 → 0.7.54

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,
@@ -8432,7 +8447,7 @@
8432
8447
  var D2R$2 = Math.PI / 180;
8433
8448
  var R2D$3 = 180 / Math.PI;
8434
8449
  var HALF_PI = Math.PI / 2;
8435
- var SQRT2$1 = Math.sqrt(2);
8450
+ var SQRT2$2 = Math.sqrt(2);
8436
8451
  var SQRT3$1 = Math.sqrt(3);
8437
8452
  var ASIN_ONE_THIRD$1 = Math.asin(1 / 3);
8438
8453
  var EDGE_SCALE = Math.acos(-1 / 3) / 2;
@@ -9003,7 +9018,7 @@
9003
9018
  function narukawaFaceForward(lam, phi) {
9004
9019
  var a = lam - Math.asin(Math.sin(lam) / SQRT3$1);
9005
9020
  var theta = Math.atan(2 * SQRT3$1 / Math.PI * a);
9006
- var denominator = 2 + SQRT2$1 * Math.tan(phi);
9021
+ var denominator = 2 + SQRT2$2 * Math.tan(phi);
9007
9022
  var q = denominator > 0 ? (2 + Math.cos(lam)) / denominator : 0;
9008
9023
  var r = q * SQRT3$1 / Math.cos(theta);
9009
9024
  return [r, theta];
@@ -9023,7 +9038,7 @@
9023
9038
  lam = (lo + hi) / 2;
9024
9039
  var q = r * Math.cos(theta) / SQRT3$1;
9025
9040
  var phi = q < EPS$1 ? HALF_PI :
9026
- Math.atan(((2 + Math.cos(lam)) / q - 2) / SQRT2$1);
9041
+ Math.atan(((2 + Math.cos(lam)) / q - 2) / SQRT2$2);
9027
9042
  return [phi, lam];
9028
9043
  }
9029
9044
 
@@ -9463,7 +9478,7 @@
9463
9478
 
9464
9479
  var D2R$1 = Math.PI / 180;
9465
9480
  var R2D$2 = 180 / Math.PI;
9466
- var SQRT2 = Math.sqrt(2);
9481
+ var SQRT2$1 = Math.sqrt(2);
9467
9482
  var SQRT3 = Math.sqrt(3);
9468
9483
  var ASIN_ONE_THIRD = Math.asin(1 / 3);
9469
9484
  var MARKLEY_LATITUDE = Math.acos(1 / 3) * 0.5 * R2D$2;
@@ -10047,7 +10062,7 @@
10047
10062
 
10048
10063
  function leeRaw(lam, phi) {
10049
10064
  var s = stereographicRaw(lam, phi);
10050
- var z = [s[0] * SQRT2, s[1] * SQRT2];
10065
+ var z = [s[0] * SQRT2$1, s[1] * SQRT2$1];
10051
10066
  var i, j;
10052
10067
  // Pick the sector whose rotation maximizes the real part of rot * z.
10053
10068
  var sector = 0;
@@ -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
@@ -18705,6 +18826,602 @@
18705
18826
  });
18706
18827
  }
18707
18828
 
18829
+ class TinyQueue {
18830
+ constructor(data = [], compare = defaultCompare) {
18831
+ this.data = data;
18832
+ this.length = this.data.length;
18833
+ this.compare = compare;
18834
+
18835
+ if (this.length > 0) {
18836
+ for (let i = (this.length >> 1) - 1; i >= 0; i--) this._down(i);
18837
+ }
18838
+ }
18839
+
18840
+ push(item) {
18841
+ this.data.push(item);
18842
+ this.length++;
18843
+ this._up(this.length - 1);
18844
+ }
18845
+
18846
+ pop() {
18847
+ if (this.length === 0) return undefined;
18848
+
18849
+ const top = this.data[0];
18850
+ const bottom = this.data.pop();
18851
+ this.length--;
18852
+
18853
+ if (this.length > 0) {
18854
+ this.data[0] = bottom;
18855
+ this._down(0);
18856
+ }
18857
+
18858
+ return top;
18859
+ }
18860
+
18861
+ peek() {
18862
+ return this.data[0];
18863
+ }
18864
+
18865
+ _up(pos) {
18866
+ const {data, compare} = this;
18867
+ const item = data[pos];
18868
+
18869
+ while (pos > 0) {
18870
+ const parent = (pos - 1) >> 1;
18871
+ const current = data[parent];
18872
+ if (compare(item, current) >= 0) break;
18873
+ data[pos] = current;
18874
+ pos = parent;
18875
+ }
18876
+
18877
+ data[pos] = item;
18878
+ }
18879
+
18880
+ _down(pos) {
18881
+ const {data, compare} = this;
18882
+ const halfLength = this.length >> 1;
18883
+ const item = data[pos];
18884
+
18885
+ while (pos < halfLength) {
18886
+ let left = (pos << 1) + 1;
18887
+ let best = data[left];
18888
+ const right = left + 1;
18889
+
18890
+ if (right < this.length && compare(data[right], best) < 0) {
18891
+ left = right;
18892
+ best = data[right];
18893
+ }
18894
+ if (compare(best, item) >= 0) break;
18895
+
18896
+ data[pos] = best;
18897
+ pos = left;
18898
+ }
18899
+
18900
+ data[pos] = item;
18901
+ }
18902
+ }
18903
+
18904
+ function defaultCompare(a, b) {
18905
+ return a < b ? -1 : a > b ? 1 : 0;
18906
+ }
18907
+
18908
+ /*
18909
+ Copyright (c) 2016-2026, Vladimir Agafonkin
18910
+
18911
+ Permission to use, copy, modify, and/or distribute this software for any
18912
+ purpose with or without fee is hereby granted, provided that the above
18913
+ copyright notice and this permission notice appear in all copies.
18914
+
18915
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
18916
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
18917
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
18918
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
18919
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
18920
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
18921
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
18922
+
18923
+ Based on mapbox/polylabel 2.1.0. Adapted for mapshaper to operate on
18924
+ topological polygon shapes, use a bounded single-cell search and support
18925
+ alternative centroid-aware objectives.
18926
+ */
18927
+
18928
+
18929
+ var BLOCK_SIZE = 32;
18930
+ var DEFAULT_PRECISION = 0.01;
18931
+ var DEFAULT_PRECISION_FLOOR = 1e-4;
18932
+ // Hard ceiling on cell creation. Besides bounding runtime, this limits the
18933
+ // priority queue to O(DEFAULT_PROBE_LIMIT) entries on pathological rings.
18934
+ var DEFAULT_PROBE_LIMIT = 100000;
18935
+ var DEFAULT_TOLERANCE = 0.1;
18936
+ var DEFAULT_WEIGHT = 0.6;
18937
+ var MAX_WEIGHT_PENALTY = 0.25;
18938
+ var SQRT2 = Math.SQRT2;
18939
+
18940
+ // Find an inner point using one of four polylabel-based policies.
18941
+ // Returns null for collapsed polygons, so callers can fall back to the legacy
18942
+ // anchor-point finder.
18943
+ function findPolylabelPoint(shp, arcs, opts) {
18944
+ var polygon = buildPolygon(shp, arcs);
18945
+ if (!polygon) return null;
18946
+
18947
+ opts = opts || {};
18948
+ var precision = getPrecision(polygon, opts);
18949
+ if (!(precision > 0)) return null;
18950
+
18951
+ var limit = opts.probe_limit > 0 ? opts.probe_limit : DEFAULT_PROBE_LIMIT;
18952
+ var pole = findPole(polygon, precision, limit);
18953
+ if (!isUsablePoint(pole)) return null;
18954
+
18955
+ var method = opts.method || 'centroid2';
18956
+ if (method == 'pole') return pole;
18957
+
18958
+ var centroid = getCentroidCell(polygon);
18959
+ if (!(centroid.d > 0)) return pole;
18960
+
18961
+ var tolerance = opts.tolerance;
18962
+ if (!(tolerance >= 0 && tolerance <= 1)) tolerance = DEFAULT_TOLERANCE;
18963
+ var minClearance = pole.distance * (1 - tolerance);
18964
+
18965
+ if (method == 'centroid') {
18966
+ return centroid.d >= minClearance ? toResult(centroid, pole.probes) : pole;
18967
+ }
18968
+ if (method == 'centroid2') {
18969
+ if (centroid.d >= minClearance) return toResult(centroid, pole.probes);
18970
+ return findClosestAcceptablePoint(
18971
+ polygon, centroid.x, centroid.y, pole, minClearance, precision, limit
18972
+ );
18973
+ }
18974
+ if (method == 'weighted') {
18975
+ var weight = opts.weight;
18976
+ if (!(weight >= 0)) weight = DEFAULT_WEIGHT;
18977
+ return findWeightedPoint(polygon, centroid, pole, weight, precision, limit);
18978
+ }
18979
+ return null;
18980
+ }
18981
+
18982
+ function isUsablePoint(p) {
18983
+ return p && isFinite(p.x) && isFinite(p.y) && p.distance > 0;
18984
+ }
18985
+
18986
+ function buildPolygon(shp, arcs) {
18987
+ var outerIds = shp && geom.getMaxPath(shp, arcs);
18988
+ if (!outerIds) return null;
18989
+
18990
+ var outer = pathToRing(outerIds, arcs);
18991
+ var outerArea = geom.getPlanarPathArea(outerIds, arcs);
18992
+ if (!outer || outerArea === 0) return null;
18993
+
18994
+ var outerBounds = arcs.getSimpleShapeBounds(outerIds);
18995
+ var rings = [outer];
18996
+ var area = Math.abs(outerArea);
18997
+ var perimeter = getRingPerimeter$1(outer);
18998
+
18999
+ // Mapshaper stores multipart polygons as a flat list of rings. Polylabel
19000
+ // expects one outer ring followed by its holes, so omit other outer rings.
19001
+ (shp || []).forEach(function(ids) {
19002
+ if (ids === outerIds) return;
19003
+ var ringArea = geom.getPlanarPathArea(ids, arcs);
19004
+ if (ringArea === 0 || ringArea * outerArea >= 0) return;
19005
+ var bounds = arcs.getSimpleShapeBounds(ids);
19006
+ if (!outerBounds.intersects(bounds)) return;
19007
+ var ring = pathToRing(ids, arcs);
19008
+ if (!ring || !geom.testPointInRing(ring[0][0], ring[0][1], outerIds, arcs)) {
19009
+ return;
19010
+ }
19011
+ rings.push(ring);
19012
+ area -= Math.abs(ringArea);
19013
+ perimeter += getRingPerimeter$1(ring);
19014
+ });
19015
+
19016
+ if (!(area > 0) || !(perimeter > 0)) return null;
19017
+ return createPolygonContext(rings, outerBounds, area, perimeter);
19018
+ }
19019
+
19020
+ function pathToRing(ids, arcs) {
19021
+ var iter = arcs.getShapeIter(ids);
19022
+ var points = [];
19023
+ var prevX, prevY;
19024
+ while (iter.hasNext()) {
19025
+ if (points.length === 0 || iter.x != prevX || iter.y != prevY) {
19026
+ points.push([iter.x, iter.y]);
19027
+ prevX = iter.x;
19028
+ prevY = iter.y;
19029
+ }
19030
+ }
19031
+ if (points.length < 3) return null;
19032
+ var a = points[0];
19033
+ var b = points[points.length - 1];
19034
+ if (a[0] != b[0] || a[1] != b[1]) points.push([a[0], a[1]]);
19035
+ return points.length > 3 ? points : null;
19036
+ }
19037
+
19038
+ function getRingPerimeter$1(ring) {
19039
+ var sum = 0;
19040
+ for (var i = 1; i < ring.length; i++) {
19041
+ sum += geom.distance2D(
19042
+ ring[i - 1][0], ring[i - 1][1], ring[i][0], ring[i][1]
19043
+ );
19044
+ }
19045
+ return sum;
19046
+ }
19047
+
19048
+ function createPolygonContext(rings, bounds, area, perimeter) {
19049
+ var pointCount = rings.reduce(function(sum, ring) {
19050
+ return sum + ring.length;
19051
+ }, 0);
19052
+ var coords = new Float64Array(pointCount * 2);
19053
+ var ringEnds = new Uint32Array(rings.length);
19054
+ var k = 0;
19055
+ rings.forEach(function(ring, i) {
19056
+ ring.forEach(function(p) {
19057
+ coords[k++] = p[0];
19058
+ coords[k++] = p[1];
19059
+ });
19060
+ ringEnds[i] = k;
19061
+ });
19062
+ return {
19063
+ area: area,
19064
+ perimeter: perimeter,
19065
+ bounds: bounds,
19066
+ coords: coords,
19067
+ ringEnds: ringEnds,
19068
+ blocks: buildBlocks(coords, ringEnds)
19069
+ };
19070
+ }
19071
+
19072
+ function getPrecision(polygon, opts) {
19073
+ if (opts.precision > 0) return opts.precision;
19074
+ var extent = Math.max(polygon.bounds.width(), polygon.bounds.height());
19075
+ var relative = opts.precision_fraction > 0 ?
19076
+ opts.precision_fraction : DEFAULT_PRECISION;
19077
+ var floor = opts.precision_floor >= 0 ?
19078
+ opts.precision_floor : DEFAULT_PRECISION_FLOOR;
19079
+ // 2A/P is rotation-invariant and approximates a polygon's local thickness.
19080
+ return Math.max(relative * 2 * polygon.area / polygon.perimeter, floor * extent);
19081
+ }
19082
+
19083
+ function findPole(polygon, precision, limit) {
19084
+ var queue = new TinyQueue([], compareMax);
19085
+ var centroid = getCentroidCell(polygon);
19086
+ var best = centroid.d > 0 ? centroid :
19087
+ new Cell(polygon.coords[0], polygon.coords[1], 0, polygon);
19088
+ var bounds = polygon.bounds;
19089
+ var bboxCell = new Cell(bounds.centerX(), bounds.centerY(), 0, polygon);
19090
+ if (bboxCell.d > best.d) best = bboxCell;
19091
+
19092
+ var probes = 2;
19093
+ var exhausted = false;
19094
+
19095
+ function addCell(x, y, h, seed) {
19096
+ // If the cell center is already too close to an edge for this cell to
19097
+ // improve the result, the distance scan can stop at that edge. A bailed
19098
+ // scan is never scored or queued because its returned distance is only a
19099
+ // threshold, not the center's actual signed distance.
19100
+ var threshold = best.d - Math.max(0, h * SQRT2 - precision);
19101
+ var cell = new Cell(x, y, h, polygon, threshold, seed);
19102
+ probes++;
19103
+ if (cell.bailed) return;
19104
+ if (cell.d > best.d) best = cell;
19105
+ if (cell.max > best.d + precision) queue.push(cell);
19106
+ }
19107
+
19108
+ var h = Math.max(bounds.width(), bounds.height()) / 2;
19109
+ addCell(bounds.centerX(), bounds.centerY(), h, null);
19110
+ while (queue.length > 0) {
19111
+ if (probes + 4 > limit) {
19112
+ exhausted = true;
19113
+ break;
19114
+ }
19115
+ var cell = queue.pop();
19116
+ if (cell.max - best.d <= precision) continue;
19117
+ h = cell.h / 2;
19118
+ addCell(cell.x - h, cell.y - h, h, cell);
19119
+ addCell(cell.x + h, cell.y - h, h, cell);
19120
+ addCell(cell.x - h, cell.y + h, h, cell);
19121
+ addCell(cell.x + h, cell.y + h, h, cell);
19122
+ }
19123
+ return toResult(best, probes, exhausted);
19124
+ }
19125
+
19126
+ // Among points whose clearance is near the maximum, find the point closest to
19127
+ // the centroid. This is a constrained objective, not a general centroid bias.
19128
+ function findClosestAcceptablePoint(
19129
+ polygon, centroidX, centroidY, pole, minClearance, precision, limit
19130
+ ) {
19131
+ var queue = new TinyQueue([], compareOffset);
19132
+ var bounds = polygon.bounds;
19133
+ var h = Math.max(bounds.width(), bounds.height()) / 2;
19134
+ var root = new Cell(bounds.centerX(), bounds.centerY(), h, polygon);
19135
+ setOffsetBounds(root, centroidX, centroidY);
19136
+ queue.push(root);
19137
+
19138
+ var best = {
19139
+ x: pole.x,
19140
+ y: pole.y,
19141
+ d: pole.distance,
19142
+ offset: geom.distance2D(pole.x, pole.y, centroidX, centroidY)
19143
+ };
19144
+ var probes = pole.probes + 1;
19145
+ var exhausted = false;
19146
+
19147
+ while (queue.length > 0) {
19148
+ if (probes + 4 > limit) {
19149
+ exhausted = true;
19150
+ break;
19151
+ }
19152
+ var cell = queue.pop();
19153
+ if (cell.minOffset >= best.offset || cell.max < minClearance) continue;
19154
+ if (cell.d >= minClearance && cell.offset < best.offset) {
19155
+ best = cell;
19156
+ }
19157
+ if (cell.h <= precision / 2) continue;
19158
+ h = cell.h / 2;
19159
+ [
19160
+ [cell.x - h, cell.y - h],
19161
+ [cell.x + h, cell.y - h],
19162
+ [cell.x - h, cell.y + h],
19163
+ [cell.x + h, cell.y + h]
19164
+ ].forEach(function(xy) {
19165
+ var child = new Cell(xy[0], xy[1], h, polygon);
19166
+ setOffsetBounds(child, centroidX, centroidY);
19167
+ if (child.minOffset < best.offset && child.max >= minClearance) {
19168
+ queue.push(child);
19169
+ }
19170
+ });
19171
+ probes += 4;
19172
+ }
19173
+ return toResult(best, probes, exhausted);
19174
+ }
19175
+
19176
+ function setOffsetBounds(cell, x, y) {
19177
+ cell.offset = geom.distance2D(cell.x, cell.y, x, y);
19178
+ cell.minOffset = Math.max(0, cell.offset - cell.h * SQRT2);
19179
+ }
19180
+
19181
+ function findWeightedPoint(polygon, centroid, pole, weight, precision, limit) {
19182
+ if (weight === 0) return pole;
19183
+ var bounds = polygon.bounds;
19184
+ var refDist = Math.max(bounds.width(), bounds.height()) / 2;
19185
+ var queue = new TinyQueue([], compareScore);
19186
+ var best = new Cell(pole.x, pole.y, 0, polygon);
19187
+ setWeightedScore(best, centroid, refDist, weight);
19188
+
19189
+ var h = Math.max(bounds.width(), bounds.height()) / 2;
19190
+ var root = new Cell(bounds.centerX(), bounds.centerY(), h, polygon);
19191
+ setWeightedScore(root, centroid, refDist, weight);
19192
+ queue.push(root);
19193
+ var probes = pole.probes + 1;
19194
+ var exhausted = false;
19195
+
19196
+ while (queue.length > 0) {
19197
+ if (probes + 4 > limit) {
19198
+ exhausted = true;
19199
+ break;
19200
+ }
19201
+ var cell = queue.pop();
19202
+ if (cell.score > best.score) best = cell;
19203
+ if (cell.maxScore - best.score <= precision) continue;
19204
+ h = cell.h / 2;
19205
+ [
19206
+ [cell.x - h, cell.y - h],
19207
+ [cell.x + h, cell.y - h],
19208
+ [cell.x - h, cell.y + h],
19209
+ [cell.x + h, cell.y + h]
19210
+ ].forEach(function(xy) {
19211
+ var child = new Cell(xy[0], xy[1], h, polygon);
19212
+ setWeightedScore(child, centroid, refDist, weight);
19213
+ if (child.maxScore > best.score + precision) queue.push(child);
19214
+ if (child.score > best.score) best = child;
19215
+ });
19216
+ probes += 4;
19217
+ }
19218
+ return toResult(best, probes, exhausted);
19219
+ }
19220
+
19221
+ function setWeightedScore(cell, centroid, refDist, weight) {
19222
+ var offset = geom.distance2D(cell.x, cell.y, centroid.x, centroid.y);
19223
+ var minOffset = Math.max(0, offset - cell.h * SQRT2);
19224
+ var pointWeight = getCentroidWeight(offset, refDist, weight);
19225
+ var maxWeight = getCentroidWeight(minOffset, refDist, weight);
19226
+ cell.score = cell.d * pointWeight;
19227
+ cell.maxScore = cell.max * maxWeight;
19228
+ }
19229
+
19230
+ function getCentroidWeight(offset, refDist, weight) {
19231
+ return refDist > 0 ?
19232
+ 1 - Math.min(weight * offset / refDist, MAX_WEIGHT_PENALTY) : 1;
19233
+ }
19234
+
19235
+ function getCentroidCell(polygon) {
19236
+ var coords = polygon.coords;
19237
+ var end = polygon.ringEnds[0];
19238
+ var area = 0;
19239
+ var x = 0;
19240
+ var y = 0;
19241
+ for (var i = 0, j = end - 2; i < end; j = i, i += 2) {
19242
+ var ax = coords[i];
19243
+ var ay = coords[i + 1];
19244
+ var bx = coords[j];
19245
+ var by = coords[j + 1];
19246
+ var f = ax * by - bx * ay;
19247
+ x += (ax + bx) * f;
19248
+ y += (ay + by) * f;
19249
+ area += f * 3;
19250
+ }
19251
+ if (area === 0) return new Cell(coords[0], coords[1], 0, polygon);
19252
+ return new Cell(x / area, y / area, 0, polygon);
19253
+ }
19254
+
19255
+ function Cell(x, y, h, polygon, maxD, seed) {
19256
+ this.x = x;
19257
+ this.y = y;
19258
+ this.h = h;
19259
+ this.nsx1 = 0;
19260
+ this.nsy1 = 0;
19261
+ this.nsx2 = 0;
19262
+ this.nsy2 = 0;
19263
+ this.bailed = false;
19264
+ this.d = pointToPolygonDistance(
19265
+ this, polygon, maxD === undefined ? -Infinity : maxD, seed || null
19266
+ );
19267
+ this.max = this.d + h * SQRT2;
19268
+ }
19269
+
19270
+ function pointToPolygonDistance(cell, polygon, maxD, seed) {
19271
+ var coords = polygon.coords;
19272
+ var ringEnds = polygon.ringEnds;
19273
+ var blocks = polygon.blocks;
19274
+ var x = cell.x;
19275
+ var y = cell.y;
19276
+ var inside = false;
19277
+ var minDistSq = Infinity;
19278
+ var thresholdSq = maxD > 0 ? maxD * maxD : -1;
19279
+ var stride = BLOCK_SIZE * 2;
19280
+ var blockIndex = 0;
19281
+ var ringStart = 0;
19282
+
19283
+ if (seed) {
19284
+ cell.nsx1 = seed.nsx1;
19285
+ cell.nsy1 = seed.nsy1;
19286
+ cell.nsx2 = seed.nsx2;
19287
+ cell.nsy2 = seed.nsy2;
19288
+ minDistSq = getSegmentDistanceSq(
19289
+ x, y, seed.nsx1, seed.nsy1, seed.nsx2, seed.nsy2
19290
+ );
19291
+ if (minDistSq <= thresholdSq) {
19292
+ cell.bailed = true;
19293
+ return maxD;
19294
+ }
19295
+ }
19296
+
19297
+ for (var r = 0; r < ringEnds.length; r++) {
19298
+ var ringEnd = ringEnds[r];
19299
+ var bx = coords[ringEnd - 2];
19300
+ var by = coords[ringEnd - 1];
19301
+ for (var start = ringStart; start < ringEnd;
19302
+ start += stride, blockIndex += 4) {
19303
+ var end = Math.min(start + stride, ringEnd);
19304
+ var xmin = blocks[blockIndex];
19305
+ var ymin = blocks[blockIndex + 1];
19306
+ var xmax = blocks[blockIndex + 2];
19307
+ var ymax = blocks[blockIndex + 3];
19308
+ var dx = x < xmin ? xmin - x : x > xmax ? x - xmax : 0;
19309
+ var dy = y < ymin ? ymin - y : y > ymax ? y - ymax : 0;
19310
+ var skipDist = dx * dx + dy * dy >= minDistSq;
19311
+ var skipCross = y < ymin || y >= ymax || x > xmax;
19312
+ if (skipDist && skipCross) {
19313
+ bx = coords[end - 2];
19314
+ by = coords[end - 1];
19315
+ continue;
19316
+ }
19317
+ for (var i = start; i < end; i += 2) {
19318
+ var ax = coords[i];
19319
+ var ay = coords[i + 1];
19320
+ if (!skipCross && (ay > y !== by > y) &&
19321
+ x < (bx - ax) * (y - ay) / (by - ay) + ax) {
19322
+ inside = !inside;
19323
+ }
19324
+ if (!skipDist) {
19325
+ var distSq = getSegmentDistanceSq(x, y, ax, ay, bx, by);
19326
+ if (distSq < minDistSq) {
19327
+ minDistSq = distSq;
19328
+ cell.nsx1 = ax;
19329
+ cell.nsy1 = ay;
19330
+ cell.nsx2 = bx;
19331
+ cell.nsy2 = by;
19332
+ if (minDistSq <= thresholdSq) {
19333
+ cell.bailed = true;
19334
+ return maxD;
19335
+ }
19336
+ }
19337
+ }
19338
+ bx = ax;
19339
+ by = ay;
19340
+ }
19341
+ }
19342
+ ringStart = ringEnd;
19343
+ }
19344
+ return minDistSq === 0 ? 0 : (inside ? 1 : -1) * Math.sqrt(minDistSq);
19345
+ }
19346
+
19347
+ function buildBlocks(coords, ringEnds) {
19348
+ var stride = BLOCK_SIZE * 2;
19349
+ var blockCount = 0;
19350
+ var ringStart = 0;
19351
+ for (var r = 0; r < ringEnds.length; r++) {
19352
+ blockCount += Math.ceil((ringEnds[r] - ringStart) / stride);
19353
+ ringStart = ringEnds[r];
19354
+ }
19355
+ var blocks = new Float64Array(blockCount * 4);
19356
+ var k = 0;
19357
+ ringStart = 0;
19358
+ for (r = 0; r < ringEnds.length; r++) {
19359
+ var ringEnd = ringEnds[r];
19360
+ for (var start = ringStart; start < ringEnd; start += stride) {
19361
+ var end = Math.min(start + stride, ringEnd);
19362
+ var prev = start === ringStart ? ringEnd - 2 : start - 2;
19363
+ var xmin = coords[prev];
19364
+ var ymin = coords[prev + 1];
19365
+ var xmax = xmin;
19366
+ var ymax = ymin;
19367
+ for (var i = start; i < end; i += 2) {
19368
+ var x = coords[i];
19369
+ var y = coords[i + 1];
19370
+ if (x < xmin) xmin = x;
19371
+ if (x > xmax) xmax = x;
19372
+ if (y < ymin) ymin = y;
19373
+ if (y > ymax) ymax = y;
19374
+ }
19375
+ blocks[k++] = xmin;
19376
+ blocks[k++] = ymin;
19377
+ blocks[k++] = xmax;
19378
+ blocks[k++] = ymax;
19379
+ }
19380
+ ringStart = ringEnd;
19381
+ }
19382
+ return blocks;
19383
+ }
19384
+
19385
+ function getSegmentDistanceSq(px, py, x, y, bx, by) {
19386
+ var dx = bx - x;
19387
+ var dy = by - y;
19388
+ if (dx !== 0 || dy !== 0) {
19389
+ var t = ((px - x) * dx + (py - y) * dy) / (dx * dx + dy * dy);
19390
+ if (t > 1) {
19391
+ x = bx;
19392
+ y = by;
19393
+ } else if (t > 0) {
19394
+ x += dx * t;
19395
+ y += dy * t;
19396
+ }
19397
+ }
19398
+ dx = px - x;
19399
+ dy = py - y;
19400
+ return dx * dx + dy * dy;
19401
+ }
19402
+
19403
+ function toResult(cell, probes, exhausted) {
19404
+ return {
19405
+ x: cell.x,
19406
+ y: cell.y,
19407
+ distance: cell.d,
19408
+ probes: probes || 0,
19409
+ exhausted: !!exhausted
19410
+ };
19411
+ }
19412
+
19413
+ function compareMax(a, b) {
19414
+ return b.max - a.max;
19415
+ }
19416
+
19417
+ function compareOffset(a, b) {
19418
+ return a.minOffset - b.minOffset;
19419
+ }
19420
+
19421
+ function compareScore(a, b) {
19422
+ return b.maxScore - a.maxScore;
19423
+ }
19424
+
18708
19425
  function simplifyArcsFast(arcs, dist) {
18709
19426
  var xx = [],
18710
19427
  yy = [],
@@ -18776,8 +19493,20 @@
18776
19493
  simplifyPolygonFast: simplifyPolygonFast
18777
19494
  });
18778
19495
 
18779
- // Find a point inside a polygon and located away from the polygon edge
18780
- // Method:
19496
+ // Find an interior point for label or symbol placement. The default method
19497
+ // finds a pole of inaccessibility, then moves toward the centroid while
19498
+ // retaining at least 90% of the pole's clearance.
19499
+ function findAnchorPoint(shp, arcs, opts) {
19500
+ var method = opts && opts.method || 'centroid2';
19501
+ var p;
19502
+ if (method != 'legacy') {
19503
+ p = findPolylabelPoint(shp, arcs, opts);
19504
+ if (p) return p;
19505
+ }
19506
+ return findLegacyAnchorPoint(shp, arcs);
19507
+ }
19508
+
19509
+ // Legacy sampled-ray method:
18781
19510
  // - get the largest ring of the polygon
18782
19511
  // - get an array of x-values distributed along the horizontal extent of the ring
18783
19512
  // - for each x:
@@ -18788,8 +19517,7 @@
18788
19517
  // - return the adjusted point having the maximum weighted distance from the edge
18789
19518
  //
18790
19519
  // (distance is weighted to slightly favor points near centroid)
18791
- //
18792
- function findAnchorPoint(shp, arcs) {
19520
+ function findLegacyAnchorPoint(shp, arcs) {
18793
19521
  var maxPath = shp && geom.getMaxPath(shp, arcs),
18794
19522
  pathBounds = maxPath && arcs.getSimpleShapeBounds(maxPath),
18795
19523
  thresh, simple;
@@ -18843,7 +19571,13 @@
18843
19571
  // Look for even better fit close to best-fit point
18844
19572
  p2 = probeForBestAnchorPoint(shp, arcs, p.x - hstep / 2,
18845
19573
  p.x + hstep / 2, 2, weight);
18846
- if (p2.distance > p.distance) {
19574
+ // The refinement can come back empty even though the coarse sweep did not,
19575
+ // because it samples only two x-values inside a narrow band. On a ring that
19576
+ // encloses no real area -- a collapsed sliver that runs out to a point and
19577
+ // back along itself, which a polygon buffer readily produces -- a ray at
19578
+ // either of those two can enclose no interval at all. The coarse result
19579
+ // already in hand stands in that case.
19580
+ if (p2 && p2.distance > p.distance) {
18847
19581
  p = p2;
18848
19582
  }
18849
19583
  }
@@ -18912,16 +19646,42 @@
18912
19646
  return p;
18913
19647
  }
18914
19648
 
19649
+ // Ceiling on the vertical scan below, which is otherwise unbounded: the scan
19650
+ // walks its chord in steps of vstep, so it runs for roughly the shape's height
19651
+ // over vstep, and nothing constrains that ratio. A tall hairline sliver -- the
19652
+ // kind a buffer mosaic produces between two nearly-coincident arcs -- makes it
19653
+ // astronomical.
19654
+ //
19655
+ // Truncating is safe rather than free: the scan only replaces p when it finds a
19656
+ // strictly better point, so stopping early keeps the best point so far, which
19657
+ // is still inside the polygon -- but on a shape thin enough to need more steps
19658
+ // than this, that point can differ from the one an unbounded scan would reach
19659
+ // (measured on a 1.1m x 111km rectangle: same polygon, ~12% less clearance).
19660
+ // The ceiling does bind on real data -- two of the -clean fixtures reach
19661
+ // ~180,000 steps here -- yet cutting every scan off at 10,000 left all 744
19662
+ // buffer fixture cases byte-identical, because the long tail is usually a
19663
+ // plateau the scan walks without improving on.
19664
+ var MAX_SCAN_STEPS = 10000;
19665
+
18915
19666
  // Try to find a better-fit point than @p by scanning vertically
18916
19667
  // Modify p in-place
18917
19668
  function scanForBetterPoint(p, shp, arcs, vstep, weight) {
18918
19669
  var x = p.x,
18919
19670
  y = p.y,
18920
19671
  dmax = p.distance,
18921
- d;
18922
-
18923
- while (true) {
18924
- y += vstep;
19672
+ d, y2;
19673
+
19674
+ for (var i = 0; i < MAX_SCAN_STEPS; i++) {
19675
+ y2 = y + vstep;
19676
+ // Stop when the step cannot move the point at all. vstep is the width of
19677
+ // the probe band, and on a shape narrower than the floating-point spacing
19678
+ // at its own coordinates that band collapses to nothing (see the
19679
+ // refinement probe in findAnchorPoint2), leaving y + vstep === y. Every
19680
+ // iteration would then retest the same point forever: a collapsed mosaic
19681
+ // tile 20nm wide, at longitude -119, hung here indefinitely rather than
19682
+ // merely running slowly.
19683
+ if (y2 === y) break;
19684
+ y = y2;
18925
19685
  d = geom.getPointToShapeDistance(x, y, shp, arcs) * weight(x, y);
18926
19686
  // overcome vary small local minima
18927
19687
  if (d > dmax * 0.90 && geom.testPointInPolygon(x, y, shp, arcs)) {
@@ -18999,7 +19759,8 @@
18999
19759
 
19000
19760
  var AnchorPoints = /*#__PURE__*/Object.freeze({
19001
19761
  __proto__: null,
19002
- findAnchorPoint: findAnchorPoint
19762
+ findAnchorPoint: findAnchorPoint,
19763
+ findLegacyAnchorPoint: findLegacyAnchorPoint
19003
19764
  });
19004
19765
 
19005
19766
  // Returns a function for calculating the percentage of a shape's perimeter by length that
@@ -19250,6 +20011,8 @@
19250
20011
  return bbox;
19251
20012
  }
19252
20013
 
20014
+ var EXPRESSION_INNER_POINT_OPTS = {method: 'centroid2', tolerance: 0.1};
20015
+
19253
20016
  // Returns a function to return a feature proxy by id
19254
20017
  // (the proxy appears as "this" or "$" in a feature expression)
19255
20018
  function initFeatureProxy(lyr, arcs, optsArg) {
@@ -19465,7 +20228,8 @@
19465
20228
  }
19466
20229
 
19467
20230
  function innerXY() {
19468
- _innerXY = _innerXY || findAnchorPoint(_ids, arcs);
20231
+ _innerXY = _innerXY ||
20232
+ findAnchorPoint(_ids, arcs, EXPRESSION_INNER_POINT_OPTS);
19469
20233
  return _innerXY;
19470
20234
  }
19471
20235
 
@@ -21677,7 +22441,22 @@
21677
22441
  var tile = mosaic[tileId];
21678
22442
  return filter(tile[0]); // test tile ring, ignoring any holes (does this matter?)
21679
22443
  });
21680
- filledIds.forEach(assignTileToAdjacentShape);
22444
+ // Choose every tile's owner before filling any of them. A tile that has just
22445
+ // been filled would otherwise present itself to the next one as a feature
22446
+ // boundary, so a gap divided into sections could award every section to
22447
+ // whichever feature received the first -- along a cut as long as the
22448
+ // boundaries it was meant to divide between.
22449
+ var chosen = filledIds.map(findAdjacentShapeId);
22450
+ var deferredIds = [];
22451
+ filledIds.forEach(function(tileId, i) {
22452
+ if (chosen[i] > -1) {
22453
+ tileShapeIndex.addTileToShape(chosen[i], tileId);
22454
+ } else {
22455
+ deferredIds.push(tileId);
22456
+ }
22457
+ });
22458
+ // A tile bordered only by other gaps can be filled once they have been.
22459
+ deferredIds.forEach(assignTileToAdjacentShape);
21681
22460
  return {
21682
22461
  removed: filledIds.length,
21683
22462
  remaining: remainingIds.length - filledIds.length
@@ -21688,6 +22467,26 @@
21688
22467
  return tileShapeIndex.getUnusedTileIds().map(tileIdToTile);
21689
22468
  };
21690
22469
 
22470
+ // Return unused tiles together with the source-feature owner of each boundary
22471
+ // arc. Used by -clean to partition multi-owner gaps before assigning them.
22472
+ this.getUnusedTileData = function(filter) {
22473
+ return tileShapeIndex.getUnusedTileIds().reduce(function(out, tileId) {
22474
+ var tile = mosaic[tileId];
22475
+ if (filter && !filter(tile[0])) return out;
22476
+ var boundary = [];
22477
+ tile[0].forEach(function(arcId) {
22478
+ var neighborTileId = arcTileIndex.getShapeIdByArcId(~arcId);
22479
+ var shapeId = neighborTileId < 0 ? -1 :
22480
+ tileShapeIndex.getShapeIdByTileId(neighborTileId);
22481
+ if (shapeId >= 0) {
22482
+ boundary.push({arcId: arcId, shapeId: shapeId});
22483
+ }
22484
+ });
22485
+ out.push({tileId: tileId, tile: tile, boundary: boundary});
22486
+ return out;
22487
+ }, []);
22488
+ };
22489
+
21691
22490
  this.getTilesByShapeIds = function(shapeIds) {
21692
22491
  return getTileIdsByShapeIds(shapeIds).map(tileIdToTile);
21693
22492
  };
@@ -22137,7 +22936,9 @@
22137
22936
  return mosaic[id];
22138
22937
  }
22139
22938
 
22140
- function assignTileToAdjacentShape(tileId) {
22939
+ // The shape sharing the longest boundary with a tile, or -1 if the tile is
22940
+ // bordered only by unassigned tiles.
22941
+ function findAdjacentShapeId(tileId) {
22141
22942
  var ring = mosaic[tileId][0];
22142
22943
  var arcs = nodes.arcs;
22143
22944
  var arcId, neighborShapeId, neighborTileId, arcLen;
@@ -22154,6 +22955,11 @@
22154
22955
  maxArcLen = arcLen;
22155
22956
  }
22156
22957
  }
22958
+ return shapeId;
22959
+ }
22960
+
22961
+ function assignTileToAdjacentShape(tileId) {
22962
+ var shapeId = findAdjacentShapeId(tileId);
22157
22963
  if (shapeId > -1) {
22158
22964
  tileShapeIndex.addTileToShape(shapeId, tileId);
22159
22965
  }
@@ -22179,6 +22985,26 @@
22179
22985
  }
22180
22986
  }
22181
22987
 
22988
+ // Flags, by absolute arc id, for arcs with no mosaic tile on one of their sides:
22989
+ // the border between the mosaic and the space outside it. Space that polygons
22990
+ // enclose becomes a tile, however narrow, so an arc facing untiled space faces
22991
+ // the outside, and a gap there is one that gap filling cannot reach.
22992
+ //
22993
+ // Only the mosaic is needed, not the tile assignment a MosaicIndex goes on to
22994
+ // make, so this builds the cheaper half on its own.
22995
+ function getOutsideFacingArcFlags(nodes) {
22996
+ var mosaic = buildPolygonMosaic(nodes).mosaic;
22997
+ var arcTileIndex = new ShapeArcIndex(mosaic, nodes.arcs);
22998
+ var flags = new Uint8Array(nodes.arcs.size());
22999
+ for (var i = 0; i < flags.length; i++) {
23000
+ if (arcTileIndex.getShapeIdByArcId(i) < 0 ||
23001
+ arcTileIndex.getShapeIdByArcId(~i) < 0) {
23002
+ flags[i] = 1;
23003
+ }
23004
+ }
23005
+ return flags;
23006
+ }
23007
+
22182
23008
  // Map arc ids to shape ids, assuming perfect topology
22183
23009
  // (an arcId maps to at most one shape)
22184
23010
  // Supports looking up a shape id using an arc id.
@@ -22209,7 +23035,8 @@
22209
23035
  var MosaicIndex$1 = /*#__PURE__*/Object.freeze({
22210
23036
  __proto__: null,
22211
23037
  MosaicIndex: MosaicIndex,
22212
- ShapeArcIndex: ShapeArcIndex
23038
+ ShapeArcIndex: ShapeArcIndex,
23039
+ getOutsideFacingArcFlags: getOutsideFacingArcFlags
22213
23040
  });
22214
23041
 
22215
23042
  // Assumes that arcs do not intersect except at endpoints
@@ -22256,6 +23083,13 @@
22256
23083
  profileStart('dissolvePolygonGroups2');
22257
23084
  profileStart('dpg2.NodeCollection');
22258
23085
  var arcFilter = getArcPresenceTest(lyr.shapes, dataset.arcs);
23086
+ if (opts._mosaic_cut_arcs) {
23087
+ var layerArcFilter = arcFilter;
23088
+ arcFilter = function(arcId) {
23089
+ var id = arcId < 0 ? ~arcId : arcId;
23090
+ return layerArcFilter(arcId) || opts._mosaic_cut_arcs[id];
23091
+ };
23092
+ }
22259
23093
  var nodes = new NodeCollection(dataset.arcs, arcFilter);
22260
23094
  profileEnd('dpg2.NodeCollection');
22261
23095
  var mosaicOpts = {
@@ -22267,12 +23101,17 @@
22267
23101
  var mosaicIndex = new MosaicIndex(lyr, nodes, mosaicOpts);
22268
23102
  profileEnd('dpg2.MosaicIndex');
22269
23103
  // gap fill doesn't work yet with overlapping shapes
22270
- var fillGaps = !opts.allow_overlaps && (opts.sliver_control || opts.gap_fill_area);
23104
+ var fillGaps = !opts.allow_overlaps &&
23105
+ (opts.gap_width || opts.sliver_control || opts.gap_fill_area);
22271
23106
  var cleanupData, filterData;
22272
23107
  if (fillGaps) {
22273
23108
  profileStart('dpg2.removeGaps');
22274
- var sliverOpts = utils.extend({sliver_control: 1}, opts);
22275
- filterData = getSliverFilter(lyr, dataset, sliverOpts);
23109
+ var filterOpts = utils.extend({}, opts);
23110
+ // Legacy area path: keep the historical default of full sliver control.
23111
+ if (filterOpts.gap_width == null && filterOpts.sliver_control == null) {
23112
+ filterOpts.sliver_control = 1;
23113
+ }
23114
+ filterData = getSliverFilter(lyr, dataset, filterOpts);
22276
23115
  cleanupData = mosaicIndex.removeGaps(filterData.filter);
22277
23116
  profileEnd('dpg2.removeGaps');
22278
23117
  }
@@ -22513,6 +23352,1245 @@
22513
23352
  target.push.apply(target, source);
22514
23353
  }
22515
23354
 
23355
+ // Two boundaries that should be one, but were digitized or computed twice, drift
23356
+ // apart by more than the single interval that ordinary vertex snapping repairs,
23357
+ // and their vertices are staggered, so no pairwise snapping ever sees them. In
23358
+ // the test corpus the worst of them sit ~20 intervals apart. This multiple keeps
23359
+ // the default repair at sub-micron scale, far below any gap a map could show,
23360
+ // while still catching them.
23361
+ var PRECISION_SEAM_INTERVALS = 100;
23362
+
23363
+ // Minimum accepted seam length, as a multiple of the median segment length. Two
23364
+ // boundaries that merely pass close to each other for a vertex or two are not a
23365
+ // seam.
23366
+ var MIN_SEAM_SEGMENT_FACTOR = 3;
23367
+
23368
+ // Collapse duplicate boundaries: two arcs carrying what should be one line,
23369
+ // digitized or computed twice and drifting apart by no more than floating-point
23370
+ // rounding (see PRECISION_SEAM_INTERVALS). Vertices on both banks are snapped to
23371
+ // the line midway between them, leaving a single shared boundary.
23372
+ //
23373
+ // Runs unconditionally. Left in place, such a seam becomes an enclosed sliver
23374
+ // that gap filling awards to one neighbor, giving that feature a zero-width spike
23375
+ // along the shared border.
23376
+ //
23377
+ // Returns the number of duplicate boundaries collapsed (see countConnectedGaps).
23378
+ function collapseDuplicateBoundaries(layers, dataset) {
23379
+ var polygonLayers = layers.filter(function(lyr) {
23380
+ return lyr.geometry_type == 'polygon' && lyr.shapes && lyr.shapes.length > 1;
23381
+ });
23382
+ if (polygonLayers.length === 0 || !dataset.arcs) return 0;
23383
+
23384
+ var spherical = isLatLngCRS(getDatasetCRS(dataset));
23385
+ var changed = 0;
23386
+ polygonLayers.forEach(function(lyr) {
23387
+ changed += collapseLayerDuplicates(lyr, dataset.arcs, spherical);
23388
+ });
23389
+ if (changed > 0) {
23390
+ rebuildTopology(dataset);
23391
+ verbose(utils.format('Closed %s duplicate boundar%s', changed,
23392
+ changed == 1 ? 'y' : 'ies'));
23393
+ }
23394
+ return changed;
23395
+ }
23396
+
23397
+ function collapseLayerDuplicates(lyr, arcs, spherical) {
23398
+ var medianSeg = measureMedianSegment(lyr, arcs);
23399
+ var distance = getPrecisionSeamDistance(arcs.getBounds().toArray(), spherical);
23400
+ if (!(distance > 0)) return 0;
23401
+
23402
+ // Keep the automatic search radius when the closing distance is smaller. This
23403
+ // lets us see the complete seam and reject it as a unit instead of partially
23404
+ // snapping the already-near portion while leaving its wider mouth open. A
23405
+ // duplicate boundary often has a mouth several orders of magnitude wider than
23406
+ // the seam behind it.
23407
+ var searchDistance = Math.max(distance, medianSeg * GAP_WIDTH_SEGMENT_FRACTION);
23408
+ var found = findLayerSeams(lyr, arcs, distance, searchDistance, spherical,
23409
+ medianSeg * MIN_SEAM_SEGMENT_FACTOR, null);
23410
+ if (!found) return 0;
23411
+ var snapped = profileWrap('cg.snapSeams', function() {
23412
+ return snapSeams(found.seams, found.seeds, found.arcsById, arcs,
23413
+ found.footpoints, distance);
23414
+ });
23415
+ return countConnectedGaps(snapped);
23416
+ }
23417
+
23418
+ // A crack that opens onto the space outside the mosaic is not enclosed by
23419
+ // polygons, so no tile covers it and gap filling cannot reach it. Snapping the
23420
+ // facing pair of vertices at each open end together encloses the crack, handing
23421
+ // it to the mosaic as an ordinary gap to be filled -- or divided between its
23422
+ // neighbors, where it borders three or more of them -- like any other.
23423
+ //
23424
+ // Only those pairs move: two coordinates per mouth, each by half the width of the
23425
+ // mouth. Interior gaps are left exactly as they were found, so they are filled the
23426
+ // same way whether or not this pass runs.
23427
+ //
23428
+ // Returns the number of mouths pinched shut.
23429
+ function pinchOuterCrackMouths(lyr, dataset, nodes, opts) {
23430
+ var arcs = dataset.arcs;
23431
+ if (!lyr.shapes || lyr.shapes.length < 2 || !arcs) return 0;
23432
+ var crs = getDatasetCRS(dataset);
23433
+ var spherical = isLatLngCRS(crs);
23434
+ var medianSeg = measureMedianSegment(lyr, arcs);
23435
+ // Same width as interior gap filling, from the same automatic default.
23436
+ var distance = resolveCloseDistance(opts, crs,
23437
+ medianSeg * GAP_WIDTH_SEGMENT_FRACTION);
23438
+ if (!(distance > 0)) return 0;
23439
+
23440
+ profileStart('cg.outsideFacingArcs');
23441
+ var outsideFacing = getOutsideFacingArcFlags(nodes);
23442
+ profileEnd('cg.outsideFacingArcs');
23443
+ var found = findLayerSeams(lyr, arcs, distance, distance, spherical,
23444
+ medianSeg * MIN_SEAM_SEGMENT_FACTOR, function(arcId) {
23445
+ return outsideFacing[arcId] === 1;
23446
+ });
23447
+ if (!found) return 0;
23448
+
23449
+ var pinched = profileWrap('cg.pinchMouths', function() {
23450
+ return pinchSeamMouths(found.seams, found.arcsById, arcs, nodes,
23451
+ found.footpoints, distance, spherical);
23452
+ });
23453
+ if (pinched > 0) {
23454
+ rebuildTopology(dataset);
23455
+ message(utils.format('Closed %s external gap%s', pinched,
23456
+ utils.pluralSuffix(pinched)));
23457
+ }
23458
+ return pinched;
23459
+ }
23460
+
23461
+ // Seams between facing arcs owned by different features: stretches where two
23462
+ // boundaries run within @distance of each other for at least @minSeamLength.
23463
+ // @keepArc, when given, limits which arcs are considered.
23464
+ function findLayerSeams(lyr, arcs, distance, searchDistance, spherical,
23465
+ minSeamLength, keepArc) {
23466
+ profileStart('cg.collectVertices');
23467
+ var owners = getArcOwners(lyr.shapes, arcs.size());
23468
+ var records = collectExternalVertices(arcs, owners, keepArc);
23469
+ profileEnd('cg.collectVertices');
23470
+ if (records.length < 4) return null;
23471
+
23472
+ profileStart('cg.findMouthSeeds');
23473
+ var seeds = findMouthSeeds(records, searchDistance, spherical);
23474
+ profileEnd('cg.findMouthSeeds');
23475
+ if (seeds.length === 0) return null;
23476
+
23477
+ var arcsById = indexArcRecords(records);
23478
+ var footpoints = new ArcFootpoints(arcs, spherical);
23479
+ profileStart('cg.growSeams');
23480
+ var seams = growSeamsFromSeeds(seeds, arcsById, footpoints, distance, spherical,
23481
+ minSeamLength);
23482
+ profileEnd('cg.growSeams');
23483
+ if (seams.length === 0) return null;
23484
+ return {seams: seams, seeds: seeds, arcsById: arcsById, footpoints: footpoints};
23485
+ }
23486
+
23487
+ // Same segment median as interior gap-width=auto (all polygon-ring segments).
23488
+ function measureMedianSegment(lyr, arcs) {
23489
+ return profileWrap('cg.medianSegment', function() {
23490
+ return getMedianPolygonSegmentLength(lyr, arcs);
23491
+ });
23492
+ }
23493
+
23494
+ function rebuildTopology(dataset) {
23495
+ profileStart('cg.rebuildTopology');
23496
+ dataset.arcs.dedupCoords();
23497
+ buildTopology(dataset);
23498
+ profileEnd('cg.rebuildTopology');
23499
+ }
23500
+
23501
+ // One crack presents many seams: a seam is a stretch of two facing arcs, and
23502
+ // every node along either side ends one arc and starts another -- where a third
23503
+ // feature meets the boundary, or where an intersection cut fell. Reporting seams
23504
+ // would give a number several times the number of cracks there are to see, so
23505
+ // seams meeting along a shared arc count once.
23506
+ //
23507
+ // Two cracks that face the same arc at different stretches of it are counted as
23508
+ // one. Erring towards the smaller number suits a count of gaps closed better than
23509
+ // splitting one crack into the arcs it happens to be divided into.
23510
+ function countConnectedGaps(seams) {
23511
+ var parents = new Map();
23512
+
23513
+ function find(id) {
23514
+ var parent = parents.get(id);
23515
+ if (parent === undefined) {
23516
+ parents.set(id, id);
23517
+ return id;
23518
+ }
23519
+ while (parent !== id) {
23520
+ id = parent;
23521
+ parent = parents.get(id);
23522
+ }
23523
+ return id;
23524
+ }
23525
+
23526
+ seams.forEach(function(seam) {
23527
+ var a = find(seam.arcA);
23528
+ var b = find(seam.arcB);
23529
+ if (a !== b) parents.set(a, b);
23530
+ });
23531
+ var roots = new Set();
23532
+ seams.forEach(function(seam) { roots.add(find(seam.arcA)); });
23533
+ return roots.size;
23534
+ }
23535
+
23536
+ // Closing distance for the default pass, which collapses duplicate boundaries
23537
+ // and nothing else. @bounds is the arc bounding box in coordinate units; the seam
23538
+ // walk measures distances in meters when those units are degrees.
23539
+ function getPrecisionSeamDistance(bounds, spherical) {
23540
+ var interval = getHighPrecisionSnapInterval(bounds) * PRECISION_SEAM_INTERVALS;
23541
+ return spherical ? degreesToMeters(interval) : interval;
23542
+ }
23543
+
23544
+ function resolveCloseDistance(opts, crs, autoDistance) {
23545
+ var arg = opts.gap_width;
23546
+ if (arg == null || arg === 'auto') return autoDistance;
23547
+ if (+arg === 0) return 0;
23548
+ return convertDistanceParam(arg, crs);
23549
+ }
23550
+
23551
+ // An external arc belongs to exactly one feature. Shared arcs are excluded: they
23552
+ // already represent clean topology and must not be moved by this repair.
23553
+ function getArcOwners(shapes, arcCount) {
23554
+ var first = new Int32Array(arcCount);
23555
+ var second = new Int32Array(arcCount);
23556
+ for (var i = 0; i < arcCount; i++) {
23557
+ first[i] = -1;
23558
+ second[i] = -1;
23559
+ }
23560
+ traversePaths(shapes, function(o) {
23561
+ var id = o.arcId < 0 ? ~o.arcId : o.arcId;
23562
+ var owner = o.shapeId;
23563
+ if (first[id] == -1) first[id] = owner;
23564
+ else if (first[id] != owner) second[id] = owner;
23565
+ });
23566
+ return {first: first, second: second};
23567
+ }
23568
+
23569
+ function collectExternalVertices(arcs, owners, keepArc) {
23570
+ var data = arcs.getVertexData();
23571
+ var records = [];
23572
+ var offs = 0;
23573
+ for (var arcId = 0; arcId < data.nn.length; arcId++) {
23574
+ var n = data.nn[arcId];
23575
+ if (owners.first[arcId] >= 0 && owners.second[arcId] == -1 &&
23576
+ (!keepArc || keepArc(arcId))) {
23577
+ for (var k = 0; k < n; k++) {
23578
+ var i = offs + k;
23579
+ records.push({
23580
+ x: data.xx[i],
23581
+ y: data.yy[i],
23582
+ i: i,
23583
+ arc: arcId,
23584
+ k: k,
23585
+ owner: owners.first[arcId]
23586
+ });
23587
+ }
23588
+ }
23589
+ offs += n;
23590
+ }
23591
+ return records;
23592
+ }
23593
+
23594
+ function indexArcRecords(records) {
23595
+ var byArc = {};
23596
+ records.forEach(function(o) {
23597
+ if (!byArc[o.arc]) byArc[o.arc] = [];
23598
+ byArc[o.arc].push(o);
23599
+ });
23600
+ Object.keys(byArc).forEach(function(id) {
23601
+ byArc[id].sort(function(a, b) { return a.k - b.k; });
23602
+ });
23603
+ return byArc;
23604
+ }
23605
+
23606
+ // Mouth seeds: mutual nearest external vertices on different owners/arcs.
23607
+ // A single positive-distance pair is enough to start an edge walk.
23608
+ function findMouthSeeds(records, distance, spherical) {
23609
+ var index = new Flatbush(records.length);
23610
+ records.forEach(function(o) { index.add(o.x, o.y, o.x, o.y); });
23611
+ index.finish();
23612
+
23613
+ var nearest = new Int32Array(records.length);
23614
+ var distances = new Float64Array(records.length);
23615
+ nearest.fill(-1);
23616
+ records.forEach(function(a, ai) {
23617
+ var padY = getCoordinatePad(distance, a.y, spherical, false);
23618
+ var padX = getCoordinatePad(distance, a.y, spherical, true);
23619
+ var ids = index.search(a.x - padX, a.y - padY, a.x + padX, a.y + padY);
23620
+ var best = -1, bestDist = Infinity;
23621
+ ids.forEach(function(bi) {
23622
+ var b = records[bi];
23623
+ if (bi == ai || b.owner == a.owner || b.arc == a.arc) return;
23624
+ var d = spherical ?
23625
+ fastLonLatDistance(a.x, a.y, b.x, b.y) :
23626
+ distance2D$1(a.x, a.y, b.x, b.y);
23627
+ if (d <= distance && d < bestDist) {
23628
+ best = bi;
23629
+ bestDist = d;
23630
+ }
23631
+ });
23632
+ nearest[ai] = best;
23633
+ distances[ai] = bestDist;
23634
+ });
23635
+
23636
+ var seen = {};
23637
+ var seeds = [];
23638
+ records.forEach(function(a, ai) {
23639
+ var bi = nearest[ai];
23640
+ if (bi < 0 || !(distances[ai] > 0)) return;
23641
+ var best = records[bi];
23642
+ // Positive-distance matches must be mutual nearest neighbors.
23643
+ if (nearest[bi] != ai) return;
23644
+ var lo = Math.min(a.i, best.i), hi = Math.max(a.i, best.i);
23645
+ var key = lo + ':' + hi;
23646
+ if (seen[key]) return;
23647
+ seen[key] = true;
23648
+ seeds.push({a: a, b: best, distance: distances[ai]});
23649
+ });
23650
+ return seeds;
23651
+ }
23652
+
23653
+ function getCoordinatePad(distance, lat, spherical, longitude) {
23654
+ if (spherical) {
23655
+ var deg = distance / R$3 * R2D$8;
23656
+ return longitude ? deg / Math.max(Math.cos(lat / R2D$8), 0.01) : deg;
23657
+ }
23658
+ // convertDistanceParam() has already converted physical units into projected
23659
+ // coordinate units (or left unitless values unchanged).
23660
+ return distance;
23661
+ }
23662
+
23663
+ // Grow a seam from each mouth seed by walking both arcs while each vertex stays
23664
+ // within closeDistance of the opposite arc's edges. Accept only seams whose
23665
+ // walked length on both sides meets minSeamLength. Overlapping seeds that grow
23666
+ // into the same arc-pair range are merged.
23667
+ function growSeamsFromSeeds(seeds, arcsById, footpoints, closeDistance,
23668
+ spherical, minSeamLength) {
23669
+ var seams = [];
23670
+ // Seeds crowd along a crack: every facing pair of vertices along it is one, and
23671
+ // each grows into the same stretch of the same two arcs, since what the walk
23672
+ // finds is the whole run of in-tolerance vertices around the seed rather than
23673
+ // anything particular to where it started. Walking that run once per seed
23674
+ // instead of once is what made this repair take minutes on a detailed mosaic,
23675
+ // so a seed already inside a run grown for its own two arcs is passed over.
23676
+ var grownByPair = new Map();
23677
+ seeds.forEach(function(seed) {
23678
+ var key = arcPairKey(seed.a.arc, seed.b.arc);
23679
+ var grown = grownByPair.get(key);
23680
+ if (!grown) {
23681
+ grown = [];
23682
+ grownByPair.set(key, grown);
23683
+ }
23684
+ if (seamContainsSeed(grown, seed)) return;
23685
+ var seam = growSeamFromSeed(seed, arcsById, footpoints, closeDistance,
23686
+ spherical);
23687
+ if (!seam) return;
23688
+ // Runs rejected as too short are recorded too, so that the seeds along them
23689
+ // are not walked again either.
23690
+ grown.push(seam);
23691
+ if (seam.lengthA < minSeamLength || seam.lengthB < minSeamLength) return;
23692
+ seams.push(seam);
23693
+ });
23694
+ return mergeOverlappingSeams(seams);
23695
+ }
23696
+
23697
+ function arcPairKey(arcA, arcB) {
23698
+ return arcA < arcB ? arcA + '|' + arcB : arcB + '|' + arcA;
23699
+ }
23700
+
23701
+ function indexSeamsByArcPair(seams) {
23702
+ var byPair = new Map();
23703
+ seams.forEach(function(seam) {
23704
+ var key = arcPairKey(seam.arcA, seam.arcB);
23705
+ var group = byPair.get(key);
23706
+ if (!group) {
23707
+ group = [];
23708
+ byPair.set(key, group);
23709
+ }
23710
+ group.push(seam);
23711
+ });
23712
+ return byPair;
23713
+ }
23714
+
23715
+ function growSeamFromSeed(seed, arcsById, footpoints, closeDistance, spherical) {
23716
+ var sideA = arcsById[seed.a.arc];
23717
+ var sideB = arcsById[seed.b.arc];
23718
+ if (!sideA || !sideB) return null;
23719
+
23720
+ var rangeA = expandRangeOnArc(sideA, seed.a.k, sideB, footpoints, closeDistance,
23721
+ spherical);
23722
+ var rangeB = expandRangeOnArc(sideB, seed.b.k, sideA, footpoints, closeDistance,
23723
+ spherical);
23724
+ if (!rangeA || !rangeB) return null;
23725
+
23726
+ return {
23727
+ arcA: seed.a.arc,
23728
+ arcB: seed.b.arc,
23729
+ loA: rangeA.lo,
23730
+ hiA: rangeA.hi,
23731
+ loB: rangeB.lo,
23732
+ hiB: rangeB.hi,
23733
+ lengthA: rangeA.length,
23734
+ lengthB: rangeB.length
23735
+ };
23736
+ }
23737
+
23738
+ // Expand along @side from seed index @seedK while each vertex remains within
23739
+ // closeDistance of @other's edges. Returns the inclusive index range and the
23740
+ // path length covered by the accepted stretch.
23741
+ function expandRangeOnArc(side, seedK, other, footpoints, closeDistance, spherical) {
23742
+ if (seedK < 0 || seedK >= side.length) return null;
23743
+ if (!vertexWithinTolerance(side[seedK], other, footpoints, closeDistance)) {
23744
+ return null;
23745
+ }
23746
+ var lo = seedK;
23747
+ var hi = seedK;
23748
+ while (lo > 0 &&
23749
+ vertexWithinTolerance(side[lo - 1], other, footpoints, closeDistance)) {
23750
+ lo--;
23751
+ }
23752
+ while (hi + 1 < side.length &&
23753
+ vertexWithinTolerance(side[hi + 1], other, footpoints, closeDistance)) {
23754
+ hi++;
23755
+ }
23756
+ return {
23757
+ lo: lo,
23758
+ hi: hi,
23759
+ length: pathLengthBetween(side, lo, hi, spherical)
23760
+ };
23761
+ }
23762
+
23763
+ function vertexWithinTolerance(vertex, otherSide, footpoints, closeDistance) {
23764
+ return !!footpoints.closestPoint(vertex.x, vertex.y, otherSide, closeDistance);
23765
+ }
23766
+
23767
+ function pathLengthBetween(side, lo, hi, spherical) {
23768
+ var len = 0;
23769
+ for (var k = lo; k < hi; k++) {
23770
+ var a = side[k], b = side[k + 1];
23771
+ len += spherical ?
23772
+ fastLonLatDistance(a.x, a.y, b.x, b.y) :
23773
+ distance2D$1(a.x, a.y, b.x, b.y);
23774
+ }
23775
+ return len;
23776
+ }
23777
+
23778
+ function mergeOverlappingSeams(seams) {
23779
+ if (seams.length <= 1) return seams;
23780
+ var out = [];
23781
+ // Only seams on the same two arcs can overlap, so each one is compared against
23782
+ // those alone rather than against every seam found so far.
23783
+ var byPair = new Map();
23784
+ seams.forEach(function(seam) {
23785
+ var a = Math.min(seam.arcA, seam.arcB);
23786
+ var b = Math.max(seam.arcA, seam.arcB);
23787
+ var swapped = seam.arcA > seam.arcB;
23788
+ var norm = {
23789
+ arcA: a,
23790
+ arcB: b,
23791
+ loA: swapped ? seam.loB : seam.loA,
23792
+ hiA: swapped ? seam.hiB : seam.hiA,
23793
+ loB: swapped ? seam.loA : seam.loB,
23794
+ hiB: swapped ? seam.hiA : seam.hiB,
23795
+ lengthA: swapped ? seam.lengthB : seam.lengthA,
23796
+ lengthB: swapped ? seam.lengthA : seam.lengthB
23797
+ };
23798
+ var key = arcPairKey(norm.arcA, norm.arcB);
23799
+ var group = byPair.get(key);
23800
+ if (!group) {
23801
+ group = [];
23802
+ byPair.set(key, group);
23803
+ }
23804
+ var existing = group.find(function(o) {
23805
+ return rangesOverlap(o.loA, o.hiA, norm.loA, norm.hiA) &&
23806
+ rangesOverlap(o.loB, o.hiB, norm.loB, norm.hiB);
23807
+ });
23808
+ if (existing) {
23809
+ existing.loA = Math.min(existing.loA, norm.loA);
23810
+ existing.hiA = Math.max(existing.hiA, norm.hiA);
23811
+ existing.loB = Math.min(existing.loB, norm.loB);
23812
+ existing.hiB = Math.max(existing.hiB, norm.hiB);
23813
+ existing.lengthA = Math.max(existing.lengthA, norm.lengthA);
23814
+ existing.lengthB = Math.max(existing.lengthB, norm.lengthB);
23815
+ } else {
23816
+ group.push(norm);
23817
+ out.push(norm);
23818
+ }
23819
+ });
23820
+ return out;
23821
+ }
23822
+
23823
+ function rangesOverlap(lo1, hi1, lo2, hi2) {
23824
+ return lo1 <= hi2 && lo2 <= hi1;
23825
+ }
23826
+
23827
+ // Snap every vertex in an accepted seam range to the midpoint between itself
23828
+ // and its nearest footpoint on the opposite arc. Works with staggered vertices:
23829
+ // the opposite side need not have a coinciding vertex. Mouth seed pairs are
23830
+ // forced to their shared vertex midpoint so the crack tip closes exactly.
23831
+ // Returns the seams that moved coordinates.
23832
+ function snapSeams(seams, seeds, arcsById, arcs, footpoints, closeDistance) {
23833
+ arcs.getVertexData();
23834
+ var targets = new CoordinateTargets();
23835
+ var snapped = [];
23836
+
23837
+ seams.forEach(function(seam) {
23838
+ var sideA = arcsById[seam.arcA];
23839
+ var sideB = arcsById[seam.arcB];
23840
+ if (!sideA || !sideB) return;
23841
+ var before = targets.size();
23842
+ snapSideToOpposite(sideA, seam.loA, seam.hiA, sideB, arcs, footpoints,
23843
+ closeDistance, targets);
23844
+ snapSideToOpposite(sideB, seam.loB, seam.hiB, sideA, arcs, footpoints,
23845
+ closeDistance, targets);
23846
+ if (targets.size() > before) snapped.push(seam);
23847
+ });
23848
+
23849
+ // Mouth pairs win over footpoint midpoints so both tips land on one point.
23850
+ var seamsByPair = indexSeamsByArcPair(seams);
23851
+ seeds.forEach(function(seed) {
23852
+ if (!(seed.distance > 0)) return;
23853
+ var group = seamsByPair.get(arcPairKey(seed.a.arc, seed.b.arc));
23854
+ if (!group || !seamContainsSeed(group, seed)) return;
23855
+ var mid = [(seed.a.x + seed.b.x) / 2, (seed.a.y + seed.b.y) / 2];
23856
+ targets.set(seed.a.x, seed.a.y, mid);
23857
+ targets.set(seed.b.x, seed.b.y, mid);
23858
+ });
23859
+
23860
+ return applyTargets(arcs, targets) ? snapped : [];
23861
+ }
23862
+
23863
+ // Snap the facing pair of vertices at each open end of a seam together, which
23864
+ // closes the crack off there without disturbing the boundary between.
23865
+ //
23866
+ // An end counts as open when the two banks turn away from each other there. Where
23867
+ // the crack carries on instead, into the next pair of arcs, pinching would divide
23868
+ // one gap into a row of them -- each awarded separately, so the boundary would
23869
+ // zigzag from bank to bank along a crack that should have been resolved whole.
23870
+ //
23871
+ // Returns the number of mouths pinched shut.
23872
+ function pinchSeamMouths(seams, arcsById, arcs, nodes, footpoints, distance,
23873
+ spherical) {
23874
+ var data = arcs.getVertexData();
23875
+ var targets = new CoordinateTargets();
23876
+ var pinched = 0;
23877
+
23878
+ seams.forEach(function(seam) {
23879
+ var sideA = arcsById[seam.arcA];
23880
+ var sideB = arcsById[seam.arcB];
23881
+ if (!sideA || !sideB) return;
23882
+ [seam.loA, seam.hiA].forEach(function(kA, i) {
23883
+ var outwardA = i === 0 ? -1 : 1;
23884
+ var a = sideA[kA];
23885
+ // The two runs were grown from the seed independently, so they need not end
23886
+ // opposite each other; the nearest vertex within the facing run is the one
23887
+ // to close against.
23888
+ var kB = nearestVertexInRange(a, sideB, seam.loB, seam.hiB, distance,
23889
+ spherical);
23890
+ if (kB < 0) return;
23891
+ var outwardB = kB - seam.loB < seam.hiB - kB ? -1 : 1;
23892
+ if (!endIsOpen(sideA, seam.arcA, kA, outwardA, sideB, arcs, nodes,
23893
+ footpoints, distance) &&
23894
+ !endIsOpen(sideB, seam.arcB, kB, outwardB, sideA, arcs, nodes,
23895
+ footpoints, distance)) {
23896
+ return;
23897
+ }
23898
+ var b = sideB[kB];
23899
+ var mid = [(a.x + b.x) / 2, (a.y + b.y) / 2];
23900
+ var before = targets.size();
23901
+ targets.set(data.xx[a.i], data.yy[a.i], mid);
23902
+ targets.set(data.xx[b.i], data.yy[b.i], mid);
23903
+ if (targets.size() > before) pinched++;
23904
+ });
23905
+ });
23906
+
23907
+ return applyTargets(arcs, targets) ? pinched : 0;
23908
+ }
23909
+
23910
+ // True if the boundary has left the facing arc behind by the vertex just past
23911
+ // @k, so that the crack ends there.
23912
+ //
23913
+ // Where the arc itself ends at @k, the question is what carries on past the node:
23914
+ // an arc continuing along the crack keeps its next vertex within tolerance of the
23915
+ // facing bank, whereas one turning away from the crack -- the coverage boundary at
23916
+ // the crack's mouth, say -- does not. A crack whose banks are split into several
23917
+ // arcs is walked as several seams, and this is what keeps their inner ends from
23918
+ // being taken for mouths.
23919
+ function endIsOpen(side, arcId, k, outward, other, arcs, nodes, footpoints,
23920
+ distance) {
23921
+ var next = k + outward;
23922
+ if (next >= 0 && next < side.length) {
23923
+ return !footpoints.closestPoint(side[next].x, side[next].y, other, distance);
23924
+ }
23925
+ var entering = outward > 0 ? arcId : ~arcId;
23926
+ var connected = nodes.getConnectedArcs(entering);
23927
+ for (var i = 0; i < connected.length; i++) {
23928
+ // Connected ids lead into the node, so the vertex adjacent to it is the last
23929
+ // but one along each.
23930
+ var v = arcs.getVertex(connected[i], -2);
23931
+ if (footpoints.closestPoint(v.x, v.y, other, distance)) return false;
23932
+ }
23933
+ return true;
23934
+ }
23935
+
23936
+ // Index of the vertex in @side[lo..hi] nearest @vertex, or -1 if the nearest is
23937
+ // further off than @maxDist or already coincides with it. A coinciding pair marks
23938
+ // a tip where the two banks already meet, which needs no pinching.
23939
+ function nearestVertexInRange(vertex, side, lo, hi, maxDist, spherical) {
23940
+ var best = -1;
23941
+ var bestDist = Infinity;
23942
+ for (var k = lo; k <= hi; k++) {
23943
+ var d = spherical ?
23944
+ fastLonLatDistance(vertex.x, vertex.y, side[k].x, side[k].y) :
23945
+ distance2D$1(vertex.x, vertex.y, side[k].x, side[k].y);
23946
+ if (d < bestDist) {
23947
+ bestDist = d;
23948
+ best = k;
23949
+ }
23950
+ }
23951
+ if (best < 0 || !(bestDist > 0) || bestDist > maxDist) return -1;
23952
+ return best;
23953
+ }
23954
+
23955
+ // Move every occurrence of a selected coordinate, including coincident arc
23956
+ // endpoints, so existing nodes remain connected. Coordinates that were not
23957
+ // selected are untouched. Returns false if there was nothing to move.
23958
+ function applyTargets(arcs, targets) {
23959
+ if (targets.size() === 0) return false;
23960
+ var data = arcs.getVertexData();
23961
+ for (var i = 0; i < data.xx.length; i++) {
23962
+ var target = targets.get(data.xx[i], data.yy[i]);
23963
+ if (target) {
23964
+ data.xx[i] = target[0];
23965
+ data.yy[i] = target[1];
23966
+ }
23967
+ }
23968
+ arcs.updateVertexData(data.nn, data.xx, data.yy, data.zz);
23969
+ return true;
23970
+ }
23971
+
23972
+ // Replacement coordinates, looked up by exact x/y. Keyed by x in a Map, with a
23973
+ // short bucket per x, because the lookup runs once per vertex in the whole arc
23974
+ // table: building a string key for each of those is slower than the seam
23975
+ // detection that precedes it.
23976
+ function CoordinateTargets() {
23977
+ var byX = new Map();
23978
+ var count = 0;
23979
+
23980
+ this.set = function(x, y, target) {
23981
+ var bucket = byX.get(x);
23982
+ if (!bucket) {
23983
+ bucket = [];
23984
+ byX.set(x, bucket);
23985
+ }
23986
+ for (var i = 0; i < bucket.length; i++) {
23987
+ if (bucket[i].y === y) {
23988
+ bucket[i].target = target;
23989
+ return;
23990
+ }
23991
+ }
23992
+ bucket.push({y: y, target: target});
23993
+ count++;
23994
+ };
23995
+
23996
+ this.get = function(x, y) {
23997
+ var bucket = byX.get(x);
23998
+ if (!bucket) return null;
23999
+ for (var i = 0; i < bucket.length; i++) {
24000
+ if (bucket[i].y === y) return bucket[i].target;
24001
+ }
24002
+ return null;
24003
+ };
24004
+
24005
+ this.size = function() { return count; };
24006
+ }
24007
+
24008
+ function seamContainsSeed(seams, seed) {
24009
+ return seams.some(function(seam) {
24010
+ var aOnA = seed.a.arc === seam.arcA &&
24011
+ seed.a.k >= seam.loA && seed.a.k <= seam.hiA;
24012
+ var aOnB = seed.a.arc === seam.arcB &&
24013
+ seed.a.k >= seam.loB && seed.a.k <= seam.hiB;
24014
+ var bOnA = seed.b.arc === seam.arcA &&
24015
+ seed.b.k >= seam.loA && seed.b.k <= seam.hiA;
24016
+ var bOnB = seed.b.arc === seam.arcB &&
24017
+ seed.b.k >= seam.loB && seed.b.k <= seam.hiB;
24018
+ return (aOnA && bOnB) || (aOnB && bOnA);
24019
+ });
24020
+ }
24021
+
24022
+ function snapSideToOpposite(side, lo, hi, other, arcs, footpoints, closeDistance,
24023
+ targets) {
24024
+ var data = arcs.getVertexData();
24025
+ for (var k = lo; k <= hi; k++) {
24026
+ var v = side[k];
24027
+ var foot = footpoints.closestPoint(v.x, v.y, other, closeDistance);
24028
+ if (!foot || !(foot.distance > 0)) continue;
24029
+ var mid = [(v.x + foot.x) / 2, (v.y + foot.y) / 2];
24030
+ targets.set(data.xx[v.i], data.yy[v.i], mid);
24031
+ }
24032
+ }
24033
+
24034
+ // Nearest-point queries against an arc, indexing each arc once rather than
24035
+ // scanning it per query.
24036
+ //
24037
+ // Growing a seam tests every vertex along one arc against the whole of the
24038
+ // facing arc, and snapping repeats that for the vertices it accepted, so a scan
24039
+ // per query costs the product of the two arcs' lengths. That is unnoticeable
24040
+ // where a mosaic's arcs run between neighboring junctions a few vertices apart,
24041
+ // but a detailed boundary is one arc of thousands of vertices, and a mosaic of
24042
+ // those presents thousands of seams to walk: enough for the same repair to run
24043
+ // for many minutes.
24044
+ //
24045
+ // The index is built from live coordinates, and seam snapping moves coordinates
24046
+ // only once every seam has been measured, so one instance serves a whole pass
24047
+ // over a layer and must not outlive it.
24048
+ function ArcFootpoints(arcs, spherical) {
24049
+ var data = arcs.getVertexData();
24050
+ var indexes = new Map();
24051
+
24052
+ function getIndex(side) {
24053
+ if (!indexes.has(side)) {
24054
+ indexes.set(side, side.length > 1 ? buildIndex(side) : null);
24055
+ }
24056
+ return indexes.get(side);
24057
+ }
24058
+
24059
+ function buildIndex(side) {
24060
+ var index = new Flatbush(side.length - 1);
24061
+ for (var i = 0; i + 1 < side.length; i++) {
24062
+ var a = side[i].i, b = side[i + 1].i;
24063
+ index.add(
24064
+ Math.min(data.xx[a], data.xx[b]), Math.min(data.yy[a], data.yy[b]),
24065
+ Math.max(data.xx[a], data.xx[b]), Math.max(data.yy[a], data.yy[b]));
24066
+ }
24067
+ index.finish();
24068
+ return index;
24069
+ }
24070
+
24071
+ // The nearest point on @side within @maxDist of (x, y), with that distance, or
24072
+ // null if the arc stays clear of it. A segment within @maxDist has its nearest
24073
+ // point inside the query box, so its own bounding box meets that box: no
24074
+ // segment near enough is missed. Nothing beyond @maxDist is reported, which the
24075
+ // callers discarded anyway.
24076
+ this.closestPoint = function(x, y, side, maxDist) {
24077
+ var index = getIndex(side);
24078
+ if (!index) return null;
24079
+ var padX = getCoordinatePad(maxDist, y, spherical, true);
24080
+ var padY = getCoordinatePad(maxDist, y, spherical, false);
24081
+ var ids = index.search(x - padX, y - padY, x + padX, y + padY);
24082
+ var bestD2 = Infinity, bestX = 0, bestY = 0;
24083
+ for (var k = 0; k < ids.length; k++) {
24084
+ var i = ids[k];
24085
+ var ax = data.xx[side[i].i], ay = data.yy[side[i].i];
24086
+ var dx = data.xx[side[i + 1].i] - ax, dy = data.yy[side[i + 1].i] - ay;
24087
+ var len2 = dx * dx + dy * dy;
24088
+ var t = len2 > 0 ? ((x - ax) * dx + (y - ay) * dy) / len2 : 0;
24089
+ if (t < 0) t = 0;
24090
+ if (t > 1) t = 1;
24091
+ var fx = ax + t * dx, fy = ay + t * dy;
24092
+ var d2 = (x - fx) * (x - fx) + (y - fy) * (y - fy);
24093
+ if (d2 < bestD2) {
24094
+ bestD2 = d2;
24095
+ bestX = fx;
24096
+ bestY = fy;
24097
+ }
24098
+ }
24099
+ if (bestD2 === Infinity) return null;
24100
+ var distance = spherical ?
24101
+ fastLonLatDistance(x, y, bestX, bestY) :
24102
+ distance2D$1(x, y, bestX, bestY);
24103
+ if (!isFinite(distance) || distance > maxDist) return null;
24104
+ return {x: bestX, y: bestY, distance: distance};
24105
+ };
24106
+ }
24107
+
24108
+ var MIN_GAP_OWNERS = 3;
24109
+ var WIDTH_FACTOR = 4;
24110
+
24111
+
24112
+ // Split enclosed sliver gaps that border three or more features along the lines
24113
+ // midway between their neighbors' boundaries. The existing mosaic assignment can
24114
+ // then distribute the resulting pieces among those features instead of handing
24115
+ // the entire long gap to one owner.
24116
+ //
24117
+ // Only the boundaries of the gaps being repaired are examined, so the cost
24118
+ // scales with those gaps rather than with the full mosaic.
24119
+ // Returns a layer of cut lines added to the dataset.
24120
+ function partitionPolygonMosaicGaps(lyr, dataset, nodes, opts) {
24121
+ if (!lyr.shapes || lyr.shapes.length < MIN_GAP_OWNERS) return false;
24122
+ var mosaicIndex = new MosaicIndex(lyr, nodes, {flat: true});
24123
+ var sliverOpts = utils.extend({}, opts);
24124
+ if (sliverOpts.gap_width == null) {
24125
+ if (sliverOpts.gap_fill_area != null || sliverOpts.min_gap_area != null ||
24126
+ sliverOpts.min_area != null || sliverOpts.sliver_control != null) {
24127
+ if (sliverOpts.gap_fill_area == null && sliverOpts.min_gap_area == null &&
24128
+ sliverOpts.min_area == null) {
24129
+ sliverOpts.gap_fill_area = 'auto';
24130
+ }
24131
+ } else {
24132
+ sliverOpts.gap_width = 'auto';
24133
+ }
24134
+ }
24135
+ var filter = getSliverFilter(lyr, dataset, sliverOpts).filter;
24136
+ var gaps = mosaicIndex.getUnusedTileData(filter).filter(function(gap) {
24137
+ return countOwners(gap.boundary) >= MIN_GAP_OWNERS;
24138
+ });
24139
+ if (gaps.length === 0) return false;
24140
+
24141
+ var minWidth = getMinDivisibleGapWidth(lyr, nodes.arcs);
24142
+ var cuts = [];
24143
+ var partitioned = 0;
24144
+ gaps.forEach(function(gap) {
24145
+ var result = buildGapPartitionCuts(gap, nodes.arcs, minWidth);
24146
+ if (!result) return;
24147
+ var before = cuts.length;
24148
+ result.lines.forEach(function(line) {
24149
+ if (line.length < 2) return;
24150
+ cuts.push(line);
24151
+ });
24152
+ if (cuts.length > before) partitioned++;
24153
+ });
24154
+ if (cuts.length === 0) return false;
24155
+
24156
+ // Both mutations below have to be announced before either happens: undo
24157
+ // captures the dataset's arc collection and layer list together, and it needs
24158
+ // the originals -- the collection this replaces, and a layer list without the
24159
+ // temporary layer added here. Restoring the arc coordinates alone would leave
24160
+ // the dataset pointing at the merged collection, with layer shapes referring to
24161
+ // arc ids from the original one.
24162
+ noteDatasetWillChange(dataset, {operation: 'partitionGaps', unit: 'arcs'});
24163
+ var firstCutArc = dataset.arcs.size();
24164
+ dataset.arcs = mergeArcs([dataset.arcs, new ArcCollection(cuts)]);
24165
+ // Keep cut arcs in a temporary line layer while addIntersectionCuts() nodes
24166
+ // them against the gap boundary. The caller removes this layer before polygon
24167
+ // cleaning and passes its updated arc ids to the mosaic node filter.
24168
+ var cutLayer = {
24169
+ geometry_type: 'polyline',
24170
+ shapes: cuts.map(function(line, i) {
24171
+ return [[firstCutArc + i]];
24172
+ })
24173
+ };
24174
+ dataset.layers.push(cutLayer);
24175
+ markDatasetChanged(dataset, {operation: 'partitionGaps', unit: 'arcs'});
24176
+ verbose(utils.format('Partitioned %s multi-feature interior gap%s',
24177
+ partitioned, utils.pluralSuffix(partitioned)));
24178
+ return cutLayer;
24179
+ }
24180
+
24181
+ function countOwners(boundary) {
24182
+ var owners = {};
24183
+ boundary.forEach(function(o) { owners[o.shapeId] = true; });
24184
+ return Object.keys(owners).length;
24185
+ }
24186
+
24187
+ // Divide a gap along the centerlines between the boundary runs that face each
24188
+ // other across it.
24189
+ //
24190
+ // The gap's boundary is a cyclic sequence of arcs, each owned by one adjacent
24191
+ // feature, so it decomposes into one run per owner. The dividing line between
24192
+ // two runs is the locus of points equidistant from both; taking the midpoint
24193
+ // between each run's vertices and its footpoint on the other run gives a line
24194
+ // that bends wherever either bank bends, and between two consecutive bends both
24195
+ // banks are straight, where the exact bisector is straight as well.
24196
+ //
24197
+ // Cost and output size track the boundary's own vertex count, so a gap that is
24198
+ // long and hairline costs no more than a compact one. (A sampled Voronoi medial
24199
+ // axis cannot make that promise: its circumcenters are only meaningful when the
24200
+ // sample spacing is finer than the channel is wide, so the work it takes grows
24201
+ // with the gap's length-to-width ratio.)
24202
+ function buildGapPartitionCuts(gap, arcs, minWidth) {
24203
+ var runs = getOwnerRuns(gap.boundary);
24204
+ if (runs.length < MIN_GAP_OWNERS) return null;
24205
+ var ring = gap.tile[0];
24206
+ // Both measures must be planar even when the source CRS is geographic: the
24207
+ // cuts are built in source-coordinate space.
24208
+ var area = Math.abs(geom.getPlanarPathArea(ring, arcs));
24209
+ var perimeter = geom.getPlanarPathPerimeter(ring, arcs);
24210
+ if (!(area > 0) || !(perimeter > 0)) return null;
24211
+ // For a long narrow polygon, 2A/P approximates its width.
24212
+ var gapWidth = area / perimeter * 2;
24213
+ if (gapWidth < minWidth) return null;
24214
+ // A modest multiplier gives facing banks enough reach where the gap is locally
24215
+ // wider, e.g. at a junction.
24216
+ var width = gapWidth * WIDTH_FACTOR;
24217
+ var ringPoints = getPathPoints(ring, arcs);
24218
+ var ringIndex = new SegmentIndex(ringPoints);
24219
+ var banks = runs.map(function(run) {
24220
+ return makeBank(getRunPoints(run.arcIds, arcs));
24221
+ });
24222
+ var lines = [];
24223
+ for (var i = 0; i < banks.length; i++) {
24224
+ for (var j = i + 1; j < banks.length; j++) {
24225
+ var line = buildCenterlineBetweenBanks(banks[i], banks[j], width, ringIndex,
24226
+ collectEndAnchors(banks, i, j, width));
24227
+ if (line.length >= 2) lines.push(line);
24228
+ }
24229
+ }
24230
+ if (lines.length === 0) return null;
24231
+ return {lines: anchorCutLines(lines, width, ringPoints)};
24232
+ }
24233
+
24234
+ function makeBank(points) {
24235
+ return {
24236
+ points: points,
24237
+ index: points.length > 1 ? new SegmentIndex(points) : null
24238
+ };
24239
+ }
24240
+
24241
+ // Make a set of centerlines divide the gap: every line has to reach the gap's
24242
+ // boundary at each end, either directly or through a shared junction with other
24243
+ // lines, or the mosaic treats it as a dangling arc and ignores it.
24244
+ //
24245
+ // A line ending on a vertex of the boundary is already joined to it, that vertex
24246
+ // being a node of the mosaic. Anywhere else, including a point part way along a
24247
+ // boundary segment, the line has to be projected past the boundary to cross it:
24248
+ // starting an extension on the boundary rather than crossing it creates no node
24249
+ // at all, and the cut is ignored.
24250
+ function anchorCutLines(lines, width, ringPoints) {
24251
+ var ringVertices = new Set(ringPoints.map(function(p) {
24252
+ return p[0] + '|' + p[1];
24253
+ }));
24254
+ var ends = [];
24255
+ lines.forEach(function(line, lineId) {
24256
+ [line[0], line[line.length - 1]].forEach(function(point, side) {
24257
+ ends.push({
24258
+ lineId: lineId,
24259
+ side: side,
24260
+ point: point,
24261
+ onBoundary: ringVertices.has(point[0] + '|' + point[1])
24262
+ });
24263
+ });
24264
+ });
24265
+ var connectors = [];
24266
+ var joined = new Uint8Array(ends.length);
24267
+ clusterCutEnds(ends, width * 3).forEach(function(cluster) {
24268
+ var hub = [0, 0];
24269
+ cluster.forEach(function(i) {
24270
+ hub[0] += ends[i].point[0];
24271
+ hub[1] += ends[i].point[1];
24272
+ });
24273
+ hub[0] /= cluster.length;
24274
+ hub[1] /= cluster.length;
24275
+ cluster.forEach(function(i) {
24276
+ joined[i] = 1;
24277
+ connectors.push([ends[i].point, hub]);
24278
+ });
24279
+ });
24280
+ var anchored = lines.map(function(line, lineId) {
24281
+ var out = line.concat();
24282
+ ends.forEach(function(end, i) {
24283
+ if (end.lineId !== lineId || joined[i] || end.onBoundary) return;
24284
+ var inward = end.side ? out[out.length - 2] : out[1];
24285
+ var beyond = projectPast$1(end.point, inward, width);
24286
+ if (!beyond) return;
24287
+ if (end.side) out.push(beyond);
24288
+ else out.unshift(beyond);
24289
+ });
24290
+ return out;
24291
+ });
24292
+ return anchored.concat(connectors);
24293
+ }
24294
+
24295
+ // Group line ends that meet in the interior of the gap, where three or more
24296
+ // centerlines converge on the point equidistant from three boundary runs. Ends
24297
+ // already on the boundary are not candidates, and neither is a pair of ends
24298
+ // from the same line.
24299
+ function clusterCutEnds(ends, maxDist) {
24300
+ var used = new Uint8Array(ends.length);
24301
+ var clusters = [];
24302
+ ends.forEach(function(end, i) {
24303
+ if (used[i] || end.onBoundary) return;
24304
+ var cluster = [];
24305
+ var stack = [i];
24306
+ used[i] = 1;
24307
+ while (stack.length) {
24308
+ var id = stack.pop();
24309
+ cluster.push(id);
24310
+ ends.forEach(function(other, j) {
24311
+ if (used[j] || other.onBoundary || other.lineId === ends[id].lineId) return;
24312
+ if (geom.distance2D(ends[id].point[0], ends[id].point[1],
24313
+ other.point[0], other.point[1]) <= maxDist) {
24314
+ used[j] = 1;
24315
+ stack.push(j);
24316
+ }
24317
+ });
24318
+ }
24319
+ var lineIds = {};
24320
+ cluster.forEach(function(id) { lineIds[ends[id].lineId] = true; });
24321
+ if (Object.keys(lineIds).length >= MIN_GAP_OWNERS) clusters.push(cluster);
24322
+ });
24323
+ return clusters;
24324
+ }
24325
+
24326
+ // One run per owner, in ring order. The ring is cyclic, so a run split across
24327
+ // the array ends is rejoined.
24328
+ function getOwnerRuns(boundary) {
24329
+ var runs = [];
24330
+ boundary.forEach(function(o) {
24331
+ var last = runs[runs.length - 1];
24332
+ if (last && last.shapeId === o.shapeId) {
24333
+ last.arcIds.push(o.arcId);
24334
+ } else {
24335
+ runs.push({shapeId: o.shapeId, arcIds: [o.arcId]});
24336
+ }
24337
+ });
24338
+ if (runs.length > 2 && runs[0].shapeId === runs[runs.length - 1].shapeId) {
24339
+ runs[0].arcIds = runs.pop().arcIds.concat(runs[0].arcIds);
24340
+ }
24341
+ return runs;
24342
+ }
24343
+
24344
+ function getPathPoints(ids, arcs) {
24345
+ var points = [];
24346
+ var iter = arcs.getShapeIter(ids);
24347
+ while (iter.hasNext()) {
24348
+ points.push([iter.x, iter.y]);
24349
+ }
24350
+ return points;
24351
+ }
24352
+
24353
+ function getRunPoints(arcIds, arcs) {
24354
+ var points = [];
24355
+ arcIds.forEach(function(arcId) {
24356
+ var iter = arcs.getArcIter(arcId);
24357
+ while (iter.hasNext()) {
24358
+ var last = points[points.length - 1];
24359
+ // consecutive arcs share their joining vertex
24360
+ if (!last || last[0] !== iter.x || last[1] !== iter.y) {
24361
+ points.push([iter.x, iter.y]);
24362
+ }
24363
+ }
24364
+ });
24365
+ return points;
24366
+ }
24367
+
24368
+ function buildCenterlineBetweenBanks(bankA, bankB, maxDist, ringIndex, anchors) {
24369
+ var events = [];
24370
+ // Each vertex contributes a midpoint, positioned along bank A so that
24371
+ // contributions from both banks interleave in one order.
24372
+ bankA.points.forEach(function(p, i) {
24373
+ var mid = crossGapMidpoint(p, bankB, maxDist, ringIndex);
24374
+ if (mid) events.push({pos: i, point: mid.point});
24375
+ });
24376
+ bankB.points.forEach(function(p, i) {
24377
+ var mid = crossGapMidpoint(p, bankA, maxDist, ringIndex);
24378
+ if (mid) events.push({pos: mid.pos, point: mid.point});
24379
+ });
24380
+ anchors.forEach(function(event) { events.push(event); });
24381
+ events.sort(function(a, b) { return a.pos - b.pos; });
24382
+ var points = [];
24383
+ events.forEach(function(o) {
24384
+ var last = points[points.length - 1];
24385
+ if (!last || last[0] !== o.point[0] || last[1] !== o.point[1]) {
24386
+ points.push(o.point);
24387
+ }
24388
+ });
24389
+ return points;
24390
+ }
24391
+
24392
+ // Where the centerline between two boundary runs meets the ends of the gap.
24393
+ // Ordered outside bank A's own vertex positions, so they bracket the midpoints
24394
+ // between the two banks.
24395
+ //
24396
+ // Midpoints alone do not reach the ends: approaching one, a vertex's nearest point
24397
+ // on the facing bank swings round towards the end itself, and the midpoints stop
24398
+ // describing the middle of the gap. Taking the ends from the boundary is both
24399
+ // exact and what makes the cut divide the gap, since a cut has to reach the
24400
+ // boundary to have any effect.
24401
+ function collectEndAnchors(banks, i, j, maxDist) {
24402
+ var points = banks[i].points;
24403
+ var out = [];
24404
+ // walking the boundary each way round from bank A: A's last vertex leads
24405
+ // forwards, its first vertex backwards
24406
+ [[j, points.length], [i, -1]].forEach(function(o) {
24407
+ var between = runsBetween(banks, o[0] === j ? i : j, o[0]);
24408
+ var point = null;
24409
+ if (between.length === 0) {
24410
+ // The two runs meet, pinching the gap shut, and their shared node is
24411
+ // equidistant from both.
24412
+ point = o[1] < 0 ? points[0] : points[points.length - 1];
24413
+ } else if (between.length === 1 && pathLength(between[0].points) <= maxDist) {
24414
+ // A single run short enough to be a cap rather than a third bank: the gap
24415
+ // ends across it, midway along.
24416
+ point = midpointOfPath(between[0].points);
24417
+ }
24418
+ if (point) out.push({pos: o[1], point: point});
24419
+ });
24420
+ return out;
24421
+ }
24422
+
24423
+ // The runs lying between two others, walking the boundary forwards.
24424
+ function runsBetween(banks, from, to) {
24425
+ var out = [];
24426
+ for (var k = (from + 1) % banks.length; k !== to; k = (k + 1) % banks.length) {
24427
+ out.push(banks[k]);
24428
+ }
24429
+ return out;
24430
+ }
24431
+
24432
+ function pathLength(points) {
24433
+ var len = 0;
24434
+ for (var i = 1; i < points.length; i++) {
24435
+ len += geom.distance2D(points[i - 1][0], points[i - 1][1],
24436
+ points[i][0], points[i][1]);
24437
+ }
24438
+ return len;
24439
+ }
24440
+
24441
+ function midpointOfPath(points) {
24442
+ var half = pathLength(points) / 2;
24443
+ var walked = 0;
24444
+ for (var i = 1; i < points.length; i++) {
24445
+ var seg = geom.distance2D(points[i - 1][0], points[i - 1][1],
24446
+ points[i][0], points[i][1]);
24447
+ if (walked + seg >= half) {
24448
+ var t = seg > 0 ? (half - walked) / seg : 0;
24449
+ return [
24450
+ points[i - 1][0] + (points[i][0] - points[i - 1][0]) * t,
24451
+ points[i - 1][1] + (points[i][1] - points[i - 1][1]) * t
24452
+ ];
24453
+ }
24454
+ walked += seg;
24455
+ }
24456
+ return points[points.length - 1];
24457
+ }
24458
+
24459
+ // The midpoint between a vertex of one boundary run and its footpoint on
24460
+ // another, where the two runs face each other across the gap.
24461
+ //
24462
+ // Two runs can instead meet at a node and carry on along the same side of the
24463
+ // gap. Those produce midpoints too, but ones lying on the boundary rather than
24464
+ // between two banks, and a cut along them shaves a hairline strip off one feature
24465
+ // to award it to another -- the very artifact partitioning exists to avoid. So a
24466
+ // midpoint counts only if it lies inside the gap, clear of the boundary by an
24467
+ // amount comparable to the separation it came from.
24468
+ function crossGapMidpoint(point, otherBank, maxDist, ringIndex) {
24469
+ if (!otherBank.index) return null;
24470
+ var foot = otherBank.index.closestPoint(point[0], point[1], maxDist);
24471
+ if (!foot || !(foot.distSq > 0)) return null;
24472
+ var mid = [(point[0] + foot.x) / 2, (point[1] + foot.y) / 2];
24473
+ // Half the separation is what a midpoint between facing banks has. A quarter
24474
+ // allows for a gap that narrows, bringing a third stretch of its boundary
24475
+ // closer to the midpoint than the banks it came from.
24476
+ if (ringIndex.hasSegmentWithin(mid[0], mid[1], Math.sqrt(foot.distSq) / 4)) {
24477
+ return null;
24478
+ }
24479
+ // Two runs can also face each other across a feature, in a gap that bends back
24480
+ // on itself.
24481
+ if (!ringIndex.containsPoint(mid[0], mid[1])) return null;
24482
+ return {point: mid, pos: foot.pos};
24483
+ }
24484
+
24485
+ // The vertex tests above run once per boundary vertex, and the boundary of a long
24486
+ // gap in detailed data carries thousands of them, so each test has to examine a
24487
+ // bounded number of segments rather than the whole path. @points is an open
24488
+ // polyline, or a closed ring for containsPoint().
24489
+ function SegmentIndex(points) {
24490
+ var n = points.length - 1;
24491
+ var index = new Flatbush(n);
24492
+ var xmax = -Infinity;
24493
+ for (var i = 0; i < n; i++) {
24494
+ var ax = points[i][0], ay = points[i][1];
24495
+ var bx = points[i + 1][0], by = points[i + 1][1];
24496
+ index.add(Math.min(ax, bx), Math.min(ay, by), Math.max(ax, bx), Math.max(ay, by));
24497
+ if (ax > xmax) xmax = ax;
24498
+ if (bx > xmax) xmax = bx;
24499
+ }
24500
+ index.finish();
24501
+
24502
+ // Nearest point on the path within @maxDist, with its position along the path
24503
+ // (segment index plus the fraction into that segment) so that midpoints from
24504
+ // either bank can be ordered on a common parameter. A segment within @maxDist
24505
+ // of the point has its nearest point inside the query box, so its own bounding
24506
+ // box meets that box: nothing near enough is missed.
24507
+ this.closestPoint = function(x, y, maxDist) {
24508
+ var ids = index.search(x - maxDist, y - maxDist, x + maxDist, y + maxDist);
24509
+ var maxSq = maxDist * maxDist;
24510
+ var best = null;
24511
+ for (var k = 0; k < ids.length; k++) {
24512
+ var i = ids[k];
24513
+ var ax = points[i][0], ay = points[i][1];
24514
+ var dx = points[i + 1][0] - ax, dy = points[i + 1][1] - ay;
24515
+ var len2 = dx * dx + dy * dy;
24516
+ var t = len2 > 0 ? ((x - ax) * dx + (y - ay) * dy) / len2 : 0;
24517
+ if (t < 0) t = 0;
24518
+ if (t > 1) t = 1;
24519
+ var fx = ax + t * dx, fy = ay + t * dy;
24520
+ var distSq = (x - fx) * (x - fx) + (y - fy) * (y - fy);
24521
+ if (distSq <= maxSq && (!best || distSq < best.distSq)) {
24522
+ best = {x: fx, y: fy, distSq: distSq, pos: i + t};
24523
+ }
24524
+ }
24525
+ return best;
24526
+ };
24527
+
24528
+ this.hasSegmentWithin = function(x, y, dist) {
24529
+ var ids = index.search(x - dist, y - dist, x + dist, y + dist);
24530
+ var distSq = dist * dist;
24531
+ for (var k = 0; k < ids.length; k++) {
24532
+ var i = ids[k];
24533
+ if (geom.pointSegDistSq2(x, y, points[i][0], points[i][1],
24534
+ points[i + 1][0], points[i + 1][1]) < distSq) {
24535
+ return true;
24536
+ }
24537
+ }
24538
+ return false;
24539
+ };
24540
+
24541
+ // Crossings of the ray running right from the point: odd means inside. A point
24542
+ // on the ring counts as inside, as the linear test this replaces had it.
24543
+ this.containsPoint = function(x, y) {
24544
+ var ids = index.search(x, y, xmax, y);
24545
+ var inside = false;
24546
+ for (var k = 0; k < ids.length; k++) {
24547
+ var i = ids[k];
24548
+ var ay = points[i][1], by = points[i + 1][1];
24549
+ if (ay > y === by > y) continue;
24550
+ var ax = points[i][0], bx = points[i + 1][0];
24551
+ if (ax + (y - ay) / (by - ay) * (bx - ax) > x) inside = !inside;
24552
+ }
24553
+ return inside;
24554
+ };
24555
+ }
24556
+
24557
+ // The narrowest gap worth dividing, in coordinate units: the automatic
24558
+ // gap-width, one hundredth of the median distance between vertices.
24559
+ //
24560
+ // Dividing a gap is only an improvement while the gap has a width worth
24561
+ // dividing. Awarding a whole gap to one feature moves that feature's boundary
24562
+ // across to the far bank, by the width of the gap; dividing it instead gives
24563
+ // every neighbor a strip of its own, and the boundary between two strips runs
24564
+ // half the gap's width from the bank it was cut from, out and back. Wide gap:
24565
+ // the first is a visible displacement and the second is a fair division. Gap a
24566
+ // few millimeters wide: the first is imperceptible, and the second decorates
24567
+ // several features with hairline slivers, which is worse for having more of them.
24568
+ //
24569
+ // A layer's median segment length is the scale at which its boundaries were
24570
+ // drawn, so the same fraction of it that serves as the default gap-width -- what
24571
+ // mapshaper already treats as too narrow to be intended -- marks where dividing
24572
+ // stops being worth it. This is deliberately independent of the gap-width in
24573
+ // effect: raising that to fill wider gaps should not coarsen the data's own scale.
24574
+ function getMinDivisibleGapWidth(lyr, arcs) {
24575
+ var width = getMedianPolygonSegmentLength(lyr, arcs) *
24576
+ GAP_WIDTH_SEGMENT_FRACTION;
24577
+ // That median comes back in meters for geographic coordinates, whereas cuts
24578
+ // are built in the source coordinate space. Converting it back with the same
24579
+ // test it was measured under keeps the two in step.
24580
+ return arcs.isPlanar() ? width : metersToDegrees(width);
24581
+ }
24582
+
24583
+ function projectPast$1(from, toward, distance) {
24584
+ var dx = from[0] - toward[0];
24585
+ var dy = from[1] - toward[1];
24586
+ var len = Math.sqrt(dx * dx + dy * dy);
24587
+ if (!(len > 0)) return null;
24588
+ return [
24589
+ from[0] + dx / len * distance,
24590
+ from[1] + dy / len * distance
24591
+ ];
24592
+ }
24593
+
22516
24594
  cmd.cleanLayers = cleanLayers;
22517
24595
 
22518
24596
  function cleanLayers(layers, dataset, optsArg) {
@@ -22521,6 +24599,27 @@
22521
24599
  var deepClean = !opts.only_arcs;
22522
24600
  var pathClean = utils.some(layers, layerHasPaths);
22523
24601
  var nodes;
24602
+ // Collapsing duplicate boundaries runs unconditionally, and only ever merges
24603
+ // banks that differ by floating-point rounding. Left in place, such a seam
24604
+ // becomes an enclosed sliver that gap filling awards to one neighbor, giving
24605
+ // that feature a zero-width spike along the shared border.
24606
+ if (pathClean && dataset.arcs) {
24607
+ profileStart('collapseDuplicateBoundaries');
24608
+ noteArcsWillChange(dataset.arcs, {operation: 'clean-closeGaps'});
24609
+ var polygonLayers = layers.filter(function(lyr) {
24610
+ return lyr.geometry_type == 'polygon';
24611
+ });
24612
+ polygonLayers.forEach(function(lyr) {
24613
+ noteLayerWillChange(lyr, {operation: 'clean-closeGaps', unit: 'arc-ids'});
24614
+ });
24615
+ if (collapseDuplicateBoundaries(layers, dataset)) {
24616
+ markArcsChanged(dataset.arcs, {operation: 'clean-closeGaps'});
24617
+ polygonLayers.forEach(function(lyr) {
24618
+ markLayerChanged(lyr, {operation: 'clean-closeGaps', unit: 'arc-ids'});
24619
+ });
24620
+ }
24621
+ profileEnd('collapseDuplicateBoundaries');
24622
+ }
22524
24623
  if (opts.debug) {
22525
24624
  addIntersectionCuts(dataset, opts);
22526
24625
  profileEnd('cleanLayers');
@@ -22538,9 +24637,49 @@
22538
24637
  nodes = addIntersectionCuts(dataset, opts);
22539
24638
  }
22540
24639
  if (lyr.geometry_type == 'polygon') {
24640
+ // Pinching the mouths of cracks that open to the outside encloses them,
24641
+ // so that the passes below fill or divide them like any other gap. It
24642
+ // comes first for that reason, and only the pinched vertices move.
24643
+ if (opts.close_outer_gaps) {
24644
+ profileStart('pinchOuterCrackMouths');
24645
+ noteArcsWillChange(dataset.arcs, {operation: 'clean-pinchGapMouths'});
24646
+ noteLayerWillChange(lyr,
24647
+ {operation: 'clean-pinchGapMouths', unit: 'arc-ids'});
24648
+ if (pinchOuterCrackMouths(lyr, dataset, nodes, opts)) {
24649
+ markArcsChanged(dataset.arcs, {operation: 'clean-pinchGapMouths'});
24650
+ markLayerChanged(lyr,
24651
+ {operation: 'clean-pinchGapMouths', unit: 'arc-ids'});
24652
+ // A pinched pair only touches: the cutter turns each touch into a
24653
+ // node, which is what makes the crack a tile of the mosaic. Undo
24654
+ // already holds everything this re-cut changes, captured above.
24655
+ withActiveUndoTransaction(null, function() {
24656
+ nodes = addIntersectionCuts(dataset, opts);
24657
+ });
24658
+ }
24659
+ profileEnd('pinchOuterCrackMouths');
24660
+ }
24661
+ delete opts._mosaic_cut_arcs;
24662
+ profileStart('partitionPolygonMosaicGaps');
24663
+ noteArcsWillChange(dataset.arcs, {operation: 'clean-partitionGaps'});
24664
+ var cutLayer = partitionPolygonMosaicGaps(lyr, dataset, nodes, opts);
24665
+ if (cutLayer) {
24666
+ markArcsChanged(dataset.arcs, {operation: 'clean-partitionGaps'});
24667
+ // The arc collection the partition just installed is a throwaway: undo
24668
+ // puts back the collection this command started with, which the
24669
+ // partition captured. Capturing this one too would store a second copy
24670
+ // of the arc table to no purpose. Every layer baseline the re-cut needs
24671
+ // was captured by the addIntersectionCuts() call above.
24672
+ withActiveUndoTransaction(null, function() {
24673
+ nodes = addIntersectionCuts(dataset, opts);
24674
+ });
24675
+ opts._mosaic_cut_arcs = collectArcIds(cutLayer.shapes);
24676
+ dataset.layers.splice(dataset.layers.indexOf(cutLayer), 1);
24677
+ }
24678
+ profileEnd('partitionPolygonMosaicGaps');
22541
24679
  profileStart('cleanPolygonLayerGeometry');
22542
24680
  cleanPolygonLayerGeometry(lyr, dataset, opts);
22543
24681
  profileEnd('cleanPolygonLayerGeometry');
24682
+ delete opts._mosaic_cut_arcs;
22544
24683
  } else if (lyr.geometry_type == 'polyline') {
22545
24684
  profileStart('cleanPolylineLayerGeometry');
22546
24685
  cleanPolylineLayerGeometry(lyr, dataset);
@@ -22568,7 +24707,7 @@
22568
24707
 
22569
24708
  function cleanPolygonLayerGeometry(lyr, dataset, opts) {
22570
24709
  // clean polygons by apply the 'dissolve2' function to each feature
22571
- opts = Object.assign({gap_fill_area: 'auto'}, opts);
24710
+ opts = withDefaultGapWidth(opts);
22572
24711
  var groups = lyr.shapes.map(function(shp, i) {
22573
24712
  return [i];
22574
24713
  });
@@ -22577,6 +24716,30 @@
22577
24716
  markLayerChanged(lyr, {operation: 'cleanPolygonLayerGeometry', unit: 'shapes'});
22578
24717
  }
22579
24718
 
24719
+ function withDefaultGapWidth(opts) {
24720
+ opts = Object.assign({}, opts);
24721
+ if (opts.gap_width != null) return opts;
24722
+ // Legacy area/sliver options: keep the historical gap_fill_area=auto default.
24723
+ if (opts.gap_fill_area != null || opts.min_gap_area != null ||
24724
+ opts.min_area != null || opts.sliver_control != null) {
24725
+ if (opts.gap_fill_area == null && opts.min_gap_area == null &&
24726
+ opts.min_area == null) {
24727
+ opts.gap_fill_area = 'auto';
24728
+ }
24729
+ return opts;
24730
+ }
24731
+ opts.gap_width = 'auto';
24732
+ return opts;
24733
+ }
24734
+
24735
+ function collectArcIds(shapes) {
24736
+ var ids = {};
24737
+ traversePaths(shapes, function(o) {
24738
+ ids[o.arcId < 0 ? ~o.arcId : o.arcId] = true;
24739
+ });
24740
+ return ids;
24741
+ }
24742
+
22580
24743
  // Remove duplicate points from multipoint geometries
22581
24744
  // TODO: consider checking for invalid coordinates
22582
24745
  function cleanPointLayerGeometry(lyr, dataset, opts) {
@@ -24239,6 +26402,10 @@
24239
26402
  return !!(d && (d.icon || d['icon-size'] || (d['icon-color'] && d.r > 0)));
24240
26403
  }
24241
26404
 
26405
+ // An unsupported icon name renders as nothing, without a message -- this
26406
+ // function runs once per feature per render, so warning here would repeat the
26407
+ // same message for every feature (and on every redraw in the GUI). The -style
26408
+ // command warns about unsupported names when they are assigned instead.
24242
26409
  function renderIcon(d) {
24243
26410
  var type = d.icon || 'circle';
24244
26411
  var r = getIconRadius(d, type);
@@ -24247,7 +26414,6 @@
24247
26414
  if (type == 'square') return square(getIconStyleData(d, r), 0, 0);
24248
26415
  if (type == 'ring') return ring(getIconStyleData(d, r), 0, 0);
24249
26416
  if (type == 'star') return star(getIconStyleData(d, r));
24250
- message('Unknown icon type: ' + type);
24251
26417
  return empty();
24252
26418
  }
24253
26419
 
@@ -31872,13 +34038,27 @@ ${svg}
31872
34038
  var featureLength = bb.readUint32(offset);
31873
34039
  bb.setPosition(offset);
31874
34040
  var feature = Feature.getSizePrefixedRootAsFeature(bb);
31875
- geojsonFeature = fromFeature(id++, feature, headerMeta);
34041
+ geojsonFeature = readGeoJSONFeature(feature, headerMeta, id++);
31876
34042
  delete geojsonFeature.id;
31877
34043
  offset += SIZE_PREFIX_LEN + featureLength;
31878
34044
  return geojsonFeature;
31879
34045
  };
31880
34046
  }
31881
34047
 
34048
+ // The geometry field of a FlatGeobuf Feature is optional; fromFeature() assumes
34049
+ // it is present, so features without one are converted here.
34050
+ function readGeoJSONFeature(feature, headerMeta, id) {
34051
+ if (feature.geometry() === null) {
34052
+ return {
34053
+ type: 'Feature',
34054
+ id: id,
34055
+ geometry: null,
34056
+ properties: parseProperties$1(feature, headerMeta.columns)
34057
+ };
34058
+ }
34059
+ return fromFeature(id, feature, headerMeta);
34060
+ }
34061
+
31882
34062
  function buildHeaderWithCRS(headerMeta, crsMeta) {
31883
34063
  var builder = new Builder();
31884
34064
  var columnsOffset = createColumnsVector(builder, headerMeta.columns || []);
@@ -31923,24 +34103,31 @@ ${svg}
31923
34103
  function encodeCollection(source, columns, forcedType) {
31924
34104
  var headerMeta = null;
31925
34105
  var sink = null;
34106
+ // Null-geometry features encountered before the collection's geometry type is
34107
+ // known, and therefore before the header can be written.
34108
+ var deferred = [];
31926
34109
  for (var i = 0; i < source.length; i++) {
31927
34110
  var feature = source.getFeature(i);
34111
+ var properties = normalizeProperties(feature.properties, columns);
34112
+ if (!feature.geometry) {
34113
+ // A feature's geometry can go missing during export -- e.g. a path that
34114
+ // collapses to a single point at the output coordinate precision -- and a
34115
+ // layer can also contain null shapes to begin with. Write a record with
34116
+ // no geometry, matching what the GeoJSON and Shapefile exporters do.
34117
+ // Such a feature says nothing about the collection's geometry type, so it
34118
+ // must not make a homogeneous layer look mixed.
34119
+ var nullFeature = buildNullGeometryFeature(properties, columns);
34120
+ if (sink) {
34121
+ sink.append(nullFeature);
34122
+ } else {
34123
+ deferred.push(nullFeature);
34124
+ }
34125
+ continue;
34126
+ }
31928
34127
  var type = GeometryType[feature.geometry.type] || GeometryType.Unknown;
31929
34128
  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));
34129
+ headerMeta = getEncodedHeaderMeta(forcedType === null ? type : forcedType, columns, source.length);
34130
+ sink = initSink(source, headerMeta, deferred);
31944
34131
  } else if (forcedType === null && type != headerMeta.geometryType) {
31945
34132
  return null;
31946
34133
  }
@@ -31948,11 +34135,66 @@ ${svg}
31948
34135
  parseGC(feature.geometry) :
31949
34136
  parseGeometry(feature.geometry);
31950
34137
  omitRedundantGeometryType(geometry, headerMeta.geometryType);
31951
- sink.append(buildFeature(geometry, normalizeProperties(feature.properties, columns), headerMeta));
34138
+ sink.append(buildFeature(geometry, properties, headerMeta));
34139
+ }
34140
+ if (!sink) {
34141
+ // No feature had a geometry, so the collection has no geometry type.
34142
+ headerMeta = getEncodedHeaderMeta(forcedType === null ? GeometryType.Unknown : forcedType,
34143
+ columns, source.length);
34144
+ sink = initSink(source, headerMeta, deferred);
31952
34145
  }
31953
34146
  return sink.toBytes();
31954
34147
  }
31955
34148
 
34149
+ function getEncodedHeaderMeta(geometryType, columns, featuresCount) {
34150
+ return {
34151
+ geometryType: geometryType,
34152
+ columns: columns,
34153
+ envelope: null,
34154
+ featuresCount: featuresCount,
34155
+ indexNodeSize: 0,
34156
+ crs: null,
34157
+ title: null,
34158
+ description: null,
34159
+ metadata: null
34160
+ };
34161
+ }
34162
+
34163
+ function initSink(source, headerMeta, deferredFeatures) {
34164
+ var sink = new ByteSink(magicbytes.length + estimateEncodedBytes(source));
34165
+ sink.append(magicbytes);
34166
+ sink.append(buildHeader(headerMeta));
34167
+ for (var i = 0; i < deferredFeatures.length; i++) {
34168
+ sink.append(deferredFeatures[i]);
34169
+ }
34170
+ return sink;
34171
+ }
34172
+
34173
+ // Encodes a feature with attributes but no geometry, which FlatGeobuf supports
34174
+ // by omitting the Feature table's optional geometry field. buildFeature() always
34175
+ // writes a geometry, so the properties are encoded with it and then re-emitted:
34176
+ // property values are written in a typed binary layout that is better read from
34177
+ // one implementation than copied.
34178
+ function buildNullGeometryFeature(properties, columns) {
34179
+ var meta = {columns: columns};
34180
+ var withGeometry = buildFeature({xy: [], type: GeometryType.Unknown}, properties, meta);
34181
+ var bb = new ByteBuffer(withGeometry);
34182
+ var propertyBytes = Feature.getSizePrefixedRootAsFeature(bb).propertiesArray();
34183
+ var builder = new Builder();
34184
+ // Features are concatenated in the file and read in place, so every feature
34185
+ // buffer has to leave the next one 8-byte aligned or reading a Float64 array
34186
+ // of coordinates from it throws. Buffers holding coordinates are padded to a
34187
+ // multiple of 8 because of those arrays; a feature with only property bytes
34188
+ // needs the alignment requested explicitly.
34189
+ builder.prep(8, 0);
34190
+ var propertiesOffset = propertyBytes && propertyBytes.length > 0 ?
34191
+ Feature.createPropertiesVector(builder, propertyBytes) : 0;
34192
+ Feature.startFeature(builder);
34193
+ if (propertiesOffset) Feature.addProperties(builder, propertiesOffset);
34194
+ builder.finishSizePrefixed(Feature.endFeature(builder));
34195
+ return builder.asUint8Array();
34196
+ }
34197
+
31956
34198
  // Encoding the first feature is the only sample available before the buffer
31957
34199
  // has to be allocated; overshooting costs a resize, which is why it is loose.
31958
34200
  function estimateEncodedBytes(source) {
@@ -32292,6 +34534,8 @@ ${svg}
32292
34534
  var featureIndex = 0;
32293
34535
  for (var i = 0; i < state.count; i++) {
32294
34536
  var feat = exportFeatureAtIndex(state, i);
34537
+ // GeoPackage's GeoJSON insert path does not store true NULL geometries
34538
+ // (null becomes a NaN point). Skip them, matching prior behavior.
32295
34539
  if (!feat || !feat.geometry) continue;
32296
34540
  var normalized = normalizeFeature(feat, fields);
32297
34541
  try {
@@ -34389,7 +36633,8 @@ ${svg}
34389
36633
  datasets = [mergeDatasetsForExport(datasets)];
34390
36634
  }
34391
36635
  datasets.forEach(sortExportLayers);
34392
- files = await exportGeoPackage(datasets[0], opts);
36636
+ // GeoPackage bypasses exportFileContent(), where precision is normally applied.
36637
+ files = await exportGeoPackage(applyExportPrecision(datasets[0], opts), opts);
34393
36638
  } else if (format == 'geoparquet') {
34394
36639
  var layerCount = datasets.reduce(function(sum, d) {
34395
36640
  return sum + d.layers.length;
@@ -34398,7 +36643,8 @@ ${svg}
34398
36643
  files = [];
34399
36644
  for (var i = 0; i < datasets.length; i++) {
34400
36645
  sortExportLayers(datasets[i]);
34401
- files = files.concat(await exportGeoParquet(datasets[i], opts, singleFileName));
36646
+ // GeoParquet bypasses exportFileContent(), where precision is normally applied.
36647
+ files = files.concat(await exportGeoParquet(applyExportPrecision(datasets[i], opts), opts, singleFileName));
34402
36648
  }
34403
36649
  } else {
34404
36650
  files = datasets.reduce(function(memo, dataset) {
@@ -34452,6 +36698,15 @@ ${svg}
34452
36698
  });
34453
36699
  }
34454
36700
 
36701
+ // Apply -o precision= the same way exportFileContent() does for formats that
36702
+ // take their own export path (GeoPackage, GeoParquet).
36703
+ function applyExportPrecision(dataset, opts) {
36704
+ if (!opts.precision) return dataset;
36705
+ dataset = copyDatasetForExport(dataset);
36706
+ setCoordinatePrecision(dataset, opts.precision, !!opts.fix_geometry);
36707
+ return dataset;
36708
+ }
36709
+
34455
36710
  // Return an array of objects with 'filename' and 'content' members.
34456
36711
  //
34457
36712
  function exportFileContent(dataset, opts) {
@@ -36724,11 +38979,15 @@ ${svg}
36724
38979
  },
36725
38980
  minGapAreaOpt = {
36726
38981
  old_alias: 'min-gap-area',
36727
- describe: 'threshold for filling gaps, e.g. 1.5km2 (default is small)',
38982
+ describe: '(deprecated) use gap-width= instead',
36728
38983
  type: 'area'
36729
38984
  },
38985
+ gapWidthOpt = {
38986
+ describe: 'fill gaps narrower than this width, e.g. 2m (default is automatic)',
38987
+ type: 'distance'
38988
+ },
36730
38989
  sliverControlOpt = {
36731
- describe: 'boost gap-fill-area of slivers (0-1, default is 1)',
38990
+ describe: '(deprecated) use gap-width= instead',
36732
38991
  type: 'number'
36733
38992
  },
36734
38993
  calcOpt = {
@@ -37456,6 +39715,11 @@ ${svg}
37456
39715
 
37457
39716
  parser.command('clean')
37458
39717
  .describe('fixes geometry issues, such as polygon overlaps and gaps')
39718
+ .option('close-outer-gaps', {
39719
+ describe: 'close cracks that open to the outside of the mosaic',
39720
+ type: 'flag'
39721
+ })
39722
+ .option('gap-width', gapWidthOpt)
37459
39723
  .option('gap-fill-area', minGapAreaOpt)
37460
39724
  .option('sliver-control', sliverControlOpt)
37461
39725
  .option('snap-interval', snapIntervalOpt)
@@ -37555,7 +39819,7 @@ ${svg}
37555
39819
  });
37556
39820
 
37557
39821
  parser.command('contours')
37558
- .describe('convert a raster layer to contour lines')
39822
+ .describe('convert a raster layer to contour lines or closed bands')
37559
39823
  .option('interval', {
37560
39824
  describe: 'spacing between contour levels (default is a round interval)',
37561
39825
  type: 'number'
@@ -37575,6 +39839,10 @@ ${svg}
37575
39839
  .option('field', {
37576
39840
  describe: 'name of field to hold contour values (default is "value")'
37577
39841
  })
39842
+ .option('closed', {
39843
+ describe: 'create closed contour bands with lower and upper fields',
39844
+ type: 'flag'
39845
+ })
37578
39846
  .option('no-smoothing', {
37579
39847
  describe: 'skip smoothing away the one-pixel contour staircase',
37580
39848
  type: 'flag'
@@ -37655,8 +39923,9 @@ ${svg}
37655
39923
  type: 'flag',
37656
39924
  describe: '[points] use 2D math to find centroids of latlong points'
37657
39925
  })
39926
+ .option('gap-width', gapWidthOpt)
37658
39927
  .option('gap-fill-area', {
37659
- describe: '[polygons] threshold for filling gaps, e.g. 1.5km2',
39928
+ describe: '[polygons] (deprecated) use gap-width= instead',
37660
39929
  type: 'area'
37661
39930
  })
37662
39931
  .option('sliver-control', sliverControlOpt)
@@ -37685,8 +39954,9 @@ ${svg}
37685
39954
  .option('calc', calcOpt)
37686
39955
  .option('sum-fields', sumFieldsOpt)
37687
39956
  .option('copy-fields', copyFieldsOpt)
39957
+ .option('gap-width', gapWidthOpt)
37688
39958
  .option('gap-fill-area', {
37689
- describe: 'threshold for filling gaps, e.g. 1.5km2',
39959
+ describe: '(deprecated) use gap-width= instead',
37690
39960
  type: 'area'
37691
39961
  })
37692
39962
  .option('sliver-control', sliverControlOpt)
@@ -38216,6 +40486,17 @@ ${svg}
38216
40486
  describe: 'create an interior point for each polygon\'s largest ring',
38217
40487
  type: 'flag'
38218
40488
  })
40489
+ .option('inner-method', {
40490
+ // describe: 'centroid2, pole, centroid, weighted or legacy (default is centroid2)'
40491
+ })
40492
+ .option('inner-tolerance', {
40493
+ // describe: 'clearance loss allowed by centroid methods (default is 10%)',
40494
+ type: 'percent'
40495
+ })
40496
+ .option('inner-weight', {
40497
+ // describe: 'centroid weight used by inner-method=weighted (default is 0.6)',
40498
+ type: 'number'
40499
+ })
38219
40500
  .option('centroid', {
38220
40501
  describe: 'create a centroid point for each polygon\'s largest ring',
38221
40502
  type: 'flag'
@@ -45853,10 +48134,10 @@ ${svg}
45853
48134
  }
45854
48135
 
45855
48136
  function isClosedPath$1(arr) {
45856
- return isArrayOfPoints(arr) && arr.length > 3 && samePoint$1(arr[0], arr[arr.length - 1]);
48137
+ return isArrayOfPoints(arr) && arr.length > 3 && samePoint$2(arr[0], arr[arr.length - 1]);
45857
48138
  }
45858
48139
 
45859
- function samePoint$1(a, b) {
48140
+ function samePoint$2(a, b) {
45860
48141
  return a[0] == b[0] && a[1] == b[1];
45861
48142
  }
45862
48143
 
@@ -48205,12 +50486,12 @@ ${svg}
48205
50486
  return arr[arr.length - 1];
48206
50487
  }
48207
50488
 
48208
- function samePoint(a, b) {
50489
+ function samePoint$1(a, b) {
48209
50490
  return a && b && a[0] === b[0] && a[1] === b[1];
48210
50491
  }
48211
50492
 
48212
50493
  function isClosedPath(arr) {
48213
- return samePoint(arr[0], lastEl(arr));
50494
+ return samePoint$1(arr[0], lastEl(arr));
48214
50495
  }
48215
50496
 
48216
50497
  // remove likely rounding errors
@@ -48371,7 +50652,7 @@ ${svg}
48371
50652
  if (!nextPart) {
48372
50653
  return null;
48373
50654
  }
48374
- if (samePoint(ring[0], nextPart[0])) {
50655
+ if (samePoint$1(ring[0], nextPart[0])) {
48375
50656
  // done!
48376
50657
  ring.push(ring[0]); // close the ring
48377
50658
  return ring;
@@ -48420,7 +50701,7 @@ ${svg}
48420
50701
 
48421
50702
  function findPartStartingAt(parts, firstPoint) {
48422
50703
  for (var i=0; i<parts.length; i++) {
48423
- if (samePoint(parts[i][0], firstPoint)) {
50704
+ if (samePoint$1(parts[i][0], firstPoint)) {
48424
50705
  return parts[i];
48425
50706
  }
48426
50707
  }
@@ -48444,7 +50725,7 @@ ${svg}
48444
50725
  var part = [];
48445
50726
  var firstPoint = path[0];
48446
50727
  var lastPoint = lastEl(path);
48447
- var closed = samePoint(firstPoint, lastPoint);
50728
+ var closed = samePoint$1(firstPoint, lastPoint);
48448
50729
  var p, pp, y;
48449
50730
  for (var i=0, n=path.length; i<n; i++) {
48450
50731
  p = path[i];
@@ -48812,6 +51093,16 @@ ${svg}
48812
51093
 
48813
51094
  var POLAR_BUFFER_MARGIN_DEGREES$1 = 1e-4;
48814
51095
 
51096
+ // Distance at which to place the vertices of a round join whose chords span
51097
+ // stepAngle degrees, so that the chord MIDPOINTS -- not the vertices -- lie on
51098
+ // the offset circle of radius dist. The join then brackets the true arc instead
51099
+ // of being inscribed in it (see makeInscribedRoundJoin). The correction grows
51100
+ // with the step angle, so a gentle bend is barely moved and a sharp corner gets
51101
+ // the full ~0.5% of dist (at the default 8 segments/quadrant).
51102
+ function circumscribedJoinDist(dist, stepAngle) {
51103
+ return dist / Math.cos(stepAngle / 2 / R2D$8);
51104
+ }
51105
+
48815
51106
 
48816
51107
  // Returns a function for generating GeoJSON MultiPolygon geometries
48817
51108
  function getPolylineBufferMaker(dataset, opts) {
@@ -48821,6 +51112,7 @@ ${svg}
48821
51112
  var getOffsetPoint = getOffsetFunction(crs, opts);
48822
51113
  var roundJoinSegsPerQuadrant = opts.quad_segs >= 2 ? opts.quad_segs : 8;
48823
51114
  var roundJoinSegAngle = 90 / roundJoinSegsPerQuadrant;
51115
+ var circumscribeJoins = !!opts.circumscribe_joins; // see makeInscribedRoundJoin
48824
51116
  // Max arc step (degrees) for the coarse concave bridge (makeCoarseConcaveJoin),
48825
51117
  // the optional low-resolution alternative to makeConcaveJoin in
48826
51118
  // traceCleanOffsetSide. Larger = fewer points = faster dissolve. NOTE: a
@@ -49994,14 +52286,36 @@ ${svg}
49994
52286
  [getOffsetPoint(x, y, startDir + 180, dist)];
49995
52287
  }
49996
52288
 
49997
- // Inscribed round join: vertices on the offset arc at equal angle steps,
49998
- // each at the true offset distance (geodesic or planar).
52289
+ // Round join: an equal-angle-step polygonal approximation of the arc between
52290
+ // the incoming and outgoing offset edges, replacing the incoming edge's end
52291
+ // (p2Prev) and the outgoing edge's start (p1).
52292
+ //
52293
+ // Inscribed (default): vertices at the true offset distance, stepping from the
52294
+ // incoming perpendicular round to the outgoing one. Because its chords cut the
52295
+ // corner, the join lies INSIDE the true circular offset, falling short of it by
52296
+ // up to one sagitta (~0.5% of dist at the default 8 segments/quadrant).
52297
+ //
52298
+ // Circumscribed (circumscribe_joins): the same vertices, moved out to
52299
+ // circumscribedJoinDist so the chords span the offset circle instead of cutting
52300
+ // inside it. The LAST vertex stays at the true distance because the outgoing
52301
+ // straight edge attaches there: moving it radially outward would carry that edge
52302
+ // outward too, and since it also seeds the elbow joins of the segments that
52303
+ // follow, the bias propagates into a collar of offset-vs-source overshoot along
52304
+ // the whole outline -- which the fill-gaps mask cannot tell from a real gap fill
52305
+ // (its coast test tolerance is itself calibrated to the join sagitta). Leaving
52306
+ // that one vertex on the circle costs some accuracy: the final chord still dips
52307
+ // inside by about half a sagitta, so a gap tip whose closure happens to fall on
52308
+ // that chord is still short by ~0.25% of dist. Raise quad-segs to shrink it.
49999
52309
  function makeInscribedRoundJoin(cx, cy, startBearing, arcAngle, dist) {
50000
52310
  var pointCount = Math.max(1, Math.round(arcAngle / roundJoinSegAngle));
50001
52311
  var stepAngle = arcAngle / pointCount;
50002
52312
  var points = [];
50003
- for (var i = 1; i <= pointCount; i++) {
50004
- points.push(getOffsetPoint(cx, cy, startBearing + stepAngle * i, dist));
52313
+ var i;
52314
+ var outerDist = circumscribeJoins ?
52315
+ circumscribedJoinDist(dist, stepAngle) : dist;
52316
+ for (i = 1; i <= pointCount; i++) {
52317
+ points.push(getOffsetPoint(cx, cy, startBearing + stepAngle * i,
52318
+ i == pointCount ? dist : outerDist));
50005
52319
  }
50006
52320
  return points;
50007
52321
  }
@@ -50610,10 +52924,24 @@ ${svg}
50610
52924
  if (filterOpts.skipShape && filterOpts.skipShape(srcShape, srcArcs)) return bufShape;
50611
52925
  var intervalPct = simplifyFn ? simplifyFn(distance) / distance : 0;
50612
52926
  return filterOutlineArtifactHolesFromShape(bufShape, bufArcs, srcShape,
50613
- srcArcs, distance, intervalPct, sagPct, oneSided, sideOpts, spherical);
52927
+ srcArcs, distance, intervalPct, sagPct, oneSided, sideOpts, spherical,
52928
+ filterOpts.coverageIndex || null);
50614
52929
  });
50615
52930
  }
50616
52931
 
52932
+ // True if some other output feature covers the inside of this hole, making it
52933
+ // that feature's territory rather than an artifact of this one's
52934
+ // construction. @index is a PathIndex over every output shape: a hole is
52935
+ // outside its own feature by definition, so an enclosed probe can only be
52936
+ // another feature's.
52937
+ function holeIsNeighborTerritory(path, arcs, index) {
52938
+ var probes = getHoleInteriorProbes(getProjectedRingPoints(path, arcs, false));
52939
+ for (var i = 0; i < probes.length; i++) {
52940
+ if (index.pointIsEnclosed(probes[i])) return true;
52941
+ }
52942
+ return false;
52943
+ }
52944
+
50617
52945
  // Remove artifact rings left by dissolving the self-intersecting outline rings
50618
52946
  // made by the two-sided buffer fast path. The outline's concave-join loops can
50619
52947
  // survive the dissolve as spurious holes (and, degenerately, as zero-area rings
@@ -50623,8 +52951,12 @@ ${svg}
50623
52951
  // that its entire region lies inside the true buffer, and kept otherwise.
50624
52952
  // Filters one buffer shape (one source feature's dissolved outline); returns
50625
52953
  // the filtered shape, or null if nothing survives.
52954
+ // @coverageIndex: optional PathIndex over every output shape; a hole another
52955
+ // feature covers is kept whatever the distance test says. See the topological
52956
+ // polygon buffer, whose holes can be a neighbour's territory.
50626
52957
  function filterOutlineArtifactHolesFromShape(bufShape, bufArcs, srcShape,
50627
- srcArcs, distance, intervalPct, sagPct, oneSided, sideOpts, spherical) {
52958
+ srcArcs, distance, intervalPct, sagPct, oneSided, sideOpts, spherical,
52959
+ coverageIndex) {
50628
52960
  var sourceSegments = null;
50629
52961
  var sourceParts = null;
50630
52962
  var shape2 = (bufShape || []).filter(function(path) {
@@ -50637,6 +52969,9 @@ ${svg}
50637
52969
  return false;
50638
52970
  }
50639
52971
  if (!isHole) return true;
52972
+ if (coverageIndex && holeIsNeighborTerritory(path, bufArcs, coverageIndex)) {
52973
+ return true;
52974
+ }
50640
52975
  if (oneSided) {
50641
52976
  if (!sourceParts) {
50642
52977
  sourceParts = getSourcePathParts(srcShape, srcArcs, spherical);
@@ -52767,6 +55102,43 @@ ${svg}
52767
55102
  // floor/maxSpacing ratio at which small mosaics already sample cleanly.
52768
55103
  var FLOOR_DISTANCE_FRACTION = 0.1;
52769
55104
 
55105
+ // Soft cap on the number of entries in the boundary-segment grid, used to floor
55106
+ // the cell size (see buildSegmentGrid). The cell otherwise tracks the buffer
55107
+ // distance alone, so a small distance on a large input gives cells far shorter
55108
+ // than a single boundary segment and the index grows in inverse proportion to
55109
+ // the distance: a nationwide mosaic buffered at 1m spans ~1e7 cells, which costs
55110
+ // hundreds of megabytes and seconds of build time before any distance is
55111
+ // measured. A wider cell is always correct -- the 3x3 query window only requires
55112
+ // cell >= reach -- it just puts more segments in each bucket, so the budget
55113
+ // trades index size against bucket scan length. This value keeps the average
55114
+ // bucket near one segment on a nationwide mosaic, so the queries stay as cheap as
55115
+ // they are at large buffer distances.
55116
+ var GRID_INSERTION_BUDGET = 5e5;
55117
+
55118
+ // Rungs of the widening box search in treeGapAtPoint, and the factor between
55119
+ // them: four rungs a factor of 16 apart, so the search starts at 1/4096 of the
55120
+ // reach and climbs to it in three more steps. The step is coarse deliberately.
55121
+ // The rungs cost a geometric series dominated by the last, so a query that has
55122
+ // to climb the whole ladder pays only a few percent more than one that jumps
55123
+ // straight to the top, while a short ladder keeps the fixed per-search cost
55124
+ // down. Both numbers are fixed, and the last rung is always the full reach,
55125
+ // which is what bounds the query: it makes at most LADDER_RUNGS searches
55126
+ // whatever the input, and cannot widen past the reach.
55127
+ var LADDER_RUNGS = 4;
55128
+ var LADDER_STEP = 16;
55129
+ var LADDER_BASE = Math.pow(LADDER_STEP, LADDER_RUNGS - 1);
55130
+
55131
+ // Window size (segments in the 3x3 cell neighborhood) above which gapAtPoint
55132
+ // asks the tree instead of scanning. A scan rejects a segment belonging to the
55133
+ // query point's own feature in a couple of nanoseconds, so a window of a few
55134
+ // thousand still costs about what one tree query does -- that equivalence is
55135
+ // what the threshold marks, and below it the scan wins on fixed costs alone.
55136
+ // Purely a speed tradeoff: the two paths return the same distance, so this can
55137
+ // be retuned freely. Measured on two county mosaics, scanning up to this size
55138
+ // left the cheapest cases at their original speed while still handing over
55139
+ // early enough to keep the widest buffers off the scan's quadratic path.
55140
+ var WINDOW_SCAN_LIMIT = 3000;
55141
+
52770
55142
  // Soft target total site count. coarsen scales the gap-proportional spacing up
52771
55143
  // until the predicted total falls under this, keeping the Delaunay bounded on
52772
55144
  // dense shared-border mosaics while leaving sparse inputs at coarsen=1 (fully
@@ -52793,10 +55165,18 @@ ${svg}
52793
55165
  profileStart('medial:assembleChains');
52794
55166
  var chains = assembleChains(medial.segments, medial.coords);
52795
55167
  profileEnd('medial:assembleChains');
55168
+ profileStart('medial:recenter');
55169
+ chains = chains.map(function(chain) {
55170
+ return recenterMedialChain(chain, sites.grid);
55171
+ });
55172
+ profileEnd('medial:recenter');
52796
55173
  if (opts.smooth) {
52797
55174
  profileStart('medial:smooth');
52798
55175
  chains = chains.map(function(chain) {
52799
- return smoothMedialChain(chain, sites.grid);
55176
+ // Smoothing uses one kernel width for the whole chain (see
55177
+ // smoothMedialChain), so on a channel whose width varies it can bow the
55178
+ // line out of the narrow stretches; re-center again to pull it back.
55179
+ return recenterMedialChain(smoothMedialChain(chain, sites.grid), sites.grid);
52800
55180
  });
52801
55181
  profileEnd('medial:smooth');
52802
55182
  }
@@ -52814,7 +55194,9 @@ ${svg}
52814
55194
  for (var di = 0; di < coordDistances.length; di++) {
52815
55195
  if (coordDistances[di] > extendDist) extendDist = coordDistances[di];
52816
55196
  }
52817
- if (extendDist > 0) {
55197
+ // Local gap partitioning joins multi-owner junctions before extending only
55198
+ // the remaining outer endpoints; it opts out of this blanket extension.
55199
+ if (extendDist > 0 && !opts.no_extend) {
52818
55200
  chains = chains.map(function(chain) {
52819
55201
  return extendChainEndpoints(chain, extendDist);
52820
55202
  });
@@ -53036,6 +55418,150 @@ ${svg}
53036
55418
  return Math.sqrt(best);
53037
55419
  }
53038
55420
 
55421
+ // A medial vertex counts as off-center when its distance to the nearer bank
55422
+ // falls below this fraction of its distance to the opposite bank (1 = exactly
55423
+ // centered). Where a channel narrows past the site spacing the sampled Voronoi
55424
+ // can no longer resolve it: the triangles spanning the neck are slivers whose
55425
+ // circumcenters land on, or beyond, one bank, so the assembled chain zigzags
55426
+ // from side to side instead of running down the middle. Finer sampling cannot
55427
+ // cure this -- a channel that pinches to a point has zero width while the
55428
+ // spacing has a floor (see spacingFromGap) -- so the geometry is corrected
55429
+ // afterwards instead. 0.5 leaves the well-sampled interior (measured balance
55430
+ // ~0.9) untouched and catches the zigzag (~0.1 and below).
55431
+ var MEDIAL_BALANCE_TOLERANCE = 0.5;
55432
+
55433
+ // Pull medial vertices that are off-center, or have slipped inside a source
55434
+ // polygon, back onto the local centerline: each is replaced by the midpoint of
55435
+ // its nearest footpoint on either bank. That midpoint is equidistant from the two
55436
+ // banks by construction and lies between them, so it stays inside the channel
55437
+ // however narrow it gets; where the channel pinches shut both footpoints converge
55438
+ // on the neck and the line passes straight through it rather than bouncing off
55439
+ // the sides.
55440
+ //
55441
+ // Valid vertices are left exactly as sampled, so a well-resolved channel keeps
55442
+ // its true Voronoi geometry. Chain endpoints are also left alone: they sit
55443
+ // outside the overlap on purpose (hull rays, junction ends) so the cut-line can
55444
+ // reach the enclosing boundary, and re-centering them would pull the cut back
55445
+ // inside the tile it has to span.
55446
+ function recenterMedialChain(points, ctx) {
55447
+ if (!ctx || points.length < 3) return points;
55448
+ points = straightenChainTails(points, ctx);
55449
+ if (points.length < 3) return points;
55450
+ var out = [points[0]];
55451
+ for (var i = 1; i < points.length - 1; i++) {
55452
+ var p = recenteredVertex(ctx, points[i]);
55453
+ // Successive off-center vertices can re-center onto the same neck point;
55454
+ // keep one copy so the chain has no zero-length segments.
55455
+ if (!samePoint(p, out[out.length - 1])) out.push(p);
55456
+ }
55457
+ var last = points[points.length - 1];
55458
+ if (!samePoint(last, out[out.length - 1])) out.push(last);
55459
+ return out.length >= 2 ? out : points;
55460
+ }
55461
+
55462
+ // Straighten the runs of vertices at each end of a chain that lie inside a
55463
+ // source polygon. Such a vertex cannot be a medial point (its distance to that
55464
+ // polygon's boundary would be 0), and it cannot be re-centered either: these
55465
+ // runs trail past the mouth of a gap, where the banks converge into a border the
55466
+ // medial construction deliberately does not see (a shared arc partitions any
55467
+ // overlap by itself, so collectCandidateArcPaths prunes it), leaving no channel
55468
+ // to center them in. They are not dropped, because a chain has to poke out past
55469
+ // the gap it divides to node against the enclosing boundary (see
55470
+ // extendChainEndpoints): the run is collapsed to one straight segment, which
55471
+ // keeps the chain's reach and loses only its wandering.
55472
+ function straightenChainTails(points, ctx) {
55473
+ var last = points.length - 1;
55474
+ var lo = 0, hi = last;
55475
+ while (lo < hi && insideAnyPolygon(ctx, points[lo])) lo++;
55476
+ while (hi > lo && insideAnyPolygon(ctx, points[hi])) hi--;
55477
+ if (hi - lo < 1) return points; // no interior vertex survives: leave it alone
55478
+ if (lo === 0 && hi === last) return points;
55479
+ var out = points.slice(lo, hi + 1);
55480
+ if (lo > 0) out.unshift(points[0]);
55481
+ if (hi < last) out.push(points[last]);
55482
+ return out;
55483
+ }
55484
+
55485
+ function insideAnyPolygon(ctx, p) {
55486
+ var near = nearestSegment(ctx, p[0], p[1], -1);
55487
+ return near.id !== -1 && insideOwnerPolygon(ctx.seg, near.id, p[0], p[1]);
55488
+ }
55489
+
55490
+ // @p re-centered between its two nearest banks, or @p itself when it is already
55491
+ // a valid centerline point or only one feature is in range.
55492
+ var footA = [0, 0], footB = [0, 0]; // scratch, not retained
55493
+ function recenteredVertex(ctx, p) {
55494
+ var near = nearestSegment(ctx, p[0], p[1], -1);
55495
+ if (near.id === -1) return p;
55496
+ var far = nearestSegment(ctx, p[0], p[1], ctx.seg.feat[near.id]);
55497
+ if (far.id === -1) return p;
55498
+ // Only a pair of banks within their combined buffer reach forms a channel this
55499
+ // vertex could be the centerline of. Past the mouth of a gap the opposite bank
55500
+ // can be arbitrarily far away, and pulling the vertex to the midpoint of that
55501
+ // pair would move it hundreds of metres off any boundary.
55502
+ if (far.dist > ctx.seg.reach[near.id] + ctx.seg.reach[far.id]) return p;
55503
+ var offCenter = near.dist < far.dist * MEDIAL_BALANCE_TOLERANCE;
55504
+ if (!offCenter && !insideOwnerPolygon(ctx.seg, near.id, p[0], p[1])) return p;
55505
+ segmentFoot(ctx.seg, near.id, p[0], p[1], footA);
55506
+ segmentFoot(ctx.seg, far.id, p[0], p[1], footB);
55507
+ return [(footA[0] + footB[0]) / 2, (footA[1] + footB[1]) / 2];
55508
+ }
55509
+
55510
+ // True when (x, y) lies on the interior side of indexed segment @s, i.e. inside
55511
+ // the feature that owns it. Source rings are traversed with the polygon interior
55512
+ // on their right, and seg.side records whether the stored segment direction
55513
+ // matches its owner's traversal, so the sign of the cross product places the
55514
+ // point. Judged against the point's nearest segment, this is the test that
55515
+ // catches a medial vertex thrown out of a pinching gap into a neighbouring
55516
+ // polygon -- distance cannot: such a vertex is a channel width behind one bank
55517
+ // and two channel widths from the other, the same ratio as a merely off-center
55518
+ // point still inside the channel. A true medial vertex is never inside a source
55519
+ // polygon (its distance to that polygon's boundary would be 0).
55520
+ function insideOwnerPolygon(seg, s, x, y) {
55521
+ var dx = seg.x1[s] - seg.x0[s], dy = seg.y1[s] - seg.y0[s];
55522
+ var cross = dx * (y - seg.y0[s]) - dy * (x - seg.x0[s]);
55523
+ return cross * seg.side[s] < 0;
55524
+ }
55525
+
55526
+ // The nearest indexed source segment to (x, y), skipping segments owned by
55527
+ // @excludeFeat (-1 excludes nothing). Probes the 3x3 grid-cell neighborhood
55528
+ // (cell == max reach). id is -1 when the window holds no eligible segment.
55529
+ function nearestSegment(ctx, x, y, excludeFeat) {
55530
+ var seg = ctx.seg;
55531
+ var cx = ctx.colOf(x), cy = ctx.rowOf(y);
55532
+ var bestId = -1, best = Infinity;
55533
+ for (var gx = cx - 1; gx <= cx + 1; gx++) {
55534
+ for (var gy = cy - 1; gy <= cy + 1; gy++) {
55535
+ var bucket = ctx.grid.get(ctx.cellKey(gx, gy));
55536
+ if (!bucket) continue;
55537
+ for (var b = 0; b < bucket.length; b++) {
55538
+ var s = bucket[b];
55539
+ if (seg.feat[s] === excludeFeat) continue;
55540
+ var d2 = pointSegDistSq2(x, y, seg.x0[s], seg.y0[s], seg.x1[s], seg.y1[s]);
55541
+ if (d2 < best) { best = d2; bestId = s; }
55542
+ }
55543
+ }
55544
+ }
55545
+ return {id: bestId, dist: Math.sqrt(best)};
55546
+ }
55547
+
55548
+ // Closest point to (x, y) on indexed segment @s, clamped to its endpoints,
55549
+ // written into @out.
55550
+ function segmentFoot(seg, s, x, y, out) {
55551
+ var ax = seg.x0[s], ay = seg.y0[s];
55552
+ var dx = seg.x1[s] - ax, dy = seg.y1[s] - ay;
55553
+ var len2 = dx * dx + dy * dy;
55554
+ var t = len2 > 0 ? ((x - ax) * dx + (y - ay) * dy) / len2 : 0;
55555
+ if (t < 0) t = 0;
55556
+ if (t > 1) t = 1;
55557
+ out[0] = ax + t * dx;
55558
+ out[1] = ay + t * dy;
55559
+ }
55560
+
55561
+ function samePoint(a, b) {
55562
+ return a[0] === b[0] && a[1] === b[1];
55563
+ }
55564
+
53039
55565
  // Boundary sample spacing as a fraction of the local gap width: smaller gives a
53040
55566
  // smoother medial axis (more sites) in narrow channels. 0.5 keeps the spacing
53041
55567
  // at most half the gap, so a channel of width w has at least two samples per
@@ -53083,7 +55609,12 @@ ${svg}
53083
55609
  if (f === -1 && r === -1) continue; // arc not used by a buffered feature
53084
55610
  if (f !== -1 && r !== -1) continue; // shared interior border -- not a gap
53085
55611
  var pts = arcCoords(arcs, i);
53086
- if (pts.length >= 2) paths.push({owner: f !== -1 ? f : r, points: pts});
55612
+ // forward records whether the owner traverses the arc in the stored
55613
+ // direction, which is what fixes the interior side of its segments (see
55614
+ // insideOwnerPolygon).
55615
+ if (pts.length >= 2) {
55616
+ paths.push({owner: f !== -1 ? f : r, points: pts, forward: f !== -1});
55617
+ }
53087
55618
  }
53088
55619
  return paths;
53089
55620
  }
@@ -53136,8 +55667,15 @@ ${svg}
53136
55667
  // in a single pass, then we densify once.
53137
55668
  var grid = buildSegmentGrid$1(verts, coordDistances);
53138
55669
  var gaps = computeVertexGaps(grid, verts, coordDistances);
53139
- var coarsen = fitCoarsen(verts, gaps, coordDistances, spacingFloor);
53140
- var sites = densifyVertices(verts, gaps, coordDistances, spacingFloor, coarsen);
55670
+ // No contested channel anywhere: every vertex is either out of reach of every
55671
+ // other feature (open coast) or within the touching threshold of one (where the
55672
+ // source boundary already partitions). densify + Delaunay would only allocate
55673
+ // sites that keptSites then discards, so stop here. Single-feature inputs and
55674
+ // mosaics whose features are all farther than a buffer-diameter apart take this
55675
+ // path; a mosaic with even one real gap continues.
55676
+ if (!hasContestedGap(gaps, maxDistance)) return null;
55677
+ var coarsen = fitCoarsen(verts, gaps, coordDistances, spacingFloor, grid);
55678
+ var sites = densifyVertices(verts, gaps, coordDistances, spacingFloor, coarsen, grid);
53141
55679
  // Triangulate only the sites bordering a real gap. Its medial segments come
53142
55680
  // exclusively from cross-feature edges, and the well-shaped triangles that
53143
55681
  // bridge a gap have their apex within reach too (a far apex makes a thin
@@ -53154,65 +55692,232 @@ ${svg}
53154
55692
 
53155
55693
  // Bucket every boundary segment into a uniform grid so the nearest cross-feature
53156
55694
  // 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).
55695
+ // neighborhood. The cell is at least the maximum reach (sum of the two largest
55696
+ // buffer distances), so any in-reach segment is guaranteed to fall in that 3x3
55697
+ // window. Returns null when there is no positive reach. Reused for both the
55698
+ // per-vertex gap (drives adaptive sampling) and the per-site keep test
55699
+ // (gapAtPoint).
53161
55700
  function buildSegmentGrid$1(verts, coordDistances) {
53162
55701
  var paths = verts.paths;
53163
55702
  var maxDist = 0;
53164
55703
  for (var d = 0; d < coordDistances.length; d++) {
53165
55704
  if (coordDistances[d] > maxDist) maxDist = coordDistances[d];
53166
55705
  }
53167
- var cell = 2 * maxDist; // upper bound on any pair's reach
53168
- if (!(cell > 0)) return null;
55706
+ var reach = 2 * maxDist; // upper bound on any pair's reach
55707
+ if (!(reach > 0)) return null;
53169
55708
  var xmin = Infinity, ymin = Infinity, ymax = -Infinity;
55709
+ var totalLen = 0;
53170
55710
  paths.forEach(function(path) {
53171
55711
  var pts = path.points;
53172
55712
  for (var i = 0; i < pts.length; i++) {
53173
55713
  if (pts[i][0] < xmin) xmin = pts[i][0];
53174
55714
  if (pts[i][1] < ymin) ymin = pts[i][1];
53175
55715
  if (pts[i][1] > ymax) ymax = pts[i][1];
55716
+ if (i > 0) {
55717
+ var sdx = pts[i][0] - pts[i - 1][0];
55718
+ var sdy = pts[i][1] - pts[i - 1][1];
55719
+ totalLen += Math.sqrt(sdx * sdx + sdy * sdy);
55720
+ }
53176
55721
  }
53177
55722
  });
55723
+ // A segment occupies about len/cell cells, so the whole index is about
55724
+ // totalLen/cell entries; widening the cell to fit the budget bounds it (see
55725
+ // GRID_INSERTION_BUDGET). The reach is the floor, not the target: a cell
55726
+ // narrower than the reach would break the 3x3 query window.
55727
+ var cell = Math.max(reach, totalLen / GRID_INSERTION_BUDGET);
53178
55728
  // +1 cell index shift keeps probed -1 neighbors non-negative; rowSpan packs
53179
55729
  // (col, row) into a collision-free integer key.
53180
55730
  var rowSpan = Math.floor((ymax - ymin) / cell) + 3;
53181
55731
  function cellKey(cx, cy) { return (cx + 1) * rowSpan + (cy + 1); }
53182
55732
  function colOf(x) { return Math.floor((x - xmin) / cell); }
53183
55733
  function rowOf(y) { return Math.floor((y - ymin) / cell); }
53184
- var seg = {x0: [], y0: [], x1: [], y1: [], feat: [], reach: []};
55734
+ var seg = {x0: [], y0: [], x1: [], y1: [], feat: [], reach: [], side: []};
53185
55735
  var grid = new Map();
55736
+
55737
+ // Index a segment into every cell it crosses, column by column: within one
55738
+ // column the segment covers a single y-range (it is a straight line clipped to
55739
+ // that column's x-range), so the exact row span is two floor()s. This costs
55740
+ // O(len/cell) entries, where stamping the segment's bounding box instead costs
55741
+ // O((len/cell)^2) for a diagonal segment -- on a nationwide input buffered by a
55742
+ // few meters that quadratic term ran to ~1e9 entries and exceeded the runtime's
55743
+ // maximum Map size.
55744
+ function addSegment(ax, ay, bx, by, idx) {
55745
+ if (ax > bx) {
55746
+ var tx = ax; ax = bx; bx = tx;
55747
+ var ty = ay; ay = by; by = ty;
55748
+ }
55749
+ var dx = bx - ax, dy = by - ay;
55750
+ var cxa = colOf(ax), cxb = colOf(bx);
55751
+ for (var gx = cxa; gx <= cxb; gx++) {
55752
+ var yLo = ay, yHi = by;
55753
+ if (dx > 0) {
55754
+ // clip the segment to this column and take the y-range of the piece
55755
+ var xLo = Math.max(ax, xmin + gx * cell);
55756
+ var xHi = Math.min(bx, xmin + (gx + 1) * cell);
55757
+ yLo = ay + dy * (xLo - ax) / dx;
55758
+ yHi = ay + dy * (xHi - ax) / dx;
55759
+ }
55760
+ var gya = rowOf(Math.min(yLo, yHi));
55761
+ var gyb = rowOf(Math.max(yLo, yHi));
55762
+ for (var gy = gya; gy <= gyb; gy++) {
55763
+ var key = cellKey(gx, gy);
55764
+ var bucket = grid.get(key);
55765
+ if (bucket) bucket.push(idx); else grid.set(key, [idx]);
55766
+ }
55767
+ }
55768
+ }
55769
+
53186
55770
  paths.forEach(function(path) {
53187
55771
  var pts = path.points;
53188
55772
  var feat = path.owner;
53189
- var reach = coordDistances[feat];
55773
+ var featReach = coordDistances[feat];
55774
+ var side = path.forward === false ? -1 : 1;
53190
55775
  for (var k = 0; k + 1 < pts.length; k++) {
53191
55776
  var ax = pts[k][0], ay = pts[k][1], bx = pts[k + 1][0], by = pts[k + 1][1];
53192
55777
  var idx = seg.feat.length;
53193
55778
  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
- }
55779
+ seg.feat.push(feat); seg.reach.push(featReach); seg.side.push(side);
55780
+ addSegment(ax, ay, bx, by, idx);
53204
55781
  }
53205
55782
  });
53206
- return {seg: seg, grid: grid, cellKey: cellKey, colOf: colOf, rowOf: rowOf};
55783
+ return {seg: seg, grid: grid, cellKey: cellKey, colOf: colOf, rowOf: rowOf,
55784
+ index: buildSegmentTree(seg), maxReach: maxDist};
55785
+ }
55786
+
55787
+ // R-tree over the same segments, used only by gapAtPoint and only where the
55788
+ // grid's query window has grown expensive (see there). The grid remains the
55789
+ // index of record: it answers every other probe in this file.
55790
+ function buildSegmentTree(seg) {
55791
+ var n = seg.feat.length;
55792
+ if (n === 0) return null;
55793
+ var index = new Flatbush(n);
55794
+ for (var i = 0; i < n; i++) {
55795
+ var x0 = seg.x0[i], x1 = seg.x1[i], y0 = seg.y0[i], y1 = seg.y1[i];
55796
+ index.add(Math.min(x0, x1), Math.min(y0, y1), Math.max(x0, x1), Math.max(y0, y1));
55797
+ }
55798
+ index.finish();
55799
+ return index;
53207
55800
  }
53208
55801
 
53209
55802
  // The channel width at (x, y): distance to the nearest different-feature segment
53210
55803
  // within their combined reach, or Infinity if none. Works for any point, not
53211
55804
  // just original vertices, so a long edge whose endpoints are out of reach but
53212
55805
  // whose middle crosses a gap is still measured correctly at the interior sites.
55806
+ // Answered from whichever index is cheaper here. Both return the same number,
55807
+ // so the choice is a speed decision only: the window scan costs one pass over
55808
+ // the segments in the point's grid cells, which is nothing on a sparse
55809
+ // neighborhood but grows as the square of the buffer distance (the cell is
55810
+ // floored at the reach), while the tree search does not grow that way but pays
55811
+ // fixed costs on every call.
53213
55812
  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);
55813
+ var n = collectWindow(ctx, x, y);
55814
+ if (ctx.index && windowSize > WINDOW_SCAN_LIMIT) {
55815
+ return treeGapAtPoint(ctx, x, y, feat, reachF);
55816
+ }
55817
+ return scanWindow(ctx.seg, n, x, y, feat, reachF, Infinity);
55818
+ }
55819
+
55820
+ // Number of segments in the 3x3 cell window last collected by collectWindow.
55821
+ var windowSize = 0;
55822
+ // Scratch list of that window's buckets, reused across calls to keep the query
55823
+ // allocation-free. Safe because a query never re-enters gapAtPoint: the buckets
55824
+ // are consumed by scanWindow before anything else runs.
55825
+ var windowBuckets = [];
55826
+
55827
+ // Gather the buckets of the 3x3 cell neighborhood around (x, y) into the
55828
+ // scratch list, returning how many there are and setting windowSize to the
55829
+ // total segment count. The cell is at least the maximum reach, so this window
55830
+ // is guaranteed to hold every segment within reach of the point.
55831
+ function collectWindow(ctx, x, y) {
55832
+ var cx = ctx.colOf(x), cy = ctx.rowOf(y);
55833
+ var n = 0;
55834
+ windowSize = 0;
55835
+ for (var gx = cx - 1; gx <= cx + 1; gx++) {
55836
+ for (var gy = cy - 1; gy <= cy + 1; gy++) {
55837
+ var bucket = ctx.grid.get(ctx.cellKey(gx, gy));
55838
+ if (!bucket) continue;
55839
+ windowBuckets[n++] = bucket;
55840
+ windowSize += bucket.length;
55841
+ }
55842
+ }
55843
+ return n;
55844
+ }
55845
+
55846
+ // Exhaustive scan of the collected window: distance to the nearest
55847
+ // different-feature segment within their combined reach, or @best if none is
55848
+ // closer.
55849
+ function scanWindow(seg, n, x, y, feat, reachF, best) {
55850
+ for (var i = 0; i < n; i++) {
55851
+ var bucket = windowBuckets[i];
55852
+ for (var b = 0; b < bucket.length; b++) {
55853
+ var s = bucket[b];
55854
+ if (seg.feat[s] === feat) continue;
55855
+ var reach = reachF + seg.reach[s];
55856
+ var dsq = pointSegDistSq2(x, y, seg.x0[s], seg.y0[s], seg.x1[s], seg.y1[s]);
55857
+ if (dsq <= reach * reach) {
55858
+ var dist = Math.sqrt(dsq);
55859
+ if (dist < best) best = dist;
55860
+ }
55861
+ }
55862
+ }
55863
+ return best;
55864
+ }
55865
+
55866
+ // Same query answered from the R-tree, by widening a box search along a fixed
55867
+ // ladder of radii until the answer it holds is provably the global one.
55868
+ //
55869
+ // The invariant that makes an intermediate rung conclusive: a box of half-width
55870
+ // r contains the whole disc of radius r, so every segment closer than r to the
55871
+ // point is among the hits. If the nearest in-reach hit is itself within r, no
55872
+ // unreturned segment can beat it and the search can stop. Otherwise the answer
55873
+ // might lie in the annulus beyond r and the next rung has to look.
55874
+ //
55875
+ // The ladder is what keeps the cost sane in both directions. Starting small
55876
+ // makes the common case -- a point on one bank of a narrow gap, its neighbor a
55877
+ // few meters away -- cost one search of a tiny box, independent of the buffer
55878
+ // distance. Ending at the full reach makes the rare case -- a point with no
55879
+ // feature anywhere near it, so nothing can be proven short of exhausting the
55880
+ // reach -- cost the geometric sum of the rungs, a small multiple of going there
55881
+ // directly. The step is coarse so that there are only a handful of rungs.
55882
+ //
55883
+ // A previous attempt used neighbors() to take the k nearest instead. It was
55884
+ // much worse here, because a query that cannot find k in-reach candidates makes
55885
+ // neighbors() heap-walk every leaf in the whole reach disc -- and points with
55886
+ // no feature within reach are exactly what a coastline is made of.
55887
+ function treeGapAtPoint(ctx, x, y, feat, reachF) {
55888
+ var seg = ctx.seg;
55889
+ var index = ctx.index;
55890
+ // Nothing beyond the largest possible pair reach can pass the test below, so
55891
+ // the ladder tops out there rather than searching the whole extent.
55892
+ var maxDist = reachF + ctx.maxReach;
55893
+ var filter = function(s) { return seg.feat[s] !== feat; };
55894
+ var r = maxDist / LADDER_BASE;
55895
+ for (var k = 0; k < LADDER_RUNGS - 1; k++) {
55896
+ var best = nearestInBox(seg, index, x, y, r, reachF, filter);
55897
+ if (best <= r) return best;
55898
+ r *= LADDER_STEP;
55899
+ }
55900
+ // The top rung covers every segment that could be in reach at all, so its
55901
+ // answer is final even when nothing was found.
55902
+ return nearestInBox(seg, index, x, y, maxDist, reachF, filter);
55903
+ }
55904
+
55905
+ // Distance from (x, y) to the nearest segment that passes @filter and lies
55906
+ // within its pair reach, considering only segments whose bounding box meets the
55907
+ // box of half-width @r. Infinity if there is none.
55908
+ function nearestInBox(seg, index, x, y, r, reachF, filter) {
55909
+ var ids = index.search(x - r, y - r, x + r, y + r, filter);
55910
+ var best = Infinity;
55911
+ for (var i = 0; i < ids.length; i++) {
55912
+ var s = ids[i];
55913
+ var reach = reachF + seg.reach[s];
55914
+ var dsq = pointSegDistSq2(x, y, seg.x0[s], seg.y0[s], seg.x1[s], seg.y1[s]);
55915
+ if (dsq <= reach * reach) {
55916
+ var dist = Math.sqrt(dsq);
55917
+ if (dist < best) best = dist;
55918
+ }
55919
+ }
55920
+ return best;
53216
55921
  }
53217
55922
 
53218
55923
  // Local gap at each original vertex (drives adaptive sampling, see
@@ -53239,31 +55944,6 @@ ${svg}
53239
55944
  return gaps;
53240
55945
  }
53241
55946
 
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
55947
  // True when medial vertex c lies in the buffer overlap of features fp and fq:
53268
55948
  // within fp's radius of an fp-owned source segment AND within fq's radius of an
53269
55949
  // fq-owned source segment. Measured against the actual source segments via the
@@ -53376,21 +56056,52 @@ ${svg}
53376
56056
  return {paths: layout, count: count};
53377
56057
  }
53378
56058
 
56059
+ // True when any vertex borders a real gap: within reach of another feature, but
56060
+ // farther than the touching threshold. Matching keptSites' keep rule, so when
56061
+ // this is false every densified site would be discarded and there is nothing for
56062
+ // the medial to cut.
56063
+ function hasContestedGap(gaps, maxDistance) {
56064
+ var touch = maxDistance * TOUCHING_GAP_FRACTION;
56065
+ for (var i = 0; i < gaps.length; i++) {
56066
+ if (isFinite(gaps[i]) && gaps[i] > touch) return true;
56067
+ }
56068
+ return false;
56069
+ }
56070
+
53379
56071
  // The spacing for a path segment: the tighter of its two endpoints' gap-derived
53380
56072
  // 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);
56073
+ // Infinity means "do not densify" (see spacingFromGap). When both endpoints are
56074
+ // open-coast but the segment is long enough that its middle could still pass
56075
+ // within reach of another feature, the midpoint is probed via @ctx so a
56076
+ // contested channel bounded by long unvertexed edges is not missed.
56077
+ function segmentSpacing(path, k, gaps, maxSpacing, spacingFloor, coarsen, ctx) {
56078
+ var gA = gaps[path.vids[k]], gB = gaps[path.vids[k + 1]];
56079
+ var sA = spacingFromGap(gA, maxSpacing, spacingFloor, coarsen);
56080
+ var sB = spacingFromGap(gB, maxSpacing, spacingFloor, coarsen);
56081
+ var s = Math.min(sA, sB);
56082
+ if (isFinite(s) || !ctx) return s;
56083
+ // both endpoints open: only a long segment can hide a contested middle
56084
+ // (shorter than the reach, either endpoint would have seen it)
56085
+ var a = path.points[k], b = path.points[k + 1];
56086
+ var dx = b[0] - a[0], dy = b[1] - a[1];
56087
+ if (dx * dx + dy * dy <= 4 * maxSpacing * maxSpacing) return s;
56088
+ var midGap = gapAtPoint(ctx, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2,
56089
+ path.owner, maxSpacing);
56090
+ return spacingFromGap(midGap, maxSpacing, spacingFloor, coarsen);
53385
56091
  }
53386
56092
 
53387
56093
  // Re-sample every candidate path: emit each original vertex (tagged with its
53388
56094
  // vid) plus interior points spaced by the local gap-derived spacing (see
53389
56095
  // 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) {
56096
+ // Open-coast segments (both endpoints out of reach of every other feature) are
56097
+ // not densified -- keptSites would discard those interior points, and densifying
56098
+ // them at spacing = buffer distance is what used to emit tens of millions of
56099
+ // sites along a nationwide coastline. A segment with one contested endpoint
56100
+ // still densifies at that endpoint's rate, so a channel that pinches shut is
56101
+ // sampled into its mouth. Long open-coast edges are midpoint-probed (see
56102
+ // segmentSpacing) so a contested channel whose bounding edges lack a vertex
56103
+ // within reach is still sampled.
56104
+ function densifyVertices(verts, gaps, coordDistances, spacingFloor, coarsen, ctx) {
53394
56105
  var coords = [];
53395
56106
  var owner = [];
53396
56107
  var origin = []; // vid for original vertices, -1 for interpolated points
@@ -53404,10 +56115,11 @@ ${svg}
53404
56115
  origin.push(path.vids[k]);
53405
56116
  if (k + 1 >= m) continue; // open path: no segment past the last vertex
53406
56117
  var b = path.points[k + 1];
53407
- var s = segmentSpacing(path, k, gaps, maxSpacing, spacingFloor, coarsen);
56118
+ var s = segmentSpacing(path, k, gaps, maxSpacing, spacingFloor, coarsen, ctx);
53408
56119
  var dx = b[0] - a[0], dy = b[1] - a[1];
53409
56120
  var len = Math.sqrt(dx * dx + dy * dy);
53410
- if (s > 0 && len > s) {
56121
+ // s == Infinity for open-coast segments (see spacingFromGap): skip
56122
+ if (s > 0 && isFinite(s) && len > s) {
53411
56123
  var steps = Math.floor(len / s);
53412
56124
  for (var t = 1; t <= steps; t++) {
53413
56125
  var f = t / (steps + 1);
@@ -53421,8 +56133,15 @@ ${svg}
53421
56133
  return {coords: coords, owner: owner, origin: origin};
53422
56134
  }
53423
56135
 
56136
+ // Spacing used to densify a segment endpoint. Open coast (no other feature
56137
+ // within reach) returns Infinity so densifyVertices emits no interior points --
56138
+ // those sites cannot shape the medial and used to dominate the site count on
56139
+ // large inputs. Touching/coincident borders keep the coarse buffer-distance
56140
+ // spacing (the source boundary already partitions them; fine sampling would only
56141
+ // flood the triangulation with collinear sites). Real gaps densify at a fraction
56142
+ // of the local width, floored and capped.
53424
56143
  function spacingFromGap(gap, maxSpacing, spacingFloor, coarsen) {
53425
- if (!isFinite(gap)) return maxSpacing;
56144
+ if (!isFinite(gap)) return Infinity;
53426
56145
  // A gap at or below the buffer's positional tolerance means the two features
53427
56146
  // effectively touch: there is no contested channel to run a medial down, and
53428
56147
  // the shared source boundary already partitions the overlap. Densifying it
@@ -53440,18 +56159,18 @@ ${svg}
53440
56159
  // interior points per segment). Pure counting, no Delaunay -- cheap enough to
53441
56160
  // binary-search coarsen against. Counts pre-keep sites (the densification work),
53442
56161
  // which is what coarsen actually bounds.
53443
- function predictSiteCount(verts, gaps, coordDistances, spacingFloor, coarsen) {
56162
+ function predictSiteCount(verts, gaps, coordDistances, spacingFloor, coarsen, ctx) {
53444
56163
  var total = verts.count; // every original vertex is emitted
53445
56164
  verts.paths.forEach(function(path) {
53446
56165
  var maxSpacing = coordDistances[path.owner];
53447
56166
  var m = path.vids.length;
53448
56167
  for (var k = 0; k + 1 < m; k++) {
53449
- var s = segmentSpacing(path, k, gaps, maxSpacing, spacingFloor, coarsen);
56168
+ var s = segmentSpacing(path, k, gaps, maxSpacing, spacingFloor, coarsen, ctx);
53450
56169
  var a = path.points[k];
53451
56170
  var b = path.points[k + 1];
53452
56171
  var dx = b[0] - a[0], dy = b[1] - a[1];
53453
56172
  var len = Math.sqrt(dx * dx + dy * dy);
53454
- if (s > 0 && len > s) total += Math.floor(len / s);
56173
+ if (s > 0 && isFinite(s) && len > s) total += Math.floor(len / s);
53455
56174
  }
53456
56175
  });
53457
56176
  return total;
@@ -53461,17 +56180,17 @@ ${svg}
53461
56180
  // count decreases monotonically as coarsen grows (spacing widens), so binary
53462
56181
  // search converges; capped because near-coincident gaps (gap ~ 0) can't be
53463
56182
  // 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) {
56183
+ function fitCoarsen(verts, gaps, coordDistances, spacingFloor, ctx) {
56184
+ if (predictSiteCount(verts, gaps, coordDistances, spacingFloor, 1, ctx) <= SITE_BUDGET) {
53466
56185
  return 1;
53467
56186
  }
53468
56187
  var lo = 1, hi = 1024;
53469
- if (predictSiteCount(verts, gaps, coordDistances, spacingFloor, hi) > SITE_BUDGET) {
56188
+ if (predictSiteCount(verts, gaps, coordDistances, spacingFloor, hi, ctx) > SITE_BUDGET) {
53470
56189
  return hi; // even fully coarsened we can't fit; accept the floor-bounded count
53471
56190
  }
53472
56191
  for (var i = 0; i < 20; i++) {
53473
56192
  var mid = (lo + hi) / 2;
53474
- if (predictSiteCount(verts, gaps, coordDistances, spacingFloor, mid) > SITE_BUDGET) {
56193
+ if (predictSiteCount(verts, gaps, coordDistances, spacingFloor, mid, ctx) > SITE_BUDGET) {
53475
56194
  lo = mid;
53476
56195
  } else {
53477
56196
  hi = mid;
@@ -54586,6 +57305,10 @@ ${svg}
54586
57305
  getArcClassifier: getArcClassifier
54587
57306
  });
54588
57307
 
57308
+ // Buffer internals need a reliable interior probe, not a visually centered
57309
+ // label point. The bounded pole search avoids centroid2's second pass.
57310
+ var BUFFER_INNER_POINT_OPTS = {method: 'pole'};
57311
+
54589
57312
  function makePolygonBuffer(lyr, dataset, opts) {
54590
57313
  var spherical = isLatLngCRS(getDatasetCRS(dataset));
54591
57314
  if (spherical && sourceHasCollapsingBandEdge(lyr, dataset)) {
@@ -54692,6 +57415,15 @@ ${svg}
54692
57415
  }
54693
57416
  var output = buildPolygonBufferOutput(lyr, dataset, opts);
54694
57417
  var dataset2 = importGeoJSON(output.geojson, {type: 'polygon'});
57418
+ // A buffer is in the same CRS as its input, and the GeoJSON round trip above
57419
+ // does not carry one. Say so, because a caller that buffers this result again
57420
+ // has to parse a distance against it -- fill-gaps does exactly that when it
57421
+ // erodes the union of the dilated features. Without this, such a distance can
57422
+ // only be resolved for a lat-long input, where getDatasetCrsInfo guesses
57423
+ // wgs84 from the coordinate bounds; on projected input there is nothing to
57424
+ // guess from and the buffer failed with 'Unable to convert kilometers to
57425
+ // unknown coordinates'.
57426
+ setDatasetCrsInfo(dataset2, getDatasetCrsInfo(dataset));
54695
57427
  if (spherical) {
54696
57428
  splitAntimeridianBufferDataset(dataset2);
54697
57429
  if (output.dissolveAfterSplit) {
@@ -54703,7 +57435,21 @@ ${svg}
54703
57435
  lyr, dataset, opts, spherical, {
54704
57436
  skipShape: function(shape, arcs) {
54705
57437
  return shapeHasFillInsideHole(shape, arcs);
54706
- }
57438
+ },
57439
+ // Under the topological path a hole is often a tile withheld from this
57440
+ // feature because it is a neighbour's territory (see
57441
+ // getTopologicalBufferTileIds). The distance test cannot tell those from
57442
+ // dissolve artifacts -- both lie inside this feature's own buffer -- so
57443
+ // it deleted them, giving this feature ground another feature also
57444
+ // claims. It got worse as the radius grew, since a bigger buffer leaves
57445
+ // a shallower ring behind: across 3108 US counties it double-covered
57446
+ // 7.6km2 at R=500m and 507km2 at R=5km, mostly Virginia's independent
57447
+ // cities inside the counties enclosing them, and on 01_thin_gap_detail2
57448
+ // at 5km it consumed one of the two features outright. Holes that no
57449
+ // other feature covers are still the filter's to judge.
57450
+ coverageIndex: opts.topological ?
57451
+ new PathIndex(dataset2.layers[0].shapes.filter(Boolean), dataset2.arcs) :
57452
+ null
54707
57453
  });
54708
57454
  }
54709
57455
  cullSubTolerancePolygonArtifacts(dataset2, lyr, dataset, opts);
@@ -54878,17 +57624,30 @@ ${svg}
54878
57624
  // Mouth-gating mask: closing of the land by the mouth radius r (dilate by r,
54879
57625
  // union, erode by r). Built separately from the fill dilation because the
54880
57626
  // mouth threshold stays at the mouth size while the fill reaches further.
57627
+ // Two settings keep the dilation from falling short at the tips of a hairline
57628
+ // gap, which is where the mouth of the fill ends up: whatever the dilation fails
57629
+ // to bridge there is left as a residual indentation at the mouth, in proportion
57630
+ // to r, and no later stage can recover it.
57631
+ // - circumscribe_joins: the round joins are otherwise inscribed in the true
57632
+ // circular offset and fall a join sagitta short of it (~0.5% of r at the
57633
+ // default 8 segments/quadrant). See makeInscribedRoundJoin: the correction
57634
+ // costs no vertices, in contrast to raising quad-segs, whose cost is linear
57635
+ // in vertices through every stage below (dilate, union, erode, mask).
57636
+ // - tolerance 0: the default pre-simplification budget is 1% of the radius,
57637
+ // several times a hairline gap's width, so it smooths away the very tips
57638
+ // being bridged (and undoes the join correction with them). Skipping it
57639
+ // costs only a few percent here, since the joins dominate the vertex count.
54881
57640
  var dilatedAtR = makePolygonBuffer(lyr, dataset,
54882
- Object.assign({}, baseOpts, {radius: mouthRadius, topological: false}));
57641
+ Object.assign({}, baseOpts, {radius: mouthRadius, topological: false,
57642
+ circumscribe_joins: true, tolerance: 0}));
54883
57643
  var union = unionBufferDataset(dilatedAtR, baseOpts);
54884
57644
  if (!union || !union.arcs) return dilated;
54885
57645
  // tolerance: 0 disables the buffer's Douglas-Peucker pre-simplification for the
54886
57646
  // erosion. Pre-simplifying before an INWARD offset can push a simplified concave
54887
57647
  // vertex past its neighbours and self-intersect the eroded ring; on a large,
54888
57648
  // 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.
57649
+ // wrong side and the ring collapses, wiping the gap-fill extent. (The dilation
57650
+ // skips pre-simplification for its own reason -- see above.)
54892
57651
  var closing = makePolygonBuffer(union.layers[0], union,
54893
57652
  Object.assign({}, baseOpts, {radius: '-' + mouthRadius, topological: false,
54894
57653
  tolerance: 0}));
@@ -54946,7 +57705,7 @@ ${svg}
54946
57705
  }
54947
57706
  // hole: keep only if wide enough to hold a disk of radius keepRadius
54948
57707
  var holeShape = [reversePath(path.ids.concat())];
54949
- var anchor = findAnchorPoint(holeShape, arcs);
57708
+ var anchor = findAnchorPoint(holeShape, arcs, BUFFER_INNER_POINT_OPTS);
54950
57709
  var radius = anchor ?
54951
57710
  getPointToShapeDistance(anchor.x, anchor.y, holeShape, arcs) : 0;
54952
57711
  if (radius >= keepRadius) {
@@ -54976,8 +57735,12 @@ ${svg}
54976
57735
  // clears the arc/mouth tortuosity clause despite little depth. Requiring the patch
54977
57736
  // depth to reach this fraction of the mouth radius drops both while keeping real
54978
57737
  // 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
57738
+ // gaps reach depth/r >= 1.3, the shallow scallops only ~0.2). The margin matters,
57739
+ // because a coast-hugging strip only a few dozen metres thick can still measure a
57740
+ // large depth: depth is taken from the mouth CHORD, and that chord cuts straight
57741
+ // across whatever bay the strip follows. Those strips land around depth/r ~0.3, so
57742
+ // the floor sits above them rather than on them.
57743
+ var INLET_MIN_ABS_DEPTH = 0.4; // min patch depth as a fraction of the mouth radius
54981
57744
 
54982
57745
  // Island-bridge classification of a fill that joins >= 2 source parts, one small
54983
57746
  // (see bridgesSmallIsland). Two independent signatures mark an island bridge:
@@ -55135,9 +57898,14 @@ ${svg}
55135
57898
  var islandDataset = importGeoJSON(
55136
57899
  {type: 'GeometryCollection', geometries: islandGeoms}, {type: 'polygon'});
55137
57900
  setDatasetCrsInfo(islandDataset, crsInfo);
57901
+ // circumscribe_joins matches the closing's dilation (see makeGapFillPolygonBuffer):
57902
+ // this buffer has to cancel the island's mouth-radius reach, so if it grew a join
57903
+ // sagitta less than that dilation did, a hairline of bridge would survive the
57904
+ // erase and reconnect the island.
55138
57905
  var islandBuf = makePolygonBuffer(islandDataset.layers[0], islandDataset,
55139
57906
  Object.assign({}, opts, {fill_gaps: false, max_widening: null,
55140
- radius: mouthRadiusStr, topological: false, no_replace: true}));
57907
+ radius: mouthRadiusStr, topological: false, no_replace: true,
57908
+ circumscribe_joins: true}));
55141
57909
  if (!islandBuf || !islandBuf.arcs) return;
55142
57910
  var bridgeDataset = importGeoJSON(
55143
57911
  {type: 'GeometryCollection', geometries: bridgeGeoms}, {type: 'polygon'});
@@ -55413,6 +58181,10 @@ ${svg}
55413
58181
  if (coords.length === 0) return null;
55414
58182
  var unionDataset = getBufferDataset(coords);
55415
58183
  if (!unionDataset.arcs) return null;
58184
+ // getBufferDataset builds from bare coordinates, so carry the CRS across:
58185
+ // fill-gaps buffers this union again to erode it, and that needs a CRS to
58186
+ // resolve a distance given in real-world units.
58187
+ setDatasetCrsInfo(unionDataset, getDatasetCrsInfo(dataset));
55416
58188
  dissolveBufferDataset2(unionDataset, Object.assign({}, opts, {winding_fill: false}));
55417
58189
  return unionDataset;
55418
58190
  }
@@ -56193,22 +58965,22 @@ ${svg}
56193
58965
  var sourceIdIndex = getIdLookup(sourceIds);
56194
58966
  var bufferIdIndex = getIdToFeatureIdLookup(bufferIds);
56195
58967
  var sourceAreas = getSourceShapeAreas(shapes, sourceArcs);
56196
- var sourceInteriorPoints = getSourceInteriorPoints(shapes, sourceArcs);
56197
58968
  var ownerCtx = createTileOwnerContext(shapes, sourceArcs, sourceAreas,
56198
58969
  mosaicIndex.nodes.arcs);
56199
58970
  profileStart('topo:assignTiles');
56200
58971
  var result = shapes.map(function(shape, i) {
56201
58972
  var distance = distances[i];
56202
- var tileIds, geom;
58973
+ var tileIds;
56203
58974
  if (!distance || !shape) return null;
56204
58975
  tileIds = getTopologicalBufferTileIds(sourceIds[i], bufferIds[i],
56205
58976
  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});
58977
+ // No artifact-hole filtering here. Every hole in this geometry is a tile
58978
+ // withheld from this feature on purpose (see getTopologicalBufferTileIds),
58979
+ // so there is nothing for a single-shape heuristic to clean up and it can
58980
+ // only do harm: the interior-point territory test it used to run missed the
58981
+ // holes it could not sample, and filling those handed a neighbour's ground
58982
+ // to this feature -- 18.6 km2 of it across 3108 US counties at R=5km.
58983
+ return getTileIdsGeometry(tileIds, mosaicIndex, pathfind);
56212
58984
  });
56213
58985
  profileEnd('topo:assignTiles');
56214
58986
  return result;
@@ -56404,7 +59176,7 @@ ${svg}
56404
59176
  function getTileAnchorPoint(tileId, mosaicIndex, ownerCtx) {
56405
59177
  if (tileId in ownerCtx.anchorCache) return ownerCtx.anchorCache[tileId];
56406
59178
  var tile = mosaicIndex.mosaic[tileId];
56407
- var p = findAnchorPoint(tile, ownerCtx.mosaicArcs) ||
59179
+ var p = findAnchorPoint(tile, ownerCtx.mosaicArcs, BUFFER_INNER_POINT_OPTS) ||
56408
59180
  getPathCentroid(tile[0], ownerCtx.mosaicArcs) || null;
56409
59181
  ownerCtx.anchorCache[tileId] = p;
56410
59182
  return p;
@@ -56427,22 +59199,6 @@ ${svg}
56427
59199
  });
56428
59200
  }
56429
59201
 
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
59202
  function getIdLookup(ids) {
56447
59203
  var index = [];
56448
59204
  ids.forEach(function(id) {
@@ -56654,11 +59410,7 @@ ${svg}
56654
59410
  return false;
56655
59411
  }
56656
59412
 
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) {
59413
+ function removePositiveBufferArtifactHoles(geom, shape, arcs, distance) {
56662
59414
  if (!geom) return null;
56663
59415
  // Nothing to filter unless the result actually has interior rings. Skip the
56664
59416
  // index build (filterArtifactHoles is itself a no-op on hole-free polygons,
@@ -56682,9 +59434,7 @@ ${svg}
56682
59434
  shapeIndex: buildShapeSegmentIndex(shape, arcs),
56683
59435
  pathIndex: shape && shape.length > 0 ? new PathIndex([shape], arcs) : null,
56684
59436
  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
59437
+ buildShapeSegmentIndex(sourceHoles.map(function(h) {return h[0];}), arcs) : null
56688
59438
  };
56689
59439
  if (geom.type == 'Polygon') {
56690
59440
  geom.coordinates = filterArtifactHoles(geom.coordinates, minHoleArea, ctx);
@@ -56794,63 +59544,11 @@ ${svg}
56794
59544
  function filterArtifactHoles(polygon, minHoleArea, ctx) {
56795
59545
  if (polygon.length < 2) return polygon;
56796
59546
  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
59547
  return Math.abs(getGeoJSONRingArea(ring)) > minHoleArea &&
56801
59548
  !positiveBufferHoleIsArtifact(ring, ctx);
56802
59549
  }));
56803
59550
  }
56804
59551
 
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
59552
  function positiveBufferHoleIsArtifact(ring, ctx) {
56855
59553
  var n = ring.length - 1; // skip duplicate endpoint
56856
59554
  var step = Math.max(1, Math.floor(n / 20));
@@ -57753,7 +60451,7 @@ ${svg}
57753
60451
  debug('Short ring', coords);
57754
60452
  return;
57755
60453
  }
57756
- if (!samePoint(coords[0], lastEl(coords))) {
60454
+ if (!samePoint$1(coords[0], lastEl(coords))) {
57757
60455
  error('Open polygon ring');
57758
60456
  }
57759
60457
  rings.push(coords); // accumulate rings
@@ -64030,6 +66728,312 @@ ${svg}
64030
66728
  // message('[comment]', opts.message);
64031
66729
  }; // no-op, so -comment doesn't trigger a parsing error
64032
66730
 
66731
+ // Close the existing directed contour paths against a fixed raster/nodata
66732
+ // boundary, polygonize the resulting network and dissolve its tiles by range.
66733
+ // This deliberately uses the isoline tracer's conservative validity rule: a
66734
+ // lattice cell is excluded when any of its four sample corners is invalid.
66735
+ function buildClosedContourDataset(contourDataset, grid, contours) {
66736
+ var domain = getContourCellDomain(grid, contours.band);
66737
+ var breaks = getClosedContourBreaks(contours.levels, contours.range);
66738
+ var boundaryDataset, dataset, contourLyr, nodes, arcInfo, mosaic;
66739
+ var tileLyr, outputLyr;
66740
+ if (breaks.length === 0 || domain.activeCount === 0) {
66741
+ return createEmptyClosedContourDataset(contourDataset.info);
66742
+ }
66743
+ boundaryDataset = importGeoJSON(getBoundaryGeoJSON(traceContourDomainRings(domain)), {});
66744
+ boundaryDataset.info = contourDataset.info;
66745
+ dataset = mergeDatasets([contourDataset, boundaryDataset]);
66746
+ contourLyr = dataset.layers[0];
66747
+ nodes = addIntersectionCuts(dataset, {no_snap: true});
66748
+ arcInfo = indexContourArcs(contourLyr);
66749
+ mosaic = buildPolygonMosaic(nodes).mosaic;
66750
+ tileLyr = createBandTileLayer(mosaic, dataset.arcs, arcInfo, domain, grid,
66751
+ contours.band, breaks);
66752
+ dataset.layers = [tileLyr];
66753
+ if (tileLyr.shapes.length === 0) {
66754
+ cleanArcReferences(dataset);
66755
+ return dataset;
66756
+ }
66757
+ outputLyr = dissolvePolygonLayer2(tileLyr, dataset, {
66758
+ fields: ['lower', 'upper'],
66759
+ quiet: true
66760
+ });
66761
+ dataset.layers = [outputLyr];
66762
+ cleanArcReferences(dataset);
66763
+ return dataset;
66764
+ }
66765
+
66766
+ function getClosedContourBreaks(levels, range) {
66767
+ if (!range || !isFinite(range.min) || !isFinite(range.max)) return [];
66768
+ if (range.min === range.max) return [range.min, range.max];
66769
+ return [range.min].concat(levels.filter(function(level) {
66770
+ return level > range.min && level < range.max;
66771
+ }), [range.max]);
66772
+ }
66773
+
66774
+ function getContourCellDomain(grid, band) {
66775
+ var W = grid.width;
66776
+ var H = grid.height;
66777
+ var cellW = W - 1;
66778
+ var cellH = H - 1;
66779
+ var samples = grid.samples;
66780
+ var bands = grid.bands;
66781
+ var valid = getRasterValidityMask(grid);
66782
+ var rowStride = W * bands;
66783
+ var active = new Uint8Array(Math.max(0, cellW * cellH));
66784
+ var activeCount = 0;
66785
+ var cx, cy, pixelId, off;
66786
+ for (cy = 0; cy < cellH; cy++) {
66787
+ for (cx = 0; cx < cellW; cx++) {
66788
+ pixelId = cy * W + cx;
66789
+ if (valid && (!valid[pixelId] || !valid[pixelId + 1] ||
66790
+ !valid[pixelId + W] || !valid[pixelId + W + 1])) continue;
66791
+ off = pixelId * bands + band;
66792
+ if (samples[off] !== samples[off] ||
66793
+ samples[off + bands] !== samples[off + bands] ||
66794
+ samples[off + rowStride] !== samples[off + rowStride] ||
66795
+ samples[off + rowStride + bands] !==
66796
+ samples[off + rowStride + bands]) continue;
66797
+ active[cy * cellW + cx] = 1;
66798
+ activeCount++;
66799
+ }
66800
+ }
66801
+ return {
66802
+ active: active,
66803
+ activeCount: activeCount,
66804
+ width: cellW,
66805
+ height: cellH,
66806
+ gridWidth: W,
66807
+ gridHeight: H,
66808
+ bbox: grid.bbox
66809
+ };
66810
+ }
66811
+
66812
+ function pointIsInContourDomain(x, y, domain) {
66813
+ var bbox = domain.bbox;
66814
+ var dx, dy, cx, cy;
66815
+ if (x < bbox[0] || x > bbox[2] || y < bbox[1] || y > bbox[3] ||
66816
+ domain.width < 1 || domain.height < 1) return false;
66817
+ dx = (bbox[2] - bbox[0]) / domain.gridWidth;
66818
+ dy = (bbox[3] - bbox[1]) / domain.gridHeight;
66819
+ cx = Math.floor((x - bbox[0]) / dx - 0.5);
66820
+ cy = Math.floor((bbox[3] - y) / dy - 0.5);
66821
+ if (cx < 0) cx = 0;
66822
+ if (cy < 0) cy = 0;
66823
+ if (cx >= domain.width) cx = domain.width - 1;
66824
+ if (cy >= domain.height) cy = domain.height - 1;
66825
+ return domain.active[cy * domain.width + cx] === 1;
66826
+ }
66827
+
66828
+ // Trace the union boundary of active contour cells. Segments are directed with
66829
+ // the active domain on their right, matching Mapshaper's clockwise outer-ring
66830
+ // convention. At a point-touch between diagonal components, taking the
66831
+ // rightmost continuation keeps the two rings separate.
66832
+ function traceContourDomainRings(domain) {
66833
+ var W = domain.width;
66834
+ var H = domain.height;
66835
+ var segments = [];
66836
+ var outgoing = new Map();
66837
+ var rings = [];
66838
+ var cx, cy, id, segment;
66839
+ function isActive(x, y) {
66840
+ return x >= 0 && x < W && y >= 0 && y < H &&
66841
+ domain.active[y * W + x] === 1;
66842
+ }
66843
+ function nodeId(x, y) {
66844
+ return y * (W + 1) + x;
66845
+ }
66846
+ function addSegment(x1, y1, x2, y2, dir) {
66847
+ var seg = {
66848
+ from: nodeId(x1, y1),
66849
+ to: nodeId(x2, y2),
66850
+ dir: dir,
66851
+ visited: false
66852
+ };
66853
+ segments.push(seg);
66854
+ if (!outgoing.has(seg.from)) outgoing.set(seg.from, []);
66855
+ outgoing.get(seg.from).push(seg);
66856
+ }
66857
+ for (cy = 0; cy < H; cy++) {
66858
+ for (cx = 0; cx < W; cx++) {
66859
+ if (!isActive(cx, cy)) continue;
66860
+ if (!isActive(cx, cy - 1)) addSegment(cx, cy, cx + 1, cy, 0);
66861
+ if (!isActive(cx + 1, cy)) addSegment(cx + 1, cy, cx + 1, cy + 1, 1);
66862
+ if (!isActive(cx, cy + 1)) addSegment(cx + 1, cy + 1, cx, cy + 1, 2);
66863
+ if (!isActive(cx - 1, cy)) addSegment(cx, cy + 1, cx, cy, 3);
66864
+ }
66865
+ }
66866
+ for (id = 0; id < segments.length; id++) {
66867
+ segment = segments[id];
66868
+ if (!segment.visited) rings.push(followBoundary(segment, outgoing, domain));
66869
+ }
66870
+ return rings;
66871
+ }
66872
+
66873
+ function followBoundary(first, outgoing, domain) {
66874
+ var ring = [getDomainNodeCoords(first.from, domain)];
66875
+ var current = first;
66876
+ var candidates, next;
66877
+ while (current && !current.visited) {
66878
+ current.visited = true;
66879
+ ring.push(getDomainNodeCoords(current.to, domain));
66880
+ candidates = outgoing.get(current.to) || [];
66881
+ next = chooseBoundaryContinuation(current, candidates);
66882
+ current = next;
66883
+ }
66884
+ return ring;
66885
+ }
66886
+
66887
+ function chooseBoundaryContinuation(current, candidates) {
66888
+ var priorities = [1, 0, 3, 2];
66889
+ var i, j, candidate, turn;
66890
+ for (i = 0; i < priorities.length; i++) {
66891
+ for (j = 0; j < candidates.length; j++) {
66892
+ candidate = candidates[j];
66893
+ if (candidate.visited) continue;
66894
+ turn = (candidate.dir - current.dir + 4) % 4;
66895
+ if (turn === priorities[i]) return candidate;
66896
+ }
66897
+ }
66898
+ return null;
66899
+ }
66900
+
66901
+ function getDomainNodeCoords(id, domain) {
66902
+ var W = domain.width;
66903
+ var xId = id % (W + 1);
66904
+ var yId = Math.floor(id / (W + 1));
66905
+ var bbox = domain.bbox;
66906
+ var dx = (bbox[2] - bbox[0]) / domain.gridWidth;
66907
+ var dy = (bbox[3] - bbox[1]) / domain.gridHeight;
66908
+ var x = xId === 0 ? bbox[0] :
66909
+ xId === W ? bbox[2] : bbox[0] + (xId + 0.5) * dx;
66910
+ var y = yId === 0 ? bbox[3] :
66911
+ yId === domain.height ? bbox[1] : bbox[3] - (yId + 0.5) * dy;
66912
+ return [x, y];
66913
+ }
66914
+
66915
+ function getBoundaryGeoJSON(rings) {
66916
+ return {
66917
+ type: 'FeatureCollection',
66918
+ features: rings.map(function(coords) {
66919
+ return {
66920
+ type: 'Feature',
66921
+ properties: {},
66922
+ geometry: {type: 'LineString', coordinates: coords}
66923
+ };
66924
+ })
66925
+ };
66926
+ }
66927
+
66928
+ function indexContourArcs(lyr) {
66929
+ var records = lyr.data ? lyr.data.getRecords() : [];
66930
+ var index = new Map();
66931
+ lyr.shapes.forEach(function(shape, shapeId) {
66932
+ var level = records[shapeId] && records[shapeId].value;
66933
+ if (!shape || typeof level != 'number') return;
66934
+ shape.forEach(function(path) {
66935
+ path.forEach(function(arcId) {
66936
+ var absId = arcId < 0 ? ~arcId : arcId;
66937
+ index.set(absId, {
66938
+ level: level,
66939
+ direction: arcId < 0 ? -1 : 1
66940
+ });
66941
+ });
66942
+ });
66943
+ });
66944
+ return index;
66945
+ }
66946
+
66947
+ function createBandTileLayer(mosaic, arcs, arcInfo, domain, grid, band, breaks) {
66948
+ var shapes = [];
66949
+ var records = [];
66950
+ mosaic.forEach(function(tile) {
66951
+ var anchor = findAnchorPoint(tile, arcs, {});
66952
+ var bounds;
66953
+ if (!anchor || !pointIsInContourDomain(anchor.x, anchor.y, domain)) return;
66954
+ bounds = getTileBandBounds(tile, arcInfo, breaks, grid, band, anchor);
66955
+ if (!bounds) return;
66956
+ shapes.push(tile);
66957
+ records.push(bounds);
66958
+ });
66959
+ return {
66960
+ geometry_type: 'polygon',
66961
+ shapes: shapes,
66962
+ data: new DataTable(records)
66963
+ };
66964
+ }
66965
+
66966
+ function getTileBandBounds(tile, arcInfo, breaks, grid, band, anchor) {
66967
+ var lower = breaks[0];
66968
+ var upper = breaks[breaks.length - 1];
66969
+ var sawLower = false;
66970
+ var sawUpper = false;
66971
+ tile.forEach(function(path) {
66972
+ path.forEach(function(arcId) {
66973
+ var absId = arcId < 0 ? ~arcId : arcId;
66974
+ var info = arcInfo.get(absId);
66975
+ var direction;
66976
+ if (!info) return;
66977
+ direction = arcId < 0 ? -1 : 1;
66978
+ // Contour paths are directed with the above-level side on their left.
66979
+ // Polygon rings keep their interior on the right, so following a contour
66980
+ // arc in its original direction means this tile is below that level.
66981
+ if (direction === info.direction) {
66982
+ if (!sawUpper || info.level < upper) upper = info.level;
66983
+ sawUpper = true;
66984
+ } else {
66985
+ if (!sawLower || info.level > lower) lower = info.level;
66986
+ sawLower = true;
66987
+ }
66988
+ });
66989
+ });
66990
+ if (areAdjacentBreaks(lower, upper, breaks)) {
66991
+ return {lower: lower, upper: upper};
66992
+ }
66993
+ return getSampleBandBounds(grid, band, anchor.x, anchor.y, breaks);
66994
+ }
66995
+
66996
+ function areAdjacentBreaks(lower, upper, breaks) {
66997
+ var i = breaks.indexOf(lower);
66998
+ return i > -1 && i + 1 < breaks.length && breaks[i + 1] === upper;
66999
+ }
67000
+
67001
+ function getSampleBandBounds(grid, band, x, y, breaks) {
67002
+ var bbox = grid.bbox;
67003
+ var col = Math.floor((x - bbox[0]) / (bbox[2] - bbox[0]) * grid.width);
67004
+ var row = Math.floor((bbox[3] - y) / (bbox[3] - bbox[1]) * grid.height);
67005
+ var value, i;
67006
+ if (col < 0) col = 0;
67007
+ if (row < 0) row = 0;
67008
+ if (col >= grid.width) col = grid.width - 1;
67009
+ if (row >= grid.height) row = grid.height - 1;
67010
+ value = grid.samples[(row * grid.width + col) * grid.bands + band];
67011
+ if (value !== value) return null;
67012
+ i = 0;
67013
+ while (i + 2 < breaks.length && value >= breaks[i + 1]) i++;
67014
+ return {lower: breaks[i], upper: breaks[i + 1]};
67015
+ }
67016
+
67017
+ function createEmptyClosedContourDataset(info) {
67018
+ return {
67019
+ info: info || {},
67020
+ layers: [{
67021
+ geometry_type: 'polygon',
67022
+ shapes: [],
67023
+ data: new DataTable([])
67024
+ }]
67025
+ };
67026
+ }
67027
+
67028
+ var RasterContourBands = /*#__PURE__*/Object.freeze({
67029
+ __proto__: null,
67030
+ buildClosedContourDataset: buildClosedContourDataset,
67031
+ getClosedContourBreaks: getClosedContourBreaks,
67032
+ getContourCellDomain: getContourCellDomain,
67033
+ pointIsInContourDomain: pointIsInContourDomain,
67034
+ traceContourDomainRings: traceContourDomainRings
67035
+ });
67036
+
64033
67037
  // Trace contour lines (isolines) from a raster layer's working samples using
64034
67038
  // marching squares.
64035
67039
  //
@@ -64122,13 +67126,17 @@ ${svg}
64122
67126
 
64123
67127
  function getRasterContourLines(raster, opts) {
64124
67128
  var grid = getRasterGrid(raster);
64125
- var band, levels;
67129
+ var band, levels, range;
64126
67130
  validateRasterGridForContours(grid);
64127
67131
  band = getContourBand(grid, opts);
64128
67132
  levels = getContourLevels(grid, band, opts);
67133
+ range = getRasterSampleRange(grid, band);
64129
67134
  return {
67135
+ band: band,
67136
+ range: range,
64130
67137
  levels: levels,
64131
- lines: traceRasterContours(grid, band, levels, getCornerClearance(opts))
67138
+ lines: traceRasterContours(grid, band, levels, getCornerClearance(opts),
67139
+ {extend_to_bbox: opts && opts.closed})
64132
67140
  };
64133
67141
  }
64134
67142
 
@@ -64267,7 +67275,7 @@ ${svg}
64267
67275
  }
64268
67276
 
64269
67277
  // clearance: see CORNER_CLEARANCE; 0 places crossings exactly.
64270
- function traceRasterContours(grid, band, levels, clearance) {
67278
+ function traceRasterContours(grid, band, levels, clearance, opts) {
64271
67279
  var segments = collectContourSegments(grid, band, levels);
64272
67280
  var toMapXY = getLatticeToMapTransform(grid);
64273
67281
  var hCount = (grid.width - 1) * grid.height;
@@ -64277,6 +67285,9 @@ ${svg}
64277
67285
  stitchContourSegments(levelSegments).forEach(function(path) {
64278
67286
  var coords = getPathCoords(path, grid, band, levels[i], hCount, toMapXY,
64279
67287
  clearance);
67288
+ if (opts && opts.extend_to_bbox) {
67289
+ coords = extendContourPathToBBox(coords, path, grid, hCount);
67290
+ }
64280
67291
  if (coords.length > 1) {
64281
67292
  lines.push({value: levels[i], coords: coords});
64282
67293
  }
@@ -64285,6 +67296,39 @@ ${svg}
64285
67296
  return lines;
64286
67297
  }
64287
67298
 
67299
+ // Closed contour bands use the raster bbox as their outer frame. Open contour
67300
+ // paths that reach the outer sample-center lattice are extended through the
67301
+ // half-pixel margin to that frame. Endpoints created by skipped nodata cells
67302
+ // lie on interior lattice edges and are intentionally left in place.
67303
+ function extendContourPathToBBox(coords, path, grid, hCount) {
67304
+ if (path.length < 2 || path[0] === path[path.length - 1]) return coords;
67305
+ var first = getOuterEdgePoint(coords[0], path[0], grid, hCount);
67306
+ var last = getOuterEdgePoint(coords[coords.length - 1],
67307
+ path[path.length - 1], grid, hCount);
67308
+ if (first) coords.unshift(first);
67309
+ if (last) coords.push(last);
67310
+ return coords;
67311
+ }
67312
+
67313
+ function getOuterEdgePoint(xy, edgeId, grid, hCount) {
67314
+ var W = grid.width;
67315
+ var H = grid.height;
67316
+ var bbox = grid.bbox;
67317
+ var x, y;
67318
+ if (edgeId < hCount) {
67319
+ y = Math.floor(edgeId / (W - 1));
67320
+ if (y === 0) return [xy[0], bbox[3]];
67321
+ if (y === H - 1) return [xy[0], bbox[1]];
67322
+ return null;
67323
+ }
67324
+ edgeId -= hCount;
67325
+ y = Math.floor(edgeId / W);
67326
+ x = edgeId - y * W;
67327
+ if (x === 0) return [bbox[0], xy[1]];
67328
+ if (x === W - 1) return [bbox[2], xy[1]];
67329
+ return null;
67330
+ }
67331
+
64288
67332
  // Visit each cell once and march it for every level that falls inside its
64289
67333
  // value range, rather than scanning the whole grid once per level.
64290
67334
  function collectContourSegments(grid, band, levels) {
@@ -64505,6 +67549,21 @@ ${svg}
64505
67549
  // Contours come from the layer's working samples, so they reflect any
64506
67550
  // earlier edits (e.g. -blur, -clip) rather than the original source pixels.
64507
67551
  contours = getRasterContourLines(targetLyr.raster, opts);
67552
+ if (opts.closed) {
67553
+ dataset = contours.lines.length > 0 ?
67554
+ importGeoJSON(getContoursGeoJSON(contours.lines, DEFAULT_CONTOUR_FIELD), {}) :
67555
+ createEmptyContourDataset();
67556
+ setDatasetCrsInfo(dataset, getDatasetCrsInfo(targetDataset));
67557
+ if (contours.lines.length > 0 && !opts.no_smoothing) {
67558
+ smoothContourDataset(dataset, getRasterGrid(targetLyr.raster));
67559
+ }
67560
+ dataset = buildClosedContourDataset(dataset, getRasterGrid(targetLyr.raster),
67561
+ contours);
67562
+ outputLayers = mergeDatasetsIntoDataset(targetDataset, [dataset]);
67563
+ setOutputLayerName(outputLayers[0], targetLyr, 'contours', opts);
67564
+ message(getClosedContoursMessage(outputLayers[0], contours));
67565
+ return outputLayers;
67566
+ }
64508
67567
  message(getContoursMessage(contours));
64509
67568
  if (contours.lines.length === 0) {
64510
67569
  return [createEmptyContourLayer(targetLyr, opts)];
@@ -64588,6 +67647,17 @@ ${svg}
64588
67647
  return lyr;
64589
67648
  }
64590
67649
 
67650
+ function createEmptyContourDataset() {
67651
+ return {
67652
+ info: {},
67653
+ layers: [{
67654
+ geometry_type: 'polyline',
67655
+ shapes: [],
67656
+ data: new DataTable([])
67657
+ }]
67658
+ };
67659
+ }
67660
+
64591
67661
  function getContoursMessage(contours) {
64592
67662
  var levels = contours.levels;
64593
67663
  if (levels.length === 0) {
@@ -64599,6 +67669,16 @@ ${svg}
64599
67669
  ' (' + levels[0] + ' to ' + levels[levels.length - 1] + ')';
64600
67670
  }
64601
67671
 
67672
+ function getClosedContoursMessage(lyr, contours) {
67673
+ var count = lyr.shapes.length;
67674
+ if (count === 0) {
67675
+ return 'No closed contour bands were generated';
67676
+ }
67677
+ return 'Created ' + count + ' closed contour ' +
67678
+ (count === 1 ? 'band' : 'bands') + ' from ' + contours.levels.length +
67679
+ (contours.levels.length === 1 ? ' level' : ' levels');
67680
+ }
67681
+
64602
67682
  cmd.dashlines = function(lyr, dataset, opts) {
64603
67683
  var crs = getDatasetCRS(dataset);
64604
67684
  var defs = getStashedVar('defs');
@@ -66130,7 +69210,7 @@ ${svg}
66130
69210
 
66131
69211
  // Options that require the topology-repair algorithm and are not supported
66132
69212
  // by the no-repair fast path.
66133
- var REPAIR_REQUIRED_OPTS = ['gap_fill_area', 'sliver_control', 'allow_overlaps'];
69213
+ var REPAIR_REQUIRED_OPTS = ['gap_width', 'gap_fill_area', 'sliver_control', 'allow_overlaps'];
66134
69214
 
66135
69215
  // Sample size used to detect intersections in no-repair mode. Detection stops
66136
69216
  // after this many hits, so the warning message can include sample locations
@@ -70215,13 +73295,33 @@ ${svg}
70215
73295
  }
70216
73296
 
70217
73297
  function pointsFromPolygons(lyr, arcs, opts) {
70218
- var func = opts.inner ? findAnchorPoint : geom.getShapeCentroid;
73298
+ var innerOpts = opts.inner ? getInnerPointOptions(opts) : null;
70219
73299
  return lyr.shapes.map(function(shp) {
70220
- var p = func(shp, arcs);
73300
+ var p = innerOpts ?
73301
+ findAnchorPoint(shp, arcs, innerOpts) : geom.getShapeCentroid(shp, arcs);
70221
73302
  return p ? [[p.x, p.y]] : null;
70222
73303
  });
70223
73304
  }
70224
73305
 
73306
+ function getInnerPointOptions(opts) {
73307
+ var method = opts.inner_method || 'centroid2';
73308
+ var methods = ['legacy', 'pole', 'centroid', 'centroid2', 'weighted'];
73309
+ if (methods.indexOf(method) == -1) {
73310
+ stop$1('Unsupported inner point method:', method);
73311
+ }
73312
+ var tolerance = opts.inner_tolerance === undefined ?
73313
+ 0.1 : parsePercent(opts.inner_tolerance);
73314
+ var weight = opts.inner_weight === undefined ? 0.6 : opts.inner_weight;
73315
+ if (!(weight >= 0)) {
73316
+ stop$1('Expected inner-weight to be a non-negative number');
73317
+ }
73318
+ return {
73319
+ method: method,
73320
+ tolerance: tolerance,
73321
+ weight: weight
73322
+ };
73323
+ }
73324
+
70225
73325
  function coordinateFromValue(val) {
70226
73326
  var tmp;
70227
73327
  if (utils.isFiniteNumber(val)) {
@@ -70355,10 +73455,14 @@ ${svg}
70355
73455
  }
70356
73456
 
70357
73457
  function pointsFromPolygonsForJoin(lyr, dataset) {
70358
- // TODO use faster method to get inner points
70359
73458
  return {
70360
73459
  geometry_type: 'point',
70361
- shapes: pointsFromPolygons(lyr, dataset.arcs, {inner: true}),
73460
+ // Joins only require a reliable interior point; centroid bias adds a
73461
+ // second search without improving the containment test.
73462
+ shapes: pointsFromPolygons(lyr, dataset.arcs, {
73463
+ inner: true,
73464
+ inner_method: 'pole'
73465
+ }),
70362
73466
  data: lyr.data // TODO copy if needed
70363
73467
  };
70364
73468
  }
@@ -70403,85 +73507,6 @@ ${svg}
70403
73507
  return retn;
70404
73508
  }
70405
73509
 
70406
- class TinyQueue {
70407
- constructor(data = [], compare = defaultCompare) {
70408
- this.data = data;
70409
- this.length = this.data.length;
70410
- this.compare = compare;
70411
-
70412
- if (this.length > 0) {
70413
- for (let i = (this.length >> 1) - 1; i >= 0; i--) this._down(i);
70414
- }
70415
- }
70416
-
70417
- push(item) {
70418
- this.data.push(item);
70419
- this.length++;
70420
- this._up(this.length - 1);
70421
- }
70422
-
70423
- pop() {
70424
- if (this.length === 0) return undefined;
70425
-
70426
- const top = this.data[0];
70427
- const bottom = this.data.pop();
70428
- this.length--;
70429
-
70430
- if (this.length > 0) {
70431
- this.data[0] = bottom;
70432
- this._down(0);
70433
- }
70434
-
70435
- return top;
70436
- }
70437
-
70438
- peek() {
70439
- return this.data[0];
70440
- }
70441
-
70442
- _up(pos) {
70443
- const {data, compare} = this;
70444
- const item = data[pos];
70445
-
70446
- while (pos > 0) {
70447
- const parent = (pos - 1) >> 1;
70448
- const current = data[parent];
70449
- if (compare(item, current) >= 0) break;
70450
- data[pos] = current;
70451
- pos = parent;
70452
- }
70453
-
70454
- data[pos] = item;
70455
- }
70456
-
70457
- _down(pos) {
70458
- const {data, compare} = this;
70459
- const halfLength = this.length >> 1;
70460
- const item = data[pos];
70461
-
70462
- while (pos < halfLength) {
70463
- let left = (pos << 1) + 1;
70464
- let best = data[left];
70465
- const right = left + 1;
70466
-
70467
- if (right < this.length && compare(data[right], best) < 0) {
70468
- left = right;
70469
- best = data[right];
70470
- }
70471
- if (compare(best, item) >= 0) break;
70472
-
70473
- data[pos] = best;
70474
- pos = left;
70475
- }
70476
-
70477
- data[pos] = item;
70478
- }
70479
- }
70480
-
70481
- function defaultCompare(a, b) {
70482
- return a < b ? -1 : a > b ? 1 : 0;
70483
- }
70484
-
70485
73510
  /*
70486
73511
  ISC License
70487
73512
 
@@ -74126,6 +77151,16 @@ ${svg}
74126
77151
  stopJob(job);
74127
77152
  };
74128
77153
 
77154
+ // The icon names accepted by the icon= property of the -style command.
77155
+ // Kept in a module of its own so that both the renderer (svg-symbols.mjs) and
77156
+ // the -style command can use it without forming an import cycle.
77157
+
77158
+ var iconNames = ['circle', 'square', 'ring', 'star'];
77159
+
77160
+ function isSupportedIconName(name) {
77161
+ return iconNames.indexOf(name) > -1;
77162
+ }
77163
+
74129
77164
  cmd.svgStyle = function(lyr, dataset, opts) {
74130
77165
  var filterFn, table, fields, hasNewFields;
74131
77166
  if (getFeatureCount(lyr) === 0) {
@@ -74162,12 +77197,16 @@ ${svg}
74162
77197
  }
74163
77198
  var strVal = opts[optName].trim();
74164
77199
  var accessor = getSymbolPropertyAccessor(strVal, svgName, lyr);
77200
+ var badIcons = svgName == 'icon' ? [] : null;
74165
77201
  table.getRecords().forEach(function(rec, i) {
74166
77202
  if (filterFn && !filterFn(i)) {
74167
77203
  // make sure field exists if record is excluded by filter
74168
77204
  setUndefinedFields(rec, svgName == 'label-pos' ? labelPositionFields : [svgName]);
74169
77205
  } else {
74170
77206
  rec[svgName] = accessor(i);
77207
+ if (badIcons) {
77208
+ addUnsupportedIconName(badIcons, rec.icon);
77209
+ }
74171
77210
  if (svgName == 'label-pos') {
74172
77211
  if (!setLabelPositionStyle(rec, rec['label-pos'])) {
74173
77212
  stop$1('Unexpected value for label-pos:', rec['label-pos']);
@@ -74175,6 +77214,9 @@ ${svg}
74175
77214
  }
74176
77215
  }
74177
77216
  });
77217
+ if (badIcons && badIcons.length > 0) {
77218
+ warn(formatUnsupportedIconMessage(badIcons));
77219
+ }
74178
77220
  });
74179
77221
  if (fields.length > 0) {
74180
77222
  if (hasNewFields) {
@@ -74206,6 +77248,32 @@ ${svg}
74206
77248
  }
74207
77249
  }
74208
77250
 
77251
+ // Icon names can not be validated when options are parsed, because an icon=
77252
+ // value may be a field name or an expression. The assigned values are checked
77253
+ // instead, so that computed names are covered too. Unsupported names are a
77254
+ // warning, not an error -- features with an unsupported name render without an
77255
+ // icon.
77256
+ var maxReportedIconNames = 4;
77257
+
77258
+ function addUnsupportedIconName(names, val) {
77259
+ var name;
77260
+ if (!val) return; // a blank value removes the icon
77261
+ name = String(val);
77262
+ if (isSupportedIconName(name) || names.indexOf(name) > -1) return;
77263
+ names.push(name);
77264
+ }
77265
+
77266
+ function formatUnsupportedIconMessage(names) {
77267
+ var extra = names.length - maxReportedIconNames;
77268
+ var listed = extra > 0 ? names.slice(0, maxReportedIconNames) : names;
77269
+ var str = 'Unsupported icon ' + (names.length > 1 ? 'names' : 'name') + ': ' +
77270
+ listed.join(', ');
77271
+ if (extra > 0) {
77272
+ str += ' (and ' + extra + ' more)';
77273
+ }
77274
+ return str + '. Expected one of: ' + iconNames.join(', ');
77275
+ }
77276
+
74209
77277
  function setUndefinedFields(rec, fields) {
74210
77278
  fields.forEach(function(field) {
74211
77279
  if (field in rec === false) {
@@ -75897,7 +78965,7 @@ ${svg}
75897
78965
  return name == 'rectangle' || name == 'rectangles' || name == 'filter' && opts.cleanup;
75898
78966
  }
75899
78967
 
75900
- var version = "0.7.52";
78968
+ var version = "0.7.54";
75901
78969
 
75902
78970
  // Parse command line args into commands and run them
75903
78971
  // Function takes an optional Node-style callback. A Promise is returned if no callback is given.
@@ -78048,6 +81116,7 @@ ${svg}
78048
81116
  Logging,
78049
81117
  Profile,
78050
81118
  RasterBlur,
81119
+ RasterContourBands,
78051
81120
  RasterContours,
78052
81121
  RasterGrid,
78053
81122
  RasterReprojection,